├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── README.md ├── bot-qrcode.jpg ├── index.js ├── media ├── test.gif ├── test.mp4 └── test.txt ├── package-lock.json ├── package.json ├── run-core.js ├── src ├── core.js ├── interface │ ├── contact.js │ └── message.js ├── util │ ├── conf.js │ ├── global.js │ ├── index.js │ └── request.js └── wechat.js ├── test ├── nock.js ├── response │ ├── webwxbatchgetcontact │ ├── webwxgetcontact │ ├── webwxinit │ └── webwxsync └── unit.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # 4 space indentation 2 | [*] 3 | indent_style = space 4 | indent_size = 2 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "parserOptions": { 4 | "ecmaVersion": 2017 5 | }, 6 | "rules": { 7 | "arrow-parens": [2, "as-needed"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | **/build/app.js 25 | **/build/app.js.map 26 | /lib 27 | 28 | # Dependency directory 29 | node_modules 30 | 31 | # Optional npm cache directory 32 | .npm 33 | 34 | # Optional REPL history 35 | .node_repl_history 36 | 37 | # personal 38 | /.vscode 39 | /typings 40 | .idea/* 41 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Example 24 | example 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # personal 36 | /.vscode 37 | /typyings 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wechat4u.js 2 | 3 | ![](http://7xr8pm.com1.z0.glb.clouddn.com/nodeWechat.png) [![npm version](https://img.shields.io/npm/v/wechat4u.svg)](https://www.npmjs.org/package/wechat4u) [![wechat group](https://img.shields.io/badge/wechat-group-brightgreen.svg)](http://www.qr-code-generator.com/phpqrcode/getCode.php?cht=qr&chl=http%3A%2F%2Fweixin.qq.com%2Fg%2FA1zJ47b19KtgMnAx&chs=180x180&choe=UTF-8&chld=L|0) 4 | 5 | ## Announcing wechat4u v0.7.14 6 | 7 | ### Features 8 | 9 | - 导出和导入保持微信登录的必要数据 bot.botData ([#160](https://github.com/nodeWechat/wechat4u/pull/160)) 10 | - 修改联系人备注 bot.updateRemarkName(UserName, RemarkName) ([#121](https://github.com/nodeWechat/wechat4u/pull/121)) 11 | - 修改群名 bot.updateChatRoomName(ChatRoomUserName, NewName) ([#168](https://github.com/nodeWechat/wechat4u/pull/168)) 12 | - 转发消息 bot.forwardMsg(msg, toUserName) 13 | - 撤回消息 bot.revokeMsg(MsgID, toUserName) 14 | 15 | ### Changes 16 | 17 | - 修复大文件上传失败问题 18 | - 支持uos协议,所有微信均可使用,请先微信实名认证后使用 19 | - 发送消息的一类方法在成功时会返回完整响应数据 20 | - bot.user 对象中不再存储用户头像的 base64 数据 21 | - 移除 example 目录 22 | - 修复 Contact 和 Message 中数据某些数据不可枚举 23 | - 向上层代码传递完整的 Error 对象,并将原来的中文错误描述放在 err.tips 24 | - bot.getContact(Seq) 方法增加 Seq 参数,支持增量获取完整联系人 25 | 26 | ## 安装使用 27 | 28 | ``` 29 | npm install --save wechat4u@latest 30 | ``` 31 | 32 | ```javascript 33 | const Wechat = require('wechat4u') 34 | let bot = new Wechat() 35 | bot.start() 36 | // 或使用核心API 37 | // const WechatCore = require('wechat4u/lib/core') 38 | ``` 39 | 40 | ## 开发测试 41 | 42 | ```shell 43 | git clone https://github.com/nodeWechat/wechat4u.git 44 | cd wechat4u 45 | npm install 46 | npm run core // 命令行模式 47 | npm run compile // babel编译 48 | ``` 49 | 50 | ## 使用范例 51 | 52 | `node run-core.js` 53 | 54 | 逻辑见[代码](https://github.com/nodeWechat/wechat4u/blob/master/run-core.js),简明完整,一定要看 55 | 56 | ## 实例化Wechat类 57 | 58 | ```javascript 59 | let bot = new Wechat([botData]) 60 | ``` 61 | 62 | 若传入`botData`,则使用此机器人信息,重新开始之前的同步 63 | 64 | ## 实例属性 65 | 66 | 所有属性均只读 67 | 68 | ### bot.botData 69 | 70 | 可导出的实例基本信息,在下次new新bot时,可以填入此信息,重新同步 71 | 72 | ### bot.PROP 73 | 74 | 保持登录状态的必要信息 75 | 76 | ### bot.CONF 77 | 78 | 配置信息,包括当前服务器地址,API路径和一些常量 79 | 80 | 程序中需要使用CONF中的常量来判断当前状态的新消息类型 81 | 82 | ```javascript 83 | bot.state == bot.CONF.STATE.init // 初始化状态 84 | bot.state == bot.CONF.STATE.uuid // 已获取 UUID 85 | bot.state == bot.CONF.STATE.login // 已登录 86 | bot.state == bot.CONF.STATE.logout // 已退出登录 87 | msg.MsgType == bot.CONF.MSGTYPE_TEXT // 文本消息 88 | msg.MsgType == bot.CONF.MSGTYPE_IMAGE // 图片消息 89 | msg.MsgType == bot.CONF.MSGTYPE_VOICE // 语音消息 90 | msg.MsgType == bot.CONF.MSGTYPE_EMOTICON // 自定义表情消息 91 | msg.MsgType == bot.CONF.MSGTYPE_MICROVIDEO // 小视频消息 92 | msg.MsgType == bot.CONF.MSGTYPE_VIDEO // 视频消息 93 | ``` 94 | 95 | ### bot.state 96 | 97 | 当前状态 98 | 99 | ### bot.user 100 | 101 | 当前登录用户信息 102 | 103 | ### bot.contacts 104 | 105 | 所有联系人,包括通讯录联系人,近期联系群,公众号 106 | 107 | key为联系人UserName,UserName是本次登录时每个联系人的UUID,不过下次登录会改变 108 | 109 | value为`Contact`对象,具体属性方法见`src/interface/contact.js` 110 | 111 | ### msg 112 | 113 | 登录后接受到的所有消息 114 | 115 | msg为`Message`对象,具体属性方法见`src/interface/message.js` 116 | 117 | ## 实例API 118 | 119 | ### bot.start() 120 | 121 | 启动实例,登录和保持同步 122 | 123 | 调用该方法后,通过监听事件来处理消息 124 | 125 | ### bot.restart() 126 | 127 | 利用实例已获取的必要信息,重新登录和进行同步 128 | 129 | ### bot.stop() 130 | 131 | 停止实例,退出登录 132 | 133 | 调用该方法后,通过监听`logout`事件来登出 134 | 135 | ### bot.setPollingMessageGetter(getter) 136 | 137 | 自定义心跳消息内容 138 | 139 | `getter` 函数返回心跳消息内容 140 | 141 | `typeof(getter())` 应为 `"string"` 142 | 143 | ```javascript 144 | bot.setRollingMessageGetter(function () { 145 | // 146 | return (new Date()).toJSON(); 147 | }); 148 | ``` 149 | 150 | ### bot.setPollingIntervalGetter(getter) 151 | 152 | 自定义心跳间隔 153 | 154 | `getter` 函数返回心跳间隔(以毫秒为单位) 155 | 156 | `typeof(getter())` 应为 `"number"` 157 | 158 | ```javascript 159 | bot.setRollingIntervalGetter(function () { 160 | return 2 * 60 * 1000; 161 | }); 162 | ``` 163 | 164 | ### bot.setPollingTargetGetter(getter) 165 | 166 | 自定义心跳目标用户 167 | 168 | `getter` 函数返回目标用户的 `UserName` (形如 `@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` ) 169 | 170 | `typeof(getter())` 应为 `"string"` 171 | 172 | 注: 如要使用 `bot.user.UserName` ,需在 `login` 事件后定义目标用户 173 | 174 | ```javascript 175 | bot.setRollingmeGetter(function () { 176 | return bot.user.UserName; 177 | }); 178 | ``` 179 | 180 | > 以下方法均返回Promise 181 | 182 | ### bot.sendText(msgString, toUserName) 183 | 184 | 发送文本消息,可以包含emoji(😒)和QQ表情([坏笑]) 185 | 186 | ### bot.uploadMedia(Buffer | Stream | File, filename, toUserName) 187 | 188 | 上传媒体文件 189 | 190 | 返回 191 | 192 | ```javascript 193 | { 194 | name: name, 195 | size: size, 196 | ext: ext, 197 | mediatype: mediatype, 198 | mediaId: mediaId 199 | } 200 | ``` 201 | 202 | ### bot.sendPic(mediaId, toUserName) 203 | 204 | 发送图片,mediaId为uploadMedia返回的mediaId 205 | 206 | ```javascript 207 | bot.uploadMedia(fs.createReadStream('test.png')) 208 | .then(res => { 209 | return bot.sendPic(res.mediaId, ToUserName) 210 | }) 211 | .catch(err => { 212 | console.log(err) 213 | }) 214 | ``` 215 | 216 | ### bot.sendEmoticon(md5 | mediaId, toUserName) 217 | 218 | 发送表情,可是是表情的MD5或者uploadMedia返回的mediaId 219 | 220 | 表情的MD5,可以自己计算但是可能不存在在微信服务器中,也可以从微信返回的表情消息中获得 221 | 222 | ### bot.sendVideo(mediaId, toUserName) 223 | 224 | 发送视频 225 | 226 | ### bot.sendDoc(mediaId, name, size, ext, toUserName) 227 | 228 | 以应用卡片的形式发送文件,可以通过这个API发送语音 229 | 230 | ### bot.sendMsg(msg, toUserName) 231 | 232 | 对以上发送消息的方法的封装,是发送消息的通用方法 233 | 234 | 当`msg`为string时,发送文本消息 235 | 236 | 当`msg`为`{file:xxx,filename:'xxx.ext'}`时,发送对应媒体文件 237 | 238 | 当`msg`为`{emoticonMd5:xxx}`时,发送表情 239 | 240 | ```javascript 241 | bot.sendMsg({ 242 | file: request('https://raw.githubusercontent.com/nodeWechat/wechat4u/master/bot-qrcode.jpg'), 243 | filename: 'bot-qrcode.jpg' 244 | }, ToUserName) 245 | .catch(err => { 246 | console.log(err) 247 | }) 248 | ``` 249 | 250 | ### bot.forwardMsg(msg, toUserName) 251 | 252 | 转发消息,`msg`为`message`事件传递的`msg`对象 253 | 254 | ### bot.revokeMsg(MsgID, toUserName) 255 | 256 | 撤回消息 257 | 258 | `MsgID`为发送消息后返回的代表消息的ID 259 | 260 | ```javascript 261 | bot.sendMsg('测试撤回', toUserName) 262 | .then(res => { 263 | return bot.revokeMsg(res.MsgID, toUserName) 264 | }) 265 | .catch(err => { 266 | console.log(err) 267 | }) 268 | ``` 269 | 270 | ### bot.getContact(Seq) 271 | 272 | 获取通讯录中的联系人 273 | 274 | `Seq` 上一次调用 bot.getContact 后返回的 seq,第一次调用可不传 275 | 276 | ### bot.batchGetContact(contacts) 277 | 278 | 批量获取指定联系人数据 279 | 280 | `contacts` 数组,指定需要获取的数据 281 | 282 | 当`contacts`为`[{UserName: xxx}]`时,可获取指定联系人或群信息 283 | 284 | 当`contacts`为`[{UserName: xxx, EncryChatRoomId: xxx}]`时,可获取指定群内成员详细信息,EncryChatRoomId 可从群信息中获得 285 | 286 | ### bot.getHeadImg(HeadImgUrl) 287 | 288 | 获取联系人头像 289 | 290 | ```javascript 291 | bot.getHeadImg(bot.contacts[UserName].HeadImgUrl).then(res => { 292 | fs.writeFileSync(`${UserName}.jpg`, res.data) 293 | }).catch(err => { 294 | console.log(err) 295 | }) 296 | ``` 297 | 298 | ### bot.getMsgImg(MsgId) 299 | 300 | 获取图片或表情 301 | 302 | ```javascript 303 | bot.getMsgImg(msg.MsgId).then(res => { 304 | fs.writeFileSync(`${msg.MsgId}.jpg`, res.data) 305 | }).catch(err => { 306 | console.log(err) 307 | }) 308 | ``` 309 | 310 | ### bot.getVoice(MsgId) 311 | 312 | 获取语音 313 | 314 | ### bot.getVideo(MsgId) 315 | 316 | 获取小视频或视频 317 | 318 | ### bot.getDoc(UserName, MediaId, FileName) 319 | 320 | 获取文件,消息的`MsgType`为49且`AppMsgType`为6时即为文件。 321 | 322 | 323 | ### bot.addFriend(UserName, Content) 324 | 325 | 添加好友 326 | 327 | `UserName` 一般可从群信息中获得 328 | 329 | `Content` 验证信息 330 | 331 | ### bot.verifyUser(UserName, Ticket) 332 | 333 | 通过好友添加请求 334 | 335 | ### bot.createChatroom(Topic, MemberList) 336 | 337 | 创建群 338 | 339 | `Topic` 群聊名称 340 | 341 | `MemberList` 数组, 除自己外至少两人的UserName,格式为 [ {"UserName":"@250d8d156ad9f8b068c2e3df3464ecf2"}, {"UserName":"@42d725733741de6ac53cbe3738d8dd2e"} ] 342 | 343 | ### bot.updateChatroom(ChatRoomUserName, MemberList, fun) 344 | 345 | 更新群成员 346 | 347 | `ChatRoomUserName` '@@'开头的群UserName 348 | 349 | `MemberList` 数组,联系人UserNa 350 | 351 | `fun` 可选'addmember','delmember','invitemember' 352 | 353 | ### bot.updateChatRoomName(ChatRoomUserName, NewName) 354 | 355 | 更新群名称 356 | 357 | `ChatRoomUserName` '@@'开头的群UserName 358 | 359 | `NewName` 字符串,新的群名称 360 | 361 | ### bot.opLog(UserName, OP) 362 | 363 | 置顶或取消置顶联系人,可通过直接取消置顶群来获取群ChatRoomOwner 364 | 365 | OP == 0 取消置顶 366 | 367 | OP == 1 置顶 368 | 369 | ### bot.updateRemarkName(UserName, RemarkName) 370 | 371 | 设置联系人备注或标签 372 | 373 | ## 实例事件 374 | 375 | ### uuid 376 | 377 | 得到uuid,之后可以构造二维码或从微信服务器取得二维码 378 | 379 | ```javascript 380 | bot.on('uuid', uuid => { 381 | qrcode.generate('https://login.weixin.qq.com/l/' + uuid, { 382 | small: true 383 | }) 384 | console.log('二维码链接:', 'https://login.weixin.qq.com/qrcode/' + uuid) 385 | }) 386 | ``` 387 | 388 | ### user-avatar 389 | 390 | 手机扫描后可以得到登录用户头像的Data URL 391 | 392 | ### login 393 | 394 | 手机确认登录 395 | 396 | ### logout 397 | 398 | 成功登出 399 | 400 | ### contacts-updated 401 | 402 | 联系人更新,可得到已更新的联系人列表 403 | 404 | ### message 405 | 406 | 所有通过同步得到的消息,通过`msg.MsgType`判断消息类型 407 | 408 | ```javascript 409 | bot.on('message', msg => { 410 | switch (msg.MsgType) { 411 | case bot.CONF.MSGTYPE_STATUSNOTIFY: 412 | break 413 | case bot.CONF.MSGTYPE_TEXT: 414 | break 415 | case bot.CONF.MSGTYPE_RECALLED: 416 | break 417 | } 418 | }) 419 | ``` 420 | 421 | ### error 422 | 423 | ## Contact对象和Message对象 424 | 425 | 每个contact,继承自 interface/contact,除原本 json 外,扩展以下属性: 426 | 427 | ```javascript 428 | contact.AvatarUrl // 处理过的头像地址 429 | contact.isSelf // 是否是登录用户本人 430 | 431 | contact.getDisplayName() 432 | contact.canSearch(keyword) 433 | ``` 434 | 435 | 此外,wechat4u 在实例上提供 Contact 作为联系人的通用接口,扩展以下属性: 436 | 437 | ```javascript 438 | wechat.Contact.isRoomContact() 439 | wechat.Contact.isSpContact() 440 | wechat.Contact.isPublicContact() 441 | 442 | wechat.Contact.getUserByUserName() 443 | wechat.Contact.getSearchUser(keyword) 444 | ``` 445 | 446 | 每个msg 对象继承自 interface/message,出原本 json 外,具有以下属性: 447 | 448 | ```javascript 449 | message.isSendBySelf // 是否是本人发送 450 | 451 | message.isSendBy(contact) 452 | message.getPeerUserName() // 获取所属对话的联系人 UserName 453 | message.getDisplayTime() // 获取形如 12:00 的时间戳信息 454 | ``` 455 | 456 | ## 相关项目 457 | 458 | 关于微信网页端机器人的实现,已经有大量的轮子了。感谢各位大神!(排名不分先后。。收录的肯定也不齐。。) 459 | 460 | - [Python2 的 WeixinBot](https://github.com/Urinx/WeixinBot) 461 | - [QT 的 QWX](https://github.com/xiangzhai/qwx) 462 | - [Node,可能会写成uProxy插件的 uProxy_wechat](https://github.com/LeMasque/uProxy_wechat) 463 | - [Node,可在shell中直接运行的 wechat-user-bot](https://github.com/HalfdogStudio/wechat-user-bot) 464 | - [Python3 的 wechat_robot](https://github.com/lyyyuna/wechat_robot) 465 | - [开放协议 支持 QQ&微信 的 wxagent](https://github.com/kitech/wxagent) 466 | - [在微信网页版和 IRC 间搭建通道支持 IRC 操作的 wechatircd](https://github.com/MaskRay/wechatircd) 467 | - [Chrome 插件版的微信机器人](https://github.com/spacelan/weixin-bot-chrome-extension) 468 | 469 | 关于微信网页端的接口说明,也有好几篇分析的很厉害的文章。 470 | 471 | - [Reverland 大神的web 微信与基于node的微信机器人实现](http://reverland.org/javascript/2016/01/15/webchat-user-bot/) 472 | - [Urinx 大神的 API Map](https://github.com/Urinx/WeixinBot/blob/master/README.md) 473 | - [聂永 大神的 微信协议简单调研笔记](http://www.blogjava.net/yongboy/archive/2014/03/05/410636.html) 474 | 475 | ## License 476 | 477 | MIT 478 | -------------------------------------------------------------------------------- /bot-qrcode.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodeWechat/wechat4u/c775020944a1bf5e4a2d94f9f6517a2fd9e0005b/bot-qrcode.jpg -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * wechat4u.js 3 | * Copyright(c) 2016-2017 nodeWechat 4 | * MIT Licensed 5 | */ 6 | 7 | 'use strict' 8 | 9 | module.exports = require('./src/wechat') 10 | -------------------------------------------------------------------------------- /media/test.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodeWechat/wechat4u/c775020944a1bf5e4a2d94f9f6517a2fd9e0005b/media/test.gif -------------------------------------------------------------------------------- /media/test.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodeWechat/wechat4u/c775020944a1bf5e4a2d94f9f6517a2fd9e0005b/media/test.mp4 -------------------------------------------------------------------------------- /media/test.txt: -------------------------------------------------------------------------------- 1 | # wechat4u.js 2 | 3 | ![](http://7xr8pm.com1.z0.glb.clouddn.com/nodeWechat.png) 4 | 5 | wechat4u@0.4.0更新了大量API,增强了稳定性 6 | 7 | 测试服务器[wechat4u.duapp.com](http://wechat4u.duapp.com) 8 | 具有文本表情自动回复,监控,群发功能 9 | 10 | ## 安装使用 11 | 12 | ``` 13 | npm install --save wechat4u@latest 14 | ``` 15 | 16 | ```javascript 17 | const Wechat = require('wechat') 18 | let bot = new Wechat() 19 | bot.start() 20 | // 或使用核心API 21 | // const WechatCore = require('wechat/lib/core') 22 | ``` 23 | 24 | ## 开发测试 25 | 26 | ``` 27 | git clone https://github.com/nodeWechat/wechat4u.git 28 | cd wechat4u 29 | npm install 30 | npm run example 31 | npm run core 32 | npm run compile 33 | ``` 34 | 35 | ## 使用范例 36 | 37 | `node run-core.js` 38 | 39 | 逻辑见代码,简明完整 40 | 41 | ## 实例属性 42 | 43 | 所有属性均只读 44 | 45 | ##### bot.PROP 46 | 47 | 保持登录状态的必要信息 48 | 49 | ##### bot.CONF 50 | 51 | 配置信息,包括当前服务器地址,API路径和一些常量 52 | 53 | 程序中需要使用CONF中的常量来判断当前状态的新消息类型 54 | 55 | ```javascript 56 | bot.state === bot.CONF.STATE.init // 初始化状态 57 | bot.state === bot.CONF.STATE.uuid // 已获取 UUID 58 | bot.state === bot.CONF.STATE.login // 已登录 59 | bot.state === bot.CONF.STATE.logout // 已退出登录 60 | msg.MsgType == bot.CONF.MSGTYPE_TEXT // 文本消息 61 | msg.MsgType == bot.CONF.MSGTYPE_IMAGE // 图片消息 62 | msg.MsgType == bot.CONF.MSGTYPE_VOICE // 语音消息 63 | msg.MsgType == bot.CONF.MSGTYPE_EMOTICON // 自定义表情消息 64 | msg.MsgType == bot.CONF.MSGTYPE_MICROVIDEO // 小视频消息 65 | msg.MsgType == bot.CONF.MSGTYPE_VIDEO // 视频消息 66 | ``` 67 | 68 | ##### bot.state 69 | 70 | 当前状态 71 | 72 | ##### bot.user 73 | 74 | 当前登录用户信息 75 | 76 | ##### bot.contacts 77 | 78 | 所有联系人,包括通讯录联系人,近期联系群,公众号 79 | 80 | key为联系人UserName,UserName是本次登录时每个联系人的UUID,不过下次登录会改变 81 | 82 | value为`Contact`对象,具体属性方法见`src/interface/contact.js` 83 | 84 | ##### msg 85 | 86 | 登录后接受到的所有消息 87 | 88 | msg为`Message`对象,具体属性方法见`src/interface/message.js` 89 | 90 | ## 实例API 91 | 92 | ##### bot.start() 93 | 94 | 启动实例,登录和保持同步 95 | 96 | ##### bot.stop() 97 | 98 | 停止实例,退出登录 99 | 100 | #### 以下方法均返回Promise 101 | 102 | ##### bot.sendText(msgString, toUserName) 103 | 104 | 发送文本消息,可以包含emoji(😒)和QQ表情([坏笑]) 105 | 106 | ##### bot.uploadMedia(Stream | File) 107 | 108 | 上传媒体文件,返回: 109 | 110 | ```javascript 111 | { 112 | name: name, 113 | size: size, 114 | ext: ext, 115 | mediatype: mediatype, 116 | mediaId: mediaId 117 | } 118 | ``` 119 | 120 | ##### bot.sendPic(mediaId, toUserName) 121 | 122 | 发送图片,mediaId为uploadMedia返回的mediaId 123 | 124 | ```javascript 125 | bot.uploadMedia(fs.createReadStream('test.png')) 126 | .then(res => { 127 | return bot.sendPic(res.mediaId, ToUserName) 128 | }) 129 | .catch(err => { 130 | console.log(err) 131 | }) 132 | ``` 133 | 134 | ##### bot.sendEmoticon(md5 | mediaId, toUserName) 135 | 136 | 发送表情,可是是表情的MD5或者uploadMedia返回的mediaId 137 | 138 | 表情的MD5,可以自己计算但是可能不存在在微信服务器中,也可以从微信返回的表情消息中获得 139 | 140 | ##### bot.sendVideo(mediaId, toUserName) 141 | 142 | 发送视频 143 | 144 | ##### bot.sendDoc(mediaId, name, size, ext, toUserName) 145 | 146 | 以应用卡片的形式发送文件,可以通过这个API发送语音 147 | 148 | ##### bot.getHeadImg(HeadImgUrl) 149 | 150 | 获取联系人头像 151 | 152 | ```javascript 153 | bot.getHeadImg(bot.contacts[UserName].HeadImgUrl).then(res => { 154 | fs.writeFileSync(`${UserName}.jpg`, res.data) 155 | }).catch(err => { 156 | console.log(err) 157 | }) 158 | ``` 159 | 160 | ##### bot.getMsgImg(MsgId) 161 | 162 | 获取图片或表情 163 | 164 | ```javascript 165 | bot.getMsgImg(msg.MsgId).then(res => { 166 | fs.writeFileSync(`${msg.MsgId}.jpg`, res.data) 167 | }).catch(err => { 168 | console.log(err) 169 | }) 170 | ``` 171 | 172 | ##### bot.getVoice(MsgId) 173 | 174 | 获取语音 175 | 176 | ##### bot.getVideo(MsgId) 177 | 178 | 获取小视频或视频 179 | 180 | ## 实例事件 181 | 182 | ##### uuid 183 | 184 | 得到uuid,之后可以构造二维码或从微信服务器取得二维码 185 | 186 | ```javascript 187 | bot.on('uuid', uuid => { 188 | qrcode.generate('https://login.weixin.qq.com/l/' + uuid, { 189 | small: true 190 | }) 191 | console.log('二维码链接:', 'https://login.weixin.qq.com/qrcode/' + uuid) 192 | }) 193 | ``` 194 | 195 | ##### user-avatar 196 | 197 | 手机扫描后可以得到登录用户头像的Data URL 198 | 199 | ##### login 200 | 201 | 手机确认登录 202 | 203 | ##### logout 204 | 205 | 成功登出 206 | 207 | ##### contacts-updated 208 | 209 | 联系人更新,可得到已更新的联系人列表 210 | 211 | ##### message 212 | 213 | 所有通过同步得到的消息,通过`msg.MsgType`判断消息类型 214 | 215 | ```javascript 216 | bot.on('message', msg => { 217 | switch (msg.MsgType) { 218 | case bot.CONF.MSGTYPE_STATUSNOTIFY: 219 | break 220 | case bot.CONF.MSGTYPE_TEXT: 221 | break 222 | case bot.CONF.MSGTYPE_RECALLED: 223 | break 224 | } 225 | }) 226 | ``` 227 | 228 | ##### error 229 | 230 | ## Contact对象和Message对象 231 | 232 | 每个contact,继承自 interface/contact,除原本 json 外,扩展以下属性: 233 | 234 | ```javascript 235 | contact.AvatarUrl // 处理过的头像地址 236 | contact.isSelf // 是否是登录用户本人 237 | 238 | contact.getDisplayName() 239 | contact.canSearch(keyword) 240 | ``` 241 | 242 | 此外,wechat4u 在实例上提供 Contact 作为联系人的通用接口,扩展以下属性: 243 | 244 | ```javascript 245 | wechat.Contact.isRoomContact() 246 | wechat.Contact.isSpContact() 247 | wechat.Contact.isPublicContact() 248 | 249 | wechat.Contact.getUserByUserName() 250 | wechat.Contact.getSearchUser(keyword) 251 | ``` 252 | 253 | 每个msg 对象继承自 interface/message,出原本 json 外,具有以下属性: 254 | 255 | ```javascript 256 | message.isSendBySelf // 是否是本人发送 257 | 258 | message.isSendBy(contact) 259 | message.getPeerUserName() // 获取所属对话的联系人 UserName 260 | message.getDisplayTime() // 获取形如 12:00 的时间戳信息 261 | ``` 262 | 263 | 264 | ## 相关项目 265 | 266 | 关于微信网页端机器人的实现,已经有大量的轮子了。感谢各位大神!(排名不分先后。。收录的肯定也不齐。。) 267 | 268 | * [Python2 的 WeixinBot](https://github.com/Urinx/WeixinBot) 269 | * [QT 的 QWX](https://github.com/xiangzhai/qwx) 270 | * [Node,可能会写成uProxy插件的 uProxy_wechat](https://github.com/LeMasque/uProxy_wechat) 271 | * [Node,可在shell中直接运行的 wechat-user-bot](https://github.com/HalfdogStudio/wechat-user-bot) 272 | * [Python3 的 wechat_robot](https://github.com/lyyyuna/wechat_robot) 273 | * [开放协议 支持 QQ&微信 的 wxagent](https://github.com/kitech/wxagent) 274 | * [在微信网页版和 IRC 间搭建通道支持 IRC 操作的 wechatircd](https://github.com/MaskRay/wechatircd) 275 | * [Chrome 插件版的微信机器人](https://github.com/spacelan/weixin-bot-chrome-extension) 276 | 277 | 关于微信网页端的接口说明,也有好几篇分析的很厉害的文章。 278 | 279 | * [Reverland 大神的web 微信与基于node的微信机器人实现](http://reverland.org/javascript/2016/01/15/webchat-user-bot/) 280 | * [Urinx 大神的 API Map](https://github.com/Urinx/WeixinBot/blob/master/README.md) 281 | * [聂永 大神的 微信协议简单调研笔记](http://www.blogjava.net/yongboy/archive/2014/03/05/410636.html) 282 | 283 | 好了,差不多就这些资料了。如果想要开发个自己的,那就开工吧! 284 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechat4u", 3 | "version": "0.7.14", 4 | "description": "web wechat lib for user", 5 | "main": "lib/wechat.js", 6 | "scripts": { 7 | "compile": "babel src -d lib -s", 8 | "prepublish": "npm run compile", 9 | "lint": "eslint src", 10 | "clean": "rm -rf lib", 11 | "unit": "mocha test/unit.js --compilers js:babel-core/register", 12 | "test": "npm run lint && npm run unit", 13 | "core": "cross-env DEBUG=wechat,core node run-core.js" 14 | }, 15 | "dependencies": { 16 | "axios": "^1.1.3", 17 | "bl": "^1.1.2", 18 | "debug": "^2.2.0", 19 | "form-data": "^2.1.2", 20 | "lodash": "^4.17.11", 21 | "mime": "^1.3.4" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/nodeWechat/wechat4u.git" 26 | }, 27 | "author": "nodeWechat", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/nodeWechat/wechat4u/issues" 31 | }, 32 | "homepage": "https://github.com/nodeWechat/wechat4u#readme", 33 | "devDependencies": { 34 | "babel-cli": "^6.14.0", 35 | "babel-preset-env": "^1.7.0", 36 | "babel-register": "^6.16.3", 37 | "chai": "^3.5.0", 38 | "cross-env": "^1.0.7", 39 | "eslint": "^5.14.1", 40 | "eslint-config-standard": "^5.3.1", 41 | "eslint-plugin-promise": "^1.1.0", 42 | "eslint-plugin-standard": "^1.3.2", 43 | "mocha": "^6.0.2", 44 | "nock": "^10.0.6", 45 | "qrcode-terminal": "^0.11.0", 46 | "replay": "^2.0.6", 47 | "request": "^2.88.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /run-core.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('babel-register') 3 | const Wechat = require('./src/wechat.js') 4 | const qrcode = require('qrcode-terminal') 5 | const fs = require('fs') 6 | const request = require('request') 7 | 8 | let bot 9 | /** 10 | * 尝试获取本地登录数据,免扫码 11 | * 这里演示从本地文件中获取数据 12 | */ 13 | try { 14 | bot = new Wechat(require('./sync-data.json')) 15 | } catch (e) { 16 | bot = new Wechat() 17 | } 18 | /** 19 | * 启动机器人 20 | */ 21 | if (bot.PROP.uin) { 22 | // 存在登录数据时,可以随时调用restart进行重启 23 | bot.restart() 24 | } else { 25 | bot.start() 26 | } 27 | /** 28 | * uuid事件,参数为uuid,根据uuid生成二维码 29 | */ 30 | bot.on('uuid', uuid => { 31 | qrcode.generate('https://login.weixin.qq.com/l/' + uuid, { 32 | small: true 33 | }) 34 | console.log('二维码链接:', 'https://login.weixin.qq.com/qrcode/' + uuid) 35 | }) 36 | /** 37 | * 登录用户头像事件,手机扫描后可以得到登录用户头像的Data URL 38 | */ 39 | bot.on('user-avatar', avatar => { 40 | console.log('登录用户头像Data URL:', avatar) 41 | }) 42 | /** 43 | * 登录成功事件 44 | */ 45 | bot.on('login', () => { 46 | console.log('登录成功') 47 | // 保存数据,将数据序列化之后保存到任意位置 48 | fs.writeFileSync('./sync-data.json', JSON.stringify(bot.botData)) 49 | }) 50 | /** 51 | * 登出成功事件 52 | */ 53 | bot.on('logout', () => { 54 | console.log('登出成功') 55 | // 清除数据 56 | fs.unlinkSync('./sync-data.json') 57 | }) 58 | /** 59 | * 联系人更新事件,参数为被更新的联系人列表 60 | */ 61 | bot.on('contacts-updated', contacts => { 62 | console.log(contacts) 63 | console.log('联系人数量:', Object.keys(bot.contacts).length) 64 | }) 65 | /** 66 | * 错误事件,参数一般为Error对象 67 | */ 68 | bot.on('error', err => { 69 | console.error('错误:', err) 70 | }) 71 | /** 72 | * 如何发送消息 73 | */ 74 | bot.on('login', () => { 75 | /** 76 | * 演示发送消息到文件传输助手 77 | * 通常回复消息时可以用 msg.FromUserName 78 | */ 79 | let ToUserName = 'filehelper' 80 | 81 | /** 82 | * 发送文本消息,可以包含emoji(😒)和QQ表情([坏笑]) 83 | */ 84 | bot.sendMsg('发送文本消息,可以包含emoji(😒)和QQ表情([坏笑])', ToUserName) 85 | .catch(err => { 86 | bot.emit('error', err) 87 | }) 88 | 89 | /** 90 | * 通过表情MD5发送表情 91 | */ 92 | bot.sendMsg({ 93 | emoticonMd5: '00c801cdf69127550d93ca52c3f853ff' 94 | }, ToUserName) 95 | .catch(err => { 96 | bot.emit('error', err) 97 | }) 98 | 99 | /** 100 | * 以下通过上传文件发送图片,视频,附件等 101 | * 通用方法为入下 102 | * file为多种类型 103 | * filename必填,主要为了判断文件类型 104 | */ 105 | // bot.sendMsg({ 106 | // file: Stream || Buffer || ArrayBuffer || File || Blob, 107 | // filename: 'bot-qrcode.jpg' 108 | // }, ToUserName) 109 | // .catch(err => { 110 | // bot.emit('error',err) 111 | // }) 112 | 113 | /** 114 | * 发送图片 115 | */ 116 | bot.sendMsg({ 117 | file: request('https://raw.githubusercontent.com/nodeWechat/wechat4u/master/bot-qrcode.jpg'), 118 | filename: 'bot-qrcode.jpg' 119 | }, ToUserName) 120 | .catch(err => { 121 | bot.emit('error', err) 122 | }) 123 | 124 | /** 125 | * 发送表情 126 | */ 127 | bot.sendMsg({ 128 | file: fs.createReadStream('./media/test.gif'), 129 | filename: 'test.gif' 130 | }, ToUserName) 131 | .catch(err => { 132 | bot.emit('error', err) 133 | }) 134 | 135 | /** 136 | * 发送视频 137 | */ 138 | bot.sendMsg({ 139 | file: fs.createReadStream('./media/test.mp4'), 140 | filename: 'test.mp4' 141 | }, ToUserName) 142 | .catch(err => { 143 | bot.emit('error', err) 144 | }) 145 | 146 | /** 147 | * 发送文件 148 | */ 149 | bot.sendMsg({ 150 | file: fs.createReadStream('./media/test.txt'), 151 | filename: 'test.txt' 152 | }, ToUserName) 153 | .catch(err => { 154 | bot.emit('error', err) 155 | }) 156 | 157 | /** 158 | * 发送撤回消息请求 159 | */ 160 | bot.sendMsg('测试撤回', ToUserName) 161 | .then(res => { 162 | // 需要取得待撤回消息的MsgID 163 | return bot.revokeMsg(res.MsgID, ToUserName) 164 | }) 165 | .catch(err => { 166 | console.log(err) 167 | }) 168 | }) 169 | /** 170 | * 如何处理会话消息 171 | */ 172 | bot.on('message', msg => { 173 | /** 174 | * 获取消息时间 175 | */ 176 | console.log(`----------${msg.getDisplayTime()}----------`) 177 | /** 178 | * 获取消息发送者的显示名 179 | */ 180 | console.log(bot.contacts[msg.FromUserName].getDisplayName()) 181 | /** 182 | * 判断消息类型 183 | */ 184 | switch (msg.MsgType) { 185 | case bot.CONF.MSGTYPE_TEXT: 186 | /** 187 | * 文本消息 188 | */ 189 | console.log(msg.Content) 190 | break 191 | case bot.CONF.MSGTYPE_IMAGE: 192 | /** 193 | * 图片消息 194 | */ 195 | console.log('图片消息,保存到本地') 196 | bot.getMsgImg(msg.MsgId).then(res => { 197 | fs.writeFileSync(`./media/${msg.MsgId}.jpg`, res.data) 198 | }).catch(err => { 199 | bot.emit('error', err) 200 | }) 201 | break 202 | case bot.CONF.MSGTYPE_VOICE: 203 | /** 204 | * 语音消息 205 | */ 206 | console.log('语音消息,保存到本地') 207 | bot.getVoice(msg.MsgId).then(res => { 208 | fs.writeFileSync(`./media/${msg.MsgId}.mp3`, res.data) 209 | }).catch(err => { 210 | bot.emit('error', err) 211 | }) 212 | break 213 | case bot.CONF.MSGTYPE_EMOTICON: 214 | /** 215 | * 表情消息 216 | */ 217 | console.log('表情消息,保存到本地') 218 | bot.getMsgImg(msg.MsgId).then(res => { 219 | fs.writeFileSync(`./media/${msg.MsgId}.gif`, res.data) 220 | }).catch(err => { 221 | bot.emit('error', err) 222 | }) 223 | break 224 | case bot.CONF.MSGTYPE_VIDEO: 225 | case bot.CONF.MSGTYPE_MICROVIDEO: 226 | /** 227 | * 视频消息 228 | */ 229 | console.log('视频消息,保存到本地') 230 | bot.getVideo(msg.MsgId).then(res => { 231 | fs.writeFileSync(`./media/${msg.MsgId}.mp4`, res.data) 232 | }).catch(err => { 233 | bot.emit('error', err) 234 | }) 235 | break 236 | case bot.CONF.MSGTYPE_APP: 237 | if (msg.AppMsgType == 6) { 238 | /** 239 | * 文件消息 240 | */ 241 | console.log('文件消息,保存到本地') 242 | bot.getDoc(msg.FromUserName, msg.MediaId, msg.FileName).then(res => { 243 | fs.writeFileSync(`./media/${msg.FileName}`, res.data) 244 | console.log(res.type); 245 | }).catch(err => { 246 | bot.emit('error', err) 247 | }) 248 | } 249 | break 250 | default: 251 | break 252 | } 253 | }) 254 | /** 255 | * 如何处理红包消息 256 | */ 257 | bot.on('message', msg => { 258 | if (msg.MsgType == bot.CONF.MSGTYPE_SYS && /红包/.test(msg.Content)) { 259 | // 若系统消息中带有‘红包’,则认为是红包消息 260 | // wechat4u并不能自动收红包 261 | } 262 | }) 263 | /** 264 | * 如何处理转账消息 265 | */ 266 | bot.on('message', msg => { 267 | if (msg.MsgType == bot.CONF.MSGTYPE_APP && msg.AppMsgType == bot.CONF.APPMSGTYPE_TRANSFERS) { 268 | // 转账 269 | } 270 | }) 271 | /** 272 | * 如何处理撤回消息 273 | */ 274 | bot.on('message', msg => { 275 | if (msg.MsgType == bot.CONF.MSGTYPE_RECALLED) { 276 | // msg.Content是一个xml,关键信息是MsgId 277 | let MsgId = msg.Content.match(/(.*?)<\/msgid>.*?<\/replacemsg>/)[0] 278 | // 得到MsgId后,根据MsgId,从收到过的消息中查找被撤回的消息 279 | } 280 | }) 281 | /** 282 | * 如何处理好友请求消息 283 | */ 284 | bot.on('message', msg => { 285 | if (msg.MsgType == bot.CONF.MSGTYPE_VERIFYMSG) { 286 | bot.verifyUser(msg.RecommendInfo.UserName, msg.RecommendInfo.Ticket) 287 | .then(res => { 288 | console.log(`通过了 ${bot.Contact.getDisplayName(msg.RecommendInfo)} 好友请求`) 289 | }) 290 | .catch(err => { 291 | bot.emit('error', err) 292 | }) 293 | } 294 | }) 295 | /** 296 | * 如何直接转发消息 297 | */ 298 | bot.on('message', msg => { 299 | // 不是所有消息都可以直接转发 300 | bot.forwardMsg(msg, 'filehelper') 301 | .catch(err => { 302 | bot.emit('error', err) 303 | }) 304 | }) 305 | /** 306 | * 如何获取联系人头像 307 | */ 308 | bot.on('message', msg => { 309 | bot.getHeadImg(bot.contacts[msg.FromUserName].HeadImgUrl).then(res => { 310 | fs.writeFileSync(`./media/${msg.FromUserName}.jpg`, res.data) 311 | }).catch(err => { 312 | bot.emit('error', err) 313 | }) 314 | }) 315 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import bl from 'bl' 3 | import _debug from 'debug' 4 | import FormData from 'form-data' 5 | import mime from 'mime' 6 | import { 7 | getCONF, 8 | Request, 9 | isStandardBrowserEnv, 10 | assert, 11 | getClientMsgId, 12 | getDeviceID 13 | } from './util' 14 | 15 | const debug = _debug('core') 16 | export class AlreadyLogoutError extends Error { 17 | constructor (message = 'already logout') { 18 | super(message) 19 | // fuck the babel 20 | this.constructor = AlreadyLogoutError 21 | this.__proto__ = AlreadyLogoutError.prototype 22 | } 23 | } 24 | const CHUNK_SIZE = 0.5 * 1024 * 1024 // 0.5 MB 25 | export default class WechatCore { 26 | constructor (data) { 27 | this.PROP = { 28 | uuid: '', 29 | uin: '', 30 | sid: '', 31 | skey: '', 32 | passTicket: '', 33 | formatedSyncKey: '', 34 | webwxDataTicket: '', 35 | syncKey: { 36 | List: [] 37 | } 38 | } 39 | this.CONF = getCONF() 40 | this.COOKIE = {} 41 | this.user = {} 42 | if (data) { 43 | this.botData = data 44 | } 45 | 46 | this.request = new Request({ 47 | Cookie: this.COOKIE 48 | }) 49 | } 50 | 51 | get botData () { 52 | return { 53 | PROP: this.PROP, 54 | CONF: this.CONF, 55 | COOKIE: this.COOKIE, 56 | user: this.user 57 | } 58 | } 59 | 60 | set botData (data) { 61 | Object.keys(data).forEach(key => { 62 | Object.assign(this[key], data[key]) 63 | }) 64 | } 65 | 66 | getUUID () { 67 | return Promise.resolve().then(() => { 68 | return this.request({ 69 | method: 'POST', 70 | url: this.CONF.API_jsLogin 71 | }).then(res => { 72 | let window = { 73 | QRLogin: {} 74 | } 75 | // res.data: "window.QRLogin.code = xxx; ..." 76 | // eslint-disable-next-line 77 | eval(res.data) 78 | assert.equal(window.QRLogin.code, 200, res) 79 | 80 | this.PROP.uuid = window.QRLogin.uuid 81 | return window.QRLogin.uuid 82 | }) 83 | }).catch(err => { 84 | debug(err) 85 | err.tips = '获取UUID失败' 86 | throw err 87 | }) 88 | } 89 | 90 | checkLogin () { 91 | return Promise.resolve().then(() => { 92 | let params = { 93 | 'tip': 0, 94 | 'uuid': this.PROP.uuid, 95 | 'loginicon': true, 96 | 'r': ~new Date() 97 | } 98 | return this.request({ 99 | method: 'GET', 100 | url: this.CONF.API_login, 101 | params: params 102 | }).then(res => { 103 | let window = {} 104 | 105 | // eslint-disable-next-line 106 | eval(res.data) 107 | 108 | assert.notEqual(window.code, 400, res) 109 | 110 | if (window.code === 200) { 111 | this.CONF = getCONF(window.redirect_uri.match(/(?:\w+\.)+\w+/)[0]) 112 | this.rediUri = window.redirect_uri 113 | } else if (window.code === 201 && window.userAvatar) { 114 | // this.user.userAvatar = window.userAvatar 115 | } 116 | return window 117 | }) 118 | }).catch(err => { 119 | debug(err) 120 | err.tips = '获取手机确认登录信息失败' 121 | throw err 122 | }) 123 | } 124 | 125 | login () { 126 | return Promise.resolve().then(() => { 127 | let headers = {} 128 | headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36' 129 | headers['client-version'] = '2.0.0' 130 | headers['referer'] = 'https://wx.qq.com/?&lang=zh_CN&target=t' 131 | headers['extspam'] = 'Go8FCIkFEokFCggwMDAwMDAwMRAGGvAESySibk50w5Wb3uTl2c2h64jVVrV7gNs06GFlWplHQbY/5FfiO++1yH4ykCyNPWKXmco+wfQzK5R98D3so7rJ5LmGFvBLjGceleySrc3SOf2Pc1gVehzJgODeS0lDL3/I/0S2SSE98YgKleq6Uqx6ndTy9yaL9qFxJL7eiA/R3SEfTaW1SBoSITIu+EEkXff+Pv8NHOk7N57rcGk1w0ZzRrQDkXTOXFN2iHYIzAAZPIOY45Lsh+A4slpgnDiaOvRtlQYCt97nmPLuTipOJ8Qc5pM7ZsOsAPPrCQL7nK0I7aPrFDF0q4ziUUKettzW8MrAaiVfmbD1/VkmLNVqqZVvBCtRblXb5FHmtS8FxnqCzYP4WFvz3T0TcrOqwLX1M/DQvcHaGGw0B0y4bZMs7lVScGBFxMj3vbFi2SRKbKhaitxHfYHAOAa0X7/MSS0RNAjdwoyGHeOepXOKY+h3iHeqCvgOH6LOifdHf/1aaZNwSkGotYnYScW8Yx63LnSwba7+hESrtPa/huRmB9KWvMCKbDThL/nne14hnL277EDCSocPu3rOSYjuB9gKSOdVmWsj9Dxb/iZIe+S6AiG29Esm+/eUacSba0k8wn5HhHg9d4tIcixrxveflc8vi2/wNQGVFNsGO6tB5WF0xf/plngOvQ1/ivGV/C1Qpdhzznh0ExAVJ6dwzNg7qIEBaw+BzTJTUuRcPk92Sn6QDn2Pu3mpONaEumacjW4w6ipPnPw+g2TfywJjeEcpSZaP4Q3YV5HG8D6UjWA4GSkBKculWpdCMadx0usMomsSS/74QgpYqcPkmamB4nVv1JxczYITIqItIKjD35IGKAUwAA==' 132 | return this.request({ 133 | method: 'GET', 134 | url: this.rediUri, 135 | maxRedirects: 0, 136 | headers: headers 137 | }).then(res => { 138 | 139 | }).catch(error => { 140 | if (error.response.status === 301) { 141 | let data = error.response.data 142 | let pm = data.match(/(.*)<\/ret>/) 143 | if (pm && pm[1] === '0') { 144 | this.PROP.skey = data.match(/(.*)<\/skey>/)[1] 145 | this.PROP.sid = data.match(/(.*)<\/wxsid>/)[1] 146 | this.PROP.uin = data.match(/(.*)<\/wxuin>/)[1] 147 | this.PROP.passTicket = data.match(/(.*)<\/pass_ticket>/)[1] 148 | } 149 | if (error.response.headers['set-cookie']) { 150 | error.response.headers['set-cookie'].forEach(item => { 151 | if (/webwx.*?data.*?ticket/i.test(item)) { 152 | this.PROP.webwxDataTicket = item.match(/=(.*?);/)[1] 153 | } else if (/wxuin/i.test(item)) { 154 | this.PROP.uin = item.match(/=(.*?);/)[1] 155 | } else if (/wxsid/i.test(item)) { 156 | this.PROP.sid = item.match(/=(.*?);/)[1] 157 | } else if (/pass_ticket/i.test(item)) { 158 | this.PROP.passTicket = item.match(/=(.*?);/)[1] 159 | } 160 | }) 161 | } 162 | } else { 163 | throw error 164 | } 165 | }) 166 | }).catch(err => { 167 | debug(err) 168 | err.tips = '登录失败' 169 | throw err 170 | }) 171 | } 172 | 173 | init () { 174 | return Promise.resolve().then(() => { 175 | let t = Date.now() 176 | let r = t / -1579 177 | let params = { 178 | 'pass_ticket': this.PROP.passTicket, 179 | 'r': Math.ceil(r) 180 | } 181 | let data = { 182 | BaseRequest: this.getBaseRequest() 183 | } 184 | return this.request({ 185 | method: 'POST', 186 | url: this.CONF.API_webwxinit, 187 | params: params, 188 | data: data 189 | }).then(res => { 190 | let data = res.data 191 | if (data.BaseResponse.Ret === this.CONF.SYNCCHECK_RET_LOGOUT) { 192 | throw new AlreadyLogoutError() 193 | } 194 | assert.equal(data.BaseResponse.Ret, 0, res) 195 | this.PROP.skey = data.SKey || this.PROP.skey 196 | this.updateSyncKey(data) 197 | Object.assign(this.user, data.User) 198 | return data 199 | }) 200 | }).catch(err => { 201 | debug(err) 202 | err.tips = '微信初始化失败' 203 | throw err 204 | }) 205 | } 206 | 207 | notifyMobile (to) { 208 | return Promise.resolve().then(() => { 209 | let params = { 210 | pass_ticket: this.PROP.passTicket, 211 | lang: 'zh_CN' 212 | } 213 | let data = { 214 | 'BaseRequest': this.getBaseRequest(), 215 | 'Code': to ? 1 : 3, 216 | 'FromUserName': this.user['UserName'], 217 | 'ToUserName': to || this.user['UserName'], 218 | 'ClientMsgId': Date.now() 219 | } 220 | return this.request({ 221 | method: 'POST', 222 | url: this.CONF.API_webwxstatusnotify, 223 | params: params, 224 | data: data 225 | }).then(res => { 226 | let data = res.data 227 | assert.equal(data.BaseResponse.Ret, 0, res) 228 | }) 229 | }).catch(err => { 230 | debug(err) 231 | err.tips = '手机状态通知失败' 232 | throw err 233 | }) 234 | } 235 | 236 | getContact (seq = 0) { 237 | return Promise.resolve().then(() => { 238 | let params = { 239 | // 'pass_ticket': this.PROP.passTicket, 240 | 'seq': seq, 241 | 'skey': this.PROP.skey, 242 | 'r': +new Date() 243 | } 244 | return this.request({ 245 | method: 'GET', 246 | url: this.CONF.API_webwxgetcontact, 247 | params: params 248 | }).then(res => { 249 | let data = res.data 250 | assert.equal(data.BaseResponse.Ret, 0, res) 251 | return data 252 | }) 253 | }).catch(err => { 254 | debug(err) 255 | err.tips = '获取通讯录失败' 256 | throw err 257 | }) 258 | } 259 | 260 | batchGetContact (contacts) { 261 | return Promise.resolve().then(() => { 262 | let params = { 263 | 'pass_ticket': this.PROP.passTicket, 264 | 'type': 'ex', 265 | 'r': +new Date(), 266 | 'lang': 'zh_CN' 267 | } 268 | let data = { 269 | 'BaseRequest': this.getBaseRequest(), 270 | 'Count': contacts.length, 271 | 'List': contacts 272 | } 273 | return this.request({ 274 | method: 'POST', 275 | url: this.CONF.API_webwxbatchgetcontact, 276 | params: params, 277 | data: data 278 | }).then(res => { 279 | let data = res.data 280 | assert.equal(data.BaseResponse.Ret, 0, res) 281 | 282 | return data.ContactList 283 | }) 284 | }).catch(err => { 285 | debug(err) 286 | err.tips = '批量获取联系人失败' 287 | throw err 288 | }) 289 | } 290 | 291 | statReport (text) { 292 | return Promise.resolve().then(() => { 293 | text = text || { 294 | 'type': '[action-record]', 295 | 'data': { 296 | 'actions': [{ 297 | 'type': 'click', 298 | 'action': '发送框', 299 | 'time': +new Date() 300 | }] 301 | } 302 | } 303 | text = JSON.stringify(text) 304 | let params = { 305 | 'pass_ticket': this.PROP.passTicket, 306 | 'fun': 'new', 307 | 'lang': 'zh_CN' 308 | } 309 | let data = { 310 | 'BaseRequest': this.getBaseRequest(), 311 | 'Count': 1, 312 | 'List': [{ 313 | 'Text': text, 314 | 'Type': 1 315 | }] 316 | } 317 | return this.request({ 318 | method: 'POST', 319 | url: this.CONF.API_webwxreport, 320 | params: params, 321 | data: data 322 | }) 323 | }).catch(err => { 324 | debug(err) 325 | err.tips = '状态报告失败' 326 | throw err 327 | }) 328 | } 329 | 330 | syncCheck () { 331 | return Promise.resolve().then(() => { 332 | let params = { 333 | 'r': +new Date(), 334 | 'sid': this.PROP.sid, 335 | 'uin': this.PROP.uin, 336 | 'skey': this.PROP.skey, 337 | 'deviceid': getDeviceID(), 338 | 'synckey': this.PROP.formatedSyncKey 339 | } 340 | return this.request({ 341 | method: 'GET', 342 | url: this.CONF.API_synccheck, 343 | params: params 344 | }).then(res => { 345 | let window = { 346 | synccheck: {} 347 | } 348 | 349 | try { 350 | // eslint-disable-next-line 351 | eval(res.data) 352 | } catch (ex) { 353 | window.synccheck = { retcode: '0', selector: '0' } 354 | } 355 | if (window.synccheck.retcode === this.CONF.SYNCCHECK_RET_LOGOUT) { 356 | throw new AlreadyLogoutError() 357 | } 358 | assert.equal(window.synccheck.retcode, this.CONF.SYNCCHECK_RET_SUCCESS, res) 359 | return window.synccheck.selector 360 | }) 361 | }).catch(err => { 362 | debug(err) 363 | err.tips = '同步失败' 364 | throw err 365 | }) 366 | } 367 | 368 | sync () { 369 | return Promise.resolve().then(() => { 370 | let params = { 371 | 'sid': this.PROP.sid, 372 | 'skey': this.PROP.skey, 373 | 'pass_ticket': this.PROP.passTicket, 374 | 'lang': 'zh_CN' 375 | } 376 | let data = { 377 | 'BaseRequest': this.getBaseRequest(), 378 | 'SyncKey': this.PROP.syncKey, 379 | 'rr': ~new Date() 380 | } 381 | return this.request({ 382 | method: 'POST', 383 | url: this.CONF.API_webwxsync, 384 | params: params, 385 | data: data 386 | }).then(res => { 387 | let data = res.data 388 | if (data.BaseResponse.Ret === this.CONF.SYNCCHECK_RET_LOGOUT) { 389 | throw new AlreadyLogoutError() 390 | } 391 | assert.equal(data.BaseResponse.Ret, 0, res) 392 | 393 | this.updateSyncKey(data) 394 | this.PROP.skey = data.SKey || this.PROP.skey 395 | return data 396 | }) 397 | }).catch(err => { 398 | debug(err) 399 | err.tips = '获取新信息失败' 400 | throw err 401 | }) 402 | } 403 | 404 | updateSyncKey (data) { 405 | if (data.SyncKey) { 406 | this.PROP.syncKey = data.SyncKey 407 | } 408 | if (data.SyncCheckKey) { 409 | let synckeylist = [] 410 | for (let e = data.SyncCheckKey.List, o = 0, n = e.length; n > o; o++) { 411 | synckeylist.push(e[o]['Key'] + '_' + e[o]['Val']) 412 | } 413 | this.PROP.formatedSyncKey = synckeylist.join('|') 414 | } else if (!this.PROP.formatedSyncKey && data.SyncKey) { 415 | let synckeylist = [] 416 | for (let e = data.SyncKey.List, o = 0, n = e.length; n > o; o++) { 417 | synckeylist.push(e[o]['Key'] + '_' + e[o]['Val']) 418 | } 419 | this.PROP.formatedSyncKey = synckeylist.join('|') 420 | } 421 | } 422 | 423 | logout () { 424 | return Promise.resolve().then(() => { 425 | let params = { 426 | redirect: 1, 427 | type: 0, 428 | skey: this.PROP.skey, 429 | lang: 'zh_CN' 430 | } 431 | 432 | // data加上会出错,不加data也能登出 433 | // let data = { 434 | // sid: this.PROP.sid, 435 | // uin: this.PROP.uin 436 | // } 437 | return this.request({ 438 | method: 'POST', 439 | url: this.CONF.API_webwxlogout, 440 | params: params 441 | }).then(res => { 442 | return '登出成功' 443 | }).catch(err => { 444 | debug(err) 445 | return '可能登出成功' 446 | }) 447 | }) 448 | } 449 | 450 | sendText (msg, to) { 451 | return Promise.resolve().then(() => { 452 | let params = { 453 | 'pass_ticket': this.PROP.passTicket, 454 | 'lang': 'zh_CN' 455 | } 456 | let clientMsgId = getClientMsgId() 457 | let data = { 458 | 'BaseRequest': this.getBaseRequest(), 459 | 'Scene': 0, 460 | 'Msg': { 461 | 'Type': this.CONF.MSGTYPE_TEXT, 462 | 'Content': msg, 463 | 'FromUserName': this.user['UserName'], 464 | 'ToUserName': to, 465 | 'LocalID': clientMsgId, 466 | 'ClientMsgId': clientMsgId 467 | } 468 | } 469 | return this.request({ 470 | method: 'POST', 471 | url: this.CONF.API_webwxsendmsg, 472 | params: params, 473 | data: data 474 | }).then(res => { 475 | let data = res.data 476 | assert.equal(data.BaseResponse.Ret, 0, res) 477 | return data 478 | }) 479 | }).catch(err => { 480 | debug(err) 481 | err.tips = '发送文本信息失败' 482 | throw err 483 | }) 484 | } 485 | 486 | sendEmoticon (id, to) { 487 | return Promise.resolve().then(() => { 488 | let params = { 489 | 'fun': 'sys', 490 | 'pass_ticket': this.PROP.passTicket, 491 | 'lang': 'zh_CN' 492 | } 493 | let clientMsgId = getClientMsgId() 494 | let data = { 495 | 'BaseRequest': this.getBaseRequest(), 496 | 'Scene': 0, 497 | 'Msg': { 498 | 'Type': this.CONF.MSGTYPE_EMOTICON, 499 | 'EmojiFlag': 2, 500 | 'FromUserName': this.user['UserName'], 501 | 'ToUserName': to, 502 | 'LocalID': clientMsgId, 503 | 'ClientMsgId': clientMsgId 504 | } 505 | } 506 | 507 | if (id.indexOf('@') === 0) { 508 | data.Msg.MediaId = id 509 | } else { 510 | data.Msg.EMoticonMd5 = id 511 | } 512 | 513 | return this.request({ 514 | method: 'POST', 515 | url: this.CONF.API_webwxsendemoticon, 516 | params: params, 517 | data: data 518 | }).then(res => { 519 | let data = res.data 520 | assert.equal(data.BaseResponse.Ret, 0, res) 521 | return data 522 | }) 523 | }).catch(err => { 524 | debug(err) 525 | err.tips = '发送表情信息失败' 526 | throw err 527 | }) 528 | } 529 | 530 | // 根据文件大小切割form 531 | getMediaFormStreamData ({ name, data, type, mediatype, size, toUserName }) { 532 | let uploadMediaRequest = JSON.stringify({ 533 | BaseRequest: this.getBaseRequest(), 534 | ClientMediaId: getClientMsgId(), 535 | TotalLen: size, 536 | StartPos: 0, 537 | DataLen: size, 538 | MediaType: 4, 539 | UploadType: 2, 540 | FromUserName: this.user.UserName, 541 | ToUserName: toUserName || this.user.UserName 542 | }) 543 | 544 | // 小于0.5mb的文件直接返回form 545 | if (size <= CHUNK_SIZE) { 546 | let form = new FormData() 547 | form.append('name', name) 548 | form.append('type', type) 549 | form.append('lastModifiedDate', new Date().toGMTString()) 550 | form.append('size', size) 551 | form.append('mediatype', mediatype) 552 | form.append('uploadmediarequest', uploadMediaRequest) 553 | form.append('webwx_data_ticket', this.PROP.webwxDataTicket) 554 | form.append('pass_ticket', encodeURI(this.PROP.passTicket)) 555 | form.append('filename', data, { 556 | filename: name, 557 | contentType: type, 558 | knownLength: size 559 | }) 560 | 561 | return form 562 | } 563 | 564 | // 大于0.5mb的文件要切割 chunk 565 | const totalChunksNum = Math.ceil(size / CHUNK_SIZE) 566 | const formList = [] 567 | 568 | for (let i = 0; i < totalChunksNum; i++) { 569 | let startPos = i * CHUNK_SIZE 570 | let endPos = Math.min(size, startPos + CHUNK_SIZE) 571 | let chunk = data.slice(startPos, endPos) 572 | 573 | // 创建每个块的 FormData 574 | const form = new FormData() 575 | form.append('name', name) 576 | form.append('type', type) 577 | form.append('lastModifiedDate', new Date().toGMTString()) 578 | form.append('size', size) 579 | form.append('mediatype', mediatype) 580 | form.append('uploadmediarequest', uploadMediaRequest) 581 | form.append('webwx_data_ticket', this.PROP.webwxDataTicket) 582 | form.append('pass_ticket', encodeURI(this.PROP.passTicket)) 583 | form.append('id', 'WU_FILE_0') 584 | form.append('chunk', i) 585 | form.append('chunks', totalChunksNum) 586 | form.append('filename', chunk, { 587 | filename: name, 588 | contentType: type, 589 | knownLength: chunk.length 590 | }) 591 | formList.push({ 592 | data: form, 593 | headers: form.getHeaders() 594 | }) 595 | } 596 | 597 | return formList 598 | } 599 | 600 | // file: Stream, Buffer, File, Blob 601 | uploadMedia (file, filename, toUserName) { 602 | return Promise.resolve().then(() => { 603 | let name, type, size, ext, mediatype, data 604 | return new Promise((resolve, reject) => { 605 | if ((typeof (File) !== 'undefined' && file.constructor === File) || 606 | (typeof (Blob) !== 'undefined' && file.constructor === Blob)) { 607 | name = file.name || 'file' 608 | type = file.type 609 | size = file.size 610 | data = file 611 | return resolve() 612 | } else if (Buffer.isBuffer(file)) { 613 | if (!filename) { 614 | return reject(new Error('文件名未知')) 615 | } 616 | name = filename 617 | type = mime.lookup(name) 618 | size = file.length 619 | data = file 620 | return resolve() 621 | } else if (file.readable) { 622 | if (!file.path && !filename) { 623 | return reject(new Error('文件名未知')) 624 | } 625 | name = path.basename(file.path || filename) 626 | type = mime.lookup(name) 627 | file.pipe(bl((err, buffer) => { 628 | if (err) { 629 | return reject(err) 630 | } 631 | size = buffer.length 632 | data = buffer 633 | return resolve() 634 | })) 635 | } 636 | }).then(() => { 637 | ext = name.match(/.*\.(.*)/) 638 | if (ext) { 639 | ext = ext[1].toLowerCase() 640 | } else { 641 | ext = '' 642 | } 643 | 644 | switch (ext) { 645 | case 'bmp': 646 | case 'jpeg': 647 | case 'jpg': 648 | case 'png': 649 | mediatype = 'pic' 650 | break 651 | case 'mp4': 652 | mediatype = 'video' 653 | break 654 | default: 655 | mediatype = 'doc' 656 | } 657 | 658 | const formOrFormList = this.getMediaFormStreamData({ name, data, type, mediatype, size, toUserName }) 659 | 660 | return new Promise((resolve, reject) => { 661 | if (isStandardBrowserEnv) { 662 | return resolve({ 663 | data: formOrFormList, 664 | headers: {} 665 | }) 666 | } else if (Array.isArray(formOrFormList)) { 667 | const bufferList = formOrFormList.reduce((arr, formObj) => { 668 | formObj.data.pipe(bl((err, buffer) => { 669 | if (err) { 670 | return reject(err) 671 | } 672 | 673 | arr.push({ data: buffer, headers: formObj.headers }) 674 | })) 675 | return arr 676 | }, []) 677 | 678 | return resolve({ data: bufferList }) 679 | } else { 680 | formOrFormList.pipe(bl((err, buffer) => { 681 | if (err) { 682 | return reject(err) 683 | } 684 | return resolve({ 685 | data: buffer, 686 | headers: formOrFormList.getHeaders() 687 | }) 688 | })) 689 | } 690 | }) 691 | }).then(data => { 692 | let params = { 693 | f: 'json' 694 | } 695 | 696 | // 单块文件上传 697 | if (!Array.isArray(data.data)) { 698 | return this.request({ 699 | method: 'POST', 700 | url: this.CONF.API_webwxuploadmedia, 701 | headers: data.headers, 702 | params, 703 | data: data.data 704 | }) 705 | } 706 | 707 | const bufferList = data.data 708 | let currentChunkIndex = 0 709 | 710 | // 分块上传逻辑 711 | const processChunk = res => { 712 | if (currentChunkIndex < bufferList.length) { 713 | const chunkObj = bufferList[currentChunkIndex] 714 | return this.request({ 715 | method: 'POST', 716 | url: this.CONF.API_webwxuploadmedia, 717 | headers: chunkObj.headers, 718 | params, 719 | data: chunkObj.data 720 | }).then(res => { 721 | currentChunkIndex++ 722 | // 递归处理下一个块 723 | return processChunk(res) 724 | }) 725 | } else { 726 | // 所有块上传完成 727 | return Promise.resolve({data: { 728 | MediaId: res.data.MediaId 729 | }}) 730 | } 731 | } 732 | 733 | // 开始处理第一个块 734 | return processChunk() 735 | }).then(res => { 736 | let data = res.data 737 | let mediaId = data.MediaId 738 | assert.ok(mediaId, res) 739 | 740 | return { 741 | name: name, 742 | size: size, 743 | ext: ext, 744 | mediatype: mediatype, 745 | mediaId: mediaId 746 | } 747 | }) 748 | }).catch(err => { 749 | debug(err) 750 | err.tips = '上传媒体文件失败' 751 | throw err 752 | }) 753 | } 754 | 755 | sendPic (mediaId, to) { 756 | return Promise.resolve().then(() => { 757 | let params = { 758 | 'pass_ticket': this.PROP.passTicket, 759 | 'fun': 'async', 760 | 'f': 'json', 761 | 'lang': 'zh_CN' 762 | } 763 | let clientMsgId = getClientMsgId() 764 | let data = { 765 | 'BaseRequest': this.getBaseRequest(), 766 | 'Scene': 0, 767 | 'Msg': { 768 | 'Type': this.CONF.MSGTYPE_IMAGE, 769 | 'MediaId': mediaId, 770 | 'FromUserName': this.user.UserName, 771 | 'ToUserName': to, 772 | 'LocalID': clientMsgId, 773 | 'ClientMsgId': clientMsgId 774 | } 775 | } 776 | return this.request({ 777 | method: 'POST', 778 | url: this.CONF.API_webwxsendmsgimg, 779 | params: params, 780 | data: data 781 | }).then(res => { 782 | let data = res.data 783 | assert.equal(data.BaseResponse.Ret, 0, res) 784 | return data 785 | }) 786 | }).catch(err => { 787 | debug(err) 788 | err.tips = '发送图片失败' 789 | throw err 790 | }) 791 | } 792 | 793 | sendVideo (mediaId, to) { 794 | return Promise.resolve().then(() => { 795 | let params = { 796 | 'pass_ticket': this.PROP.passTicket, 797 | 'fun': 'async', 798 | 'f': 'json', 799 | 'lang': 'zh_CN' 800 | } 801 | let clientMsgId = getClientMsgId() 802 | let data = { 803 | 'BaseRequest': this.getBaseRequest(), 804 | 'Scene': 0, 805 | 'Msg': { 806 | 'Type': this.CONF.MSGTYPE_VIDEO, 807 | 'MediaId': mediaId, 808 | 'FromUserName': this.user.UserName, 809 | 'ToUserName': to, 810 | 'LocalID': clientMsgId, 811 | 'ClientMsgId': clientMsgId 812 | } 813 | } 814 | return this.request({ 815 | method: 'POST', 816 | url: this.CONF.API_webwxsendmsgvedio, 817 | params: params, 818 | data: data 819 | }).then(res => { 820 | let data = res.data 821 | assert.equal(data.BaseResponse.Ret, 0, res) 822 | return data 823 | }) 824 | }).catch(err => { 825 | debug(err) 826 | err.tips = '发送视频失败' 827 | throw err 828 | }) 829 | } 830 | 831 | sendDoc (mediaId, name, size, ext, to) { 832 | return Promise.resolve().then(() => { 833 | let params = { 834 | 'pass_ticket': this.PROP.passTicket, 835 | 'fun': 'async', 836 | 'f': 'json', 837 | 'lang': 'zh_CN' 838 | } 839 | let clientMsgId = getClientMsgId() 840 | let data = { 841 | 'BaseRequest': this.getBaseRequest(), 842 | 'Scene': 0, 843 | 'Msg': { 844 | 'Type': this.CONF.APPMSGTYPE_ATTACH, 845 | 'Content': `${name}6${size}${mediaId}${ext}`, 846 | 'FromUserName': this.user.UserName, 847 | 'ToUserName': to, 848 | 'LocalID': clientMsgId, 849 | 'ClientMsgId': clientMsgId 850 | } 851 | } 852 | return this.request({ 853 | method: 'POST', 854 | url: this.CONF.API_webwxsendappmsg, 855 | params: params, 856 | data: data 857 | }).then(res => { 858 | let data = res.data 859 | assert.equal(data.BaseResponse.Ret, 0, res) 860 | return data 861 | }) 862 | }).catch(err => { 863 | debug(err) 864 | err.tips = '发送文件失败' 865 | throw err 866 | }) 867 | } 868 | 869 | forwardMsg (msg, to) { 870 | return Promise.resolve().then(() => { 871 | let params = { 872 | 'pass_ticket': this.PROP.passTicket, 873 | 'fun': 'async', 874 | 'f': 'json', 875 | 'lang': 'zh_CN' 876 | } 877 | let clientMsgId = getClientMsgId() 878 | let data = { 879 | 'BaseRequest': this.getBaseRequest(), 880 | 'Scene': 2, 881 | 'Msg': { 882 | 'Type': msg.MsgType, 883 | 'MediaId': '', 884 | 'Content': msg.Content.replace(/</g, '<').replace(/>/g, '>').replace(/^.*:\n/, ''), 885 | 'FromUserName': this.user.UserName, 886 | 'ToUserName': to, 887 | 'LocalID': clientMsgId, 888 | 'ClientMsgId': clientMsgId 889 | } 890 | } 891 | let url 892 | switch (msg.MsgType) { 893 | case this.CONF.MSGTYPE_TEXT: 894 | url = this.CONF.API_webwxsendmsg 895 | if (msg.SubMsgType === this.CONF.MSGTYPE_LOCATION) { 896 | data.Msg.Type = this.CONF.MSGTYPE_LOCATION 897 | data.Msg.Content = msg.OriContent.replace(/</g, '<').replace(/>/g, '>') 898 | } 899 | break 900 | case this.CONF.MSGTYPE_IMAGE: 901 | url = this.CONF.API_webwxsendmsgimg 902 | break 903 | case this.CONF.MSGTYPE_EMOTICON: 904 | url = this.CONF.API_webwxsendemoticon 905 | params.fun = 'sys' 906 | data.Msg.EMoticonMd5 = msg.Content.replace(/^[\s\S]*?md5\s?=\s?"(.*?)"[\s\S]*?$/, '$1') 907 | if (!data.Msg.EMoticonMd5) { 908 | throw new Error('商店表情不能转发') 909 | } 910 | data.Msg.EmojiFlag = 2 911 | data.Scene = 0 912 | delete data.Msg.MediaId 913 | delete data.Msg.Content 914 | break 915 | case this.CONF.MSGTYPE_MICROVIDEO: 916 | case this.CONF.MSGTYPE_VIDEO: 917 | url = this.CONF.API_webwxsendmsgvedio 918 | data.Msg.Type = this.CONF.MSGTYPE_VIDEO 919 | break 920 | case this.CONF.MSGTYPE_APP: 921 | url = this.CONF.API_webwxsendappmsg 922 | data.Msg.Type = msg.AppMsgType 923 | data.Msg.Content = data.Msg.Content.replace( 924 | /^[\s\S]*?()[\s\S]*?(<\/attachid>[\s\S]*?<\/appmsg>)[\s\S]*?$/, 925 | `$1${msg.MediaId}$2`) 926 | break 927 | default: 928 | throw new Error('该消息类型不能直接转发') 929 | } 930 | return this.request({ 931 | method: 'POST', 932 | url: url, 933 | params: params, 934 | data: data 935 | }).then(res => { 936 | let data = res.data 937 | assert.equal(data.BaseResponse.Ret, 0, res) 938 | return data 939 | }) 940 | }).catch(err => { 941 | debug(err) 942 | err.tips = '转发消息失败' 943 | throw err 944 | }) 945 | } 946 | 947 | getMsgImg (msgId) { 948 | return Promise.resolve().then(() => { 949 | let params = { 950 | MsgID: msgId, 951 | skey: this.PROP.skey, 952 | type: 'big' 953 | } 954 | 955 | return this.request({ 956 | method: 'GET', 957 | url: this.CONF.API_webwxgetmsgimg, 958 | params: params, 959 | responseType: 'arraybuffer' 960 | }).then(res => { 961 | return { 962 | data: res.data, 963 | type: res.headers['content-type'] 964 | } 965 | }) 966 | }).catch(err => { 967 | debug(err) 968 | err.tips = '获取图片或表情失败' 969 | throw err 970 | }) 971 | } 972 | 973 | getVideo (msgId) { 974 | return Promise.resolve().then(() => { 975 | let params = { 976 | MsgID: msgId, 977 | skey: this.PROP.skey 978 | } 979 | 980 | return this.request({ 981 | method: 'GET', 982 | url: this.CONF.API_webwxgetvideo, 983 | headers: { 984 | 'Range': 'bytes=0-' 985 | }, 986 | params: params, 987 | responseType: 'arraybuffer' 988 | }).then(res => { 989 | return { 990 | data: res.data, 991 | type: res.headers['content-type'] 992 | } 993 | }) 994 | }).catch(err => { 995 | debug(err) 996 | err.tips = '获取视频失败' 997 | throw err 998 | }) 999 | } 1000 | 1001 | getVoice (msgId) { 1002 | return Promise.resolve().then(() => { 1003 | let params = { 1004 | MsgID: msgId, 1005 | skey: this.PROP.skey 1006 | } 1007 | 1008 | return this.request({ 1009 | method: 'GET', 1010 | url: this.CONF.API_webwxgetvoice, 1011 | params: params, 1012 | responseType: 'arraybuffer' 1013 | }).then(res => { 1014 | return { 1015 | data: res.data, 1016 | type: res.headers['content-type'] 1017 | } 1018 | }) 1019 | }).catch(err => { 1020 | debug(err) 1021 | err.tips = '获取声音失败' 1022 | throw err 1023 | }) 1024 | } 1025 | 1026 | getHeadImg (HeadImgUrl) { 1027 | return Promise.resolve().then(() => { 1028 | let url = this.CONF.origin + HeadImgUrl 1029 | return this.request({ 1030 | method: 'GET', 1031 | url: url, 1032 | responseType: 'arraybuffer' 1033 | }).then(res => { 1034 | return { 1035 | data: res.data, 1036 | type: res.headers['content-type'] 1037 | } 1038 | }) 1039 | }).catch(err => { 1040 | debug(err) 1041 | err.tips = '获取头像失败' 1042 | throw err 1043 | }) 1044 | } 1045 | 1046 | getDoc (FromUserName, MediaId, FileName) { 1047 | return Promise.resolve().then(() => { 1048 | let params = { 1049 | sender: FromUserName, 1050 | mediaid: MediaId, 1051 | filename: FileName, 1052 | fromuser: this.user.UserName, 1053 | pass_ticket: this.PROP.passTicket, 1054 | webwx_data_ticket: this.PROP.webwxDataTicket 1055 | } 1056 | return this.request({ 1057 | method: 'GET', 1058 | url: this.CONF.API_webwxdownloadmedia, 1059 | params: params, 1060 | responseType: 'arraybuffer' 1061 | }).then(res => { 1062 | return { 1063 | data: res.data, 1064 | type: res.headers['content-type'] 1065 | } 1066 | }) 1067 | }).catch(err => { 1068 | debug(err) 1069 | err.tips = '获取文件失败' 1070 | throw err 1071 | }) 1072 | } 1073 | 1074 | verifyUser (UserName, Ticket) { 1075 | return Promise.resolve().then(() => { 1076 | let params = { 1077 | 'pass_ticket': this.PROP.passTicket, 1078 | 'lang': 'zh_CN' 1079 | } 1080 | let data = { 1081 | 'BaseRequest': this.getBaseRequest(), 1082 | 'Opcode': 3, 1083 | 'VerifyUserListSize': 1, 1084 | 'VerifyUserList': [{ 1085 | 'Value': UserName, 1086 | 'VerifyUserTicket': Ticket 1087 | }], 1088 | 'VerifyContent': '', 1089 | 'SceneListCount': 1, 1090 | 'SceneList': [33], 1091 | 'skey': this.PROP.skey 1092 | } 1093 | return this.request({ 1094 | method: 'POST', 1095 | url: this.CONF.API_webwxverifyuser, 1096 | params: params, 1097 | data: data 1098 | }).then(res => { 1099 | let data = res.data 1100 | assert.equal(data.BaseResponse.Ret, 0, res) 1101 | return data 1102 | }) 1103 | }).catch(err => { 1104 | debug(err) 1105 | err.tips = '通过好友请求失败' 1106 | throw err 1107 | }) 1108 | } 1109 | 1110 | /** 1111 | * 添加好友 1112 | * @param UserName 待添加用户的UserName 1113 | * @param content 1114 | * @returns {Promise.} 1115 | */ 1116 | addFriend (UserName, content = '我是' + this.user.NickName) { 1117 | let params = { 1118 | 'pass_ticket': this.PROP.passTicket, 1119 | 'lang': 'zh_CN' 1120 | } 1121 | 1122 | let data = { 1123 | 'BaseRequest': this.getBaseRequest(), 1124 | 'Opcode': 2, 1125 | 'VerifyUserListSize': 1, 1126 | 'VerifyUserList': [{ 1127 | 'Value': UserName, 1128 | 'VerifyUserTicket': '' 1129 | }], 1130 | 'VerifyContent': content, 1131 | 'SceneListCount': 1, 1132 | 'SceneList': [33], 1133 | 'skey': this.PROP.skey 1134 | } 1135 | 1136 | return this.request({ 1137 | method: 'POST', 1138 | url: this.CONF.API_webwxverifyuser, 1139 | params: params, 1140 | data: data 1141 | }).then(res => { 1142 | let data = res.data 1143 | assert.equal(data.BaseResponse.Ret, 0, res) 1144 | return data 1145 | }).catch(err => { 1146 | debug(err) 1147 | err.tips = '添加好友失败' 1148 | throw err 1149 | }) 1150 | } 1151 | 1152 | // Topic: Chatroom name 1153 | // MemberList format: 1154 | // [ 1155 | // {"UserName":"@250d8d156ad9f8b068c2e3df3464ecf2"}, 1156 | // {"UserName":"@42d725733741de6ac53cbe3738d8dd2e"} 1157 | // ] 1158 | createChatroom (Topic, MemberList) { 1159 | return Promise.resolve().then(() => { 1160 | let params = { 1161 | 'pass_ticket': this.PROP.passTicket, 1162 | 'lang': 'zh_CN', 1163 | 'r': ~new Date() 1164 | } 1165 | let data = { 1166 | BaseRequest: this.getBaseRequest(), 1167 | MemberCount: MemberList.length, 1168 | MemberList: MemberList, 1169 | Topic: Topic 1170 | } 1171 | return this.request({ 1172 | method: 'POST', 1173 | url: this.CONF.API_webwxcreatechatroom, 1174 | params: params, 1175 | data: data 1176 | }).then(res => { 1177 | let data = res.data 1178 | assert.equal(data.BaseResponse.Ret, 0, res) 1179 | return data 1180 | }) 1181 | }).catch(err => { 1182 | debug(err) 1183 | err.tips = '创建群失败' 1184 | throw err 1185 | }) 1186 | } 1187 | 1188 | // fun: 'addmember' or 'delmember' or 'invitemember' 1189 | updateChatroom (ChatRoomUserName, MemberList, fun) { 1190 | return Promise.resolve().then(() => { 1191 | let params = { 1192 | fun: fun 1193 | } 1194 | let data = { 1195 | BaseRequest: this.getBaseRequest(), 1196 | ChatRoomName: ChatRoomUserName 1197 | } 1198 | if (fun === 'addmember') { 1199 | data.AddMemberList = MemberList.toString() 1200 | } else if (fun === 'delmember') { 1201 | data.DelMemberList = MemberList.toString() 1202 | } else if (fun === 'invitemember') { 1203 | data.InviteMemberList = MemberList.toString() 1204 | } 1205 | return this.request({ 1206 | method: 'POST', 1207 | url: this.CONF.API_webwxupdatechatroom, 1208 | params: params, 1209 | data: data 1210 | }).then(res => { 1211 | let data = res.data 1212 | assert.equal(data.BaseResponse.Ret, 0, res) 1213 | return data 1214 | }) 1215 | }).catch(err => { 1216 | debug(err) 1217 | err.tips = '邀请或踢出群成员失败' 1218 | throw err 1219 | }) 1220 | } 1221 | 1222 | // OP: 1 联系人置顶 0 取消置顶 1223 | // 若不传RemarkName,则会覆盖以设置的联系人备注名 1224 | opLog (UserName, OP, RemarkName) { 1225 | return Promise.resolve().then(() => { 1226 | let params = { 1227 | pass_ticket: this.PROP.passTicket 1228 | } 1229 | let data = { 1230 | BaseRequest: this.getBaseRequest(), 1231 | CmdId: 3, 1232 | OP: OP, 1233 | RemarkName: RemarkName, 1234 | UserName: UserName 1235 | } 1236 | return this.request({ 1237 | method: 'POST', 1238 | url: this.CONF.API_webwxoplog, 1239 | params: params, 1240 | data: data 1241 | }).then(res => { 1242 | let data = res.data 1243 | assert.equal(data.BaseResponse.Ret, 0, res) 1244 | return data 1245 | }) 1246 | }).catch(err => { 1247 | debug(err) 1248 | err.tips = '置顶或取消置顶失败' 1249 | throw err 1250 | }) 1251 | } 1252 | 1253 | updateRemarkName (UserName, RemarkName) { 1254 | return Promise.resolve().then(() => { 1255 | let params = { 1256 | pass_ticket: this.PROP.passTicket, 1257 | 'lang': 'zh_CN' 1258 | } 1259 | let data = { 1260 | BaseRequest: this.getBaseRequest(), 1261 | CmdId: 2, 1262 | RemarkName: RemarkName, 1263 | UserName: UserName 1264 | } 1265 | return this.request({ 1266 | method: 'POST', 1267 | url: this.CONF.API_webwxoplog, 1268 | params: params, 1269 | data: data 1270 | }).then(res => { 1271 | let data = res.data 1272 | assert.equal(data.BaseResponse.Ret, 0, res) 1273 | return data 1274 | }) 1275 | }).catch(err => { 1276 | debug(err) 1277 | err.tips = '设置用户标签失败' 1278 | throw err 1279 | }) 1280 | } 1281 | 1282 | updateChatRoomName (ChatRoomUserName, NewName) { 1283 | return Promise.resolve().then(() => { 1284 | let params = { 1285 | 'fun': 'modtopic' 1286 | } 1287 | let data = { 1288 | BaseRequest: this.getBaseRequest(), 1289 | ChatRoomName: ChatRoomUserName, 1290 | NewTopic: NewName 1291 | } 1292 | return this.request({ 1293 | method: 'POST', 1294 | url: this.CONF.API_webwxupdatechatroom, 1295 | params: params, 1296 | data: data 1297 | }).then(res => { 1298 | let data = res.data 1299 | assert.equal(data.BaseResponse.Ret, 0, res) 1300 | }) 1301 | }).catch(err => { 1302 | debug(err) 1303 | throw new Error('更新群名失败') 1304 | }) 1305 | } 1306 | 1307 | revokeMsg (msgId, toUserName) { 1308 | return Promise.resolve().then(() => { 1309 | let data = { 1310 | BaseRequest: this.getBaseRequest(), 1311 | SvrMsgId: msgId, 1312 | ToUserName: toUserName, 1313 | ClientMsgId: getClientMsgId() 1314 | } 1315 | let headers = {} 1316 | headers['ContentType'] = 'application/json; charset=UTF-8' 1317 | headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.71 Safari/537.36' 1318 | return this.request({ 1319 | method: 'POST', 1320 | url: this.CONF.API_webwxrevokemsg, 1321 | data: data, 1322 | headers: headers 1323 | }).then(res => { 1324 | let data = res.data 1325 | assert.equal(data.BaseResponse.Ret, 0, res) 1326 | return data 1327 | }) 1328 | }).catch(err => { 1329 | debug(err) 1330 | throw new Error('撤回消息失败') 1331 | }) 1332 | } 1333 | 1334 | getBaseRequest () { 1335 | return { 1336 | Uin: parseInt(this.PROP.uin), 1337 | Sid: this.PROP.sid, 1338 | Skey: this.PROP.skey, 1339 | DeviceID: getDeviceID() 1340 | } 1341 | } 1342 | } 1343 | -------------------------------------------------------------------------------- /src/interface/contact.js: -------------------------------------------------------------------------------- 1 | import { 2 | convertEmoji, 3 | getCONF 4 | } from '../util' 5 | const CONF = getCONF() 6 | 7 | /* Contact Object Example 8 | { 9 | "Uin": 0, 10 | "UserName": "", 11 | "NickName": "", 12 | "HeadImgUrl": "", 13 | "ContactFlag": 3, 14 | "MemberCount": 0, 15 | "MemberList": [], 16 | "RemarkName": "", 17 | "HideInputBarFlag": 0, 18 | "Sex": 0, 19 | "Signature": "", 20 | "VerifyFlag": 8, 21 | "OwnerUin": 0, 22 | "PYInitial": "", 23 | "PYQuanPin": "", 24 | "RemarkPYInitial": "", 25 | "RemarkPYQuanPin": "", 26 | "StarFriend": 0, 27 | "AppAccountFlag": 0, 28 | "Statues": 0, 29 | "AttrStatus": 0, 30 | "Province": "", 31 | "City": "", 32 | "Alias": "Urinxs", 33 | "SnsFlag": 0, 34 | "UniFriend": 0, 35 | "DisplayName": "", 36 | "ChatRoomId": 0, 37 | "KeyWord": "gh_", 38 | "EncryChatRoomId": "" 39 | } 40 | */ 41 | export function getUserByUserName (memberList, UserName) { 42 | if (!memberList.length) return null 43 | 44 | return memberList.find(contact => contact.UserName === UserName) 45 | } 46 | 47 | export function getDisplayName (contact) { 48 | if (isRoomContact(contact)) { 49 | if (contact.MemberCount >= 2) { 50 | return '[群] ' + (contact.RemarkName || contact.DisplayName || contact.NickName || 51 | `${getDisplayName(contact.MemberList[0])}、${getDisplayName(contact.MemberList[1])}`) 52 | } else { 53 | return '[群] ' + (contact.RemarkName || contact.DisplayName || contact.NickName || 54 | `${getDisplayName(contact.MemberList[0])}`) 55 | } 56 | } else { 57 | return contact.DisplayName || contact.RemarkName || contact.NickName || contact.UserName 58 | } 59 | } 60 | 61 | export function isRoomContact (contact) { 62 | return contact.UserName ? /^@@|@chatroom$/.test(contact.UserName) : false 63 | } 64 | 65 | export function isSpContact (contact) { 66 | return CONF.SPECIALUSERS.indexOf(contact.UserName) >= 0 67 | } 68 | 69 | export function isPublicContact (contact) { 70 | return contact.VerifyFlag & CONF.MM_USERATTRVERIFYFALG_BIZ_BRAND 71 | } 72 | 73 | const contactProto = { 74 | init: function (instance) { 75 | // 纠正错误以后保持兼容 76 | this.OriginalNickName = this.OrignalNickName = this.NickName 77 | this.OriginalRemarkName = this.OrignalRemarkName = this.RemarkName 78 | this.OriginalDisplayName = this.OrignalDisplayName = this.DisplayName 79 | this.NickName = convertEmoji(this.NickName) 80 | this.RemarkName = convertEmoji(this.RemarkName) 81 | this.DisplayName = convertEmoji(this.DisplayName) 82 | this.isSelf = this.UserName === instance.user.UserName 83 | 84 | return this 85 | }, 86 | getDisplayName: function () { 87 | return getDisplayName(this) 88 | }, 89 | canSearch: function (keyword) { 90 | if (!keyword) return false 91 | keyword = keyword.toUpperCase() 92 | 93 | let isSatisfy = key => (key || '').toUpperCase().indexOf(keyword) >= 0 94 | return ( 95 | isSatisfy(this.RemarkName) || 96 | isSatisfy(this.RemarkPYQuanPin) || 97 | isSatisfy(this.NickName) || 98 | isSatisfy(this.PYQuanPin) || 99 | isSatisfy(this.Alias) || 100 | isSatisfy(this.KeyWord) 101 | ) 102 | } 103 | } 104 | 105 | export default function ContactFactory (instance) { 106 | return { 107 | extend: function (contactObj) { 108 | contactObj = Object.setPrototypeOf(contactObj, contactProto) 109 | return contactObj.init(instance) 110 | }, 111 | getUserByUserName: function (UserName) { 112 | return instance.contacts[UserName] 113 | }, 114 | getSearchUser: function (keyword) { 115 | let users = [] 116 | for (let key in instance.contacts) { 117 | if (instance.contacts[key].canSearch(keyword)) { 118 | users.push(instance.contacts[key]) 119 | } 120 | } 121 | return users 122 | }, 123 | isSelf: function (contact) { 124 | return contact.isSelf || contact.UserName === instance.user.UserName 125 | }, 126 | getDisplayName, 127 | isRoomContact, 128 | isPublicContact, 129 | isSpContact 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/interface/message.js: -------------------------------------------------------------------------------- 1 | import {convertEmoji, formatNum} from '../util' 2 | /* Message Object Example 3 | { 4 | "FromUserName": "", 5 | "ToUserName": "", 6 | "Content": "", 7 | "StatusNotifyUserName": "", 8 | "ImgWidth": 0, 9 | "PlayLength": 0, 10 | "RecommendInfo": {}, 11 | "StatusNotifyCode": 4, 12 | "NewMsgId": "", 13 | "Status": 3, 14 | "VoiceLength": 0, 15 | "ForwardFlag": 0, 16 | "AppMsgType": 0, 17 | "Ticket": "", 18 | "AppInfo": {...}, 19 | "Url": "", 20 | "ImgStatus": 1, 21 | "MsgType": 1, 22 | "ImgHeight": 0, 23 | "MediaId": "", 24 | "MsgId": "", 25 | "FileName": "", 26 | "HasProductId": 0, 27 | "FileSize": "", 28 | "CreateTime": 0, 29 | "SubMsgType": 0 30 | } 31 | */ 32 | 33 | const messageProto = { 34 | init: function (instance) { 35 | this.MsgType = +this.MsgType 36 | this.isSendBySelf = this.FromUserName === instance.user.UserName || this.FromUserName === '' 37 | 38 | this.OriginalContent = this.Content 39 | if (this.FromUserName.indexOf('@@') === 0) { 40 | this.Content = this.Content.replace(/^@.*?(?=:)/, match => { 41 | let user = instance.contacts[this.FromUserName].MemberList.find(member => { 42 | return member.UserName === match 43 | }) 44 | return user ? instance.Contact.getDisplayName(user) : match 45 | }) 46 | } 47 | 48 | this.Content = this.Content.replace(/</g, '<').replace(/>/g, '>').replace(//g, '\n') 49 | this.Content = convertEmoji(this.Content) 50 | 51 | return this 52 | }, 53 | isSendBy: function (contact) { 54 | return this.FromUserName === contact.UserName 55 | }, 56 | getPeerUserName: function () { 57 | return this.isSendBySelf ? this.ToUserName : this.FromUserName 58 | }, 59 | getDisplayTime: function () { 60 | var time = new Date(1e3 * this.CreateTime) 61 | return time.getHours() + ':' + formatNum(time.getMinutes(), 2) 62 | } 63 | } 64 | 65 | export default function MessageFactory (instance) { 66 | return { 67 | extend: function (messageObj) { 68 | messageObj = Object.setPrototypeOf(messageObj, messageProto) 69 | return messageObj.init(instance) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/util/conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | export const CONF = { 4 | LANG: 'zh-CN', 5 | EMOTICON_REG: 'img\\sclass="(qq)?emoji (qq)?emoji([\\da-f]*?)"\\s(text="[^<>(\\s]*")?\\s?src="[^<>(\\s]*"\\s*', 6 | RES_PATH: '/zh_CN/htmledition/v2/', 7 | oplogCmdId: { 8 | TOPCONTACT: 3, 9 | MODREMARKNAME: 2 10 | }, 11 | SP_CONTACT_FILE_HELPER: 'filehelper', 12 | SP_CONTACT_NEWSAPP: 'newsapp', 13 | SP_CONTACT_RECOMMEND_HELPER: 'fmessage', 14 | CONTACTFLAG_CONTACT: 1, 15 | CONTACTFLAG_CHATCONTACT: 2, 16 | CONTACTFLAG_CHATROOMCONTACT: 4, 17 | CONTACTFLAG_BLACKLISTCONTACT: 8, 18 | CONTACTFLAG_DOMAINCONTACT: 16, 19 | CONTACTFLAG_HIDECONTACT: 32, 20 | CONTACTFLAG_FAVOURCONTACT: 64, 21 | CONTACTFLAG_3RDAPPCONTACT: 128, 22 | CONTACTFLAG_SNSBLACKLISTCONTACT: 256, 23 | CONTACTFLAG_NOTIFYCLOSECONTACT: 512, 24 | CONTACTFLAG_TOPCONTACT: 2048, 25 | MM_USERATTRVERIFYFALG_BIZ: 1, 26 | MM_USERATTRVERIFYFALG_FAMOUS: 2, 27 | MM_USERATTRVERIFYFALG_BIZ_BIG: 4, 28 | MM_USERATTRVERIFYFALG_BIZ_BRAND: 8, 29 | MM_USERATTRVERIFYFALG_BIZ_VERIFIED: 16, 30 | MM_DATA_TEXT: 1, 31 | MM_DATA_HTML: 2, 32 | MM_DATA_IMG: 3, 33 | MM_DATA_PRIVATEMSG_TEXT: 11, 34 | MM_DATA_PRIVATEMSG_HTML: 12, 35 | MM_DATA_PRIVATEMSG_IMG: 13, 36 | MM_DATA_VOICEMSG: 34, 37 | MM_DATA_PUSHMAIL: 35, 38 | MM_DATA_QMSG: 36, 39 | MM_DATA_VERIFYMSG: 37, 40 | MM_DATA_PUSHSYSTEMMSG: 38, 41 | MM_DATA_QQLIXIANMSG_IMG: 39, 42 | MM_DATA_POSSIBLEFRIEND_MSG: 40, 43 | MM_DATA_SHARECARD: 42, 44 | MM_DATA_VIDEO: 43, 45 | MM_DATA_VIDEO_IPHONE_EXPORT: 44, 46 | MM_DATA_EMOJI: 47, 47 | MM_DATA_LOCATION: 48, 48 | MM_DATA_APPMSG: 49, 49 | MM_DATA_VOIPMSG: 50, 50 | MM_DATA_STATUSNOTIFY: 51, 51 | MM_DATA_VOIPNOTIFY: 52, 52 | MM_DATA_VOIPINVITE: 53, 53 | MM_DATA_MICROVIDEO: 62, 54 | MM_DATA_SYSNOTICE: 9999, 55 | MM_DATA_SYS: 1e4, 56 | MM_DATA_RECALLED: 10002, 57 | MSGTYPE_TEXT: 1, 58 | MSGTYPE_IMAGE: 3, 59 | MSGTYPE_VOICE: 34, 60 | MSGTYPE_VIDEO: 43, 61 | MSGTYPE_MICROVIDEO: 62, 62 | MSGTYPE_EMOTICON: 47, 63 | MSGTYPE_APP: 49, 64 | MSGTYPE_VOIPMSG: 50, 65 | MSGTYPE_VOIPNOTIFY: 52, 66 | MSGTYPE_VOIPINVITE: 53, 67 | MSGTYPE_LOCATION: 48, 68 | MSGTYPE_STATUSNOTIFY: 51, 69 | MSGTYPE_SYSNOTICE: 9999, 70 | MSGTYPE_POSSIBLEFRIEND_MSG: 40, 71 | MSGTYPE_VERIFYMSG: 37, 72 | MSGTYPE_SHARECARD: 42, 73 | MSGTYPE_SYS: 1e4, 74 | MSGTYPE_RECALLED: 10002, 75 | MSG_SEND_STATUS_READY: 0, 76 | MSG_SEND_STATUS_SENDING: 1, 77 | MSG_SEND_STATUS_SUCC: 2, 78 | MSG_SEND_STATUS_FAIL: 5, 79 | APPMSGTYPE_TEXT: 1, 80 | APPMSGTYPE_IMG: 2, 81 | APPMSGTYPE_AUDIO: 3, 82 | APPMSGTYPE_VIDEO: 4, 83 | APPMSGTYPE_URL: 5, 84 | APPMSGTYPE_ATTACH: 6, 85 | APPMSGTYPE_OPEN: 7, 86 | APPMSGTYPE_EMOJI: 8, 87 | APPMSGTYPE_VOICE_REMIND: 9, 88 | APPMSGTYPE_SCAN_GOOD: 10, 89 | APPMSGTYPE_GOOD: 13, 90 | APPMSGTYPE_EMOTION: 15, 91 | APPMSGTYPE_CARD_TICKET: 16, 92 | APPMSGTYPE_REALTIME_SHARE_LOCATION: 17, 93 | APPMSGTYPE_TRANSFERS: 2e3, 94 | APPMSGTYPE_RED_ENVELOPES: 2001, 95 | APPMSGTYPE_READER_TYPE: 100001, 96 | UPLOAD_MEDIA_TYPE_IMAGE: 1, 97 | UPLOAD_MEDIA_TYPE_VIDEO: 2, 98 | UPLOAD_MEDIA_TYPE_AUDIO: 3, 99 | UPLOAD_MEDIA_TYPE_ATTACHMENT: 4, 100 | PROFILE_BITFLAG_NOCHANGE: 0, 101 | PROFILE_BITFLAG_CHANGE: 190, 102 | CHATROOM_NOTIFY_OPEN: 1, 103 | CHATROOM_NOTIFY_CLOSE: 0, 104 | StatusNotifyCode_READED: 1, 105 | StatusNotifyCode_ENTER_SESSION: 2, 106 | StatusNotifyCode_INITED: 3, 107 | StatusNotifyCode_SYNC_CONV: 4, 108 | StatusNotifyCode_QUIT_SESSION: 5, 109 | VERIFYUSER_OPCODE_ADDCONTACT: 1, 110 | VERIFYUSER_OPCODE_SENDREQUEST: 2, 111 | VERIFYUSER_OPCODE_VERIFYOK: 3, 112 | VERIFYUSER_OPCODE_VERIFYREJECT: 4, 113 | VERIFYUSER_OPCODE_SENDERREPLY: 5, 114 | VERIFYUSER_OPCODE_RECVERREPLY: 6, 115 | ADDSCENE_PF_QQ: 4, 116 | ADDSCENE_PF_EMAIL: 5, 117 | ADDSCENE_PF_CONTACT: 6, 118 | ADDSCENE_PF_WEIXIN: 7, 119 | ADDSCENE_PF_GROUP: 8, 120 | ADDSCENE_PF_UNKNOWN: 9, 121 | ADDSCENE_PF_MOBILE: 10, 122 | ADDSCENE_PF_WEB: 33, 123 | TIMEOUT_SYNC_CHECK: 0, 124 | EMOJI_FLAG_GIF: 2, 125 | KEYCODE_BACKSPACE: 8, 126 | KEYCODE_ENTER: 13, 127 | KEYCODE_SHIFT: 16, 128 | KEYCODE_ESC: 27, 129 | KEYCODE_DELETE: 34, 130 | KEYCODE_ARROW_LEFT: 37, 131 | KEYCODE_ARROW_UP: 38, 132 | KEYCODE_ARROW_RIGHT: 39, 133 | KEYCODE_ARROW_DOWN: 40, 134 | KEYCODE_NUM2: 50, 135 | KEYCODE_AT: 64, 136 | KEYCODE_NUM_ADD: 107, 137 | KEYCODE_NUM_MINUS: 109, 138 | KEYCODE_ADD: 187, 139 | KEYCODE_MINUS: 189, 140 | MM_NOTIFY_CLOSE: 0, 141 | MM_NOTIFY_OPEN: 1, 142 | MM_SOUND_CLOSE: 0, 143 | MM_SOUND_OPEN: 1, 144 | MM_SEND_FILE_STATUS_QUEUED: 0, 145 | MM_SEND_FILE_STATUS_SENDING: 1, 146 | MM_SEND_FILE_STATUS_SUCCESS: 2, 147 | MM_SEND_FILE_STATUS_FAIL: 3, 148 | MM_SEND_FILE_STATUS_CANCEL: 4, 149 | MM_EMOTICON_WEB: '_web', 150 | 151 | SYNCCHECK_RET_SUCCESS: 0, 152 | SYNCCHECK_RET_LOGOUT: 1101, 153 | SYNCCHECK_SELECTOR_NORMAL: 0, 154 | SYNCCHECK_SELECTOR_MSG: 2, 155 | SYNCCHECK_SELECTOR_MOBILEOPEN: 7, 156 | STATE: { 157 | init: 'init', 158 | uuid: 'uuid', 159 | login: 'login', 160 | logout: 'logout' 161 | }, 162 | SPECIALUSERS: ['newsapp', 'fmessage', 'filehelper', 'weibo', 'qqmail', 'fmessage', 'tmessage', 163 | 'qmessage', 'qqsync', 'floatbottle', 'lbsapp', 'shakeapp', 'medianote', 'qqfriend', 164 | 'readerapp', 'blogapp', 'facebookapp', 'masssendapp', 'meishiapp', 'feedsapp', 'voip', 165 | 'blogappweixin', 'weixin', 'brandsessionholder', 'weixinreminder', 'wxid_novlwrv3lqwv11', 166 | 'gh_22b87fa7cb3c', 'officialaccounts', 'notification_messages', 'wxid_novlwrv3lqwv11', 167 | 'gh_22b87fa7cb3c', 'wxitil', 'userexperience_alarm', 'notification_messages' 168 | ] 169 | } 170 | 171 | export function getCONF (host) { 172 | host = host || 'wx.qq.com' 173 | let origin = `https://${host}` 174 | let loginUrl = 'login.wx.qq.com' 175 | let fileUrl = 'file.wx.qq.com' 176 | let pushUrl = 'webpush.weixin.qq.com' 177 | let matchResult = host.match(/(\w+)(.qq.com|.wechat.com)/) 178 | if (matchResult && matchResult[1] && matchResult[2]) { 179 | let prefix = matchResult[1] 180 | let suffix = matchResult[2] 181 | if (suffix === '.qq.com') { 182 | prefix = ~['wx', 'wx2', 'wx8'].indexOf(prefix) ? prefix : 'wx' 183 | } else { 184 | prefix = ~['web', 'web2'].indexOf(prefix) ? prefix : 'web' 185 | } 186 | loginUrl = `login.${prefix}${suffix}` 187 | fileUrl = `file.${prefix}${suffix}` 188 | pushUrl = `webpush.${prefix}${suffix}` 189 | } 190 | let conf = {} 191 | conf.origin = origin 192 | conf.baseUri = origin + '/cgi-bin/mmwebwx-bin' 193 | conf.API_jsLogin = 'https://' + loginUrl + '/jslogin?appid=wx782c26e4c19acffb&fun=new&lang=zh-CN&redirect_uri=https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?mod=desktop' 194 | conf.API_login = 'https://' + loginUrl + '/cgi-bin/mmwebwx-bin/login' 195 | conf.API_synccheck = 'https://' + pushUrl + '/cgi-bin/mmwebwx-bin/synccheck' 196 | conf.API_webwxdownloadmedia = 'https://' + fileUrl + '/cgi-bin/mmwebwx-bin/webwxgetmedia' 197 | conf.API_webwxuploadmedia = 'https://' + fileUrl + '/cgi-bin/mmwebwx-bin/webwxuploadmedia' 198 | conf.API_webwxpreview = origin + '/cgi-bin/mmwebwx-bin/webwxpreview' 199 | conf.API_webwxinit = origin + '/cgi-bin/mmwebwx-bin/webwxinit' 200 | conf.API_webwxgetcontact = origin + '/cgi-bin/mmwebwx-bin/webwxgetcontact' 201 | conf.API_webwxsync = origin + '/cgi-bin/mmwebwx-bin/webwxsync' 202 | conf.API_webwxbatchgetcontact = origin + '/cgi-bin/mmwebwx-bin/webwxbatchgetcontact' 203 | conf.API_webwxgeticon = origin + '/cgi-bin/mmwebwx-bin/webwxgeticon' 204 | conf.API_webwxsendmsg = origin + '/cgi-bin/mmwebwx-bin/webwxsendmsg' 205 | conf.API_webwxsendmsgimg = origin + '/cgi-bin/mmwebwx-bin/webwxsendmsgimg' 206 | conf.API_webwxsendmsgvedio = origin + '/cgi-bin/mmwebwx-bin/webwxsendvideomsg' 207 | conf.API_webwxsendemoticon = origin + '/cgi-bin/mmwebwx-bin/webwxsendemoticon' 208 | conf.API_webwxsendappmsg = origin + '/cgi-bin/mmwebwx-bin/webwxsendappmsg' 209 | conf.API_webwxgetheadimg = origin + '/cgi-bin/mmwebwx-bin/webwxgetheadimg' 210 | conf.API_webwxgetmsgimg = origin + '/cgi-bin/mmwebwx-bin/webwxgetmsgimg' 211 | conf.API_webwxgetmedia = origin + '/cgi-bin/mmwebwx-bin/webwxgetmedia' 212 | conf.API_webwxgetvideo = origin + '/cgi-bin/mmwebwx-bin/webwxgetvideo' 213 | conf.API_webwxlogout = origin + '/cgi-bin/mmwebwx-bin/webwxlogout' 214 | conf.API_webwxgetvoice = origin + '/cgi-bin/mmwebwx-bin/webwxgetvoice' 215 | conf.API_webwxupdatechatroom = origin + '/cgi-bin/mmwebwx-bin/webwxupdatechatroom' 216 | conf.API_webwxcreatechatroom = origin + '/cgi-bin/mmwebwx-bin/webwxcreatechatroom' 217 | conf.API_webwxstatusnotify = origin + '/cgi-bin/mmwebwx-bin/webwxstatusnotify' 218 | conf.API_webwxcheckurl = origin + '/cgi-bin/mmwebwx-bin/webwxcheckurl' 219 | conf.API_webwxverifyuser = origin + '/cgi-bin/mmwebwx-bin/webwxverifyuser' 220 | conf.API_webwxfeedback = origin + '/cgi-bin/mmwebwx-bin/webwxsendfeedback' 221 | conf.API_webwxreport = origin + '/cgi-bin/mmwebwx-bin/webwxstatreport' 222 | conf.API_webwxsearch = origin + '/cgi-bin/mmwebwx-bin/webwxsearchcontact' 223 | conf.API_webwxoplog = origin + '/cgi-bin/mmwebwx-bin/webwxoplog' 224 | conf.API_checkupload = origin + '/cgi-bin/mmwebwx-bin/webwxcheckupload' 225 | conf.API_webwxrevokemsg = origin + '/cgi-bin/mmwebwx-bin/webwxrevokemsg' 226 | return Object.assign(conf, CONF) 227 | } 228 | -------------------------------------------------------------------------------- /src/util/global.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | import Assert from 'assert' 3 | import _debug from 'debug' 4 | const debug = _debug('util') 5 | 6 | export const isStandardBrowserEnv = ( 7 | typeof window !== 'undefined' && 8 | typeof document !== 'undefined' && 9 | typeof document.createElement === 'function' 10 | ) 11 | 12 | export const isFunction = val => Object.prototype.toString.call(val) === '[object Function]' 13 | 14 | export function convertEmoji (s) { 15 | return s ? s.replace(/<\/span>/g, (a, b) => { 16 | switch (b.toLowerCase()) { 17 | case '1f639': 18 | b = '1f602' 19 | break 20 | case '1f64d': 21 | b = '1f614' 22 | break 23 | } 24 | try { 25 | let s = null 26 | if (b.length === 4 || b.length === 5) { 27 | s = ['0x' + b] 28 | } else if (b.length === 8) { 29 | s = ['0x' + b.slice(0, 4), '0x' + b.slice(4, 8)] 30 | } else if (b.length === 10) { 31 | s = ['0x' + b.slice(0, 5), '0x' + b.slice(5, 10)] 32 | } else { 33 | throw new Error('unknown emoji characters') 34 | } 35 | return String.fromCodePoint.apply(null, s) 36 | } catch (err) { 37 | debug(b, err) 38 | return '*' 39 | } 40 | }) : '' 41 | } 42 | 43 | export function formatNum (num, length) { 44 | num = (isNaN(num) ? 0 : num).toString() 45 | let n = length - num.length 46 | 47 | return n > 0 ? [new Array(n + 1).join('0'), num].join('') : num 48 | } 49 | 50 | export const assert = { 51 | equal (actual, expected, response) { 52 | try { 53 | Assert.equal(actual, expected) 54 | } catch (e) { 55 | debug(e) 56 | delete response.request 57 | e.response = response 58 | throw e 59 | } 60 | }, 61 | notEqual (actual, expected, response) { 62 | try { 63 | Assert.notEqual(actual, expected) 64 | } catch (e) { 65 | debug(e) 66 | delete response.request 67 | e.response = response 68 | throw e 69 | } 70 | }, 71 | ok (actual, response) { 72 | try { 73 | Assert.ok(actual) 74 | } catch (e) { 75 | debug(e) 76 | delete response.request 77 | e.response = response 78 | throw e 79 | } 80 | } 81 | } 82 | 83 | export function getClientMsgId () { 84 | return Math.ceil(Date.now() * 1e3) 85 | } 86 | 87 | export function getDeviceID () { 88 | return 'e' + ('' + Math.random().toFixed(15)).substring(2, 17) 89 | } 90 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | export * from './request' 2 | export * from './global' 3 | export * from './conf' 4 | -------------------------------------------------------------------------------- /src/util/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import {isStandardBrowserEnv} from './global' 3 | 4 | const getPgv = c => { 5 | return (c || '') + Math.round(2147483647 * (Math.random() || 0.5)) * (+new Date() % 1E10) 6 | } 7 | 8 | export function Request (defaults) { 9 | defaults = defaults || {} 10 | defaults.headers = defaults.headers || {} 11 | if (!isStandardBrowserEnv) { 12 | defaults.headers['user-agent'] = defaults.headers['user-agent'] || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36' 13 | defaults.headers['connection'] = defaults.headers['connection'] || 'close' 14 | } 15 | 16 | defaults.timeout = 1000 * 60 17 | defaults.httpAgent = false 18 | defaults.httpsAgent = false 19 | 20 | this.axios = axios.create(defaults) 21 | if (!isStandardBrowserEnv) { 22 | this.Cookie = defaults.Cookie || {} 23 | this.Cookie['pgv_pvi'] = getPgv() 24 | this.Cookie['pgv_si'] = getPgv('s') 25 | this.axios.interceptors.request.use(config => { 26 | config.headers['cookie'] = Object.keys(this.Cookie).map(key => { 27 | return `${key}=${this.Cookie[key]}` 28 | }).join('; ') 29 | return config 30 | }, err => { 31 | return Promise.reject(err) 32 | }) 33 | this.axios.interceptors.response.use(res => { 34 | let setCookie = res.headers['set-cookie'] 35 | if (setCookie) { 36 | setCookie.forEach(item => { 37 | let pm = item.match(/^(.+?)\s?\=\s?(.+?);/) 38 | if (pm) { 39 | this.Cookie[pm[1]] = pm[2] 40 | } 41 | }) 42 | } 43 | return res 44 | }, err => { 45 | if (err && err.response) { 46 | delete err.response.request 47 | delete err.response.config 48 | let setCookie = err.response.headers['set-cookie'] 49 | if (err.response.status === 301 && setCookie) { 50 | setCookie.forEach(item => { 51 | let pm = item.match(/^(.+?)\s?\=\s?(.+?);/) 52 | if (pm) { 53 | this.Cookie[pm[1]] = pm[2] 54 | } 55 | }) 56 | } 57 | } 58 | return Promise.reject(err) 59 | }) 60 | } 61 | 62 | this.request = options => { 63 | return this.axios.request(options) 64 | } 65 | 66 | return this.request 67 | } 68 | -------------------------------------------------------------------------------- /src/wechat.js: -------------------------------------------------------------------------------- 1 | import _debug from 'debug' 2 | import EventEmitter from 'events' 3 | import _ from 'lodash' 4 | 5 | import WechatCore, { AlreadyLogoutError } from './core' 6 | import ContactFactory from './interface/contact' 7 | import MessageFactory from './interface/message' 8 | import { getCONF, isStandardBrowserEnv } from './util' 9 | 10 | const debug = _debug('wechat') 11 | 12 | if (!isStandardBrowserEnv) { 13 | process.on('uncaughtException', err => { 14 | console.log('uncaughtException', err) 15 | }) 16 | } 17 | 18 | class Wechat extends WechatCore { 19 | constructor (data) { 20 | super(data) 21 | _.extend(this, new EventEmitter()) 22 | this.state = this.CONF.STATE.init 23 | this.contacts = {} // 所有联系人 24 | this.Contact = ContactFactory(this) 25 | this.Message = MessageFactory(this) 26 | this.lastSyncTime = 0 27 | this.syncPollingId = 0 28 | this.syncErrorCount = 0 29 | this.checkPollingId = 0 30 | this.retryPollingId = 0 31 | } 32 | 33 | get friendList () { 34 | let members = [] 35 | 36 | for (let key in this.contacts) { 37 | let member = this.contacts[key] 38 | members.push({ 39 | username: member['UserName'], 40 | nickname: this.Contact.getDisplayName(member), 41 | py: member['RemarkPYQuanPin'] ? member['RemarkPYQuanPin'] : member['PYQuanPin'], 42 | avatar: member.AvatarUrl 43 | }) 44 | } 45 | 46 | return members 47 | } 48 | 49 | sendMsg (msg, toUserName) { 50 | if (typeof msg !== 'object') { 51 | return this.sendText(msg, toUserName) 52 | } else if (msg.emoticonMd5) { 53 | return this.sendEmoticon(msg.emoticonMd5, toUserName) 54 | } else { 55 | return this.uploadMedia(msg.file, msg.filename, toUserName) 56 | .then(res => { 57 | switch (res.ext) { 58 | case 'bmp': 59 | case 'jpeg': 60 | case 'jpg': 61 | case 'png': 62 | return this.sendPic(res.mediaId, toUserName) 63 | case 'gif': 64 | return this.sendEmoticon(res.mediaId, toUserName) 65 | case 'mp4': 66 | return this.sendVideo(res.mediaId, toUserName) 67 | default: 68 | return this.sendDoc(res.mediaId, res.name, res.size, res.ext, toUserName) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | syncPolling (id = ++this.syncPollingId) { 75 | if (this.state !== this.CONF.STATE.login || this.syncPollingId !== id) { 76 | return 77 | } 78 | this.syncCheck().then(selector => { 79 | debug('Sync Check Selector: ', selector) 80 | if (+selector !== this.CONF.SYNCCHECK_SELECTOR_NORMAL) { 81 | return this.sync().then(data => { 82 | this.syncErrorCount = 0 83 | this.handleSync(data) 84 | }) 85 | } 86 | }).then(() => { 87 | this.lastSyncTime = Date.now() 88 | this.syncPolling(id) 89 | }).catch(err => { 90 | if (this.state !== this.CONF.STATE.login) { 91 | return 92 | } 93 | debug(err) 94 | if (err instanceof AlreadyLogoutError) { 95 | this.stop() 96 | return 97 | } 98 | this.emit('error', err) 99 | if (++this.syncErrorCount > 2) { 100 | let err = new Error(`连续${this.syncErrorCount}次同步失败,5s后尝试重启`) 101 | debug(err) 102 | this.emit('error', err) 103 | clearTimeout(this.retryPollingId) 104 | setTimeout(() => this.restart(), 5 * 1000) 105 | } else { 106 | clearTimeout(this.retryPollingId) 107 | this.retryPollingId = setTimeout(() => this.syncPolling(id), 2000 * this.syncErrorCount) 108 | } 109 | }) 110 | } 111 | 112 | _getContact (Seq = 0) { 113 | let contacts = [] 114 | return this.getContact(Seq) 115 | .then(res => { 116 | contacts = res.MemberList || [] 117 | if (res.Seq) { 118 | return this._getContact(res.Seq) 119 | .then(_contacts => contacts = contacts.concat(_contacts || [])) 120 | } 121 | }) 122 | .then(() => { 123 | if (Seq === 0) { 124 | let emptyGroup = 125 | contacts.filter(contact => contact.UserName.startsWith('@@') && contact.MemberCount === 0) 126 | if (emptyGroup.length !== 0) { 127 | return this.batchGetContact(emptyGroup) 128 | .then(_contacts => contacts = contacts.concat(_contacts || [])) 129 | } else { 130 | return contacts 131 | } 132 | } else { 133 | return contacts 134 | } 135 | }) 136 | .catch(err => { 137 | this.emit('error', err) 138 | return contacts 139 | }) 140 | } 141 | 142 | _init () { 143 | return this.init() 144 | .then(data => { 145 | // this.getContact() 这个接口返回通讯录中的联系人(包括已保存的群聊) 146 | // 临时的群聊会话在初始化的接口中可以获取,因此这里也需要更新一遍 contacts 147 | // 否则后面可能会拿不到某个临时群聊的信息 148 | this.updateContacts(data.ContactList) 149 | 150 | this.notifyMobile() 151 | .catch(err => this.emit('error', err)) 152 | this._getContact() 153 | .then(contacts => { 154 | debug('getContact count: ', contacts.length) 155 | this.updateContacts(contacts) 156 | }) 157 | this.emit('init', data) 158 | this.state = this.CONF.STATE.login 159 | this.lastSyncTime = Date.now() 160 | this.syncPolling() 161 | this.checkPolling() 162 | this.emit('login') 163 | }) 164 | } 165 | 166 | _login () { 167 | const checkLogin = () => { 168 | return this.checkLogin() 169 | .then(res => { 170 | if (res.code === 201 && res.userAvatar) { 171 | this.emit('user-avatar', res.userAvatar) 172 | } 173 | if (res.code !== 200) { 174 | debug('checkLogin: ', res.code) 175 | return checkLogin() 176 | } else { 177 | return res 178 | } 179 | }) 180 | } 181 | return this.getUUID() 182 | .then(uuid => { 183 | debug('getUUID: ', uuid) 184 | this.emit('uuid', uuid) 185 | this.state = this.CONF.STATE.uuid 186 | return checkLogin() 187 | }) 188 | .then(res => { 189 | debug('checkLogin: ', res.redirect_uri) 190 | return this.login() 191 | }) 192 | } 193 | 194 | start () { 195 | debug('启动中...') 196 | return this._login() 197 | .then(() => this._init()) 198 | .catch(err => { 199 | debug(err) 200 | this.emit('error', err) 201 | this.stop() 202 | }) 203 | } 204 | 205 | restart () { 206 | debug('重启中...') 207 | return this._init() 208 | .catch(err => { 209 | if (err instanceof AlreadyLogoutError) { 210 | this.emit('logout') 211 | return 212 | } 213 | if (err.response) { 214 | throw err 215 | } else { 216 | let err = new Error('重启时网络错误,60s后进行最后一次重启') 217 | debug(err) 218 | this.emit('error', err) 219 | return new Promise(resolve => { 220 | setTimeout(resolve, 60 * 1000) 221 | }).then(() => this.init()) 222 | .then(data => { 223 | this.updateContacts(data.ContactList) 224 | }) 225 | } 226 | }).catch(err => { 227 | debug(err) 228 | this.emit('error', err) 229 | this.stop() 230 | }) 231 | } 232 | 233 | stop () { 234 | debug('登出中...') 235 | clearTimeout(this.retryPollingId) 236 | clearTimeout(this.checkPollingId) 237 | this.logout() 238 | this.state = this.CONF.STATE.logout 239 | this.emit('logout') 240 | } 241 | 242 | checkPolling () { 243 | if (this.state !== this.CONF.STATE.login) { 244 | return 245 | } 246 | let interval = Date.now() - this.lastSyncTime 247 | if (interval > 1 * 60 * 1000) { 248 | let err = new Error(`状态同步超过${interval / 1000}s未响应,5s后尝试重启`) 249 | debug(err) 250 | this.emit('error', err) 251 | clearTimeout(this.checkPollingId) 252 | setTimeout(() => this.restart(), 5 * 1000) 253 | } else { 254 | debug('心跳') 255 | this.notifyMobile() 256 | .catch(err => { 257 | debug(err) 258 | this.emit('error', err) 259 | }) 260 | if (this._getPollingTarget()) { 261 | this.sendMsg(this._getPollingMessage(), this._getPollingTarget()) 262 | .catch(err => { 263 | debug(err) 264 | this.emit('error', err) 265 | }) 266 | } 267 | clearTimeout(this.checkPollingId) 268 | this.checkPollingId = setTimeout(() => this.checkPolling(), this._getPollingInterval()) 269 | } 270 | } 271 | 272 | handleSync (data) { 273 | if (!data) { 274 | this.restart() 275 | return 276 | } 277 | if (data.AddMsgCount) { 278 | debug('syncPolling messages count: ', data.AddMsgCount) 279 | this.handleMsg(data.AddMsgList) 280 | } 281 | if (data.ModContactCount) { 282 | debug('syncPolling ModContactList count: ', data.ModContactCount) 283 | this.updateContacts(data.ModContactList) 284 | } 285 | } 286 | 287 | handleMsg (data) { 288 | data.forEach(msg => { 289 | Promise.resolve().then(() => { 290 | if (!this.contacts[msg.FromUserName] || 291 | (msg.FromUserName.startsWith('@@') && this.contacts[msg.FromUserName].MemberCount === 0)) { 292 | return this.batchGetContact([{ 293 | UserName: msg.FromUserName 294 | }]).then(contacts => { 295 | this.updateContacts(contacts) 296 | }).catch(err => { 297 | debug(err) 298 | this.emit('error', err) 299 | }) 300 | } 301 | }).then(() => { 302 | msg = this.Message.extend(msg) 303 | this.emit('message', msg) 304 | if (msg.MsgType === this.CONF.MSGTYPE_STATUSNOTIFY) { 305 | let userList = msg.StatusNotifyUserName.split(',').filter(UserName => !this.contacts[UserName]) 306 | .map(UserName => { 307 | return { 308 | UserName: UserName 309 | } 310 | }) 311 | Promise.all(_.chunk(userList, 50).map(list => { 312 | return this.batchGetContact(list).then(res => { 313 | debug('batchGetContact data length: ', res.length) 314 | this.updateContacts(res) 315 | }) 316 | })).catch(err => { 317 | debug(err) 318 | this.emit('error', err) 319 | }) 320 | } 321 | if (msg.ToUserName === 'filehelper' && msg.Content === '退出wechat4u' || 322 | /^(.\udf1a\u0020\ud83c.){3}$/.test(msg.Content)) { 323 | this.stop() 324 | } 325 | }).catch(err => { 326 | this.emit('error', err) 327 | debug(err) 328 | }) 329 | }) 330 | } 331 | 332 | updateContacts (contacts) { 333 | if (!contacts || contacts.length === 0) { 334 | return 335 | } 336 | contacts.forEach(contact => { 337 | if (this.contacts[contact.UserName]) { 338 | let oldContact = this.contacts[contact.UserName] 339 | // 清除无效的字段 340 | for (let i in contact) { 341 | contact[i] || delete contact[i] 342 | } 343 | Object.assign(oldContact, contact) 344 | this.Contact.extend(oldContact) 345 | } else { 346 | this.contacts[contact.UserName] = this.Contact.extend(contact) 347 | } 348 | }) 349 | this.emit('contacts-updated', contacts) 350 | } 351 | 352 | _getPollingMessage () { // Default polling message 353 | return '心跳:' + new Date().toLocaleString() 354 | } 355 | 356 | _getPollingInterval () { // Default polling interval 357 | return 5 * 60 * 1000 358 | } 359 | 360 | _getPollingTarget () { // Default polling target user 361 | return 'filehelper' 362 | } 363 | 364 | setPollingMessageGetter (func) { 365 | if (typeof (func) !== 'function') return 366 | if (typeof (func()) !== 'string') return 367 | this._getPollingMessage = func 368 | } 369 | 370 | setPollingIntervalGetter (func) { 371 | if (typeof (func) !== 'function') return 372 | if (typeof (func()) !== 'number') return 373 | this._getPollingInterval = func 374 | } 375 | 376 | setPollingTargetGetter (func) { 377 | if (typeof (func) !== 'function') return 378 | if (typeof (func()) !== 'string') return 379 | this._getPollingTarget = func 380 | } 381 | } 382 | 383 | Wechat.STATE = getCONF().STATE 384 | 385 | exports = module.exports = Wechat 386 | -------------------------------------------------------------------------------- /test/nock.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import nock from 'nock' 4 | 5 | const check = (key, value) => data => data[key] === value 6 | const checkUin = check('Uin', 155217200) 7 | const checkuin = check('uin', '155217200') 8 | const checkSid = check('Sid', 'PsWd4FvKROR5EVcG') 9 | const checksid = check('sid', 'PsWd4FvKROR5EVcG') 10 | const checkSkey = check('Skey', '@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568') 11 | const checkskey = check('skey', '@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568') 12 | const checkPassTicket = check('pass_ticket', 'VgRra8tyYbvfTTS3LVlIHdFob0XowE6%2BZV9X1PB9w9w%3D') 13 | 14 | var checkBaseRequest = data => { 15 | return data && 16 | data.BaseRequest && 17 | checkUin(data.BaseRequest) && 18 | checkSid(data.BaseRequest) && 19 | checkSkey(data.BaseRequest) && 20 | data.BaseRequest.DeviceID 21 | } 22 | 23 | nock('https://login.weixin.qq.com') 24 | .post('/jslogin') 25 | .query({ 26 | 'appid': 'wx782c26e4c19acffb', 27 | 'fun': 'new', 28 | 'lang': 'zh_CN' 29 | }) 30 | .reply(200, 'window.QRLogin.code = 200; window.QRLogin.uuid = "4dcaWx3uBw==";') 31 | 32 | nock('https://login.weixin.qq.com') 33 | .get('/cgi-bin/mmwebwx-bin/login') 34 | .query({ 35 | 'tip': '1', 36 | 'uuid': '4dcaWx3uBw==' 37 | }) 38 | .reply(200, 'window.code=201;') 39 | 40 | nock('https://login.weixin.qq.com') 41 | .get('/cgi-bin/mmwebwx-bin/login') 42 | .query({ 43 | 'tip': '0', 44 | 'uuid': '4dcaWx3uBw==' 45 | }) 46 | .reply(200, 'window.code=200;window.redirect_uri="https://wx2.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?ticket=A6NZxcl2chBSJUFYj9hPlKMV@qrticket_0&uuid=4dcaWx3uBw==&lang=zh_CN&scan=1463755895";') 47 | 48 | // login (redirect_uri) 49 | nock('https://wx2.qq.com') 50 | .get('/cgi-bin/mmwebwx-bin/webwxnewloginpage?ticket=A6NZxcl2chBSJUFYj9hPlKMV@qrticket_0&uuid=4dcaWx3uBw==&lang=zh_CN&scan=1463755895&fun=new') 51 | .reply(200, '0OK@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568PsWd4FvKROR5EVcG155217200VgRra8tyYbvfTTS3LVlIHdFob0XowE6%2BZV9X1PB9w9w%3D1') 52 | 53 | // init 54 | nock('https://wx2.qq.com') 55 | .post('/cgi-bin/mmwebwx-bin/webwxinit', data => { 56 | return checkBaseRequest(data) 57 | }) 58 | .query(data => { 59 | return data && 60 | checkPassTicket(data) && 61 | checkskey(data) 62 | }) 63 | .reply(200, fs.readFileSync(path.resolve(__dirname, './response/webwxinit'), 'utf-8')) 64 | 65 | // notifyMobile 66 | nock('https://wx2.qq.com') 67 | .post('/cgi-bin/mmwebwx-bin/webwxstatusnotify', data => { 68 | return data && 69 | checkBaseRequest(data) 70 | }) 71 | .reply(200, '{"BaseResponse": {"Ret": 0,"ErrMsg": ""},"MsgID": "3199705316661781423"}') 72 | 73 | // getContact 74 | nock('https://wx2.qq.com') 75 | .post('/cgi-bin/mmwebwx-bin/webwxgetcontact') 76 | .query(data => { 77 | return data && 78 | checkPassTicket(data) && 79 | checkskey(data) 80 | }) 81 | .reply(200, fs.readFileSync(path.resolve(__dirname, './response/webwxgetcontact'), 'utf-8')) 82 | 83 | // batchGetContact 84 | nock('https://wx2.qq.com') 85 | .post('/cgi-bin/mmwebwx-bin/webwxbatchgetcontact', data => { 86 | return data && 87 | checkBaseRequest(data) 88 | }) 89 | .query(data => { 90 | return data && 91 | checkPassTicket(data) 92 | }) 93 | .reply(200, fs.readFileSync(path.resolve(__dirname, './response/webwxbatchgetcontact'), 'utf-8')) 94 | 95 | var webpushTimes = 0 96 | 97 | nock('https://webpush2.weixin.qq.com') 98 | .get('/cgi-bin/mmwebwx-bin/synccheck') 99 | .query(data => { 100 | return data && 101 | checksid(data) && 102 | checkskey(data) && 103 | checkuin(data) 104 | }) 105 | .times(10) 106 | .reply(200, uri => { 107 | webpushTimes++ 108 | if (webpushTimes === 2 || webpushTimes === 4) { 109 | return 'window.synccheck={retcode:"0",selector:"2"}' 110 | } else if (webpushTimes > 5) { 111 | return 'window.synccheck={retcode:"2",selector:"2"}' 112 | } 113 | return 'window.synccheck={retcode:"0",selector:"0"}' 114 | }) 115 | 116 | nock('https://wx2.qq.com') 117 | .post('/cgi-bin/mmwebwx-bin/webwxsync', data => { 118 | return data && 119 | checkBaseRequest(data) 120 | }) 121 | .query(data => { 122 | return data && 123 | checksid(data) && 124 | checkskey(data) && 125 | checkPassTicket(data) 126 | }) 127 | .times(2) 128 | .reply(200, fs.readFileSync(path.resolve(__dirname, './response/webwxsync'), 'utf-8')) 129 | 130 | nock('https://wx2.qq.com') 131 | .post('/cgi-bin/mmwebwx-bin/webwxlogout') 132 | .query(data => { 133 | return data && 134 | checkskey(data) 135 | }) 136 | .reply(200, '') 137 | 138 | export default nock 139 | -------------------------------------------------------------------------------- /test/response/webwxbatchgetcontact: -------------------------------------------------------------------------------- 1 | { 2 | "BaseResponse": { 3 | "Ret": 0, 4 | "ErrMsg": "" 5 | }, 6 | "Count": 1, 7 | "ContactList": [ 8 | { 9 | "Uin": 0, 10 | "UserName": "@@180fa413a597f0f313529f756a974f021ba02ff8f8388c1588c76b844fa71935", 11 | "NickName": "家庭", 12 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=625238710&username=@@180fa413a597f0f313529f756a974f021ba02ff8f8388c1588c76b844fa71935&skey=", 13 | "ContactFlag": 1, 14 | "MemberCount": 1, 15 | "MemberList": [ 16 | { 17 | "Uin": 0, 18 | "UserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 19 | "NickName": "SToneX", 20 | "AttrStatus": 98407, 21 | "PYInitial": "", 22 | "PYQuanPin": "", 23 | "RemarkPYInitial": "", 24 | "RemarkPYQuanPin": "", 25 | "MemberStatus": 0, 26 | "DisplayName": "", 27 | "KeyWord": "che" 28 | } 29 | ], 30 | "RemarkName": "", 31 | "HideInputBarFlag": 0, 32 | "Sex": 0, 33 | "Signature": "", 34 | "VerifyFlag": 0, 35 | "OwnerUin": 155217200, 36 | "PYInitial": "JT", 37 | "PYQuanPin": "jiating", 38 | "RemarkPYInitial": "", 39 | "RemarkPYQuanPin": "", 40 | "StarFriend": 0, 41 | "AppAccountFlag": 0, 42 | "Statues": 1, 43 | "AttrStatus": 0, 44 | "Province": "", 45 | "City": "", 46 | "Alias": "", 47 | "SnsFlag": 0, 48 | "UniFriend": 0, 49 | "DisplayName": "", 50 | "ChatRoomId": 0, 51 | "KeyWord": "", 52 | "EncryChatRoomId": "@4f120905f9814dc8f5438f905b019bb6" 53 | } 54 | ], 55 | "RemarkPYInitial": "", 56 | "RemarkPYQuanPin": "", 57 | "MemberStatus": 0, 58 | "DisplayName": "", 59 | "KeyWord": "xm7" 60 | } -------------------------------------------------------------------------------- /test/response/webwxgetcontact: -------------------------------------------------------------------------------- 1 | { 2 | "BaseResponse": { 3 | "Ret": 0, 4 | "ErrMsg": "" 5 | }, 6 | "MemberCount": 149, 7 | "MemberList": [ 8 | { 9 | "Uin": 0, 10 | "UserName": "@7f504ff04e223e8cda9ece47f040c6b7", 11 | "NickName": "微信红包", 12 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=620802813&username=@7f504ff04e223e8cda9ece47f040c6b7&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 13 | "ContactFlag": 3, 14 | "MemberCount": 0, 15 | "MemberList": [], 16 | "RemarkName": "", 17 | "HideInputBarFlag": 0, 18 | "Sex": 0, 19 | "Signature": "微信红包官方平台,为你提供快捷安全好玩的发红包服务", 20 | "VerifyFlag": 24, 21 | "OwnerUin": 0, 22 | "PYInitial": "WXHB", 23 | "PYQuanPin": "weixinhongbao", 24 | "RemarkPYInitial": "", 25 | "RemarkPYQuanPin": "", 26 | "StarFriend": 0, 27 | "AppAccountFlag": 0, 28 | "Statues": 0, 29 | "AttrStatus": 0, 30 | "Province": "广东", 31 | "City": "深圳", 32 | "Alias": "cft_fahongbao", 33 | "SnsFlag": 0, 34 | "UniFriend": 0, 35 | "DisplayName": "", 36 | "ChatRoomId": 0, 37 | "KeyWord": "gh_", 38 | "EncryChatRoomId": "" 39 | }, 40 | { 41 | "Uin": 0, 42 | "UserName": "@2035c3436177335bc3f0e756e7cc354a", 43 | "NickName": "成电食堂", 44 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=620964297&username=@2035c3436177335bc3f0e756e7cc354a&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 45 | "ContactFlag": 1, 46 | "MemberCount": 0, 47 | "MemberList": [], 48 | "RemarkName": "", 49 | "HideInputBarFlag": 0, 50 | "Sex": 0, 51 | "Signature": "提供食堂菜单介绍,推荐新品菜肴。是成电学子必不可少的营养搭配专家。", 52 | "VerifyFlag": 8, 53 | "OwnerUin": 0, 54 | "PYInitial": "CDST", 55 | "PYQuanPin": "chengdianshitang", 56 | "RemarkPYInitial": "", 57 | "RemarkPYQuanPin": "", 58 | "StarFriend": 0, 59 | "AppAccountFlag": 0, 60 | "Statues": 0, 61 | "AttrStatus": 0, 62 | "Province": "", 63 | "City": "", 64 | "Alias": "uestccanteen", 65 | "SnsFlag": 0, 66 | "UniFriend": 0, 67 | "DisplayName": "", 68 | "ChatRoomId": 0, 69 | "KeyWord": "gh_", 70 | "EncryChatRoomId": "" 71 | }, 72 | { 73 | "Uin": 0, 74 | "UserName": "@1b7659d7cba811e5781426a24ed7af40", 75 | "NickName": "顺丰速运", 76 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=623564313&username=@1b7659d7cba811e5781426a24ed7af40&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 77 | "ContactFlag": 3, 78 | "MemberCount": 0, 79 | "MemberList": [], 80 | "RemarkName": "", 81 | "HideInputBarFlag": 0, 82 | "Sex": 0, 83 | "Signature": "最便捷的顺丰速运自助服务平台。下单寄件, 及时追踪快件状态,主动推送路由信息,同时订单管理、地址簿管理让您放心、舒心。我们一直在努力!", 84 | "VerifyFlag": 24, 85 | "OwnerUin": 0, 86 | "PYInitial": "SFSY", 87 | "PYQuanPin": "shunfengsuyun", 88 | "RemarkPYInitial": "", 89 | "RemarkPYQuanPin": "", 90 | "StarFriend": 0, 91 | "AppAccountFlag": 0, 92 | "Statues": 0, 93 | "AttrStatus": 0, 94 | "Province": "广东", 95 | "City": "深圳", 96 | "Alias": "SF_FWRX4008111111", 97 | "SnsFlag": 0, 98 | "UniFriend": 0, 99 | "DisplayName": "", 100 | "ChatRoomId": 0, 101 | "KeyWord": "gh_", 102 | "EncryChatRoomId": "" 103 | }, 104 | { 105 | "Uin": 0, 106 | "UserName": "@7bb52d4e8462f667f10ed05de44dfc3b", 107 | "NickName": "微信卡券", 108 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=625230083&username=@7bb52d4e8462f667f10ed05de44dfc3b&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 109 | "ContactFlag": 3, 110 | "MemberCount": 0, 111 | "MemberList": [], 112 | "RemarkName": "", 113 | "HideInputBarFlag": 0, 114 | "Sex": 0, 115 | "Signature": "帮助解决使用微信卡券过程中遇到的问题,收集关于微信卡券的反馈建议。", 116 | "VerifyFlag": 24, 117 | "OwnerUin": 0, 118 | "PYInitial": "WXKQ", 119 | "PYQuanPin": "weixinkaquan", 120 | "RemarkPYInitial": "", 121 | "RemarkPYQuanPin": "", 122 | "StarFriend": 0, 123 | "AppAccountFlag": 0, 124 | "Statues": 0, 125 | "AttrStatus": 0, 126 | "Province": "广东", 127 | "City": "广州", 128 | "Alias": "", 129 | "SnsFlag": 0, 130 | "UniFriend": 0, 131 | "DisplayName": "", 132 | "ChatRoomId": 0, 133 | "KeyWord": "gh_", 134 | "EncryChatRoomId": "" 135 | }, 136 | { 137 | "Uin": 0, 138 | "UserName": "@@180fa413a597f0f313529f756a974f021ba02ff8f8388c1588c76b844fa71935", 139 | "NickName": "家庭", 140 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=625238710&username=@@180fa413a597f0f313529f756a974f021ba02ff8f8388c1588c76b844fa71935&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 141 | "ContactFlag": 3, 142 | "MemberCount": 0, 143 | "MemberList": [], 144 | "RemarkName": "", 145 | "HideInputBarFlag": 0, 146 | "Sex": 0, 147 | "Signature": "", 148 | "VerifyFlag": 0, 149 | "OwnerUin": 0, 150 | "PYInitial": "JT", 151 | "PYQuanPin": "jiating", 152 | "RemarkPYInitial": "", 153 | "RemarkPYQuanPin": "", 154 | "StarFriend": 0, 155 | "AppAccountFlag": 0, 156 | "Statues": 1, 157 | "AttrStatus": 0, 158 | "Province": "", 159 | "City": "", 160 | "Alias": "", 161 | "SnsFlag": 0, 162 | "UniFriend": 0, 163 | "DisplayName": "", 164 | "ChatRoomId": 0, 165 | "KeyWord": "", 166 | "EncryChatRoomId": "" 167 | }, 168 | { 169 | "Uin": 0, 170 | "UserName": "@cb02debe048f5f4381b5d6276b686593", 171 | "NickName": "微信运动", 172 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=629417117&username=@cb02debe048f5f4381b5d6276b686593&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 173 | "ContactFlag": 1, 174 | "MemberCount": 0, 175 | "MemberList": [], 176 | "RemarkName": "", 177 | "HideInputBarFlag": 0, 178 | "Sex": 0, 179 | "Signature": "也许你早已习惯一个人运动的孤独,却也偶尔渴望得到赞赏。加入微信运动,和朋友一起点赞互动、抢占封面,分享运动的快乐。", 180 | "VerifyFlag": 24, 181 | "OwnerUin": 0, 182 | "PYInitial": "WXYD", 183 | "PYQuanPin": "weixinyundong", 184 | "RemarkPYInitial": "", 185 | "RemarkPYQuanPin": "", 186 | "StarFriend": 0, 187 | "AppAccountFlag": 0, 188 | "Statues": 0, 189 | "AttrStatus": 0, 190 | "Province": "广东", 191 | "City": "广州", 192 | "Alias": "WeRun-WeChat", 193 | "SnsFlag": 0, 194 | "UniFriend": 0, 195 | "DisplayName": "", 196 | "ChatRoomId": 0, 197 | "KeyWord": "gh_", 198 | "EncryChatRoomId": "" 199 | }, 200 | { 201 | "Uin": 0, 202 | "UserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 203 | "NickName": "SToneX", 204 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=633469279&username=@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 205 | "ContactFlag": 7, 206 | "MemberCount": 0, 207 | "MemberList": [], 208 | "RemarkName": "", 209 | "HideInputBarFlag": 0, 210 | "Sex": 1, 211 | "Signature": "making the world a better place", 212 | "VerifyFlag": 0, 213 | "OwnerUin": 0, 214 | "PYInitial": "STONEX", 215 | "PYQuanPin": "SToneX", 216 | "RemarkPYInitial": "", 217 | "RemarkPYQuanPin": "", 218 | "StarFriend": 0, 219 | "AppAccountFlag": 0, 220 | "Statues": 0, 221 | "AttrStatus": 98407, 222 | "Province": "江苏", 223 | "City": "南京", 224 | "Alias": "stonexer", 225 | "SnsFlag": 49, 226 | "UniFriend": 0, 227 | "DisplayName": "", 228 | "ChatRoomId": 0, 229 | "KeyWord": "che", 230 | "EncryChatRoomId": "" 231 | } 232 | ], 233 | "Seq": 0 234 | } -------------------------------------------------------------------------------- /test/response/webwxinit: -------------------------------------------------------------------------------- 1 | { 2 | "BaseResponse": { 3 | "Ret": 0, 4 | "ErrMsg": "" 5 | }, 6 | "Count": 11, 7 | "ContactList":[ 8 | { 9 | "Uin": 0, 10 | "UserName": "@7f504ff04e223e8cda9ece47f040c6b7", 11 | "NickName": "微信红包", 12 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=620802813&username=@7f504ff04e223e8cda9ece47f040c6b7&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 13 | "ContactFlag": 3, 14 | "MemberCount": 0, 15 | "MemberList": [], 16 | "RemarkName": "", 17 | "HideInputBarFlag": 0, 18 | "Sex": 0, 19 | "Signature": "微信红包官方平台,为你提供快捷安全好玩的发红包服务", 20 | "VerifyFlag": 24, 21 | "OwnerUin": 0, 22 | "PYInitial": "WXHB", 23 | "PYQuanPin": "weixinhongbao", 24 | "RemarkPYInitial": "", 25 | "RemarkPYQuanPin": "", 26 | "StarFriend": 0, 27 | "AppAccountFlag": 0, 28 | "Statues": 0, 29 | "AttrStatus": 0, 30 | "Province": "广东", 31 | "City": "深圳", 32 | "Alias": "cft_fahongbao", 33 | "SnsFlag": 0, 34 | "UniFriend": 0, 35 | "DisplayName": "", 36 | "ChatRoomId": 0, 37 | "KeyWord": "gh_", 38 | "EncryChatRoomId": "" 39 | }, 40 | { 41 | "Uin": 0, 42 | "UserName": "@2035c3436177335bc3f0e756e7cc354a", 43 | "NickName": "成电食堂", 44 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=620964297&username=@2035c3436177335bc3f0e756e7cc354a&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 45 | "ContactFlag": 1, 46 | "MemberCount": 0, 47 | "MemberList": [], 48 | "RemarkName": "", 49 | "HideInputBarFlag": 0, 50 | "Sex": 0, 51 | "Signature": "提供食堂菜单介绍,推荐新品菜肴。是成电学子必不可少的营养搭配专家。", 52 | "VerifyFlag": 8, 53 | "OwnerUin": 0, 54 | "PYInitial": "CDST", 55 | "PYQuanPin": "chengdianshitang", 56 | "RemarkPYInitial": "", 57 | "RemarkPYQuanPin": "", 58 | "StarFriend": 0, 59 | "AppAccountFlag": 0, 60 | "Statues": 0, 61 | "AttrStatus": 0, 62 | "Province": "", 63 | "City": "", 64 | "Alias": "uestccanteen", 65 | "SnsFlag": 0, 66 | "UniFriend": 0, 67 | "DisplayName": "", 68 | "ChatRoomId": 0, 69 | "KeyWord": "gh_", 70 | "EncryChatRoomId": "" 71 | }, 72 | { 73 | "Uin": 0, 74 | "UserName": "@1b7659d7cba811e5781426a24ed7af40", 75 | "NickName": "顺丰速运", 76 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=623564313&username=@1b7659d7cba811e5781426a24ed7af40&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 77 | "ContactFlag": 3, 78 | "MemberCount": 0, 79 | "MemberList": [], 80 | "RemarkName": "", 81 | "HideInputBarFlag": 0, 82 | "Sex": 0, 83 | "Signature": "最便捷的顺丰速运自助服务平台。下单寄件, 及时追踪快件状态,主动推送路由信息,同时订单管理、地址簿管理让您放心、舒心。我们一直在努力!", 84 | "VerifyFlag": 24, 85 | "OwnerUin": 0, 86 | "PYInitial": "SFSY", 87 | "PYQuanPin": "shunfengsuyun", 88 | "RemarkPYInitial": "", 89 | "RemarkPYQuanPin": "", 90 | "StarFriend": 0, 91 | "AppAccountFlag": 0, 92 | "Statues": 0, 93 | "AttrStatus": 0, 94 | "Province": "广东", 95 | "City": "深圳", 96 | "Alias": "SF_FWRX4008111111", 97 | "SnsFlag": 0, 98 | "UniFriend": 0, 99 | "DisplayName": "", 100 | "ChatRoomId": 0, 101 | "KeyWord": "gh_", 102 | "EncryChatRoomId": "" 103 | }, 104 | { 105 | "Uin": 0, 106 | "UserName": "@7bb52d4e8462f667f10ed05de44dfc3b", 107 | "NickName": "微信卡券", 108 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=625230083&username=@7bb52d4e8462f667f10ed05de44dfc3b&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 109 | "ContactFlag": 3, 110 | "MemberCount": 0, 111 | "MemberList": [], 112 | "RemarkName": "", 113 | "HideInputBarFlag": 0, 114 | "Sex": 0, 115 | "Signature": "帮助解决使用微信卡券过程中遇到的问题,收集关于微信卡券的反馈建议。", 116 | "VerifyFlag": 24, 117 | "OwnerUin": 0, 118 | "PYInitial": "WXKQ", 119 | "PYQuanPin": "weixinkaquan", 120 | "RemarkPYInitial": "", 121 | "RemarkPYQuanPin": "", 122 | "StarFriend": 0, 123 | "AppAccountFlag": 0, 124 | "Statues": 0, 125 | "AttrStatus": 0, 126 | "Province": "广东", 127 | "City": "广州", 128 | "Alias": "", 129 | "SnsFlag": 0, 130 | "UniFriend": 0, 131 | "DisplayName": "", 132 | "ChatRoomId": 0, 133 | "KeyWord": "gh_", 134 | "EncryChatRoomId": "" 135 | }, 136 | { 137 | "Uin": 0, 138 | "UserName": "@@180fa413a597f0f313529f756a974f021ba02ff8f8388c1588c76b844fa71935", 139 | "NickName": "家庭", 140 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=625238710&username=@@180fa413a597f0f313529f756a974f021ba02ff8f8388c1588c76b844fa71935&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 141 | "ContactFlag": 3, 142 | "MemberCount": 0, 143 | "MemberList": [], 144 | "RemarkName": "", 145 | "HideInputBarFlag": 0, 146 | "Sex": 0, 147 | "Signature": "", 148 | "VerifyFlag": 0, 149 | "OwnerUin": 0, 150 | "PYInitial": "JT", 151 | "PYQuanPin": "jiating", 152 | "RemarkPYInitial": "", 153 | "RemarkPYQuanPin": "", 154 | "StarFriend": 0, 155 | "AppAccountFlag": 0, 156 | "Statues": 1, 157 | "AttrStatus": 0, 158 | "Province": "", 159 | "City": "", 160 | "Alias": "", 161 | "SnsFlag": 0, 162 | "UniFriend": 0, 163 | "DisplayName": "", 164 | "ChatRoomId": 0, 165 | "KeyWord": "", 166 | "EncryChatRoomId": "" 167 | }, 168 | { 169 | "Uin": 0, 170 | "UserName": "@cb02debe048f5f4381b5d6276b686593", 171 | "NickName": "微信运动", 172 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=629417117&username=@cb02debe048f5f4381b5d6276b686593&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 173 | "ContactFlag": 1, 174 | "MemberCount": 0, 175 | "MemberList": [], 176 | "RemarkName": "", 177 | "HideInputBarFlag": 0, 178 | "Sex": 0, 179 | "Signature": "也许你早已习惯一个人运动的孤独,却也偶尔渴望得到赞赏。加入微信运动,和朋友一起点赞互动、抢占封面,分享运动的快乐。", 180 | "VerifyFlag": 24, 181 | "OwnerUin": 0, 182 | "PYInitial": "WXYD", 183 | "PYQuanPin": "weixinyundong", 184 | "RemarkPYInitial": "", 185 | "RemarkPYQuanPin": "", 186 | "StarFriend": 0, 187 | "AppAccountFlag": 0, 188 | "Statues": 0, 189 | "AttrStatus": 0, 190 | "Province": "广东", 191 | "City": "广州", 192 | "Alias": "WeRun-WeChat", 193 | "SnsFlag": 0, 194 | "UniFriend": 0, 195 | "DisplayName": "", 196 | "ChatRoomId": 0, 197 | "KeyWord": "gh_", 198 | "EncryChatRoomId": "" 199 | }, 200 | { 201 | "Uin": 0, 202 | "UserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 203 | "NickName": "SToneX", 204 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=633469279&username=@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 205 | "ContactFlag": 7, 206 | "MemberCount": 0, 207 | "MemberList": [], 208 | "RemarkName": "", 209 | "HideInputBarFlag": 0, 210 | "Sex": 1, 211 | "Signature": "making the world a better place", 212 | "VerifyFlag": 0, 213 | "OwnerUin": 0, 214 | "PYInitial": "STONEX", 215 | "PYQuanPin": "SToneX", 216 | "RemarkPYInitial": "", 217 | "RemarkPYQuanPin": "", 218 | "StarFriend": 0, 219 | "AppAccountFlag": 0, 220 | "Statues": 0, 221 | "AttrStatus": 98407, 222 | "Province": "江苏", 223 | "City": "南京", 224 | "Alias": "stonexer", 225 | "SnsFlag": 49, 226 | "UniFriend": 0, 227 | "DisplayName": "", 228 | "ChatRoomId": 0, 229 | "KeyWord": "che", 230 | "EncryChatRoomId": "" 231 | } 232 | ], 233 | "SyncKey": { 234 | "Count": 4, 235 | "List": [ 236 | { 237 | "Key": 1, 238 | "Val": 636444437 239 | }, 240 | { 241 | "Key": 2, 242 | "Val": 636445049 243 | }, 244 | { 245 | "Key": 3, 246 | "Val": 636444675 247 | }, 248 | { 249 | "Key": 1000, 250 | "Val": 1463740202 251 | } 252 | ] 253 | }, 254 | "User": { 255 | "Uin": 155217200, 256 | "UserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 257 | "NickName": "SToneX", 258 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgeticon?seq=1980801123&username=@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 259 | "RemarkName": "", 260 | "PYInitial": "", 261 | "PYQuanPin": "", 262 | "RemarkPYInitial": "", 263 | "RemarkPYQuanPin": "", 264 | "HideInputBarFlag": 0, 265 | "StarFriend": 0, 266 | "Sex": 1, 267 | "Signature": "making the world a better place", 268 | "AppAccountFlag": 0, 269 | "VerifyFlag": 0, 270 | "ContactFlag": 0, 271 | "WebWxPluginSwitch": 0, 272 | "HeadImgFlag": 1, 273 | "SnsFlag": 49 274 | }, 275 | "ChatSet": "filehelper,@@22e2e1f42c303adeda023a1affa9a9be06f94c1d55189cedf28aa5ad78d09466,@@3ebf5e3b9aaae4fb002da88d2f96f1266a9ba0940af389ae188e05fde83004d0,@@fd04c1950a724b4443471dc95aa7ca9ff1af4592666b1de6a754b0f7525514d3,@@f19c122a8ead15ea8c3ad88622695563652c88fc8e864da83874cad4192f6d59,@7a67b2ee7822d1b5b912a164c60c349f5764fc2b74842f01881ee8def5e8915a,@@1b754c7ae914f1223703ece64acbbbf375befa56d77fa02005279f277f223b69,@@232e56f745cca4caa8db2291ea029d5293907dbee850205da04ff4e7054ca0cf,@339651ba569948c2c1f18e547e5fcb49cc2672af2a4ece71ed97e1c3a572858a,@84d7f0d41b6c3ba1f5314fa300f92f14,@@267cd63603075529efed71f38d599ef7b24681adc955b4222321013103d8196c,@43ad02c5b14ed2de8c9c69b92884df94e66300a7ce624cdaef05199963241849,@@ed757bbda181ec895d9531e7c1a6d8abe3ea455e68108e10cff202f44992c577,", 276 | "SKey": "@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 277 | "ClientVersion": 369299473, 278 | "SystemTime": 1463755898, 279 | "GrayScale": 1, 280 | "InviteStartCount": 40, 281 | "MPSubscribeMsgCount": 2, 282 | "MPSubscribeMsgList": [ 283 | { 284 | "UserName": "@a0d0cdaeaf1d3472907419902bb953b6", 285 | "MPArticleCount": 1, 286 | "MPArticleList": [ 287 | { 288 | "Title": "记一次惊心动魄的前端性能优化之旅", 289 | "Digest": "记一次线上事故", 290 | "Cover": "http://mmbiz.qpic.cn/mmbiz/aVp1YC8UV0dg6DicB8n6CoVTHv0TodBdme3KnWahTv6tmycKC8O2tXOF8TNkPqf6OOgWV16meEadJrZbiaueecCA/640?wxtype=jpeg&wxfrom=0|0|0", 291 | "Url": "http://mp.weixin.qq.com/s?__biz=MjM5NTEwMTAwNg==&mid=2650208222&idx=1&sn=312e2be82eb5282951c9b0b6194c8c09&scene=0#rd" 292 | } 293 | ], 294 | "Time": 1463749351, 295 | "NickName": "SegmentFault" 296 | }, 297 | { 298 | "UserName": "@49a171edcd2c7aeab788db4ef39b3175", 299 | "MPArticleCount": 2, 300 | "MPArticleList": [ 301 | { 302 | "Title": "微软官方正式宣布《光环5》Forge地图编辑器将会发布PC版 Xboxone游戏DIY开发将更加便利", 303 | "Digest": "今天(5月20日)微软官方正式宣布《光环5》Forge地图编辑器将会发布Win10版并登陆Win10应用商店", 304 | "Cover": "http://mmbiz.qpic.cn/mmbiz/lH8WicibwIjzprUIQYicDxYkota21ZzhViaNERgqew1T6l4B3eIg66x9tEKZpK8gLYpFKxxKnibpEBdIupxic0RVmkMQ/640?wxtype=jpeg&wxfrom=0|0|0", 305 | "Url": "http://mp.weixin.qq.com/s?__biz=MzA3MzAxNDA2OA==&mid=2706436424&idx=1&sn=db16eefa48ff7404cdcf9f565b75f603&scene=0#rd" 306 | }, 307 | { 308 | "Title": "Xbox360版《使命召唤:黑色行动》销量暴涨130倍 Xboxone兼容效应凸显", 309 | "Digest": "来自英国Amazon的销量统计显示在5月18日Xbox360版《使命召唤:黑色行动 Call of Duty", 310 | "Cover": "http://mmbiz.qpic.cn/mmbiz/lH8WicibwIjzprUIQYicDxYkota21ZzhViaNNzXI4PM2gQrCtEYvSVuGbONxmkboMhcicBVPkLobhrliciaY9w0OibsfUg/300?wxtype=jpeg&wxfrom=0|0|0", 311 | "Url": "http://mp.weixin.qq.com/s?__biz=MzA3MzAxNDA2OA==&mid=2706436424&idx=2&sn=285ba5d92d3120e9bfdbb82269aedff9&scene=0#rd" 312 | } 313 | ], 314 | "Time": 1463747450, 315 | "NickName": "XBOX早知道" 316 | } 317 | ], 318 | "ClickReportInterval": 600000 319 | } -------------------------------------------------------------------------------- /test/response/webwxsync: -------------------------------------------------------------------------------- 1 | { 2 | "BaseResponse": { 3 | "Ret": 0, 4 | "ErrMsg": "" 5 | }, 6 | "AddMsgCount": 2, 7 | "AddMsgList": [ 8 | { 9 | "MsgId": "4442356190966618405", 10 | "FromUserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 11 | "ToUserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 12 | "MsgType": 51, 13 | "Content": "<msg>
<op id='4'>
<username>wxid_7313013130512,filehelper,902809793@chatroom,2914059377@chatroom,1738755346@chatroom,spacelan,3219370221@chatroom,298807678@chatroom,3537286444@chatroom,wxid_h0qgj9zwhp7551,gh_ea5e7ab441b6,gh_3dfda90e39d6,gh_8ee26bcfc5f9,2068844157@chatroom,wxid_zrjrjgl6gj1c22,fei540943389,2540097512@chatroom,gh_8c0180124f84,wxid_8637436368012,gh_7a139c64a53c,qqwanggou001,2328785499@chatroom,gh_8a8cce549a85,169695945@chatroom,wxid_ad0mytsvtomy21,1790339878@chatroom,wxid_5twedgeqi43521,wxid_bfgwvqhbw8ft12,gh_f8a282e7e529,wxid_qoetub8pfvsh12,wxid_vh3nh6splar512,sht1994118,wxid_6e5fuoxvow6f22,chenweijun331977,wxid_xbdb29l1mrml21,gh_1b8e40cada67,q327113864,wxid_wfxt5hgvw62i12,wxid_psd6w419wjsh21,xhxt2008,wxid_0594825947611,1551969831@chatroom,wxid_0872158721211,261901900@chatroom,jning931127,gh_03da264e115e,wxid_1toycb9o3gju12,wxid_65z4e7qe6x6m12,wxid_7ji5bfynk5pa22,leizongmin,wxid_1232152320811,gh_7aac992b0363,wxid_7919069183712,wxid_1330683306611,wxid_hj6fcbgeop1m12,44707134@chatroom,wxid_15ywf6wsm3rs12,wxid_yaj8cf0bvapn52,wskywangxiao,l726726888,gh_5ac2faed86ac,beijixiong155056,wxid_4193851938311,3757820410@chatroom,wxid_7847138493012,wxid_77oep032b9x111,1385638665@chatroom,goddy128,wxid_p7ft5mobcghb21,wxid_v8exw55kp4n112,wxid_yn3uhcmjzqmv11,chenshifan001,chenxujia_0406,cwclovecsm,wxid_bwwcspoy7zen11,wxid_6990419903812,halibotedeyouxiang,629900912@chatroom,mengdase,1285413529@chatroom,wxid_eo2cmpxx136921,wxid_8e9rdl8al14k11,1417327348@chatroom,wxid_4txm7uf3bixb11,gh_3e0c4efe9687,qie1250151726,wxid_4778517786212,wxid_aizz678xr26t51,1003130624@chatroom,a36503294,SSTVXU,gh_5bb9fc8068d9,luanquan,zhaoyuhuanweixin,818644081@chatroom,www_V5_cn,wxid_6043240434612,riobard,wxid_gsbo9hlenvoq22</username>
<unreadchatlist>
<chat>
<username>902809793@chatroom</username>
<lastreadtime>1463754424</lastreadtime>
</chat>
<chat>
<username>2914059377@chatroom</username>
<lastreadtime>1463753124</lastreadtime>
</chat>
<chat>
<username>gh_ea5e7ab441b6</username>
<lastreadtime>1463720034</lastreadtime>
</chat>
<chat>
<username>gh_8c0180124f84</username>
<lastreadtime>1463656879</lastreadtime>
</chat>
<chat>
<username>MomentsUnreadMsgStatus</username>
<lastreadtime>1463158935</lastreadtime>
</chat>
</unreadchatlist>
<unreadfunctionlist>
</unreadfunctionlist>
</op>
</msg>", 14 | "Status": 3, 15 | "ImgStatus": 1, 16 | "CreateTime": 1463755899, 17 | "VoiceLength": 0, 18 | "PlayLength": 0, 19 | "FileName": "", 20 | "FileSize": "", 21 | "MediaId": "", 22 | "Url": "", 23 | "AppMsgType": 0, 24 | "StatusNotifyCode": 4, 25 | "StatusNotifyUserName": "@339651ba569948c2c1f18e547e5fcb49cc2672af2a4ece71ed97e1c3a572858a,filehelper,@@22e2e1f42c303adeda023a1affa9a9be06f94c1d55189cedf28aa5ad78d09466,@@3ebf5e3b9aaae4fb002da88d2f96f1266a9ba0940af389ae188e05fde83004d0,@@fd04c1950a724b4443471dc95aa7ca9ff1af4592666b1de6a754b0f7525514d3,@70ab99ca1c2e4df48de528ae29ce0fbd,@@1b754c7ae914f1223703ece64acbbbf375befa56d77fa02005279f277f223b69,@@180fa413a597f0f313529f756a974f021ba02ff8f8388c1588c76b844fa71935,@@f19c122a8ead15ea8c3ad88622695563652c88fc8e864da83874cad4192f6d59,@7a67b2ee7822d1b5b912a164c60c349f5764fc2b74842f01881ee8def5e8915a,@d89e585741b44686b43781cbd83fcdda,@97109f88e39691870bd7b691693ef2ad,@abd907b0a27c909b474822c71e34d0af,@@232e56f745cca4caa8db2291ea029d5293907dbee850205da04ff4e7054ca0cf,@8e6e7e848881943024373475e5eb2137f62761d7c137c3f2337222e2466e292c,@84d7f0d41b6c3ba1f5314fa300f92f14,@@267cd63603075529efed71f38d599ef7b24681adc955b4222321013103d8196c,@b2eaf06e6fca0a59091aec4dc53e546c,@43ad02c5b14ed2de8c9c69b92884df94e66300a7ce624cdaef05199963241849,@5228c41c5864e6876061fd96a3676ef2,@973a9a9c494ea40fe42143adcdedbd84,@@ed757bbda181ec895d9531e7c1a6d8abe3ea455e68108e10cff202f44992c577,@1b7659d7cba811e5781426a24ed7af40,@@bdb11cc12d5b6134c18bafb3870bb18f3981b4b19e90fa83ea55a57ef1c24e05,@e532dcd6984a21d1f8c40d9ee14ab79ad31f0bf1bf3183215668a7c0e1eeaf1f,@@5a95f75e97fce4ac94e00ca84df8028fc95ac88784facad9462b10eab8bdda76,@6e503d143b232ea63caa4489215683cd930ac62e71b7615eecdecdadcc455fd2,@3b233e3bf704f036c441d0d2ad1f9cabe0ff2fbc87a0f4f2c57e48c39aeb28ed,@2e8eefceee672de78b9921dd2bd931e5,@6821db112aa438d0b408a14196493f59d14116a3153b00bfb83586f3fc23e5b1,@2af4f90d933ba52fffe0e558d551bb871e004197808f83f9ad21ae3f92f4de83,@16fa931dba40ade8a4ea6e7c898ba5e1,@a5d0dfda5a1345ee07f5623e22ee05309bf306f3e9e7fa5f95421b15a1ef15fe,@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8,@510073889f1172c62900fbde22a9f6a147b535a1f0b066bb22e7318661e13113,@a7e4ac5ed53e8126c6bc291d4f54018b,@77606b6ef1f4664c6077d19c39b925d4,@106c13e1bd6fef28fa4327e23b960933e0d63c39c0f04c4450b1a66552211e8d,@944995d2a2106a4544d2752e605ef09fdd5698e63b9fa40e9c824b5388dd73f8,@489901ee910fb3a6ad6439e184f07fb6,@f6e939701047a89b52d071251e80a374206b4bf1eeb9140498a01076134933f6,@@2506a204db5af496004ed404bfa12959031f868bd77f0f311be474c883da0463,@dd8f7e98b68df1dd30206a19724761c282ad491a090de98d66239e0895808c7a,@@b23663d8223d095f09955211032a62893ee604b78d5c731089f84b4cdf6f6234,@913d088123f85a47dec47a05fb7349c6,@13312c0060d14d05ce45ec6f5c738213,@946a23b9c0025311114d7002190ddfce78a7ab1030e1961ccbb6342262ed45f2,@5850474942d686dac2372900d508ecf96777ce265e90d1f33739c38d62fad2c1,@ee5481813de66d3db4bf84153dd7c257c2961e151fca9ca31592a703be035ccf,@f842fe4a42b4ac43495d73e866ca62f8,@85912117ff22032e16b6ba62d525b4a0cb360c06f91b58a2745fdbf1c2b48346,@7bb52d4e8462f667f10ed05de44dfc3b,@4fcb8ac339e5250fee41781ebcd6bf8cb8caf0ea455b84a3bf690e1e8ec7ef49,@3d0cf36747a5f8a9299a6a4b8d7551e05d613c94618996b28b2716b74168728f,@3263b6785dbf48511e4e7b622080d0c62c84668f824fa124394b2d3e3ddb279e,@@0b59b0670e3c529a6a38a1b1a1460514fadc462307a18cf981791aaaa590d76d,@e120944a65eba3d106767dcdd033fff191713ee63b6f4b21855001d57f32ca77,@fe6d418bb94e6d775c449c944f44dd3ef33d1c8a61a06dacadf0f77a3a698f0b,@42fc42968c82e6ef6c5f373c8b460443,@947fc8e6b8bdd97a1bb4c6baf5b414eb,@d58b074a91f1d008f63fd0af3221e722,@45ca12e75c49deadb31d65b1c88204f5e76abbcc72484bb92b6c9e26961c5699,@1fefaaba7e843cea1b71a9992ed9f0c7fb38bdf8496e3a73e8479369c1b53de3,@@2ac25e5869f6d8a0369f4d333a554f1f11f26f800c2d6edb576dea836d864cf6,@51c41a81f0802376e26ad0d3c2fbc89c89e9206555621cacae0c4e967c8c9ad1,@955976a16011603f3c8533697c72aa4d7aba9290a9c295b8d79c61ca39eb7ad6,@@476fe5b2b15ac216a727cb09f3bc260897ed8d7448049672aef11b63ddadc28a,@411b3d15d08bef67d797ae7f0978d1e3,@6648d6570c26491b69c94857fba82abbd0ea783badc198d5dd6630c09b365b4d,@fe89ad3f479b5d0b3319443b01718d1396111e8f77eca218c22928b5c00ab1cf,@2d0c47fd0fce4872536191e9dfe55d99590f0ea6fe17f5df050fb8307386e9ed,@05c4ff9934f2bd8a7aec830086851aab,@925f8b60fd7d13129eb572313fab5212,@d1c9e9dcc37407877870e188fc9888fa,@1a37dfe536da44aa9f84f60c28c16e6f905179474f773591f6523883743d4d50,@b136de5596d3a7da4d965a088b2284fd224c756ccf2f8a219b37bbf5ad7fd2be,@7962f256818fceaec3b958d92ded8ee20dcee8c843a3a3757362405ed2131896,@@3fbf03446ab041dd4e652942cdc6bf343a44248f9e7ae25ee823d4a92ebddbdb,@544820fc7fd9c197dc46d9bdb5abdbf5,@@b3622827e463af2667ebe4c408d89e781a628ef557a87df6dcf5adfbdb7b06b3,@f2ec960d1736e9fd23a71cabf8a978900a3413ae78fd4bb9387eb1a8d306249f,@ed137e1c8513f0e6dc56f27b34f4380eefc0471a5fc080675c64f11c1ba27e90,@@73ee96919fddc6092eabcb27438eba628bc56df2bb5f89ee01368d6cb817d8af,@b2d92a727462efa35b7e8d33d31a3770208b750559bb454902d937c873d1e646,@358505f3cf454c1eca7177af2af1b846,@6329ec4916697109f731dab67c2c991d,@7c2530024418b1c94d5d7fb0f696793ac2b6edf806010bcc6ee7ce7e0229d52d,@b7f0b9a12a4e515efd797898437364b01135fad314d8b4fd3e2e6579606d5a2a,@@0d03fd688236be730d875af6a81d50f1dffbee01e7e23b1f06850e0150395981,@d53c6d659f5ef174bdc3c58b449ae27c,@df186b358f43330a231fa296f0c85e25,@4843f6c9705ed2f2fbe8c1cccd344cbe,@9fc24b6488400c5132c32ab8dbc0d59b,@970afe19850fae35664ce3992061b0167a6c1a13ee0e6b959ac93a1eaa29fa1f,@@9ed64a46473dbf59ca76bd021ec280333f6b49f69d09b5da270981f7b7ebcb48,@03c96f47661b3ab4535b525e6ff363a4,@55d1b9bf453003b86b0829db529171766c0aa13763b5f5b32a8c531a332d5064,@c78beceecfd88c03289a302d2c9c6407,@eaf122e72abe21c72a13ac9d6224c15ad99bfa6fc317fca584a1140cc59e2f63", 26 | "RecommendInfo": { 27 | "UserName": "", 28 | "NickName": "", 29 | "QQNum": 0, 30 | "Province": "", 31 | "City": "", 32 | "Content": "", 33 | "Signature": "", 34 | "Alias": "", 35 | "Scene": 0, 36 | "VerifyFlag": 0, 37 | "AttrStatus": 0, 38 | "Sex": 0, 39 | "Ticket": "", 40 | "OpCode": 0 41 | }, 42 | "ForwardFlag": 0, 43 | "AppInfo": { 44 | "AppID": "", 45 | "Type": 0 46 | }, 47 | "HasProductId": 0, 48 | "Ticket": "", 49 | "ImgHeight": 0, 50 | "ImgWidth": 0, 51 | "SubMsgType": 0, 52 | "NewMsgId": 4442356190966618405 53 | }, 54 | { 55 | "MsgId": "6599448710267926107", 56 | "FromUserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 57 | "ToUserName": "@2ea0ad63b5550c8842c95cb987adea2491fc9e3db0899696f696149cd30688e8", 58 | "MsgType": 1, 59 | "Content": "Hello World", 60 | "Status": 3, 61 | "ImgStatus": 1, 62 | "CreateTime": 1463839052, 63 | "VoiceLength": 0, 64 | "PlayLength": 0, 65 | "FileName": "", 66 | "FileSize": "", 67 | "MediaId": "", 68 | "Url": "", 69 | "AppMsgType": 0, 70 | "StatusNotifyCode": 0, 71 | "StatusNotifyUserName": "", 72 | "RecommendInfo": { 73 | "UserName": "", 74 | "NickName": "", 75 | "QQNum": 0, 76 | "Province": "", 77 | "City": "", 78 | "Content": "", 79 | "Signature": "", 80 | "Alias": "", 81 | "Scene": 0, 82 | "VerifyFlag": 0, 83 | "AttrStatus": 0, 84 | "Sex": 0, 85 | "Ticket": "", 86 | "OpCode": 0 87 | }, 88 | "ForwardFlag": 0, 89 | "AppInfo": { 90 | "AppID": "", 91 | "Type": 0 92 | }, 93 | "HasProductId": 0, 94 | "Ticket": "", 95 | "ImgHeight": 0, 96 | "ImgWidth": 0, 97 | "SubMsgType": 0, 98 | "NewMsgId": 6599448710267926107 99 | } 100 | ], 101 | "ModContactCount": 1, 102 | "ModContactList": [ 103 | { 104 | "UserName": "@@3ebf5e3b9aaae4fb002da88d2f96f1266a9ba0940af389ae188e05fde83004d0", 105 | "NickName": "Vue.js Podcast 群二", 106 | "Sex": 0, 107 | "HeadImgUpdateFlag": 1, 108 | "ContactType": 0, 109 | "Alias": "", 110 | "ChatRoomOwner": "@944995d2a2106a4544d2752e605ef09fdd5698e63b9fa40e9c824b5388dd73f8", 111 | "HeadImgUrl": "/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=0&username=@@3ebf5e3b9aaae4fb002da88d2f96f1266a9ba0940af389ae188e05fde83004d0&skey=@crypt_8e4ad7fa_2703a47aaf8cd4d3e61b855795e38568", 112 | "ContactFlag": 3, 113 | "MemberCount": 462, 114 | "MemberList": [ 115 | { 116 | "Uin": 195765975, 117 | "UserName": "@e0ec8ddd68b83d04a51839008fdaa78e", 118 | "NickName": "吃圡死月不會碼代碼", 119 | "AttrStatus": 37851687, 120 | "PYInitial": "", 121 | "PYQuanPin": "", 122 | "RemarkPYInitial": "", 123 | "RemarkPYQuanPin": "", 124 | "MemberStatus": 0, 125 | "DisplayName": "", 126 | "KeyWord": "" 127 | } 128 | ], 129 | "HideInputBarFlag": 0, 130 | "Signature": "", 131 | "VerifyFlag": 0, 132 | "RemarkName": "", 133 | "Statues": 0, 134 | "AttrStatus": 0, 135 | "Province": "", 136 | "City": "", 137 | "SnsFlag": 0, 138 | "KeyWord": "" 139 | } 140 | ], 141 | "DelContactCount": 0, 142 | "DelContactList": [], 143 | "ModChatRoomMemberCount": 0, 144 | "ModChatRoomMemberList": [], 145 | "Profile": { 146 | "BitFlag": 0, 147 | "UserName": { 148 | "Buff": "" 149 | }, 150 | "NickName": { 151 | "Buff": "" 152 | }, 153 | "BindUin": 0, 154 | "BindEmail": { 155 | "Buff": "" 156 | }, 157 | "BindMobile": { 158 | "Buff": "" 159 | }, 160 | "Status": 0, 161 | "Sex": 0, 162 | "PersonalCard": 0, 163 | "Alias": "", 164 | "HeadImgUpdateFlag": 0, 165 | "HeadImgUrl": "", 166 | "Signature": "" 167 | }, 168 | "ContinueFlag": 0, 169 | "SyncKey": { 170 | "Count": 10, 171 | "List": [ 172 | { 173 | "Key": 1, 174 | "Val": 636444437 175 | }, 176 | { 177 | "Key": 2, 178 | "Val": 636445051 179 | }, 180 | { 181 | "Key": 3, 182 | "Val": 636444675 183 | }, 184 | { 185 | "Key": 11, 186 | "Val": 636445020 187 | }, 188 | { 189 | "Key": 13, 190 | "Val": 636434059 191 | }, 192 | { 193 | "Key": 201, 194 | "Val": 1463755899 195 | }, 196 | { 197 | "Key": 203, 198 | "Val": 1463752834 199 | }, 200 | { 201 | "Key": 1000, 202 | "Val": 1463747283 203 | }, 204 | { 205 | "Key": 1001, 206 | "Val": 1463740232 207 | }, 208 | { 209 | "Key": 1002, 210 | "Val": 1463719049 211 | } 212 | ] 213 | }, 214 | "SKey": "" 215 | } -------------------------------------------------------------------------------- /test/unit.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node, mocha */ 2 | import {expect} from 'chai' 3 | 4 | import * as util from '../src/util' 5 | import MessageFactory from '../src/interface/message' 6 | import ContactFactory, * as contactMethod from '../src/interface/contact' 7 | 8 | describe('Util', () => { 9 | it('is not browser', () => { 10 | expect(util.isStandardBrowserEnv).to.equal(false) 11 | }) 12 | 13 | it('is function', () => { 14 | expect(util.isFunction(() => {})).to.equal(true) 15 | expect(util.isFunction(null)).to.equal(false) 16 | }) 17 | 18 | it('convert emoji method', () => { 19 | expect(util.convertEmoji('').charCodeAt()).to.equal(42) 20 | expect(util.convertEmoji('')).to.equal('') 21 | expect(util.convertEmoji(undefined)).to.equal('') 22 | }) 23 | 24 | it('format num', () => { 25 | expect(util.formatNum(0, 2)).to.equal('00') 26 | expect(util.formatNum(2, 2)).to.equal('02') 27 | expect(util.formatNum(20, 2)).to.equal('20') 28 | }) 29 | }) 30 | 31 | describe('Message interface', () => { 32 | describe('Message: ', () => { 33 | let immutNewMessage1 = { 34 | FromUserName: 'test', 35 | Content: '<a>
bc' 36 | } 37 | 38 | let immutNewMessage2 = { 39 | FromUserName: '123', 40 | Content: '<a>
bc' 41 | } 42 | 43 | let Message 44 | let newMessage1 45 | let newMessage2 46 | 47 | beforeEach(() => { 48 | Message = MessageFactory({user: {UserName: 'test'}}) 49 | newMessage1 = Message.extend(immutNewMessage1) 50 | newMessage2 = Message.extend(immutNewMessage2) 51 | }) 52 | 53 | it('property stable', () => { 54 | expect(newMessage1.FromUserName).to.equal('test') 55 | }) 56 | 57 | it('content parse', () => { 58 | expect(newMessage1.Content).to.equal('\nbc') 59 | }) 60 | 61 | it('isSendBySelf', () => { 62 | expect(newMessage1.isSendBySelf).to.equal(true) 63 | expect(newMessage2.isSendBySelf).to.equal(false) 64 | }) 65 | 66 | it('isSendBy', () => { 67 | expect(newMessage2.isSendBy({UserName: '123'})).to.equal(true) 68 | expect(newMessage2.isSendBy({UserName: 'test'})).to.equal(false) 69 | }) 70 | }) 71 | }) 72 | 73 | describe('Contact interface: ', () => { 74 | describe('method: ', () => { 75 | it('get user by UserName', () => { 76 | var user = {UserName: 'test'} 77 | var list = [user] 78 | 79 | expect(contactMethod.getUserByUserName(list, 'test')).to.equal(user) 80 | }) 81 | 82 | it('is room contact', () => { 83 | expect(contactMethod.isRoomContact({UserName: '@@123'})).to.equal(true) 84 | expect(contactMethod.isRoomContact({UserName: '123'})).to.equal(false) 85 | }) 86 | }) 87 | 88 | describe('Contact: ', () => { 89 | let immutUser1 = { 90 | UserName: 'test', 91 | NickName: 'test', 92 | HeadImgUrl: '/test' 93 | } 94 | 95 | let immutUser2 = { 96 | UserName: '@@test', 97 | NickName: 'test', 98 | HeadImgUrl: '/test' 99 | } 100 | 101 | let immutInstance = { 102 | user: immutUser1, 103 | baseUri: 'https://wx2.qq.com/', 104 | contacts: {} 105 | } 106 | 107 | let Contact 108 | let instance 109 | 110 | beforeEach(() => { 111 | instance = Object.assign({}, immutInstance) 112 | 113 | Contact = ContactFactory(instance) 114 | 115 | instance.contacts[immutUser1.UserName] = Contact.extend(immutUser1) 116 | instance.contacts[immutUser2.UserName] = Contact.extend(immutUser2) 117 | }) 118 | 119 | it('property stable', () => { 120 | const user1 = instance.contacts[immutUser1.UserName] 121 | expect(user1.NickName).to.equal('test') 122 | }) 123 | 124 | it('getDisplayName', () => { 125 | const user1 = instance.contacts[immutUser1.UserName] 126 | expect(user1.getDisplayName()).to.equal('test') 127 | }) 128 | 129 | it('can search', () => { 130 | const user1 = instance.contacts[immutUser1.UserName] 131 | const user2 = instance.contacts[immutUser2.UserName] 132 | expect(user1.canSearch('te')).to.equal(true) 133 | expect(user2.canSearch('123')).to.equal(false) 134 | }) 135 | 136 | it('isSelf', () => { 137 | const user1 = instance.contacts[immutUser1.UserName] 138 | const user2 = instance.contacts[immutUser2.UserName] 139 | expect(user1.isSelf).to.equal(true) 140 | expect(user2.isSelf).to.equal(false) 141 | }) 142 | 143 | it('get user by username', () => { 144 | const user1 = instance.contacts[immutUser1.UserName] 145 | expect(Contact.getUserByUserName('test')).to.equal(user1) 146 | }) 147 | 148 | it('get search user', () => { 149 | const user1 = instance.contacts[immutUser1.UserName] 150 | expect(Contact.getSearchUser('te')[0]).to.equal(user1) 151 | expect(Contact.getSearchUser('123').length).to.equal(0) 152 | }) 153 | 154 | it('isRoomContact', () => { 155 | const user1 = instance.contacts[immutUser1.UserName] 156 | const user2 = instance.contacts[immutUser2.UserName] 157 | expect(Contact.isRoomContact(user2)).to.equal(true) 158 | expect(Contact.isRoomContact(user1)).to.equal(false) 159 | }) 160 | }) 161 | }) 162 | --------------------------------------------------------------------------------