├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── cli.js ├── lib ├── commands.js ├── consts.js ├── logger.js ├── wechat_base.js └── wechat_client.js ├── package.json ├── res ├── wx20160408.js └── wx20160629.js └── test └── webchat_client.js /.eslintignore: -------------------------------------------------------------------------------- 1 | res/ 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "node": true, 5 | "mocha": true, 6 | "es6": true 7 | }, 8 | "rules": { 9 | "indent": [2, 2, {"SwitchCase": 1}], 10 | "quotes": [2, "single"], 11 | "curly": 2, 12 | "no-eval": 2, 13 | "no-console": 0, 14 | "no-use-before-define": [2, "nofunc"], 15 | "no-unused-vars": [2, {"args":"none"}], 16 | "no-inline-comments": 0, 17 | "camelcase": 0, 18 | "no-warning-comments": 0, 19 | "comma-dangle": [1, "always-multiline"], 20 | "arrow-parens": [2, "always"], 21 | "space-before-function-paren": [2, "never"], 22 | "object-curly-spacing": [2, "always"], 23 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.0" 4 | - "4.1" 5 | - "4.0" 6 | sudo: false 7 | branches: 8 | only: 9 | - master 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 goorockey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-wechat-terminal 2 | 3 | [![NPM](https://nodei.co/npm/node-wechat-terminal.png?downloads=true)](https://nodei.co/npm/node-wechat-terminal) 4 | 5 | [![Build Status](https://travis-ci.org/goorockey/node-wechat-terminal.svg?branch=master)](https://travis-ci.org/goorockey/node-wechat-terminal) 6 | [![node version](https://img.shields.io/badge/node.js-%3E%3D4.0.0-brightgreen.svg)](http://nodejs.org/download) 7 | [![Codacy Badge](https://api.codacy.com/project/badge/grade/892f526d24c34902aca382a4e35b0842)](https://www.codacy.com/app/kelvingu616/node-wechat-terminal) 8 | [![dependencies](https://david-dm.org/goorockey/node-wechat-terminal.png)](https://david-dm.org/goorockey/node-wechat-terminal) 9 | [![devDependencies](https://david-dm.org/goorockey/node-wechat-terminal/dev-status.png)](https://david-dm.org/goorockey/node-wechat-terminal#info=devDependencies) 10 | 11 | Wechat client in terminal 12 | 13 | ## Requirement 14 | 15 | - nodejs (>=4.0.0) 16 | 17 | ## Getting Started 18 | 19 | $ npm install -g node-wechat-terminal 20 | $ wechat-terminal 21 | 22 | (Login by scanning QRCode) 23 | 24 | UserName> \h # show help message 25 | COMMAND DESCRIPTION 26 | \h Print this help information 27 | \logout Logout 28 | \user Display user info 29 | \chat List chat or select chat target by index 30 | \contact List contact or select chat target by index 31 | \back Quit chat 32 | \search Search in contact 33 | \history Display history of chat 34 | \room List room in contact 35 | \member List member of room 36 | 37 | ## Features 38 | 39 | - List contacts 40 | 41 | Me>\contact 42 | Contacts: 43 | #0 Me 44 | #1 James 45 | #2 Stephen 46 | ... 47 | 48 | - Search user in contacts 49 | 50 | Me>\search a 51 | #1 James 52 | #6 Harden 53 | 54 | - Select target to chat by contact index, and send message 55 | 56 | Me>\contact 1 57 | Me => James>Hi 58 | 59 | - Display chat history 60 | 61 | Me => James>\history 62 | Chat history with James: 63 | TIME FROM TO MESSAGE 64 | 14:00:00 Me James Hi 65 | 66 | - Logout 67 | 68 | Me>\logout # or Ctrl-C / Ctrl-D 69 | 70 | ## Inspired by 71 | 72 | - [uProxy_wechat](https://github.com/LeMasque/uProxy_wechat) 73 | 74 | ## License 75 | 76 | MIT 77 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | var readline = require('readline'); 5 | var chalk = require('chalk'); 6 | var WechatClient = require('./lib/wechat_client'); 7 | var logger = require('./lib/logger'); 8 | var commands = require('./lib/commands'); 9 | var notifier = require('node-notifier'); 10 | 11 | 12 | var wechat = new WechatClient(); 13 | var rl = readline.createInterface({ 14 | input: process.stdin, 15 | output: process.stdout, 16 | terminal: true, 17 | completer: completer, 18 | }); 19 | 20 | wechat.on(WechatClient.EVENTS.ERROR, () => { rl.close(); }); 21 | wechat.on(WechatClient.EVENTS.CHAT_CHANGE, () => { updatePrompt(); }); 22 | wechat.on(WechatClient.EVENTS.LOGIN, () => { startConsole(); }); 23 | wechat.on(WechatClient.EVENTS.LOGOUT, () => { 24 | logger.info('Logout.'); 25 | rl.close(); 26 | }); 27 | wechat.on(WechatClient.EVENTS.MESSAGE, (data) => { 28 | rl.prompt(true); 29 | notifier.notify({ 30 | title: data.from, 31 | message: data.message, 32 | }); 33 | }); 34 | 35 | wechat.login(); 36 | 37 | function startConsole() { 38 | logger.info('Login successfully.'); 39 | 40 | updatePrompt(); 41 | rl.prompt(true); 42 | 43 | rl.on('line', onUserInput) 44 | .on('SIGINT', onPreExit) 45 | .on('close', onExit); 46 | } 47 | 48 | function onUserInput(msg) { 49 | rl.pause(); 50 | commands.parse(msg, wechat); 51 | rl.prompt(true); 52 | } 53 | 54 | function onPreExit() { 55 | rl.question('Are you sure you want to exit?(y/N)', (answer) => { 56 | if (answer.match(/^y(es)?$/i)) { 57 | rl.close(); 58 | } else { 59 | rl.prompt(true); 60 | } 61 | }); 62 | } 63 | 64 | function onExit() { 65 | wechat.logout().then(() => { process.exit(0); }); 66 | } 67 | 68 | function updatePrompt() { 69 | var prompt = 'wechat'; 70 | if (wechat.isLogined()) { 71 | prompt = chalk.bold.blue(wechat.getUser()); 72 | 73 | var chat = wechat.getChat(); 74 | if (chat) { 75 | prompt += chalk.yellow(' => ') + chalk.bold.blue(chat); 76 | } 77 | } 78 | rl.setPrompt(prompt + chalk.yellow('> ')); 79 | } 80 | 81 | function completer(line) { 82 | var hits = commands.ALL_CMD.filter((c) => { return c.indexOf(line) === 0; }); 83 | return [hits.length ? hits : commands.ALL_CMD, line]; 84 | } 85 | -------------------------------------------------------------------------------- /lib/commands.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var logger = require('./logger'); 3 | var columnify = require('columnify'); 4 | var WechatClient = require('./wechat_client'); 5 | 6 | 7 | const CMD_MAP = { 8 | '\\h': ['Print this help information', help], 9 | '\\logout': ['Logout', WechatClient.prototype.logout], 10 | '\\user': ['Display user info', WechatClient.prototype.displayUserInfo], 11 | '\\chat': ['List chat or select chat target by index', WechatClient.prototype.listChat], 12 | '\\contact': ['List contact or select chat target by index', WechatClient.prototype.listContact], 13 | '\\back': ['Quit chat', WechatClient.prototype.quitChat], 14 | '\\search': ['Search in contact', WechatClient.prototype.searchContact], 15 | '\\history': ['Display history of chat', WechatClient.prototype.chatHistory], 16 | '\\room': ['List room in contact', WechatClient.prototype.listRoom], 17 | '\\member': ['List member of room', WechatClient.prototype.listMember], 18 | }; 19 | 20 | const DEBUG_CMD_MAP = { 21 | '\\debug': ['Debug mode', debug], 22 | '\\network': ['Display network history', WechatClient.prototype.displayNetworkHistory], 23 | }; 24 | 25 | const ALL_CMD = exports.ALL_CMD = _.keys(CMD_MAP); 26 | const ALL_DEBUG_CMD = _.keys(DEBUG_CMD_MAP); 27 | 28 | const EXTRA_HELP_INFO = '**CAUTIOUS**: After select chat target, input is sent after ENTER!'; 29 | 30 | function help() { 31 | var data = _.transform(CMD_MAP, (result, value, key) => { 32 | result[key] = value[0]; 33 | }); 34 | console.log(columnify(data, { 35 | columns: ['COMMAND', 'DESCRIPTION'], 36 | minWidth: 20, 37 | })); 38 | 39 | console.log('\n' + EXTRA_HELP_INFO); 40 | } 41 | 42 | function debug() { 43 | var levels = ['debug', 'info']; 44 | var index = levels.indexOf(logger.transports.console.level); 45 | index = Math.max((index + 1) % levels.length, 0); 46 | logger.transports.console.level = levels[index]; 47 | logger.debug('Log mode: ' + levels[index]); 48 | } 49 | 50 | exports.parse = function(input, wechat) { 51 | if (!input) { 52 | return; 53 | } 54 | 55 | // if in chat, send message directly 56 | if (!input.startsWith('\\') && wechat.getChat()) { 57 | wechat.sendMsg(input); 58 | return; 59 | } 60 | 61 | var cmd = _.trim(input.split(' ')[0]); 62 | if (!_.includes(ALL_CMD, cmd) && !_.includes(ALL_DEBUG_CMD, cmd)) { 63 | logger.error(`Command not found: ${cmd}. Use \h to get help.`); 64 | return; 65 | } 66 | 67 | var args = _.trim(input.slice(cmd.length + 1)); 68 | var func = (CMD_MAP[cmd] || DEBUG_CMD_MAP[cmd])[1]; 69 | func.bind(wechat)(args); 70 | }; 71 | 72 | -------------------------------------------------------------------------------- /lib/consts.js: -------------------------------------------------------------------------------- 1 | exports.DOMAIN_LIST = [ 2 | 'wx2.qq.com', 'qq.com', 3 | 'web1.wechat.com', 'web2.wechat.com', 4 | 'wechat.com', 'web1.wechatapp.com', 'wechatapp.com', 5 | ] 6 | 7 | exports.DOMAIN = { 8 | 'qq.com': { 9 | 'login': 'https://login.weixin.qq.com', 10 | 'sync': 'https://wx.qq.com', 11 | 'web': 'https://wx.qq.com', 12 | 'file': 'https://file.wx.qq.com', 13 | }, 14 | 'wx2.qq.com': { 15 | 'login': 'https://login.weixin.qq.com', 16 | 'sync': 'https://webpush2.weixin.qq.com', 17 | 'web': 'https://wx2.qq.com', 18 | 'file': 'https://file2.wx.qq.com', 19 | }, 20 | 'web1.wechat.com': { 21 | 'login': 'https://login.wechat.com', 22 | 'sync': 'https://webpush1.wechat.com', 23 | 'web': 'https://web1.wechat.com', 24 | 'file': 'https://file1.wechat.com', 25 | }, 26 | 'web2.wechat.com': { 27 | 'login': 'https://login.wechat.com', 28 | 'sync': 'https://webpush2.wechat.com', 29 | 'web': 'https://web2.wechat.com', 30 | 'file': 'https://file2.wechat.com', 31 | }, 32 | 'wechat.com': { 33 | 'login': 'https://login.wechat.com', 34 | 'sync': 'https://webpush.wechat.com', 35 | 'web': 'https://web.wechat.com', 36 | 'file': 'https://file.wechat.com', 37 | }, 38 | 'web1.wechatapp.com': { 39 | 'login': 'https://login.wechatapp.com', 40 | 'sync': 'https://webpush1.wechatapp.com', 41 | 'web': 'https://web1.wechatapp.com', 42 | 'file': 'https://file1.wechatapp.com', 43 | }, 44 | 'wechatapp.com': { 45 | 'login': 'https://login.wechatapp.com', 46 | 'sync': 'https://webpush.wechatapp.com', 47 | 'web': 'https://web.wechatapp.com', 48 | 'file': 'https://file.wechatapp.com', 49 | }, 50 | }; 51 | 52 | exports.URL = { 53 | // login 54 | JSLOGIN: '/jslogin', 55 | LOGIN_QRCODE: '/l/', 56 | LOGIN_QRCODE_FETCH: '/qrcode/', 57 | CHECK_LOGIN: '/cgi-bin/mmwebwx-bin/login', 58 | 59 | // sync 60 | SYNC_CHECK: '/cgi-bin/mmwebwx-bin/synccheck', 61 | 62 | // web 63 | LOGOUT: '/cgi-bin/mmwebwx-bin/webwxlogout', 64 | INIT: '/cgi-bin/mmwebwx-bin/webwxinit', 65 | SYNC: '/cgi-bin/mmwebwx-bin/webwxsync', 66 | GET_CONTACT: '/cgi-bin/mmwebwx-bin/webwxgetcontact', 67 | BATCH_GET_CONTACT: '/cgi-bin/mmwebwx-bin/webwxbatchgetcontact', 68 | SEND_MSG: '/cgi-bin/mmwebwx-bin/webwxsendmsg', 69 | NOTIFY_MOBILE: '/cgi-bin/mmwebwx-bin/webwxstatusnotify', 70 | }; 71 | 72 | exports.WX_APP_ID = 'wx782c26e4c19acffb'; 73 | exports.TIMEOUT_SYNC_CHECK = 3000; 74 | exports.TIMEOUT_LONG_PULL = 35000; 75 | exports.MAX_NETWORK_HISTORY = 50; 76 | exports.MAX_CHAT_HISTORY = 80; 77 | 78 | exports.STATUS_NOTIFY = { 79 | READED: 1, 80 | ENTER_SESSION: 2, 81 | INITED: 3, 82 | SYNC_CONV: 4, 83 | QUIT_SESSION: 5, 84 | }; 85 | 86 | exports.MSG_TYPE = { 87 | TEXT: 1, 88 | IMAGE: 3, 89 | VOICE: 34, 90 | VIDEO: 43, 91 | MICROVIDEO: 62, 92 | EMOTICON: 47, 93 | APP: 49, 94 | VOIPMSG: 50, 95 | VOIPNOTIFY: 52, 96 | VOIPINVITE: 53, 97 | LOCATION: 48, 98 | STATUSNOTIFY: 51, 99 | SYSNOTICE: 9999, 100 | POSSIBLEFRIEND_MSG: 40, 101 | VERIFYMSG: 37, 102 | SHARECARD: 42, 103 | SYS: 10000, 104 | RECALLED: 10002, 105 | }; 106 | 107 | exports.CONTACT_FLAG = { 108 | CONTACT: 1, 109 | CHATCONTACT: 2, 110 | SUBSCRIBE: 3, 111 | CHATROOMCONTACT: 4, 112 | BLACKLISTCONTACT: 8, 113 | DOMAINCONTACT: 16, 114 | HIDECONTACT: 32, 115 | FAVOURCONTACT: 64, 116 | SNSBLACKLISTCONTACT: 256, 117 | NOTIFYCLOSECONTACT: 512, 118 | TOPCONTACT: 2048, 119 | }; 120 | 121 | exports.PROFILE_BITFLAG = { 122 | NOCHANGE: 0, 123 | CHANGE: 190, 124 | }; 125 | 126 | exports.STATUS_NOTIFY_CODE = { 127 | READED: 1, 128 | ENTER_SESSION: 2, 129 | INITED: 3, 130 | SYNC_CONV: 4, 131 | QUIT_SESSION: 5, 132 | }; 133 | 134 | exports.CHATROOM_NOTIFY = { 135 | OPEN: 1, 136 | CLOSE: 0, 137 | }; 138 | 139 | exports.SEX = { 140 | MALE: 1, 141 | FEMALE: 2, 142 | }; 143 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | 3 | var logger = new (winston.Logger)({ 4 | transports: [ 5 | new (winston.transports.Console)({ json: false, colorize: true, level: 'info' }), 6 | // new winston.transports.File({ filename: __dirname + '/../debug.log', json: false }) 7 | ], 8 | exceptionHandlers: [ 9 | new (winston.transports.Console)({ json: true, timestamp: true, colorize: true }), 10 | // new winston.transports.File({ filename: __dirname + '/../exceptions.log', json: false }) 11 | ], 12 | }); 13 | 14 | module.exports = logger; 15 | -------------------------------------------------------------------------------- /lib/wechat_base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var EventEmitter = require('events'); 5 | 6 | var consts = require('./consts'); 7 | 8 | module.exports = class WechatBase extends EventEmitter { 9 | 10 | constructor() { 11 | super(); 12 | 13 | this._initData(); 14 | } 15 | 16 | _initData() { 17 | this.loginData = {}; 18 | this.user = {}; 19 | this.chat = {}; 20 | this.chatList = []; 21 | this.contacts = {}; 22 | this.contactList = []; 23 | } 24 | 25 | _isSelf(user) { 26 | var username = user.UserName || user; 27 | return username && (username === this.user.UserName); 28 | } 29 | 30 | isLogined() { 31 | return Boolean(this.loginData.skey && this.loginData.sid && this.loginData.uin); 32 | } 33 | 34 | static isContact(user) { 35 | return user.UserName.startsWith('@') && 36 | Boolean(user.ContactFlag & consts.CONTACT_FLAG.CONTACT) && 37 | !WechatBase.isSubscribe(user); 38 | } 39 | 40 | static isSubscribe(user) { 41 | return Number.parseInt(user.ContactFlag, 10) === consts.CONTACT_FLAG.SUBSCRIBE; 42 | } 43 | 44 | static isRoomContact(user) { 45 | var name = user.UserName || user; 46 | return name && /^@@|@chatroom$/.test(name); 47 | } 48 | 49 | static isMuted(user) { 50 | if (!user) { 51 | return false; 52 | } 53 | 54 | return WechatBase.isRoomContact(user) ? 55 | Number.parseInt(user.Statues, 10) === consts.CHATROOM_NOTIFY.CLOSE : 56 | Boolean(user.ContactFlag & consts.CONTACT_FLAG.NOTIFYCLOSECONTACT); 57 | } 58 | 59 | _updateLoginData(loginData) { 60 | return _.extend(this.loginData, loginData); 61 | } 62 | 63 | _setUserInfo(userInfo) { 64 | return _.extend(this.user, userInfo); 65 | } 66 | 67 | _updateUserInfo(userInfo) { 68 | if (_.isEmpty(userInfo) || 69 | Number.parseInt(userInfo.BitFlag, 10) !== consts.PROFILE_BITFLAG.CHANGE) { 70 | return; 71 | } 72 | 73 | if (userInfo.NickName.Buff) { 74 | this._setUserInfo({ NickName: userInfo.NickName.BUff }); 75 | } 76 | } 77 | 78 | _getFormateSyncKey() { 79 | return (_.map(this.loginData.syncKey.List, (item) => { 80 | return item.Key + '_' + item.Val; 81 | })).join('|'); 82 | } 83 | 84 | _getMessagePeerUserName(msg) { 85 | if (this._isSentMsg(msg)) { 86 | return msg.ToUserName; 87 | } else { 88 | return msg.FromUserName; 89 | } 90 | } 91 | 92 | _isSentMsg(msg) { 93 | return this._isSelf(msg.FromUserName); 94 | } 95 | 96 | _removeEmoji(name) { 97 | return name && name.replace(/<\/span>/g, ''); 98 | } 99 | 100 | // contact 101 | _addContact(user) { 102 | if (_.isEmpty(user)) { 103 | return; 104 | } 105 | 106 | user.NickName = this._removeEmoji(user.NickName); 107 | user.RemarkName = this._removeEmoji(user.RemarkName); 108 | 109 | if (this.contacts[user.UserName]) { 110 | _.invoke(this.contactList, function() { 111 | // this: each item 112 | return (this.UserName === user.UserName) ? user : this; 113 | }); 114 | } else { 115 | this.contactList.push(user); 116 | } 117 | 118 | this.contacts[user.UserName] = user; 119 | 120 | // update chat 121 | if (user.UserName === this.chat.UserName) { 122 | this._setChat(user); 123 | } 124 | } 125 | 126 | _addContacts(contactList) { 127 | _.each(contactList, this._addContact.bind(this)); 128 | } 129 | 130 | _delContact(user) { 131 | if (this.contacts[user.UserName]) { 132 | _.remove(this.contactList, (u) => { return u.UserName === user.UserName; }); 133 | } 134 | delete this.contacts[user.UserName]; 135 | 136 | if (user.UserName === this.chat.UserName) { 137 | this._setChat(); 138 | } 139 | } 140 | 141 | _delContacts(contactList) { 142 | _.each(contactList, this._delContact.bind(this)); 143 | } 144 | 145 | _setChat(user) { 146 | this.chat = user || {}; 147 | } 148 | 149 | _removeInvalidContact(nameList) { 150 | return _.filter(nameList, (name) => { 151 | var user = this.contacts[name]; 152 | if (!user) { 153 | return true; 154 | } 155 | 156 | return WechatBase.isContact(user); 157 | }); 158 | } 159 | 160 | _initChatList(nameList) { 161 | this.chatList = _.filter(nameList.split(','), (item) => { 162 | return item.startsWith('@'); 163 | }); 164 | 165 | this._batchGetContact(this.chatList) 166 | .then(() => { 167 | this.chatList = this._removeInvalidContact(this.chatList); 168 | }); 169 | 170 | return this.chatList; 171 | } 172 | 173 | _addChatList(msgList) { 174 | var list = []; 175 | _.each(msgList, (msg) => { 176 | var name = msg.UserName || this._getMessagePeerUserName(msg); 177 | if (name.startsWith('@') && this.chatList.indexOf(name) < 0) { 178 | list.push(name); 179 | this.chatList.push(name); 180 | } 181 | }); 182 | 183 | this._batchGetContact(list).then(() => { 184 | this.chatList = this._removeInvalidContact(this.chatList); 185 | }); 186 | return this.chatList; 187 | } 188 | 189 | _deleteChatList(chatList) { 190 | this.chatList = _.difference(this.chatList, chatList); 191 | return this.chatList; 192 | } 193 | 194 | _getUserNickName(userName, full) { 195 | if (_.isEmpty(userName)) { 196 | return ''; 197 | } 198 | 199 | userName = userName.UserName || userName; 200 | var user = this.contacts[userName]; 201 | if (!user) { 202 | return userName; 203 | } 204 | 205 | var name = user.RemarkName || user.NickName; 206 | if (full && user.RemarkName) { 207 | name = `${user.NickName} (${user.RemarkName})`; 208 | } 209 | 210 | if (WechatBase.isRoomContact(userName)) { 211 | name += ' (Room)'; 212 | } 213 | return name; 214 | } 215 | 216 | _updateChatData(data) { 217 | this._updateLoginData({ syncKey: data.SyncKey }); 218 | this._updateUserInfo(data.Profile); 219 | 220 | // delete contact 221 | this._delContacts(data.DelContactList); 222 | 223 | // update contact 224 | this._addContacts(data.ModContactList); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /lib/wechat_client.js: -------------------------------------------------------------------------------- 1 | /* eslint no-eval: 0 */ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'); 5 | var chalk = require('chalk'); 6 | var request = require('request-promise'); 7 | var qrcode = require('qrcode-terminal'); 8 | var parseXMLString = require('xml2js').parseString; 9 | var columnify = require('columnify'); 10 | 11 | var WechatBase = require('./wechat_base'); 12 | var consts = require('./consts'); 13 | var logger = require('./logger'); 14 | 15 | module.exports = class WechatClient extends WechatBase { 16 | 17 | constructor() { 18 | super(); 19 | 20 | this.networkHistory = []; 21 | this.server = { 22 | login: 'https://login.weixin.qq.com', 23 | sync: 'https://webpush.weixin.qq.com', 24 | web: 'https://wx.qq.com', 25 | file: 'https://file.wx.qq.com', 26 | }; 27 | 28 | this.rq = request.defaults({ 29 | jar: true, 30 | gzip: true, 31 | forever: true, 32 | headers: { 33 | 'Referer': 'https://web.wechat.com/', 34 | 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:41.0) Gecko/20100101 Firefox/41.0', 35 | }, 36 | agentOptions: { rejectUnauthorized: false }, // ignore https cert error 37 | transform: this._saveNetworkHistory.bind(this), 38 | }); 39 | } 40 | 41 | static get EVENTS() { 42 | return { 43 | LOGIN: 'login', 44 | LOGOUT: 'logout', 45 | ERROR: 'err', 46 | CHAT_CHANGE: 'chat_change', 47 | MESSAGE: 'message', 48 | }; 49 | } 50 | 51 | static getDeviceID() { 52 | return 'e' + String(Math.random().toFixed(15)).substring(2, 17); 53 | } 54 | 55 | static getMsgID() { 56 | return (Date.now() + Math.random().toFixed(3)).replace('.', ''); 57 | } 58 | 59 | _setChat(user) { 60 | super._setChat(user); 61 | this.emit(WechatClient.EVENTS.CHAT_CHANGE); 62 | } 63 | 64 | _saveNetworkHistory(body, response) { 65 | this.networkHistory.push(response); 66 | if (this.networkHistory.length > consts.MAX_NETWORK_HISTORY) { 67 | this.networkHistory.shift(); 68 | } 69 | return body; 70 | } 71 | 72 | _parseObjResponse(field) { 73 | return (body, response) => { 74 | this._saveNetworkHistory(body, response); 75 | 76 | var window = {}; 77 | if (field) { 78 | window[field] = {}; 79 | } 80 | 81 | eval(body); 82 | return field && window[field] || window; 83 | }; 84 | } 85 | 86 | _parseBaseResponse(body, response) { 87 | this._saveNetworkHistory(body, response); 88 | 89 | if (!body || !body.BaseResponse) { 90 | throw `Invalid response.(body=${body})`; 91 | } 92 | 93 | if (Number.parseInt(body.BaseResponse.Ret, 10) !== 0) { 94 | throw body.BaseResponse.Ret; 95 | } 96 | return body; 97 | } 98 | 99 | _printLoginQR(uuid) { 100 | var url = this.server.login + consts.URL.LOGIN_QRCODE + uuid; 101 | qrcode.generate(url, { small: true }); 102 | logger.info('Scan above qrcode using mobile wechat.'); 103 | 104 | // return uuid for chained calls 105 | return uuid; 106 | } 107 | 108 | _checkLogin(uuid) { 109 | logger.info('Waiting for scan...'); 110 | return new Promise((resolve, reject) => { 111 | var self = this; 112 | (function doCheckLogin() { 113 | var options = { 114 | uri: self.server.login + consts.URL.CHECK_LOGIN, 115 | qs: { 116 | _: Date.now(), 117 | r: ~Date.now(), 118 | loginicon: false, 119 | tip: 0, 120 | uuid: uuid, 121 | }, 122 | qsStringifyOptions: { 123 | encode: false, // disable encode for '=' in uuid 124 | }, 125 | transform: self._parseObjResponse().bind(this), 126 | timeout: consts.TIMEOUT_LONG_PULL, 127 | }; 128 | 129 | self.rq(options).then((resp) => { 130 | switch (Number.parseInt(resp.code, 10)) { 131 | case 200: 132 | return resolve(resp.redirect_uri); 133 | case 400: 134 | return reject('UUID expired. Try again please.'); 135 | case 500: 136 | case 0: 137 | return reject('Server error. Try again please.'); 138 | case 201: 139 | logger.info('QRCode is scanned.'); 140 | return doCheckLogin(); 141 | default: 142 | return doCheckLogin(); 143 | } 144 | }).catch(doCheckLogin) 145 | .done(); 146 | })(); 147 | }); 148 | } 149 | 150 | _parseDomain(url) { 151 | var m = url.match('^https?://([^/]+)/?.*$'); 152 | var domain = m[1]; 153 | 154 | for (var _domain of consts.DOMAIN_LIST) { 155 | if (domain.indexOf(_domain) > -1) { 156 | this.server = consts.DOMAIN[_domain]; 157 | break; 158 | } 159 | } 160 | 161 | this.server.web = 'https://' + domain; 162 | 163 | return url; 164 | } 165 | 166 | _webwxnewloginpage(url) { 167 | var options = { 168 | uri: url, 169 | qs: { fun: 'new', version: 'v2' }, 170 | }; 171 | 172 | return new Promise((resolve, reject) => { 173 | this.rq(options).then((resp) => { 174 | parseXMLString(resp, { trim: true, explicitArray: false }, (err, result) => { 175 | if (err) { 176 | return reject('Failed to parse login data.'); 177 | } 178 | 179 | var data = result.error; 180 | if (Number.parseInt(data.ret, 10) !== 0 || 181 | !data.skey || !data.wxsid || !data.wxuin || !data.pass_ticket) { 182 | return reject('Failed to get login data.'); 183 | } 184 | 185 | this._updateLoginData({ 186 | skey: data.skey, 187 | sid: data.wxsid, 188 | uin: data.wxuin, 189 | passTicket: data.pass_ticket, 190 | }); 191 | 192 | resolve(); 193 | }); 194 | }).catch((err) => { 195 | logger.error('Failed to login.'); 196 | reject(err); 197 | }); 198 | }); 199 | } 200 | 201 | _genBaseRequest(data) { 202 | return _.extend({ 203 | BaseRequest: { 204 | Uin: this.loginData.uin, 205 | Sid: this.loginData.sid, 206 | SKey: this.loginData.skey, 207 | DeviceID: WechatClient.getDeviceID(), 208 | }, 209 | }, data); 210 | } 211 | 212 | _saveChatHistory(from, to, content) { 213 | var time = new Date().toLocaleTimeString(); 214 | var msg = { 215 | time: time, 216 | from: this._getUserNickName(from), 217 | to: this._getUserNickName(to), 218 | message: content, 219 | }; 220 | 221 | var fromUser = this.contacts[from]; 222 | if (fromUser) { 223 | fromUser.chatHistory = fromUser.chatHistory || []; 224 | fromUser.chatHistory.push(msg); 225 | if (fromUser.chatHistory.length > consts.MAX_CHAT_HISTORY) { 226 | fromUser.chatHistory.shift(); 227 | } 228 | } 229 | 230 | var toUser = this.contacts[to]; 231 | if (toUser) { 232 | toUser.chatHistory = toUser.chatHistory || []; 233 | toUser.chatHistory.push(msg); 234 | if (toUser.chatHistory.length > consts.MAX_CHAT_HISTORY) { 235 | toUser.chatHistory.shift(); 236 | } 237 | } 238 | } 239 | 240 | _printMsg(msg) { 241 | if (WechatClient.isMuted(this.contacts[msg.PeerUserName])) { 242 | return; 243 | } 244 | 245 | var time = new Date(msg.CreateTime * 1000).toLocaleTimeString(); 246 | var data = { 247 | time: time, 248 | from: this._getUserNickName(msg.FromUserName), 249 | message: msg.Content, 250 | }; 251 | 252 | if (!WechatClient.isRoomContact(msg.FromUserName)) { 253 | data.to = this._getUserNickName(msg.ToUserName); 254 | } 255 | 256 | if (msg.ActualSender) { 257 | data.from += ' : ' + this._getUserNickName(msg.ActualSender); 258 | } 259 | 260 | console.log('\n' + columnify([data])); 261 | 262 | // TODO: support emitting text mesage only now 263 | this.emit(WechatClient.EVENTS.MESSAGE, data); 264 | } 265 | 266 | _statusNotifyProcess(msg) { 267 | switch (Number.parseInt(msg.StatusNotifyCode, 10)) { 268 | case consts.STATUS_NOTIFY_CODE.SYNC_CONV: 269 | this._initChatList(msg.StatusNotifyUserName); 270 | break; 271 | 272 | case consts.STATUS_NOTIFY_CODE.ENTER_SESSION: 273 | this._addChatList([msg]); 274 | break; 275 | 276 | default: 277 | break; 278 | } 279 | } 280 | 281 | _commonMsgProcess(msg) { 282 | // parse sender of msg in chat room 283 | msg.Content = msg.Content.replace(/^(@[a-zA-Z0-9]+|[a-zA-Z0-9_-]+):/, (_, sender) => { 284 | msg.ActualSender = sender; 285 | return ''; 286 | }); 287 | 288 | msg.PeerUserName = this._getMessagePeerUserName(msg); 289 | 290 | return msg; 291 | } 292 | 293 | _processMediaMessage(msg) { 294 | if (WechatClient.isMuted(this.contacts[msg.PeerUserName])) { 295 | return; 296 | } 297 | 298 | console.log('New message ' + 299 | `(Peer=${this._getUserNickName(msg.PeerUserName)}, ` + 300 | `Type=${msg.msgTypeText})`); 301 | 302 | msg.Content = `[${msg.msgTypeText}]`; 303 | } 304 | 305 | _messageProcess(msgs) { 306 | _.each(msgs, (msg) => { 307 | msg.msgTypeText = _.findKey(consts.MSG_TYPE, _.partial(_.isEqual, msg.MsgType)); 308 | logger.debug(`New message(Type=${msg.msgTypeText})`); 309 | 310 | this._commonMsgProcess(msg); 311 | 312 | switch (Number.parseInt(msg.MsgType, 10)) { 313 | case consts.MSG_TYPE.TEXT: 314 | this._printMsg(msg); 315 | break; 316 | 317 | case consts.MSG_TYPE.IMAGE: 318 | msg.Content = '[Image]'; 319 | this._printMsg(msg); 320 | break; 321 | 322 | case consts.MSG_TYPE.EMOTICON: 323 | if (this._isSentMsg(msg)) { 324 | msg.Content = '[Sent a sticker. View on phone]'; 325 | } else { 326 | msg.Content = '[Received a sticker. View on phone]'; 327 | this._printMsg(msg); 328 | } 329 | break; 330 | 331 | case consts.MSG_TYPE.VOICE: 332 | msg.Content = '[Voice]'; 333 | this._printMsg(msg); 334 | break; 335 | 336 | case consts.MSG_TYPE.VIDEO: 337 | msg.Content = '[Video]'; 338 | this._printMsg(msg); 339 | break; 340 | 341 | case consts.MSG_TYPE.STATUS_NOTIFY: 342 | this._statusNotifyProcess(msg); 343 | break; 344 | 345 | default: 346 | break; 347 | } 348 | 349 | this._saveChatHistory(msg.FromUserName, msg.toUserName, msg.Content); 350 | this._addChatList([msg]); 351 | }); 352 | } 353 | 354 | _notifyMobile(type, toUserName) { 355 | var options = { 356 | uri: this.server.web + consts.URL.NOTIFY_MOBILE, 357 | method: 'POST', 358 | json: this._genBaseRequest({ 359 | Code: type, 360 | FromUserName: this.user.UserName, 361 | ToUserName: toUserName, 362 | ClientMsgId: Date.now(), 363 | }), 364 | }; 365 | 366 | this.rq(options); 367 | } 368 | 369 | _webwxsync() { 370 | var options = { 371 | uri: this.server.web + consts.URL.SYNC, 372 | method: 'POST', 373 | qs: { 374 | sid: this.loginData.sid, 375 | skey: this.loginData.skey, 376 | }, 377 | json: this._genBaseRequest({ 378 | rr: ~Date.now(), 379 | SyncKey: this.loginData.syncKey, 380 | }), 381 | transform: this._parseBaseResponse.bind(this), 382 | }; 383 | 384 | return new Promise((resolve, reject) => { 385 | this.rq(options).then((data) => { 386 | this._updateChatData(data); 387 | this._messageProcess(data.AddMsgList); 388 | resolve(); 389 | }).catch(reject).done(); 390 | }); 391 | } 392 | 393 | _batchGetContact(userList) { 394 | userList = userList || this.contacts; 395 | var list = _.map(userList, (item) => { 396 | return { 397 | UserName: item.UserName || item, 398 | EncryChatRoomId: item.EncryChatRoomId || '', 399 | }; 400 | }); 401 | 402 | var options = { 403 | uri: this.server.web + consts.URL.BATCH_GET_CONTACT, 404 | method: 'POST', 405 | qs: { 406 | r: Date.now(), 407 | type: 'ex', 408 | }, 409 | json: this._genBaseRequest({ 410 | Count: list.length, 411 | List: list, 412 | }), 413 | transform: this._parseBaseResponse.bind(this), 414 | }; 415 | 416 | return new Promise((resolve, reject) => { 417 | this.rq(options).then((data) => { 418 | this._addContacts(data.ContactList); 419 | resolve(); 420 | }).catch(reject).done(); 421 | }); 422 | } 423 | 424 | _synccheck() { 425 | if (!this.isLogined()) { 426 | return; 427 | } 428 | 429 | var options = { 430 | uri: this.server.sync + consts.URL.SYNC_CHECK, 431 | qs: { 432 | _: Date.now(), 433 | deviceid: WechatClient.getDeviceID(), 434 | r: Date.now(), 435 | sid: this.loginData.sid, 436 | skey: this.loginData.skey, 437 | synckey: this._getFormateSyncKey(), 438 | uin: this.loginData.uin, 439 | }, 440 | transform: this._parseObjResponse('synccheck').bind(this), 441 | timeout: consts.TIMEOUT_LONG_PULL, 442 | }; 443 | 444 | var logout = false; 445 | this.rq(options).then((resp) => { 446 | switch (Number.parseInt(resp.retcode, 10)) { 447 | case 0: 448 | break; 449 | case 1100: 450 | logout = true; 451 | return this.logout(); 452 | default: 453 | throw `Sync failed.(ret=${resp.retcode})`; 454 | } 455 | 456 | if (Number.parseInt(resp.selector, 10) !== 0) { 457 | this._webwxsync(); 458 | } 459 | }).catch((err) => { 460 | // logger.error(err); 461 | }).then(() => { 462 | if (!logout) { 463 | setTimeout(this._synccheck.bind(this), consts.TIMEOUT_SYNC_CHECK); 464 | } 465 | }) 466 | .done(); 467 | } 468 | 469 | _wxgetcontact() { 470 | var options = { 471 | uri: this.server.web + consts.URL.GET_CONTACT, 472 | qs: { 473 | r: Date.now(), 474 | skey: this.loginData.skey, 475 | pass_ticket: this.loginData.passTicket, 476 | }, 477 | transform: this._parseBaseResponse.bind(this), 478 | json: true, 479 | }; 480 | 481 | return new Promise((resolve, reject) => { 482 | this.rq(options).then((data) => { 483 | this._addContacts(data.MemberList); 484 | resolve(); 485 | }).catch((err) => { 486 | logger.error('Failed to getContact.'); 487 | reject(err); 488 | }).done(); 489 | }); 490 | } 491 | 492 | _wxinit() { 493 | return new Promise((resolve, reject) => { 494 | var options = { 495 | uri: this.server.web + consts.URL.INIT, 496 | method: 'POST', 497 | qs: { 498 | r: ~Date.now(), 499 | }, 500 | json: this._genBaseRequest(), 501 | transform: this._parseBaseResponse.bind(this), 502 | }; 503 | 504 | this.rq(options).then((data) => { 505 | this._updateLoginData({ 506 | skey: data.SKey, 507 | syncKey: data.SyncKey, 508 | }); 509 | 510 | this._setUserInfo(data.User); 511 | this._addContact(data.User); 512 | this._addContacts(data.ContactList); 513 | this._initChatList(data.ChatSet); 514 | 515 | this._notifyMobile(consts.STATUS_NOTIFY.INITED); 516 | 517 | return resolve(this.user); 518 | }).catch((err) => { 519 | logger.error('Failed to init. Please try again.'); 520 | reject(err); 521 | }).done(); 522 | }); 523 | } 524 | 525 | _errorHandler(reason) { 526 | logger.error(reason); 527 | this.emit(WechatClient.EVENTS.ERROR, reason); 528 | } 529 | 530 | _getUUID() { 531 | var options = { 532 | uri: this.server.login + consts.URL.JSLOGIN, 533 | qs: { 534 | _: Date.now(), 535 | appid: consts.WX_APP_ID, 536 | fun: 'new', 537 | lang: 'en_US', 538 | }, 539 | transform: this._parseObjResponse('QRLogin').bind(this), 540 | }; 541 | 542 | return this.rq(options).then((resp) => { 543 | return resp.uuid; 544 | }); 545 | } 546 | 547 | login() { 548 | return this._getUUID() 549 | .then(this._printLoginQR.bind(this)) 550 | .then(this._checkLogin.bind(this)) 551 | .then(this._parseDomain.bind(this)) 552 | .then(this._webwxnewloginpage.bind(this)) 553 | .then(this._wxinit.bind(this)) 554 | .then(() => { this.emit(WechatClient.EVENTS.LOGIN, this.user); }) 555 | .then(this._wxgetcontact.bind(this)) 556 | .then(this._synccheck.bind(this)) 557 | .catch(this._errorHandler.bind(this)) 558 | .done(); 559 | } 560 | 561 | logout() { 562 | return new Promise((resolve, reject) => { 563 | if (!this.isLogined()) { 564 | return resolve(); 565 | } 566 | 567 | var options = { 568 | uri: this.server.web + consts.URL.LOGOUT, 569 | simple: false, 570 | method: 'POST', 571 | qs: { 572 | skey: this.loginData.skey, 573 | type: 0, 574 | redirect: 0, 575 | }, 576 | form: { 577 | sid: this.loginData.sid, 578 | uin: this.loginData.uin, 579 | }, 580 | }; 581 | 582 | this.rq(options) 583 | .finally(() => { 584 | this._initData(); 585 | this.emit(WechatClient.EVENTS.LOGOUT); 586 | resolve(); 587 | }); 588 | }); 589 | } 590 | 591 | sendMsg(msg) { 592 | msg = _.trim(msg); 593 | if (!msg) { 594 | return; 595 | } 596 | 597 | if (!this.chat || !this.chat.UserName) { 598 | logger.info('Select chat target first.'); 599 | return; 600 | } 601 | 602 | var msgId = WechatClient.getMsgID(); 603 | var options = { 604 | uri: this.server.web + consts.URL.SEND_MSG, 605 | method: 'POST', 606 | qs: { pass_ticket: this.loginData.passTicket }, 607 | json: this._genBaseRequest({ 608 | Msg: { 609 | Content: msg, 610 | Type: consts.MSG_TYPE.TEXT, 611 | FromUserName: this.user.UserName, 612 | ToUserName: this.chat.UserName, 613 | ClientMsgId: msgId, 614 | LocalID: msgId, 615 | }, 616 | }), 617 | transform: this._parseBaseResponse.bind(this), 618 | }; 619 | 620 | this.rq(options).then(() => { 621 | this._saveChatHistory(this.user.UserName, this.chat.UserName, msg); 622 | logger.debug('Msg sent.'); 623 | }).catch((err) => { 624 | logger.error('Failed to send message.'); 625 | logger.debug(err); 626 | }); 627 | } 628 | 629 | listChat(input) { 630 | if (_.isEmpty(input)) { 631 | if (_.isEmpty(this.chatList)) { 632 | console.log('No chat.'); 633 | return; 634 | } 635 | 636 | console.log(chalk.bold.green('Chats:')); 637 | _.each(this.chatList, (name, index) => { 638 | console.log(`#${index} ${this._getUserNickName(name)}`); 639 | }); 640 | return; 641 | } 642 | 643 | var index = Number.parseInt(input, 10); 644 | if (Number.isNaN(index)) { 645 | logger.error('Enter index of chat please.'); 646 | return; 647 | } 648 | 649 | var name = index < 0 ? this.user.UserName : this.chatList[index]; 650 | var user = this.contacts[name]; 651 | if (!_.isEmpty(user)) { 652 | this._setChat(user); 653 | } 654 | } 655 | 656 | listContact(input) { 657 | if (_.isEmpty(input)) { 658 | console.log(chalk.bold.green('Contacts:')); 659 | _.each(this.contactList, (user, index) => { 660 | console.log(`#${index} ${this._getUserNickName(user)}`); 661 | }); 662 | return; 663 | } 664 | 665 | var index = Number.parseInt(input, 10); 666 | if (Number.isNaN(index)) { 667 | logger.error('Enter index of contact please.'); 668 | return; 669 | } 670 | 671 | var user = index < 0 ? this.user : this.contactList[index]; 672 | if (!_.isEmpty(user)) { 673 | this._setChat(user); 674 | } 675 | } 676 | 677 | quitChat() { 678 | this._setChat(); 679 | } 680 | 681 | getUser() { 682 | return this._getUserNickName(this.user); 683 | } 684 | 685 | getChat() { 686 | return this._getUserNickName(this.chat); 687 | } 688 | 689 | searchContact(input) { 690 | if (_.isEmpty(input)) { 691 | return; 692 | } 693 | 694 | _.each(this.contactList, (user, index) => { 695 | if (user.RemarkName.match(input) || 696 | user.NickName.match(input)) { 697 | console.log(`#${index} ${this._getUserNickName(user, true)}`); 698 | } 699 | }); 700 | } 701 | 702 | listRoom() { 703 | _.each(this.contactList, (user, index) => { 704 | if (WechatClient.isRoomContact(user)) { 705 | console.log(`#${index} ${this._getUserNickName(user)}`); 706 | } 707 | }); 708 | } 709 | 710 | listMember(input) { 711 | var room = this.chat; 712 | if (!_.isEmpty(input)) { 713 | var index = Number.parseInt(input, 10); 714 | if (!Number.isNaN(index)) { 715 | room = this.contactList[index]; 716 | } 717 | } 718 | 719 | if (_.isEmpty(room)) { 720 | return; 721 | } 722 | 723 | if (!WechatClient.isRoomContact(room)) { 724 | logger.error(this._getUserNickName(room) + ' is not room chat.'); 725 | return; 726 | } 727 | 728 | console.log(chalk.bold.green(`Member of ${this._getUserNickName(room)}:`)); 729 | _.each(room.MemberList, (name) => { 730 | console.log('- ' + this._getUserNickName(name)); 731 | }); 732 | } 733 | 734 | displayNetworkHistory(input) { 735 | if (_.isEmpty(input)) { 736 | _.each(this.networkHistory, (item, index) => { 737 | console.log(`#${index} ${item.request.method} ${item.request.uri.pathname}`); 738 | }); 739 | 740 | return; 741 | } 742 | 743 | var index = Number.parseInt(input, 10); 744 | if (Number.isNaN(index) || 745 | !_.inRange(index, 0, this.networkHistory.length)) { 746 | logger.error('Invalid index.'); 747 | return; 748 | } 749 | 750 | var item = this.networkHistory[index]; 751 | console.log(chalk.bold.green(`Network history (#${index}):`)); 752 | console.log(item.toJSON()); 753 | } 754 | 755 | chatHistory(input) { 756 | var username; 757 | if (_.isEmpty(input)) { 758 | if (_.isEmpty(this.chat)) { 759 | logger.error('Select target chat first, please.'); 760 | return; 761 | } 762 | username = this.chat.UserName; 763 | } else { 764 | var index = Number.parseInt(input, 10); 765 | if (Number.isNaN(index)) { 766 | logger.error('Invalid index.'); 767 | return; 768 | } 769 | username = this.chatList[index]; 770 | } 771 | 772 | var user = this.contacts[username]; 773 | if (_.isEmpty(user)) { 774 | logger.error('User not found.'); 775 | return; 776 | } 777 | 778 | console.log(chalk.bold.green(`Chat history with ${this._getUserNickName(user)}:`)); 779 | if (_.isEmpty(user.chatHistory)) { 780 | console.log('No history yet.'); 781 | } else { 782 | console.log(columnify(user.chatHistory, { 783 | columns: ['time', 'from', 'to', 'message'], 784 | preserveNewLines: true, 785 | })); 786 | } 787 | } 788 | 789 | displayUserInfo(input) { 790 | var user = this.user; 791 | if (!_.isEmpty(input)) { 792 | var index = Number.parseInt(input, 10); 793 | if (Number.isNaN(index)) { 794 | logger.error('Invalid index.'); 795 | return; 796 | } 797 | 798 | user = this.contactList[index]; 799 | } 800 | 801 | if (_.isEmpty(user)) { 802 | logger.error('User not found.'); 803 | return; 804 | } 805 | 806 | console.log(chalk.bold.green(`Information of ${this._getUserNickName(user)}:`)); 807 | console.log('NickName: ' + user.NickName); 808 | console.log('RemarkName: ' + user.RemarkName); 809 | console.log('Gender: ' + _.findKey(consts.SEX, _.partial(_.isEqual, user.Sex))); 810 | console.log(`Region: ${user.Province || ''} ${user.City || ''}`); // FIXME: empty for this.user 811 | console.log('Signature: ' + user.Signature); 812 | } 813 | } 814 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-wechat-terminal", 3 | "version": "0.2.2", 4 | "description": "Wechat client in terminal", 5 | "main": "cli.js", 6 | "scripts": { 7 | "test": "node node_modules/eslint/bin/eslint.js . && node node_modules/mocha/bin/mocha", 8 | "start": "node cli.js" 9 | }, 10 | "bin": { 11 | "wechat-terminal": "./cli.js" 12 | }, 13 | "keywords": [ 14 | "wechat", 15 | "terminal" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/goorockey/node-wechat-terminal.git" 20 | }, 21 | "author": "goorockey", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/goorockey/node-wechat-terminal/issues" 25 | }, 26 | "engines": { 27 | "node": ">=4.0.0" 28 | }, 29 | "engineStrict": true, 30 | "homepage": "https://github.com/goorockey/node-wechat-terminal#readme", 31 | "dependencies": { 32 | "chalk": "^1.1.3", 33 | "columnify": "^1.5.2", 34 | "lodash": "^4.13.1", 35 | "node-notifier": "^4.6.0", 36 | "qrcode-terminal": "git+https://github.com/goorockey/qrcode-terminal.git", 37 | "request": "2.71.0", 38 | "request-promise": "3.0.0", 39 | "winston": "^2.1.1", 40 | "xml2js": "^0.4.13" 41 | }, 42 | "devDependencies": { 43 | "chai": "^3.4.0", 44 | "eslint": "^2.9.0", 45 | "mocha": "^2.3.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/webchat_client.js: -------------------------------------------------------------------------------- 1 | require('chai').should(); 2 | var WechatClient = require('../lib/wechat_client'); 3 | 4 | const NETWORK_TIMEOUT = 10000; 5 | 6 | describe('wechat_client', function() { 7 | describe('login', function() { 8 | describe('#isLogined()', function() { 9 | it('should return false if not logined', function() { 10 | var wechat = new WechatClient(); 11 | wechat.isLogined().should.be.false; 12 | wechat.getUser().should.be.empty; 13 | wechat.getChat().should.be.empty; 14 | }); 15 | }); 16 | 17 | describe('#getUUID()', function() { 18 | this.timeout(NETWORK_TIMEOUT); 19 | 20 | it('should return uuid', function(done) { 21 | /* FIXME: timeout exceeded in travis-ci 22 | var wechat = new WechatClient(); 23 | wechat._getUUID().then((uuid) => { 24 | uuid.should.not.be.empty; 25 | uuid.should.be.a('string'); 26 | done(); 27 | }); 28 | */ 29 | done(); 30 | }); 31 | }); 32 | 33 | describe('#checkLogin()', function() { 34 | it('should success with redirect_uri', function(done) { 35 | done(); 36 | }); 37 | 38 | it('should fail with UUID expired', function(done) { 39 | done(); 40 | }); 41 | 42 | it('should fail with server error', function(done) { 43 | done(); 44 | }); 45 | 46 | it('should retry when qrcode is scanned', function(done) { 47 | done(); 48 | }); 49 | 50 | it('should retry when timeout', function(done) { 51 | done(); 52 | }); 53 | }); 54 | 55 | describe('#webwxnetloginpage()', function() { 56 | it('should success and get login data', function(done) { 57 | done(); 58 | }); 59 | 60 | it('should fail with error ret code', function(done) { 61 | done(); 62 | }); 63 | }); 64 | 65 | describe('#wxinit()', function() { 66 | it('should init all user data', function(done) { 67 | done(); 68 | }); 69 | 70 | it('should fail with error ret code', function(done) { 71 | done(); 72 | }); 73 | }); 74 | 75 | describe('#getContact()', function() { 76 | it('should save contact list', function(done) { 77 | done(); 78 | }); 79 | }); 80 | 81 | describe('#syncCheck()', function() { 82 | it('should not call when not logined', function(done) { 83 | done(); 84 | }); 85 | 86 | it('should retry when timeout', function(done) { 87 | done(); 88 | }); 89 | 90 | it('should retry when error happened', function(done) { 91 | done(); 92 | }) 93 | 94 | it('should exit when logout', function(done) { 95 | done(); 96 | }); 97 | 98 | it('should call wxsync()', function(done) { 99 | done(); 100 | }); 101 | }); 102 | }); 103 | }); 104 | --------------------------------------------------------------------------------