IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    web 微信与基于node的微信机器人实现

    Liu Yuyang发表于 2016-01-24 14:41:33
    love 0

    协议分析

    我使用firefox浏览器调试工具,查看浏览器通信及美化web微信javascript代码。非常好用,没出现Chromium中文乱码的问题。

    登录

    获取uuid

    与登录有关的第一个GET请求。

    https://login.weixin.qq.com/jslogin?appid=wx782c26e4c19acffb&redirect_uri=https%3A%2F%2Fwx.qq.com%2Fcgi-bin%2Fmmwebwx-bin%2Fwebwxnewloginpage&fun=new&lang=en_US&_=1452859503801
    

    响应

    window.QRLogin.code = 200; window.QRLogin.uuid = "gd94hc3_fg==";
    

    我们猜200表示OK,uuid表示什么呢,难道不是universe unique identy?

    获取uuid对应的二维码图片

    接着第二个GET请求,使用上面得到的uuid请求二维码图像。

    https://login.weixin.qq.com/qrcode/gd94hc3_fg==
    

    响应就是一个二维码图片。

    检查二维码扫描状态

    一个GET请求

    https://login.weixin.qq.com/cgi-bin/mmwebwx-bin/login?loginicon=true&uuid=gd94hc3_fg==&tip=0&r=-1160587432&_=1452859503803
    

    这里的参数r是时间戳(~Date.now()),而_这个是jquery强行加上防止IE缓存的参数,服务器并不使用。服务器保持连接不中断,在大概27000ms后返回:

    window.code=408;
    

    HTTP code 408表示连接超时。接着又一个这种请求

    https://login.weixin.qq.com/cgi-bin/mmwebwx-bin/login?loginicon=true&uuid=gd94hc3_fg==&tip=0&r=-1160614525&_=1452859503804
    

    大概若干次后, 很久很久这个请求还是被服务器hold住没有返回。然后我再扫描时二维码失效了。。

    如果在移动端扫描过二维码,那么上面的请求将返回

    window.code=201;window.userAvatar = 'data:img/jpg;base64,/9j/4...'
    

    显然,http code 201一般表示新资源被建立(created)。同时继续另一个稍有不同的新的GET请求。

    https://login.weixin.qq.com/cgi-bin/mmwebwx-bin/login?loginicon=true&uuid=gfKtKTG7EQ==&tip=0&r=-1163246522&_=1452862177016
    

    注意,这时候tip变成0了。

    移动端如果不确认登录,经过27000ms左右后依然返回

    window.code=408;
    

    因此可以确定大概408就是状态不变超时继续的意义。接下来继续上述GET请求。

    一旦移动端点击确认登录,上述GET请求立马返回

    window.code=200;
    window.redirect_uri="https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?ticket=Ac8jAIUKtSn5vBxlXAinpFXL@qrticket_0&uuid=gd94hc3_fg==&lang=en_US&scan=1452862897";
    

    scan参数就是Date.now()。这时一个重定向页面又参数中附加上了一个ticket,看到这里,Oauth五个大字从脑海中无名升起。下面,域名就变了。

    webwxnewloginpage

    接下来一个GET请求

    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxinit?r=-1163944362&lang=en_US&pass_ticket=KlRMZmPcELxJHikrTsq6UEuDiy%252BZn1wFQ1VoeVAHUls82tXXB4L89ePbSghP6ICI
    

    返回这么个东西:

    <error><ret>0</ret><message>OK</message><skey>@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c</skey><wxsid>L9W0ddcaijmzhYhu</wxsid><wxuin>2684027137</wxuin><pass_ticket>KlRMZmPcELxJHikrTsq6UEuDiy%2BZn1wFQ1VoeVAHUls82tXXB4L89ePbSghP6ICI</pass_ticket><isgrayscale>1</isgrayscale></error>
    

    这些返回的参数

    • skey: 我有点考据癖,发现qq空间也用这个东西,维基百科说是一种一次性密码生成系统。大概这个值也是这么若干次哈希生成出来的。
    • wxsid: weixin session id
    • wxuin: weixin user identity number
    • pass_ticket: 通关文牒

    同时,返回的包头里包含set-cookie设置了cookie来标识用户。Cookie设置了上面的

    • wxsid
    • wxuin

    另有

    • wxloadtime: web微信页面加载时间。它是在计时并且不断汇报给服务器的。
    • mm_lang: 界面语言,我还想考证下mm什么意思然而并没有考证出来
    • webwx_data_ticket(这个域在qq.com上,其他都在wx.qq.com上),不知道干什么用的,似乎标识用户资源信息时得用上。所有的用户资源比如图片音频什么的都在qq的域名上。(后面会讨论这个问题)

    至此,登录过程完成。获取了cookie、pass_ticket和skey。

    基本信息获取

    webwxinit

    初始化整个webqq的信息获取。

    一个POST请求(sorry,我忘记和上文的passticket啊uin啊对应一致了。

    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxinit?r=-1163944362&lang=en_US&pass_ticket=KlRMZmPcELxJHikrTsq6UEuDiy%252BZn1wFQ1VoeVAHUls82tXXB4L89ePbSghP6ICI
    

    post payload

    {"BaseRequest":{"Uin":"2684027137","Sid":"L9W0ddcaijmzhYhu","Skey":"@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c","DeviceID":"e159973572418266"}}
    

    返回一个巨大的json,包含页面首次更新所需的基本信息

    • BaseResponse: 标识返回是否出错
    • Count:登录时显示的常用联系人列表中条目个数
    • ContactList:常用联系人列表(包括特殊联系人、群和私信)
    • SyncKey:更新Key,不太清楚是啥,似乎类似activesync的一种协议
    • User: 自己的信息,用户uin,Username,NickName,HeadImgUrl等
    • ClientVersion
    • SystemTime
    • GrayScale: 不知什么
    • InviteStartCount: 不知什么
    • MPSubscribeMsgCount: 这两条是有关web微信中间一栏阅读列表的
    • MPSubscribeMsgList: 同上
    • ClickReportInterval:点击报告间隔,似乎只是为了报告些性能信息https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxstatreport?fun=new

    这个请求会在ContactList里获得常用联系人信息,接着web微信会使用这些信息来batchgetcontact获取详细群组或者个人信息(详见下文)。并且,最最关键的是Synckey,用这个key来不断跟踪web微信客户端的变化。

    webwxgetcontact

    获取联系人列表的GET请求。

    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetcontact?lang=en_US&pass_ticket=KlRMZmPcELxJHikrTsq6UEuDiy%252BZn1wFQ1VoeVAHUls82tXXB4L89ePbSghP6ICI&r=1452862903198&seq=0&skey=@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c
    

    返回包含联系人信息列表的JSON数据

    • BaseResponse
    • MemberCount
    • MemberList
    • Seq: 只见过返回0

    batchgetcontact

    这是获取用户信息最重要的请求。POST请求:

    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxbatchgetcontact?type=ex&r=1453373586582&pass_ticket=cNQtWm5HAlkezd4WDrmrb6TBQYtkdHM4jaqbSWWYCT0EzIWzxBLHTu6Rb4fPw%252Fhf
    

    type=ex硬编码不知道想表达什么,r是时间戳。

    post data有两种,分别对应针对用户和群的查询:

    // UserName是要查询具体信息的用户名,EncryChatRoomId是该用户所属的群用户名。不知道为什么web微信要这么设计
    {"BaseRequest":{"Uin":2684027137,"Sid":"rnggO94JNo8B3Irp","Skey":"@crypt_3bb2969_890811a1e096f98662389b04dac3dcb8","DeviceID":"e559659465724952"},"Count":1,"List":[{"UserName":"@83cdf89d8ae7bf82d1fba26693b4952f","EncryChatRoomId":"@@8432d9b1c96038e5229185af62caa626add1a6b87554eb91cc5f5b63a207c8b3"}]}
    {"BaseRequest":{"Uin":2684027137,"Sid":"rnggO94JNo8B3Irp","Skey":"@crypt_3bb2969_890811a1e096f98662389b04dac3dcb8","DeviceID":"e559659465724952"},"Count":1,"List":[{"UserName":"@83cdf89d8ae7bf82d1fba26693b4952f","EncryChatRoomId":"@@8432d9b1c96038e5229185af62caa626add1a6b87554eb91cc5f5b63a207c8b3"}]}
    
    // UserName是想要查询具体信息的群UserName
    {"BaseRequest":{"Uin":2684027137,"Sid":"L9W0ddcaijmzhYhu","Skey":"@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c","DeviceID":"e017670883684764"},"Count":2,"List":[{"UserName":"@@9dc894837da0c2ac9932a85afb91af2d085807013562857e8b4a0ca66661ec68","EncryChatRoomId":""},{"UserName":"@@69dfa612d6bbefebbf24c323b6103680ac32c6a7b41e0862cc24f0b6f3174a08","EncryChatRoomId":""}]}
    

    返回典型User集合如下,上文提及的getcontact和batchgetcontact得到的都是这样:

    {
    "Uin": 0,
    "UserName": "filehelper",
    "NickName": "文件传输助手",
    "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=620730115&username=filehelper&skey=@crypt_3bb2969_2e9301eaab7a4b13a3a893a0bb5e8dfb",
    "ContactFlag": 3,
    "MemberCount": 0,
    "MemberList": [],
    "RemarkName": "",
    "HideInputBarFlag": 0,
    "Sex": 0,
    "Signature": "",
    "VerifyFlag": 0,
    "OwnerUin": 0,
    "PYInitial": "WJCSZS",
    "PYQuanPin": "wenjianchuanshuzhushou",
    "RemarkPYInitial": "",
    "RemarkPYQuanPin": "",
    "StarFriend": 0,
    "AppAccountFlag": 0,
    "Statues": 0,
    "AttrStatus": 0,
    "Province": "",
    "City": "",
    "Alias": "",
    "SnsFlag": 0,
    "UniFriend": 0,
    "DisplayName": "",
    "ChatRoomId": 0,
    "KeyWord": "fil",
    "EncryChatRoomId": ""
    }
    

    典型群信息集合

    {
    "Uin": 0,
    "UserName": "@@3376dc306923e39c2c5c43915012b1157af80fdc21f1cfb703ee720d09e13315",
    "NickName": "BJ NodeJS Club",
    "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=639556586&username=@@3376dc306923e39c2c5c43915012b1157af80fdc21f1cfb703ee720d09e13315&skey=",
    "ContactFlag": 3,
    "MemberCount": 421,
    "MemberList": [{
      "Uin": 0,
      "UserName": "@eb59926a7755e31f3030a883845eb647",
      "NickName": "hain",
      "AttrStatus": 98407,
      "PYInitial": "",
      "PYQuanPin": "",
      "RemarkPYInitial": "",
      "RemarkPYQuanPin": "",
      "MemberStatus": 0,
      "DisplayName": "",
      "KeyWord": "hai"
      }
    ...// 省略若干Member
    ],
    "RemarkName": "",
    "HideInputBarFlag": 0,
    "Sex": 0,
    "Signature": "",
    "VerifyFlag": 0,
    "OwnerUin": 246642915,
    "PYInitial": "BJNODEJSCLUB",
    "PYQuanPin": "BJNodeJSClub",
    "RemarkPYInitial": "",
    "RemarkPYQuanPin": "",
    "StarFriend": 0,
    "AppAccountFlag": 0,
    "Statues": 0,
    "AttrStatus": 0,
    "Province": "",
    "City": "",
    "Alias": "",
    "SnsFlag": 0,
    "UniFriend": 0,
    "DisplayName": "",
    "ChatRoomId": 0,
    "KeyWord": "",
    "EncryChatRoomId": "@9d9762417362d83c838bb54afacdac14"
    }
    

    比较有意思的是,这里群信息中的EncryChatRoomId只用来请求头像图片(参见下文),而请求中的EncryChatRoomId的值却填写的是群的UserName。

    webwxgeticon和webwxgetheadimg

    用户头像获取,每个User有个对应的HeadImgUrl。都不太一样

    // 带skey用户
    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxgeticon?seq=620730145&username=@63f002cacb7eebc558206af36c8758e68374063d653e51181eeb41a19a723399&skey=@crypt_3bb2969_890811a1e096f98662389b04dac3dcb8
    // skey为空的群图像,用户有时也有这种请求,不明确为何。ps: 用户信息中的HeadImgUrl也不会带skey。
    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=639547833&username=@@8432d9b1c96038e5229185af62caa626add1a6b87554eb91cc5f5b63a207c8b3&skey=
    // 带chatroomid的非好友群中用户头像, 其中chatroomid是群信息中的encrychatroomid。
    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@81c029b96b211aed1f4da9ea8a2acea7&skey=@crypt_3bb2969_890811a1e096f98662389b04dac3dcb8&chatroomid=@1d5974437ca911fe1315d415d98645ed
    

    似乎第一次请求会带上有值的skey,之后就不一定了不确定。

    信息收发

    syncheck与webwxsync(长连接和消息更新)

    基本信息获取完毕,接下来一个长GET连接,服务器可能会保持连接很久才返回,一旦断开客户断继续立即连接。这样服务器可以随时将更新的消息推送到客户端。

    https://webpush.weixin.qq.com/cgi-bin/mmwebwx-bin/synccheck?r=1452862903206&skey=%40crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c&sid=L9W0ddcaijmzhYhu&uin=2684027137&deviceid=e881718509293654&synckey=1_639545758%7C2_639547230%7C3_639546681%7C1000_1452852659&_=1452862890152
    

    一个典型的返回为

    window.synccheck={retcode:"0",selector:"2"}
    

    分析webwx源码可以看到,如果retcode不是0且selector不为0,则发出这么一个POST请求

    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxsync?sid=L9W0ddcaijmzhYhu&skey=@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c&lang=en_US&pass_ticket=KlRMZmPcELxJHikrTsq6UEuDiy%252BZn1wFQ1VoeVAHUls82tXXB4L89ePbSghP6ICI
    

    请求载荷为

    {"BaseRequest":{"Uin":2684027137,"Sid":"L9W0ddcaijmzhYhu","Skey":"@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c","DeviceID":"e431325091638023"},"SyncKey":{"Count":4,"List":[{"Key":1,"Val":639545758},{"Key":2,"Val":639547230},{"Key":3,"Val":639546681},{"Key":1000,"Val":1452852659}]},"rr":-1163958067}
    

    其中rr为~Date.now()。这里第一次传递的是webwxinit时得到的SyncKey。一般开始有四个键值对,不知道对应什么意义。

    返回为一个JSON对象,几个比较重要的属性包含

    BaseResponse
    AddMsgCount:新增消息数
    AddMsgList:新增消息列表
    ModContactCount: 变更联系人数目
    ModContactList: 变更联系人列表
    SyncKey:新的synckey列表
    

    接下来,又一个syncheck请求要向服务器表示上面的webwxsync响应已经收到了,更新SyncKey。

    https://webpush.weixin.qq.com/cgi-bin/mmwebwx-bin/synccheck?r=1452862906986&skey=%40crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c&sid=L9W0ddcaijmzhYhu&uin=2684027137&deviceid=e390320631258365&synckey=1_639545758%7C2_639547231%7C3_639546681%7C11_639547225%7C13_639540102%7C203_1452862598%7C1000_1452852659&_=1452862890153
    

    若synccheck返回为(我没从源码中看出selector的意义)。

    window.synccheck={retcode:"0",selector:"0"}
    

    则继续发出相同请求

    若返回不为以上返回值,继续POST请求webwxsync

    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxsync?sid=L9W0ddcaijmzhYhu&skey=@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c&lang=en_US&pass_ticket=KlRMZmPcELxJHikrTsq6UEuDiy%252BZn1wFQ1VoeVAHUls82tXXB4L89ePbSghP6ICI
    

    请求参数如下

    {“BaseRequest”:{“Uin”:2684027137,”Sid”:”L9W0ddcaijmzhYhu”,”Skey”:”@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c”,”DeviceID”:”e544554467912584”},”SyncKey”:{“Count”:7,”List”:[{“Key”:1,”Val”:639545758},{“Key”:2,”Val”:639547231},{“Key”:3,”Val”:639546681},{“Key”:11,”Val”:639547232},{“Key”:13,”Val”:639540102},{“Key”:203,”Val”:1452862989},{“Key”:1000,”Val”:1452852659}]},”rr”:-1164083935}

    这时候使用的syncKey是上次webwxsync时返回的新synckey

    该过程循环往复,每次都伴随着SyncKey的不断更新。通过这个机制web微信实现信息的增量更新同步。

    消息发送

    一个POST请求

    https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxsendmsg?lang=en_US&pass_ticket=KlRMZmPcELxJHikrTsq6UEuDiy%252BZn1wFQ1VoeVAHUls82tXXB4L89ePbSghP6ICI
    

    请求载荷为

    {"BaseRequest":{"Uin":2684027137,"Sid":"L9W0ddcaijmzhYhu","Skey":"@crypt_3bb2969_5d9682fbe6794c9437337ef278f6615c","DeviceID":"e888644980385645"},"Msg":{"Type":1,"Content":"养鸡厂233\n","FromUserName":"@30a777fdcc5ed46bbbf7bc695515afba9ad194cc720e006e6bb775054102636f","ToUserName":"@@48967eff39f174a33012bde5af8878227dd4ba8f64dd5ecf7a6495d5b979e6ac","LocalID":"14528638294750795","ClientMsgId":"14528638294750795"}}
    

    其中type标识了发送消息的类型,1表示文本消息。另从源码中看到, LocalID和ClientMsgId这样计算

    (Date.now() + Math.random().toFixed(3)).replace('.', '')
    

    该请求返回一个JSON对象

    • BaseResponse
    • MsgID: 服务器返回消息id // 微信公众平台文档中也提到可以用来排序或排重好像。
    • LocalID: 发送时指定的本地id.

    消息接收实例

    一个典型收到文本信息的例子:

    "AddMsgCount": 2,
    "AddMsgList": [{
        "MsgId": "6276659661087965644",
        "FromUserName": "@@cd020d3254fa869dfdc88a2ef4e4e201384b909eeabcb3724463bf64c5f7a452",
        "ToUserName": "@31908794c3035a00b386bc9ef0526ee8d95b3c426d53fb4f9466b67d7a0b5fef",
        "MsgType": 1,
        "Content": "@f88c48e531bc7de94072a206729750ff:<br/>好了,收工,你们聊",
        "Status": 3,
        "ImgStatus": 1,
        "CreateTime": 1453391337,
        "VoiceLength": 0,
        "PlayLength": 0,
        "FileName": "",
        "FileSize": "",
        "MediaId": "",
        "Url": "",
        "AppMsgType": 0,
        "StatusNotifyCode": 0,
        "StatusNotifyUserName": "",
        "RecommendInfo": {
          "UserName": "",
          "NickName": "",
          "QQNum": 0,
          "Province": "",
          "City": "",
          "Content": "",
          "Signature": "",
          "Alias": "",
          "Scene": 0,
          "VerifyFlag": 0,
          "AttrStatus": 0,
          "Sex": 0,
          "Ticket": "",
          "OpCode": 0
          }
        ,
        "ForwardFlag": 0,
        "AppInfo": {
          "AppID": "",
          "Type": 0
          }
        ,
        "HasProductId": 0,
        "Ticket": "",
        "ImgHeight": 0,
        "ImgWidth": 0,
        "SubMsgType": 0,
        "NewMsgId": 6276659661087965644
      }
      ,{
        "MsgId": "1010502497825347915",
        "FromUserName": "@c1019d5180ee2ef97a302737205b788502476a24cb1e870ac99da1aad788ac5f",
        "ToUserName": "@31908794c3035a00b386bc9ef0526ee8d95b3c426d53fb4f9466b67d7a0b5fef",
        "MsgType": 1,
        "Content": "牛逼哄哄的啊!",
        "Status": 3,
        "ImgStatus": 1,
        "CreateTime": 1453391337,
        "VoiceLength": 0,
        "PlayLength": 0,
        "FileName": "",
        "FileSize": "",
        "MediaId": "",
        "Url": "",
        "AppMsgType": 0,
        "StatusNotifyCode": 0,
        "StatusNotifyUserName": "",
        "RecommendInfo": {
          "UserName": "",
          "NickName": "",
          "QQNum": 0,
          "Province": "",
          "City": "",
          "Content": "",
          "Signature": "",
          "Alias": "",
          "Scene": 0,
          "VerifyFlag": 0,
          "AttrStatus": 0,
          "Sex": 0,
          "Ticket": "",
          "OpCode": 0
          }
        ,
        "ForwardFlag": 0,
        "AppInfo": {
          "AppID": "",
          "Type": 0
          }
        ,
        "HasProductId": 0,
        "Ticket": "",
        "ImgHeight": 0,
        "ImgWidth": 0,
        "SubMsgType": 0,
        "NewMsgId": 1010502497825347915
      }
    ],
    

    这个例子中,可以看到群信息中包含

    "Content": "@f88c48e531bc7de94072a206729750ff:<br/>好了,收工,你们聊",
    

    这样的内容,而这个
    前面的部分,实际上在微信中是标识群中发言人的UserName。因此,展示或者记录的时候需要先解析UserName到NickName或者DisplayName。这个过程Web微信通过getcontact、batchgetcontact两类请求请求数据,并且缓存起来。

    ModContactList中则包含变更的联系人信息,一个典型例子

    "ModContactCount": 1,
    "ModContactList": [{
      "UserName": "@@a6dd369b835a7f6a3fc8d4f0ace3b2de80c9c097f0439955043e6b716eb007fb",
      "NickName": "",
      "Sex": 0,
      "HeadImgUpdateFlag": 1,
      "ContactType": 0,
      "Alias": "",
      "ChatRoomOwner": "@31908794c3035a00b386bc9ef0526ee8d95b3c426d53fb4f9466b67d7a0b5fef",
      "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=0&username=@@a6dd369b835a7f6a3fc8d4f0ace3b2de80c9c097f0439955043e6b716eb007fb&skey=@crypt_3bb2969_2e9301eaab7a4b13a3a893a0bb5e8dfb",
      "ContactFlag": 2,
      "MemberCount": 2,
      "MemberList": [{
        "Uin": 236008826,
        "UserName": "@912d2268c7e87688e5c8f33003b488f3adcfd3b4d25fcb9a271550bfc18a7328",
        "NickName": "膜法师",
        "AttrStatus": 4357,
        "PYInitial": "",
        "PYQuanPin": "",
        "RemarkPYInitial": "",
        "RemarkPYQuanPin": "",
        "MemberStatus": 0,
        "DisplayName": "",
        "KeyWord": ""
        }
        ,{
        "Uin": 2684027137,
        "UserName": "@31908794c3035a00b386bc9ef0526ee8d95b3c426d53fb4f9466b67d7a0b5fef",
        "NickName": "狂风落尽深红色绿树成荫子满枝",
        "AttrStatus": 131169,
        "PYInitial": "",
        "PYQuanPin": "",
        "RemarkPYInitial": "",
        "RemarkPYQuanPin": "",
        "MemberStatus": 0,
        "DisplayName": "",
        "KeyWord": ""
        }
    ],
    "HideInputBarFlag": 0,
    "Signature": "",
    "VerifyFlag": 0,
    "RemarkName": "",
    "Statues": 1,
    "AttrStatus": 0,
    "Province": "",
    "City": "",
    "SnsFlag": 0,
    "KeyWord": ""
    }
    

    一旦ModContactList中有内容,则更新本地联系人缓存。

    这里只就文本消息进行了举例。实际上web微信有多种格式消息的支持。参见附录。

    总结:一图胜千言

    ![图]

    整个流程如图所示。

    我们应该注意到几点:

    • 首先,有三个服务器:第一个用来认证,返回一个ticket,并且检查ticket和uuid是否对应。第二个服务器负责登录、保持会话、更新消息、执行功能。第三个服务器仅仅保持长连接,我猜因为服务器端一直保持着每个用户的长连接来轮询,这种耗费资源的事情也确实应该分离出单独的服务器。
    • 其次,js代码都是单线程的,客户端通过保持synccheck长连接来接收服务器端的消息,并在更新状态后继续synccheck。
    • 最后,注意Synckey所起的作用,通过synckey的更新服务器知道客户端已经收到了哪些消息。

    实现:又一个机器人——wechat-user-bot

    在nodejs中我们可以实现完整的web微信功能。我准备做个机器人,

    一方面,作为将来某系统的一部分。
    另一方面,greathoul曾经说过有些人有需求来管理微信群
    最主要的,for fun。

    流程控制

    目标:在NodeJS中模拟上述流程

    流程概要

    正如上一章节所述。整个流程可以简化为。

                   /----用户操作
                   |
    登录认证-->长连接-->更新-\
                   |         |
                   \---------/
    

    在功能上,我们的机器人目前只完成了信息记录和聊天的功能。

    Javascript中的流程控制

    Javascript社区有四种驯服事件驱动异步编程的实践:

    • callback
    • Promise
    • Promise + Generator
    • Async
    回调地狱(Callback Hell)

    假设你要运行一个任务管理器
    输入id后,需要依次 执行 针对id的task1, task2, task3, task4…
    在js这种异步语言里会是这样…

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function taskRunner(id) {
    task1(id, function(id){
    task2(id, function(id){
    task3(id, function(id){
    task4(id, function(id){
    ...// callback hell
    })
    })
    })
    })
    }

    what a hell. 更糟糕的是,如果其中某个任务出错了,我怎么知道哪里出错了?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    function asyncOperation() {
    setTimeout(function() {
    throw new Error("MyError!");
    }, 1000);
    }

    try {
    asyncOperation();
    } catch (e) {
    console.log("I cannot catch : ", e);
    }

    try和catch 没有用?并不能捕获错误。那么…只能在异步回调里使用try。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function asyncOperation() {
    setTimeout(function() {
    try {
    throw new Error("Error");
    } catch(e) {
    console.log("asyncOperation catch it: ", e.message);
    }
    }, 1000);
    }

    function run() {
    try {
    asyncOperation();
    } catch (e) {
    console.log("I got no error", e);
    }
    }

    好吧,调用者又不能捕获错误了。run调用者怎么能知道发生了什么可呢。接下来其他函数怎么知道发生了什么?

    Nodejs社区采用一种error first的调用惯例。下一个回调可以收到err,判断,作出动作。但想象一下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function taskRunner(id) {
    task1(id, function(err, task1Output){
    if (err) handleError(err);
    doSomething(task1Output);
    task2(task1Output, function(err, res){
    if (err) handleError(err);
    task3(id, function(err){
    if (err) handleError(err);
    task4(id, function(err){
    if (err) handleError(err);
    ...// callback hell
    })
    })
    })
    })
    }

    看起来不好看是一回事。

    如果doSomething出错了,错误如何捕获?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    function taskRunner(id) {
    task1(id, function(err, task1Output){
    if (err) handleError(err);
    try {
    doSomething(task1Output);
    } catch(e) {
    handleError(e);
    }
    task2(task1Output, function(err, res){
    if (err) handleError(err);
    task3(id, function(){
    if (err) handleError(err);
    task4(id, function(){
    if (err) handleError(err);
    ...// callback hell
    })
    })
    })
    })
    }

    再如果对handleError想做出错处理,再如果我们在流程中嵌入了更多的同步和异步混用的代码,他们都要处理错误…What the fuck…

    再也不想看这样的代码。我们的大脑并不能很好的切合这种模式。而且,如果在task3和task4之间我想加入一个task5…考虑下你的当你想git blame…
    也许把函数分离出来会好很多, 但我感觉好不到哪里。

    超简Promise实现

    我们想写这样的代码…

    1
    task1().then(task2).then(task3)...

    我们想做这样的错误处理

    1
    2
    3
    task1().then(task2, handleError).then(task3, handleError)...
    // or
    task1().then(task2).then(task3)....then(taskN).catch(handleError);

    他们创造了Promise,一个许诺,一个代表未来的值。

    一个一秒后才到来的事物。

    一个神奇的设计, Promise几个标准规定了挺多,哦,也不多。不过我觉得核心的就三点:

    1. thenable
    2. 状态与缓存
    3. 能串行(Promise链)

    我们可以试着实现一个,实际Promise规范并没有规定实现方法,应该有很多实现方法。我们的方法是:

    • 同一个Promise可以用then注册多个回调,推入dfs中最后依次触发。
    • then返回一个Promise实现串行,而这个Promise将接下来要then的回调注册到自己的dfs中,一旦触发则调用自己的resolve函数将返回值喂给下一个then注册的回调函数。
    • resolve的延迟通过process.nextTick或者setTimeout(fn, 0)实现。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    function Promise(f) {
    var cache; // 未来的 值
    var dfs = []; // Promise链上Defferds链, deferds保存当前回调callback, 如果值到来了,传给桥Promise的resolve
    // 如果没解析,则添加到defferd链上,如果解析了,则调用df链上所有回调
    var status = "pending";

    this.then = function(callback) {
    return new Promise(function(resolve) {
    handle({
    callback: callback,
    resolve: resolve
    })
    })
    }

    function handle(df) {
    if (status == "pending") {
    dfs.push(df);
    } else if (status == "fulfiled") {
    var ret = df.callback(cache);
    df.resolve(ret);
    }
    }

    function resolve(value) {
    cache = value;
    status = "fulfiled";
    process.nextTick(()=> {
    dfs.forEach(function(df) {
    handle(df);
    })
    })
    }

    f(resolve);
    }


    var p1 = new Promise(function(resolve, reject){
    setTimeout(function() {
    resolve(15);
    }, 1000);
    });

    p1.then(function(value){
    console.log("task 1", value);
    return value + 1;
    }).then(function(value){
    console.log("task 2", value);
    })

    当然,还有个处理reject的部分,类似如此实现错误的“冒泡”。

    对了,如果对new操作符有疑问, new 之后新的对象引用的闭包变量并不是函数中的变量,好奇怪。new 并不 创建闭包。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    > function f(){
    var a = 1;
    this.then = function(t){a = t};
    this.print=function(){console.log(a)}}
    undefined
    > x = new f()
    f { then: [Function], print: [Function] }
    > y = new f()
    f { then: [Function], print: [Function] }
    > x.then(5)
    undefined
    > x.print()
    5
    undefined
    > y.print()
    1
    undefined

    好了,我们已经能够通过Promise成功实现异步串行。然而,还是有哪里不对劲的样子。Promise只能传递单一的值,Promise的语法非常繁复,Promise无法取消,没法用优雅的方式查看Promise链的状态。
    详细可以看看YDKJS。但我们能以更优雅的方式实现异步程序的编写和维护,处理错误,知道异步程序只被执行了一次等等。

    超简Promise+Generator

    后来,generator出现了,本来这玩意儿只是设计来循环。然而,yield能双向通信暂停程序却在同一个函数作用域的神奇属性被用来结合Promise做起了流程控制。想象一下

    iterator可以yield出Promise,Promise resolve后可以将异步操作的结果返回iterator。

    假设我们啊,有个社工库,我们根据id能获取其中的用户名和密码。这是什么例子。。。
    考虑以下逻辑…

    1
    2
    3
    4
    5
    6
    7
    function getInfo(id) {
    return getUser(id).then(function(username) {
    return getPass(username).then(function(password){
    return {username: username, password: password}
    })
    })
    }

    我们倒是想这样, 让user和name获取并行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    function *getInfo(id) {
    var user = getUser(id);
    var pass = getPass(user);
    console.log("trying: ", yield user, yield pass); // 同时yield
    }

    function getUser(id) {
    console.log("get username");
    return new Promise(function(resolve, reject) {
    setTimeout(function(){
    resolve("name-" + id);
    }, 1000);
    })
    }

    function getPass(username) {
    console.log("get password");
    return new Promise(function(resolve, reject) {
    setTimeout(function(){
    resolve("@us3r-");
    }, 1000);
    })
    }

    我们实现个async函数来帮助我们完成繁复的Promise+generator过程(似乎也是简化的co)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function async(g) {
    return function() {
    var it = g.apply(this, arguments);

    function handle(result) {
    if (result.done)
    return result.value;
    return result.value.then(function(res) {
    return handle(it.next(res))
    })
    }
    return handle(it.next())
    }
    }

    var find = async(getInfo);
    find("reverland");

    就这么并行了。

    还可以串行

    1
    2
    3
    4
    5
    function *taskRunner(id) {
    var task1Output = yield getUser(id);
    var task2Output = yield getPass(task1Output);
    console.log("trying: ", task2Output);
    }
    Async/Await

    ES7中吸收了C#中async和await关键字,这样能更加优雅的书写和维护异步程序。

    async function taskRunner(id) {
    var user = await getUser(id);
    var pass = await getPass(user);
    console.log(user, pass);
    }

    然而你需要一个编译器将ES7的代码编译成当前可执行的代码。

    总结

    从回调到async await经历了漫长的道路。

    其中,EventEmitter(观察者模式)也被用来进行异步程序的流程控制,回调注入(比如async库,如果我没理解错的话)也被用来进行流程控制。都一定程度解决了一定的问题。能力有限,不讨论了,
    《深入浅出NodeJS》里有精彩解说。

    WechatUserbot

    NodeJS环境中提供的网络请求、文件系统等功能喂机器人的实现提供了基础。NodeJS中并没有处理Cookie的核心库,我自己写了下发现依然出错,后来就用request了。fs主要是dump返回调试用。

    流程控制使用Promise,我选择了hard模式。因为写这个机器人也是加深对Promise的理解。而且随着业务的变化,各种dirtyhack开始出现。。。

    附录

    API一览

    API_webwxdownloadmedia: 'https://' + o + '/cgi-bin/mmwebwx-bin/webwxgetmedia',
    API_webwxuploadmedia: 'https://' + o + '/cgi-bin/mmwebwx-bin/webwxuploadmedia',
    API_webwxpreview: '/cgi-bin/mmwebwx-bin/webwxpreview',
    API_webwxinit: '/cgi-bin/mmwebwx-bin/webwxinit?r=' + ~new Date,
    API_webwxgetcontact: '/cgi-bin/mmwebwx-bin/webwxgetcontact',
    API_webwxsync: '/cgi-bin/mmwebwx-bin/webwxsync',
    API_webwxbatchgetcontact: '/cgi-bin/mmwebwx-bin/webwxbatchgetcontact',
    API_webwxgeticon: '/cgi-bin/mmwebwx-bin/webwxgeticon',
    API_webwxsendmsg: '/cgi-bin/mmwebwx-bin/webwxsendmsg',
    API_webwxsendmsgimg: '/cgi-bin/mmwebwx-bin/webwxsendmsgimg',
    API_webwxsendemoticon: '/cgi-bin/mmwebwx-bin/webwxsendemoticon',
    API_webwxsendappmsg: '/cgi-bin/mmwebwx-bin/webwxsendappmsg',
    API_webwxgetheadimg: '/cgi-bin/mmwebwx-bin/webwxgetheadimg',
    API_webwxgetmsgimg: '/cgi-bin/mmwebwx-bin/webwxgetmsgimg',
    API_webwxgetmedia: '/cgi-bin/mmwebwx-bin/webwxgetmedia',
    API_webwxgetvideo: '/cgi-bin/mmwebwx-bin/webwxgetvideo',
    API_webwxlogout: '/cgi-bin/mmwebwx-bin/webwxlogout',
    API_webwxgetvoice: '/cgi-bin/mmwebwx-bin/webwxgetvoice',
    API_webwxupdatechatroom: '/cgi-bin/mmwebwx-bin/webwxupdatechatroom',
    API_webwxcreatechatroom: '/cgi-bin/mmwebwx-bin/webwxcreatechatroom',
    API_webwxstatusnotify: '/cgi-bin/mmwebwx-bin/webwxstatusnotify',
    API_webwxcheckurl: '/cgi-bin/mmwebwx-bin/webwxcheckurl',
    API_webwxverifyuser: '/cgi-bin/mmwebwx-bin/webwxverifyuser',
    API_webwxfeedback: '/cgi-bin/mmwebwx-bin/webwxsendfeedback',
    API_webwxreport: '/cgi-bin/mmwebwx-bin/webwxstatreport',
    API_webwxsearch: '/cgi-bin/mmwebwx-bin/webwxsearchcontact',
    API_webwxoplog: '/cgi-bin/mmwebwx-bin/webwxoplog'
    

    消息类型

    上述webwxsync获得的AddMsgList中可能收到的各种信息。详细的信息种类列表可以参见webwx的angularjs源码。

    MSGTYPE_TEXT: 1,
    MSGTYPE_IMAGE: 3,
    MSGTYPE_VOICE: 34,
    MSGTYPE_VIDEO: 43,
    MSGTYPE_MICROVIDEO: 62,
    MSGTYPE_EMOTICON: 47,
    MSGTYPE_APP: 49,
    MSGTYPE_VOIPMSG: 50,
    MSGTYPE_VOIPNOTIFY: 52,
    MSGTYPE_VOIPINVITE: 53,
    MSGTYPE_LOCATION: 48,
    MSGTYPE_STATUSNOTIFY: 51,
    MSGTYPE_SYSNOTICE: 9999,
    MSGTYPE_POSSIBLEFRIEND_MSG: 40,
    MSGTYPE_VERIFYMSG: 37,
    MSGTYPE_SHARECARD: 42,
    MSGTYPE_SYS: 10000,
    MSGTYPE_RECALLED: 10002,  // 撤销消息
    

    不同的消息类型有不同的作用和处理方式。web微信的功能包含表情、图像消息

    参考资料

    • Who Add “_” Single Underscore Query Parameter?


沪ICP备19023445号-2号
友情链接