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)
}