Repository: fritx/wxbot Branch: next Commit: 38a99cceea22 Files: 13 Total size: 21.3 KB Directory structure: gitextract_1_5is914/ ├── .gitignore ├── Dockerfile ├── README.md ├── package.json └── src/ ├── index.js ├── ipc.js ├── parseMsg.js ├── preload.js ├── preloadIpc.js ├── replyMsg.js ├── runner.js ├── runnerIpc.js └── util.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ node_modules download *.out *.log ================================================ FILE: Dockerfile ================================================ FROM ubuntu:latest MAINTAINER huangruichang "532079207@qq.com" RUN apt-get upgrade && apt-get update -y EXPOSE 8233 # basic tools RUN apt-get install git curl -y # running electron on linux headless # https://github.com/segmentio/nightmare/issues/224#issuecomment-141575361 RUN apt-get install -y libgtk2.0-0 libgconf-2-4 \ libasound2 libxtst6 libxss1 libnss3 xvfb # nodejs related # https://nodejs.org/en/download/package-manager/ RUN curl -sL https://deb.nodesource.com/setup_7.x | bash RUN apt-get install -y nodejs # WXBOT HOME ENV WXBOT_HOME /home/wxbot # clone wxbot RUN git clone https://github.com/fritx/wxbot.git $WXBOT_HOME # replace npm and electron download source RUN echo -e "registry=https://registry.npm.taobao.org\nelectron_mirror=https://npm.taobao.org/mirrors/electron/" > ~/.npmrc RUN cd $WXBOT_HOME && npm install # install http-server RUN npm install http-server -g # Start wxbot CMD xvfb-run --server-args="-screen 0 1024x768x24" node $WXBOT_HOME & http-server /tmp -p 8233 -s ================================================ FILE: README.md ================================================ # wxbot 微信机器人    - __普通个人号 微信机器人/外挂__ (不同于[webot](https://github.com/node-webot/webot)等公众号机器人) - 意义: 个人号可充当公众号使用 关系增强/门槛降低/更多行为/依旧自动化 - 与[qqbot](https://github.com/xhan/qqbot)/[wqq](https://github.com/fritx/wqq)等不同: 基于浏览器/用户行为自动化 更贴近用户/更可靠 - 基于浏览器桌面平台[electron](https://github.com/atom/electron) 跨平台win/linux/mac - 基于微信网页版 - 目前处于高度开发和观察阶段 - 目前代码提供自动回复 可自行定制 请使用较新版本的electron>=v1.0 如果electron=v0.x 可以查看分支[wxbot#electron-v0](https://github.com/fritx/wxbot/tree/electron-v0) ```plain $ cd wxbot $ npm install $ node . # 运行 需扫二维码登录 ``` ## 功能实现 - [x] 自动回复 - [x] 识别并回复相同的文本/表情/emoji - [x] 识别图片/语音/视频/小视频 - [x] 识别位置/名片/链接/附件 - [x] 识别转账/在线聊天/实时对讲 - [x] 发送图片 - [x] 下载自定义表情/名片/图片/语音/附件 - [ ] 下载视频/小视频 - [ ] 接受好友请求并回复 - [ ] 感应系统消息 时间/邀请加群/红包等 - [ ] loop错误超时解锁 - [x] 探索运行于无界面平台 [atom/electron#228](https://github.com/atom/electron/issues/228) ## 无界面linux运行 - 从命令行输出 获取二维码图片url 自行打开/扫描 - 参照配置 [segmentio/nightmare#224 (comment)](https://github.com/segmentio/nightmare/issues/224#issuecomment-141575361) ## Docker 相关 ```plain $ cd wxbot $ docker build -t wxbot . $ docker run -d -p 8233:8233 # 浏览器访问 docker 的 8233 端口,即可获取图片 ``` ## 如何正确地下载electron 参考 ================================================ FILE: package.json ================================================ { "name": "wxbot", "version": "0.1.0", "description": "普通个人号 微信机器人/外挂", "repository": "git@github.com:fritx/wxbot.git", "homepage": "https://github.com/fritx/wxbot", "keywords": [ "weixin", "wx", "qq", "bot" ], "main": "src", "scripts": { "start": "node ." }, "dependencies": { "electron": "^1.6.2", "mime": "^1.3.4" } } ================================================ FILE: src/index.js ================================================ let { spawn } = require('child_process') let { join } = require('path') let ipc = require('./ipc') let electron = require('electron') let runner = join(__dirname, 'runner.js') let proc = spawn(electron, ['--js-flags="--harmony"', runner], { stdio: [null, null, null, 'ipc'] }) let child = ipc(proc) child.on('runner', (k, args) => { console[k](`runner:${k}`, ...args) }) process.on('exit', end) process.on('SIGINT', end) process.on('SIGTERM', end) process.on('SIGQUIT', end) process.on('SIGHUP', end) process.on('SIGBREAK', end) function end () { if (proc.connected) proc.disconnect() proc.kill() } ================================================ FILE: src/ipc.js ================================================ /* eslint-disable */ // https://github.com/segmentio/nightmare/blob/master/lib%2Fipc.js /** * Module dependencies */ var Emitter = require('events').EventEmitter; // var sliced = require('sliced'); /** * Export `ipc` */ module.exports = ipc; /** * Initialize `ipc` */ function ipc(process) { var emitter = new Emitter(); var emit = emitter.emit; // no parent if (!process.send) { return emitter; } process.on('message', function(data) { // emit.apply(emitter, sliced(data)); emit.apply(emitter, [...data]); }); emitter.emit = function() { if(process.connected){ // process.send(sliced(arguments)); process.send(Array.from(arguments)); } } return emitter; } ================================================ FILE: src/parseMsg.js ================================================ function debug(...args){ var json = JSON.stringify(args) console.log(json) } module.exports = parseMsg // msg = { // from, room, style, // type: // not supported|not recognized // text|picture|app|card|location|attach // sticker|emoticon|transfer // voice|video|microvideo|video/voice call // real-time location|real-time voice // ---- // red packet|recall // new member|member is stranger // real-time location ended|real-time voice ended // text|title|desc|src|poster... // } function parseMsg ($msg) { var msg = {} var $message = $msg.closest('.message') var $nickname = $message.find('.nickname') var $titlename = $('.title_name') if ($nickname.length) { // 群聊 var from = $nickname.text() var room = $titlename.text() } else { // 单聊 var from = $titlename.text() var room = null } Object.assign(msg, { from, room }) debug('来自', from, room) // 这里的nickname会被remark覆盖 if ($msg.is('.message_system')) { var ctn = $msg.find('.content').text() debug('接收', '系统标记', ctn) Object.assign(msg, { style: 'system', text: ctn }) var mat if (ctn === '收到红包,请在手机上查看' || ctn === 'Red packet received. View on phone.') { // text = '发毛红包' Object.assign(msg, { type: 'red packet' }) } else if (ctn === '位置共享已经结束' || ctn === 'Real-time Location session ended.') { // text = '位置共享已经结束' Object.assign(msg, { type: 'real-time location ended' }) } else if (ctn === '实时对讲已经结束') { // text = '实时对讲已经结束' Object.assign(msg, { type: 'real-time voice ended' }) } else if (mat = ctn.match(/"(.+)"邀请"(.+)"加入了群聊/)) { // text = '加毛人' Object.assign(msg, { type: 'new member', by: mat[1], who: mat[2] }) } else if (mat = ctn.match(/"(.+)"与群里其他人都不是微信朋友关系,请注意隐私安全/)) { // text = '加毛人' Object.assign(msg, { type: 'member is stranger', who: mat[1] }) } else if (mat = ctn.match(/You were removed from the group chat by "(.+)"/)) { Object.assign(msg, { type: 'removed', by: mat[1] }) } else if (mat = ctn.match(/(.+)(撤回了一条消息| withdrew a message)/)) { // text = '撤你妹' Object.assign(msg, { type: 'recall', by: mat[1] }) } else { // 无视 Object.assign(msg, { type: 'not recognized', text: ctn }) } } else if ($msg.is('.emoticon')) { // 用户自定义表情 var src = $msg.find('.msg-img').prop('src') debug('接收', 'emoticon', src) // reply.text = '发毛表情' Object.assign(msg, { type: 'emoticon', src }) } else if ($msg.is('.picture')) { var src = $msg.find('.msg-img').prop('src') debug('接收', 'picture', src) // reply.text = '发毛图片' // reply.image = join(__dirname, '../fuck.jpeg') Object.assign(msg, { type: 'picture', src }) } else if ($msg.is('.location')) { var src = $msg.find('.img').prop('src') var desc = $msg.find('.desc').text() debug('接收', 'location', desc) // reply.text = desc Object.assign(msg, { type: 'location', src, desc }) } else if ($msg.is('.attach')) { var title = $msg.find('.title').text() var size = $msg.find('span:first').text() var $download = $msg.find('a[download]') // 可触发下载 var src = $download.prop('href') debug('接收', 'attach', title, size) // reply.text = title + '\n' + size Object.assign(msg, { type: 'attach', title, size, src }) } else if ($msg.is('.microvideo')) { var poster = $msg.find('img').prop('src') // 限制 var src = $msg.find('video').prop('src') // 限制 debug('接收', 'microvideo', poster) // reply.text = '发毛小视频' Object.assign(msg, { type: 'microvideo', poster, src }) } else if ($msg.is('.video')) { var poster = $msg.find('.msg-img').prop('src') // 限制 debug('接收', 'video', poster) // reply.text = '发毛视频' Object.assign(msg, { type: 'video', poster }) } else if ($msg.is('.voice')) { $msg[0].click() var duration = parseInt($msg.find('.duration').text()) var src = $('#jp_audio_1').prop('src') // 认证限制 debug('接收', 'voice', `${duration}s`, src) // reply.text = '发毛语音' Object.assign(msg, { type: 'voice', duration, src }) } else if ($msg.is('.card')) { var name = $msg.find('.display_name').text() var wxid = $msg.find('.signature').text() // 微信注释掉了 var img = $msg.find('.img').prop('src') // 认证限制 debug('接收', 'card', name, wxid) // reply.text = name + '\n' + wxid Object.assign(msg, { type: 'card', name, img }) } else if ($msg.is('a.app')) { var url = $msg.attr('href') url = decodeURIComponent(url.match(/requrl=(.+?)&/)[1]) var title = $msg.find('.title').text() var desc = $msg.find('.desc').text() var img = $msg.find('.cover').prop('src') // 认证限制 debug('接收', 'link', title, desc, url) // reply.text = title + '\n' + url Object.assign(msg, { type: 'app', url, title, desc, img }) } else if ($msg.is('.plain')) { var text = '' var ctn = '' var normal = false var $text = $msg.find('.js_message_plain') $text.contents().each(function(i, node){ if (node.nodeType === Node.TEXT_NODE) { ctn += node.nodeValue } else if (node.nodeType === Node.ELEMENT_NODE) { var $el = $(node) if ($el.is('br')) ctn += '\n' else if ($el.is('.qqemoji, .emoji')) { ctn += $el.attr('text').replace(/_web$/, '') } } }) if (ctn === '[收到了一个表情,请在手机上查看]' || ctn === '[Send an emoji, view it on mobile]' || ctn === '[Received a sticker. View on phone]') { // 微信表情包 // text = '发毛表情' Object.assign(msg, { type: 'sticker' // 微信内部表情 }) } else if (ctn === '[收到一条微信转账消息,请在手机上查看]' || ctn === '[Received a micro-message transfer message, please view on the phone]' || ctn === '[Received transfer. View on phone.]') { // text = '转毛帐' Object.assign(msg, { type: 'transfer' }) } else if (ctn === '[收到一条视频/语音聊天消息,请在手机上查看]' || ctn === '[Receive a video / voice chat message, view it on your phone]' || ctn === '[Received video/voice chat message. View on phone.]') { // text = '聊jj' Object.assign(msg, { type: 'video/voice call' }) } else if (ctn === '我发起了实时对讲') { // text = '对讲你妹' Object.assign(msg, { type: 'real-time voice' }) } else if (ctn === '该类型暂不支持,请在手机上查看' || ctn === '[收到一条网页版微信暂不支持的消息类型,请在手机上查看]') { // text = '不懂' Object.assign(msg, { type: 'not supported' }) } else if (ctn.match(/(.+)发起了位置共享,请在手机上查看/) || ctn.match(/(.+)Initiated location sharing, please check on the phone/) || ctn.match(/(.+)started a real\-time location session\. View on phone/)) { // text = '发毛位置共享' Object.assign(msg, { type: 'real-time location' }) } else { normal = true // text = ctn Object.assign(msg, { type: 'text', text: ctn }) } debug('接收', 'text', ctn) // if (normal && !text.match(/叼|屌|diao|丢你|碉堡/i)) text = '' // reply.text = text } else { console.log('未成功解析消息', $msg.html()) Object.assign(msg, { type: 'not recognized' }) } return msg } ================================================ FILE: src/preload.js ================================================ require('./preloadIpc') let { clipboard, nativeImage } = require('electron') let { s, sa, delay, download } = require('./util') let parseMsg = require('./parseMsg') let replyMsg = require('./replyMsg') // 禁用微信网页绑定的beforeunload // 导致页面无法正常刷新和关闭 window.__defineSetter__('onbeforeunload', () => { // noop }) document.addEventListener('DOMContentLoaded', () => { // 禁止外层网页滚动 影响使用 document.body.style.overflow = 'hidden' detectPage() }) async function autoReply () { while (true) { // 保持回复消息 try { let msg = await detectMsg() console.log('解析得到msg', JSON.stringify(msg)) let reply = await replyMsg(msg) console.log('reply', JSON.stringify(reply)) if (reply) { // continue // test: 不作回复 pasteMsg(reply) await clickSend(reply) } } catch (err) { console.error('自动回复出现err', err) } } } async function detectMsg () { // 重置回"文件传输助手" 以能接收未读红点 s('img[src*=filehelper]').closest('.chat_item').click() let reddot while (true) { await delay(100) reddot = s('.web_wechat_reddot, .web_wechat_reddot_middle') if (reddot) break } let item = reddot.closest('.chat_item') item.click() await delay(100) let $msg = $([ '.message:not(.me) .bubble_cont > div', '.message:not(.me) .bubble_cont > a.app', '.message:not(.me) .emoticon', '.message_system' ].join(', ')).last() let msg = parseMsg($msg) return msg } async function clickSend (opt) { if (opt.text) { s('.btn_send').click() } else if (opt.image) { // fixme: 超时处理 while (true) { await delay(300) let btn = s('.dialog_ft .btn_primary') if (btn) { btn.click() // 持续点击 } else { return } } } } // 借用clipboard 实现输入文字 更新ng-model=EditAreaCtn function pasteMsg (opt) { let oldImage = clipboard.readImage() let oldHtml = clipboard.readHtml() let oldText = clipboard.readText() clipboard.clear() // 必须清空 if (opt.image) { // 不知为啥 linux上 clipboard+nativeimage无效 try { clipboard.writeImage(nativeImage.createFromPath(opt.image)) } catch (err) { opt.image = null opt.text = '妈蛋 发不出图片' } } if (opt.html) clipboard.writeHtml(opt.html) if (opt.text) clipboard.writeText(opt.text) s('#editArea').focus() document.execCommand('paste') clipboard.writeImage(oldImage) clipboard.writeHtml(oldHtml) clipboard.writeText(oldText) } function detectPage () { let ps = [ detectCache(), // 协助跳转 detectLogin(), detectChat() ] // 同时判断login和chat 判断完成则同时释放 Promise.race(ps) .then(data => { ps.forEach(p => p.cancel()) let { page, qrcode } = data console.log(`目前处于${page}页面`) if (page === 'login') { download(qrcode) } else if (page === 'chat') { autoReply() } }) } // 需要定制promise 提供cancel方法 function detectChat () { let toCancel = false let p = (async () => { while (true) { if (toCancel) return await delay(300) let item = s('.chat_item') if (item) { return { page: 'chat' } } } })() p.cancel = () => { toCancel = true } return p } // 需要定制promise 提供cancel方法 function detectLogin () { let toCancel = false let p = (async () => { while (true) { if (toCancel) return await delay(300) // 共有两次load事件 仅处理后一次 // 第1次src https://res.wx.qq.com/a/wx_fed/webwx/res/static/img/2z6meE1.gif // 第2次src https://login.weixin.qq.com/qrcode/IbAG40QD6A== let img = s('.qrcode img') if (img && img.src.endsWith('==')) { return { page: 'login', qrcode: img.src } } } })() p.cancel = () => { toCancel = true } return p } // 需要定制promise 提供cancel方法 // 可能跳到缓存了退出登陆用户头像的界面,手动点一下切换用户,以触发二维码下载 function detectCache () { let toCancel = false let p = (async () => { while (true) { if (toCancel) return await delay(300) let btn = s('.association .button_default') if (btn) btn.click() // 持续点击 } })() p.cancel = () => { toCancel = true } return p } ================================================ FILE: src/preloadIpc.js ================================================ let { ipcRenderer } = require('electron') ;['log', 'info', 'warn', 'error'].forEach(k => { let fn = console[k].bind(console) console[k] = (...args) => { fn(...args) ipcRenderer.send('renderer', k, args) } }) ================================================ FILE: src/replyMsg.js ================================================ let { download } = require('./util') let { join } = require('path') module.exports = replyMsg let handlers = { 'text' ({ text }) { return { text } }, 'picture' ({ src }) { saveMedia(src) let largeSrc = src.replace('&type=slave', '') saveMedia(largeSrc, 'large') // 大图 return { image: join(__dirname, '../fuck.jpeg') } }, 'emoticon' () { // 用户自定义表情 return { text: '发毛表情' } }, 'sticker' () { // 微信内部表情 return { text: '发毛表情' } }, 'voice' ({ src }) { saveMedia(src) return { text: '发毛语音' } }, 'video' () { return { text: '发毛视频' } }, 'microvideo' () { return { text: '发毛小视频' } }, 'location' ({ src, desc }) { saveMedia(src) return { text: desc } }, 'attach' ({ src, title, size }) { saveMedia(src) return { text: title + '\n' + size } }, 'app' ({ title, url }) { return { text: title + '\n' + url } }, 'card' ({ name, wxid }) { return { text: name } }, 'transfer' () { return { text: '转毛帐' } }, 'video/voice call' () { return { text: '聊jj' } }, // 似乎功能已经不支持 // 'real-time voice' () { // return { text: '对讲你妹' } // }, /* 以下为无法自动感应病回应的消息 */ 'red packet' () { return { text: '发毛红包' } }, 'recall' ({ by }) { // return { text: `${by} 撤回了消息` } return { text: '撤jj' } }, 'new member' ({ who, by }) { // return { text: `${by} 邀请了 ${who}` } return { text: '加毛人' } }, 'member is stranger' ({ who }) { return { text: `大家要小心 ${who}` } }, 'real-time location ended' () { return { text: '位置共享已经结束' } }, // 似乎功能已经不支持 // 'real-time voice ended' () { // return { text: '实时对讲已经结束' } // }, 'removed' () { return null }, 'not recognized' () { return null // 忽略消息 不回应 }, /* 以上为无法自动感应病回应的消息 */ 'not supported' () { return { text: '不懂' } } } // fixme: attach文件附带content-deposition 覆盖download属性设置的filename // https://stackoverflow.com/questions/23872902/chrome-download-attribute-not-working function saveMedia (src, suffix) { let mat = src.match(/msgid=(\d+)/i) let msgid = mat && mat[1] let filename = msgid || '' if (suffix) filename += '_' + suffix download(src, filename) } async function replyMsg (msg) { let handler = handlers[msg.type] || handlers['not recognized'] let reply = await toPromise( handler(msg) ) return reply } function toPromise (ret) { if (ret && ret.then) return ret return Promise.resolve(ret) } ================================================ FILE: src/runner.js ================================================ require('./runnerIpc') let { app, session, ipcMain, BrowserWindow } = require('electron') let { tmpdir } = require('os') let { join } = require('path') // let open = require('open') let mime = require('mime') let downloadDir = join(__dirname, '../download') try { fs.mkdirSync(downloadDir) } catch (err) { // ignore } let win // 将renderer的输出 转发到terminal ipcMain.on('renderer', (e, k, args) => { console[k]('renderer', k, args) }) app.on('activate', () => { if (win) win.show() }) app.on('ready', () => { let show = true // 是否显示浏览器窗口 let preload = join(__dirname, 'preload.js') win = new BrowserWindow({ webPreferences: { preload, nodeIntegration: false }, width: 900, height: 610, show }) // Ctrl+C只会发送win.close 并且如果已登录 窗口还关不掉 // 所以干脆改为窗口关闭 直接退出 // https://github.com/electron/electron/issues/5273 win.on('close', e => { e.preventDefault() win.destroy() }) win.once('ready-to-show', () => { win.show() }) win.loadURL('https://wx.qq.com') let sess = session.defaultSession sess.on('will-download', async (e, item) => { let url = item.getURL() if (/\/qrcode\/.+==/.test(url)) { // 登录二维码 let dest = join(tmpdir(), `qrcode_${Date.now()}.jpg`) let state = await saveItem(item, dest, '二维码保存') // todo: 如果是运行在无界面环境 则需要将二维码通过url展示出来 // 如果不显示浏览器窗口 则调用程序单独打开二维码 // if (!show && state === 'completed') { // open(dest, err => { // if (err) { // console.error('二维码打开 err:', err) // } // }) // } } else { // 下载消息中的多媒体文件 图片/语音 let mimeType = item.getMimeType() let filename = item.getFilename() let ext = mime.extension(mimeType) // 修复mime缺少映射关系: `audio/mp3` => `mp3` if (mimeType === 'audio/mp3') ext = 'mp3' if (ext === 'bin') ext = '' if (ext) filename += '.' + ext let date = new Date().toJSON() filename = date + '_' + filename // 跨平台文件名容错 // http://blog.fritx.me/?weekly/160227 filename = filename.replace(/[\\\/:\*\,"\?<>|]/g, '_') let dest = join(downloadDir, filename) await saveItem(item, dest, `文件保存 ${filename}`) } }) }) async function saveItem (item, dest, log) { item.setSavePath(dest) return await new Promise(rs => { item.on('done', (e, state) => { console.log(`${log} state:${state}`) rs(state) }) }) } ================================================ FILE: src/runnerIpc.js ================================================ let parent = require('./ipc')(process) ;['log', 'info', 'warn', 'error'].forEach(k => { let fn = console[k].bind(console) console[k] = (...args) => { fn(...args) parent.emit('runner', k, args) } }) ================================================ FILE: src/util.js ================================================ exports.s = s exports.sa = sa exports.download = download exports.delay = delay async function delay (duration) { return new Promise(rs => { setTimeout(() => rs(), duration) }) } function download (href, filename = '') { console.log('触发下载', filename, href) let a = document.createElement('a') a.download = filename a.href = href a.click() } function s (selector) { return document.querySelector(selector) } function sa (selector) { return document.querySelectorAll(selector) }