Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Whatsapp Voice Call #3258

Open
brewkon opened this issue Apr 5, 2024 · 2 comments
Open

Whatsapp Voice Call #3258

brewkon opened this issue Apr 5, 2024 · 2 comments
Assignees

Comments

@brewkon
Copy link

brewkon commented Apr 5, 2024

XMPP 协商

发起或者接受语音通话第一步是发起XMPP 协商,这个协商过程非常重要。下面是协商一个包

      <call to='xxx@s.whatsapp.net' id='xxxx'>
          <offer call-creator='xxx.0:0@s.whatsapp.net' call-id='xxx'
              device_class='2015'>
              <audio rate='8000' enc='opus' />
              <audio rate='16000' enc='opus' />
              <net medium='3' />
              <capability ver='1'>ac</capability>
              <enc v='2' type='pkmsg'>xxxxxxxx</enc>
              <encopt keygen='2' />
          </offer>
      </call>
  1. 语音编码参数 opus 编码, 采样率 16000/8000
  2. pkmsg 这个是加密字段,用来协议后续发送语音数据的秘钥
  3. call-id 同一个通话将使用相同的callid, 类似一个房间号一样,同一个callid的用户将可以听到各自的声音。

Stun UDP 内网穿透

NAT为设备提供内网IP地址,以便在专用本地网络中使用,但是这个地址不能在外部使用。没有外部的ip地址, 双方就无法直接进行通信。为了解决这个问题,就需要Stun 技术,也就是传说中的UDP 打洞。
在这里插入图片描述

Turn 数据中转

UDP 打洞并不能一定成功,所以当点对点失败的时候,为了能正常进行语音通话,需要有一个保底策略,那就是在turn服务器中转数据流。
TURN服务器具有公共地址,因此即使端点位于防火墙或代理之后,也可以与其他端点进行通信。TURN服务器虽然只有这么一个简单的任务 —— 中继流, 但与STUN服务器不同,它们本身就消耗了大量带宽。换句话说,TURN服务器需要更强大。
在这里插入图片描述

RTP 数据包发送

依赖上面的STUN/TURN 服务器的协商结果, 如果STUN 成功,则直接将加密的RTP 数据发送给对方,否则将加密的RTP数据发给TURN 服务器。下面是RTP 数据格式在这里插入图片描述

由于whatsapp的安全性,所有的RTP 数据都是被加密的, 加密使用的秘钥来源第一步XMPP 的秘钥协商。需要发送的音频数据需要使用 XMPP 协商的编码方式,否则可能不能播放。

@brewkon
Copy link
Author

brewkon commented Apr 16, 2024

Whatsapp VoiceCall

客户端通过websocket连接到服务器,客户端发起语音通话请求,并且完成必要的协商之后,就可以直接将语音数据发送给服务器,服务器接收到对方的语音数据之后也会通过websocket将语音数据转发给客户端

获取协商秘钥

XMPP 在发起语音通话请求的时候,需要带上一个秘钥,这个秘钥长32字节,通过特殊算法生成。这个算法需要三个参数:

  1. 自身jid
  2. 对方jid
  3. 时间戳(服务端自动获取,不需要生成)
        //发送获取秘钥请求
        JSONObject result = new JSONObject();
        result.put("command", "GetSecret");
        result.put("selfjid", "自己的@whatsapp.com");
        result.put("otherjid", "对方@whatsapp.com");
        SendCommand(result);
    
        //接收到服务器返回的消息, secret 字段是经过base64 编码,需要解码,解码之后是32字节
        {
            "secret": "Xh+LtW/gRxC92B4UK/gLAzqERAqL9U2ArNetO3Zy0h0=",
            "command": "ResponseSecret"
        }

发起XMPP 语音请求

  1. 发起语音请求。这个请求需要通过xmpp 通道发送出去,发出去之后,WA服务器会回一个ack包,这个ack包需要通过websocket发给中转服务器
 <call to='接收方@s.whatsapp.net' id='随机生成32字节'>
        <offer call-creator='发送方.0:0@s.whatsapp.net' call-id='随机生成32字节' device_class='2015'>
            <privacy>联系人的token,  同步联系人的时候 privacy_token节点下 trusted_contact 数据 </privacy>
            <audio rate='16000' enc='opus'/>
            <net medium='3'/>
            <capability ver='1'>AQT3CcT6</capability>
            <enc v='2' type='msg'>从服务器获取的32字节秘钥序列化成pb之后加密</enc>
            <encopt keygen='2'/>
        </offer>
    </call>
 //下面是消息pb 结构的一部分,需要将返回的32字节秘钥 设置到 Call->callKey 中,序列化之后加密
    message Message {
        optional string conversation = 1;
        optional SenderKeyDistributionMessage senderKeyDistributionMessage = 2;
        optional ImageMessage imageMessage = 3;
        optional ContactMessage contactMessage = 4;
        optional LocationMessage locationMessage = 5;
        optional ExtendedTextMessage extendedTextMessage = 6;
        optional DocumentMessage documentMessage = 7;
        optional AudioMessage audioMessage = 8;
        optional VideoMessage videoMessage = 9;
        optional Call call = 10;
        ... ...
        ... ...
    }

    message Call {
        optional bytes callKey = 1;
        optional string conversionSource = 2;
        optional bytes conversionData = 3;
        optional uint32 conversionDelaySeconds = 4;
    }
  1. 处理ack 回包。
    发送完第一个包之后,服务器会返回一个ack包, 需要将这个ack包转成xml格式,然后通过websocket 发送给服务器
      //xmpp 转xml 需要注意, 节点部分的值需要base64 之后再发过来
    <ack from='对方@s.whatsapp.net' class='call' type='offer' id='xxxx'>
        <relay attribute_padding='1' peer_pid='0' self_pid='1' uuid='xxx' call-creator='xxx@s.whatsapp.net' call-id='xxx' joinable='1'>
            <participant pid='0' jid='xxx@s.whatsapp.net'/>
            <token id='0'>base64的内容</token>
            <token id='1'>xxx</token>
            <token id='2'>xxx</token>
            <token id='3'>xxx</token>
            <token id='4'>xxxx</token>
            <key>xxxx</key>
            <te2 protocol='1' relay_id='0' token_id='0'>base64的内容</te2>
            <te2 protocol='1' relay_id='0' token_id='0'>base64的内容</te2>
            <te2 relay_id='0' token_id='0'>xxx</te2>
            <te2 relay_id='0' token_id='0'>xxx</te2>
            <te2 protocol='1' relay_id='1' token_id='1'>xxx</te2>
            <te2 protocol='1' relay_id='1' token_id='1'>xx</te2>
            <te2 relay_id='1' token_id='1'>xxx</te2>
            <te2 relay_id='1' token_id='1'>xxx</te2>
            <te2 protocol='1' relay_id='2' token_id='3'>xxx</te2>
            <te2 protocol='1' relay_id='2' token_id='3'>xxx</te2>
            <te2 relay_id='2' token_id='3'>xxx</te2>
            <te2 relay_id='2' token_id='3'>xxx</te2>
            <te2 protocol='1' relay_id='3' token_id='2'>xxx</te2>
            <te2 protocol='1' relay_id='3' token_id='2'>xxx</te2>
            <te2 relay_id='3' token_id='2'>xxx</te2>
            <te2 relay_id='3' token_id='2'>xxx</te2>
            <te2 protocol='1' relay_id='4' token_id='4'>xxx</te2>
            <te2 protocol='1' relay_id='4' token_id='4'>xxx</te2>
            <te2 relay_id='4' token_id='4'>xxx</te2>
            <te2 relay_id='4' token_id='4'>xxx</te2>
            <hbh_key>xxx</hbh_key>
        </relay>
        <user jid='xxx@s.whatsapp.net'>
            <device jid='xxx@s.whatsapp.net'/>
        </user>
        <rte>xxx</rte>
        <uploadfieldstat/>
        <userrate/>
        <voip_settings uncompressed='1'>xxxx</voip_settings>
    </ack>
 //将服务器回的ack 包发给中转服务器
    JSONObject result = new JSONObject();
    result.put("command", "VoiceAck");
    // 用于测试的音频文件ID,固定,正式部署的时候需要换成上传的文件
    result.put("file_uuid", "aee4d52d-6ba7-4a65-80d4-b7341b1115f0");
    result.put("ack", "服务器回的ack包打包成xml格式");
    SendCommand(result);
  1. 接收到的服务器的包必须回复ack,否则会被踢下线,下面几个常用的ack
		//接收的包
        <receipt from='xxx@s.whatsapp.net' id='xxx' t='xxx'>
            <offer call-id='xxx' call-creator='xxx@s.whatsapp.net'/>
        </receipt>

        //需要回复ack
         <ack id='xxx' to='xxx@s.whatsapp.net' class='receipt'/>
  //接收的包
        <call from='xxx@s.whatsapp.net' id='xxx' t='xxx'><preaccept call-id='xxx' call-creator='xxx@s.whatsapp.net'><audio rate='16000' enc='opus'/><encopt keygen='2'/><capability ver='1'>xxx</capability></preaccept></call>

        //需要回复ack
        <ack id='xxx' to='xxx.0:0@s.whatsapp.net' class='call' type='preaccept'/>
 //接收的包
    <call from='xxx@s.whatsapp.net' id='xxx' t='xxx'>
        <relaylatency call-id='xxx' call-creator='xxx@s.whatsapp.net'>
            <te latency='xxx'>xxx</te>
        </relaylatency>
    </call>
    //需要回复ack
    <ack id='xxx' to='xxx.0:0@s.whatsapp.net' class='call' type='relaylatency'/>
  1. 中转服务器会将一些需要发给WA服务器的包发过来,这些包需要转成xmpp 格式的数据发给WA 服务器
 <call to="xxx@s.whatsapp.net" id="xxx">
        <relaylatency call-creator="xxx.0:0@s.whatsapp.net" call-id="xxx">
            <te latency="xxx">xxx</te>
        </relaylatency>
    </call>

总结一下步骤:

1. 和中转服务器建立websocket 连接

2. 从中转服务器获取 加密秘钥

3. XMPP 发送call 请求,并且接收服务器返回的ack, 特别需要注意期间会收到很多包,都需要回ack,上面也列出了一些需要回ack的包

4. 将WA 服务器的ack包转成xml 格式发给中转服务器, 特别需要注意xml格式节点值需要base64 编码

5. 中转服务器会主动发送一些xml数据, 客户端需要将这些xml数据转成xmpp包发给服务器。

@brewkon
Copy link
Author

brewkon commented May 1, 2024

Whatsapp: +66627011785

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants