diff --git a/deploy/linux/patch_wechat4u.py b/deploy/linux/patch_wechat4u.py new file mode 100644 index 0000000..92ca2bd --- /dev/null +++ b/deploy/linux/patch_wechat4u.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""给 wechat4u 打补丁: 降低微信风控识别特征 +1. request.js: UA → 最新 Chrome 126 Windows (每账号可不同) +2. request.js: connection close → keep-alive +3. global.js: DeviceID 持久化 (不再每次随机) +4. wechat.js: syncPolling 加 25±5s 间隔 (模拟真人) +""" +import subprocess, os, re, shutil, time + +def run(cmd, timeout=30): + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return (r.stdout + r.stderr).strip() + +TMP = "/tmp/wechat4u-patch" +os.makedirs(TMP, exist_ok=True) + +# 1. 拷贝文件出容器 +print("=== 1. 拷贝文件出容器 ===") +files = { + "request.js": "/app/node_modules/wechat4u/lib/util/request.js", + "wechat.js": "/app/node_modules/wechat4u/lib/wechat.js", + "global.js": "/app/node_modules/wechat4u/lib/util/global.js", +} +for name, path in files.items(): + run(["docker", "cp", f"wxBotWebhook:{path}", f"{TMP}/{name}"]) + print(f" {name}: {os.path.getsize(f'{TMP}/{name}')}b") + +# 2. 打补丁 +print("\n=== 2. 打补丁 ===") + +# 2.1 request.js: UA + connection +p = f"{TMP}/request.js" +c = open(p, encoding='utf-8').read() +old_ua = "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';" +new_ua = "defaults.headers['user-agent'] = defaults.headers['user-agent'] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';" +if old_ua in c: + c = c.replace(old_ua, new_ua) + print(" ✅ UA → Chrome 126 Windows") +else: + print(" ⚠️ UA 未匹配") + +old_conn = "defaults.headers['connection'] = defaults.headers['connection'] || 'close';" +new_conn = "defaults.headers['connection'] = defaults.headers['connection'] || 'keep-alive';" +if old_conn in c: + c = c.replace(old_conn, new_conn) + print(" ✅ connection → keep-alive") +else: + print(" ⚠️ connection 未匹配") + +# 补全浏览器 headers (Accept, Accept-Language 等) +old_headers = "defaults.headers['connection'] = defaults.headers['connection'] || 'keep-alive';" +if "Accept-Language" not in c: + # 在 connection 行后插入完整浏览器 headers + insert = """defaults.headers['Accept'] = defaults.headers['Accept'] || 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'; +defaults.headers['Accept-Language'] = defaults.headers['Accept-Language'] || 'zh-CN,zh;q=0.9,en;q=0.8'; +defaults.headers['Accept-Encoding'] = defaults.headers['Accept-Encoding'] || 'gzip, deflate, br'; +defaults.headers['Upgrade-Insecure-Requests'] = defaults.headers['Upgrade-Insecure-Requests'] || '1'; +defaults.headers['Sec-Fetch-Dest'] = defaults.headers['Sec-Fetch-Dest'] || 'document'; +defaults.headers['Sec-Fetch-Mode'] = defaults.headers['Sec-Fetch-Mode'] || 'navigate'; +defaults.headers['Sec-Fetch-Site'] = defaults.headers['Sec-Fetch-Site'] || 'none'; +defaults.headers['Sec-Fetch-User'] = defaults.headers['Sec-Fetch-User'] || '?1'; +""" + c = c.replace(old_headers, old_headers + "\n" + insert) + print(" ✅ 补全浏览器 headers") +open(p, 'w', encoding='utf-8').write(c) + +# 2.2 global.js: DeviceID 持久化 +p = f"{TMP}/global.js" +c = open(p, encoding='utf-8').read() +old_did = """function getDeviceID() { + return 'e' + ('' + Math.random().toFixed(15)).substring(2, 17); +}""" +new_did = """var _persistDeviceID = ''; +function getDeviceID() { + if (!_persistDeviceID) { + _persistDeviceID = 'e' + ('' + Math.random().toFixed(15)).substring(2, 17); + } + return _persistDeviceID; +}""" +if old_did in c: + c = c.replace(old_did, new_did) + print(" ✅ DeviceID 持久化 (同进程固定)") +else: + print(" ⚠️ DeviceID 未匹配") +open(p, 'w', encoding='utf-8').write(c) + +# 2.3 wechat.js: syncPolling 加 25±5s 间隔 +p = f"{TMP}/wechat.js" +c = open(p, encoding='utf-8').read() +# 找到 .then 里的 _this3.syncPolling(id); (立即递归) +old_sync = """ }).then(function () { + _this3.lastSyncTime = Date.now(); + _this3.syncPolling(id); + }).catch(function (err) {""" +new_sync = """ }).then(function () { + _this3.lastSyncTime = Date.now(); + setTimeout(function () { + _this3.syncPolling(id); + }, 25000 + Math.floor(Math.random() * 5000)); + }).catch(function (err) {""" +if old_sync in c: + c = c.replace(old_sync, new_sync) + print(" ✅ syncPolling 间隔 25±5s") +else: + print(" ⚠️ syncPolling 未匹配") +open(p, 'w', encoding='utf-8').write(c) + +# 3. 语法检查 (node) +print("\n=== 3. 语法检查 ===") +for name in ["request.js", "global.js", "wechat.js"]: + r = run(["docker", "exec", "wxBotWebhook", "node", "--check", f"/tmp/{name}"], timeout=15) + # node --check 需要文件在容器内, 先拷贝验证 + run(["docker", "cp", f"{TMP}/{name}", f"wxBotWebhook:/tmp/{name}"]) + r = run(["docker", "exec", "wxBotWebhook", "node", "--check", f"/tmp/{name}"], timeout=15) + print(f" {name}: {'OK' if 'SyntaxError' not in r else r[:200]}") + +print("\n=== 补丁完成, 等待确认后拷回 ===") +print("验证通过后: 拷回容器 + 重启 wxBotWebhook") diff --git a/deploy/linux/wxbot_on_demand_login_patch.py b/deploy/linux/wxbot_on_demand_login_patch.py new file mode 100644 index 0000000..61564fc --- /dev/null +++ b/deploy/linux/wxbot_on_demand_login_patch.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +""" +wxbot_on_demand_login_patch.py — 莫荷微信「按需登录」完整补丁 +================================================================ +背景: 2026-08-13 莫荷微信被风控(频繁重登 + 未登录忙循环请求腾讯)。 +本补丁将 docker-wechatbot-webhook 改为「按需登录」: + - 空闲时零请求腾讯 (不再自动 start) + - 点"开始登录"按钮才触发登录流程 + - 登录窗口期轮询 3s 间隔+退避 (非忙循环) + - 登出/停止后回到静默, 清空残留二维码 + +改动文件 (容器内): + 1. /app/src/wechaty/init.js — DISABLE_AUTO_LOGIN 时不自动 start, 导出 start/stop/isRunning + 2. /app/main.js — 解构新导出 + 传 isRunning + 3. /app/src/route/bot.js — 新增: POST /api/bot/login|logout, GET /api/bot/status + 4. /app/src/route/index.js — 注册 bot 路由 + 传 isRunning + 5. /app/src/route/login.js — bot 未运行时不展示二维码 + 6. /app/node_modules/wechat4u/lib/wechat.js — login 忙循环加 3s+退避 (checkLogin) + 7. /app/node_modules/wechat4u/lib/util/request.js — UA/headers/keep-alive (风控降特征) + 8. /app/node_modules/wechat4u/lib/util/global.js — DeviceID 持久化 + 9. /app/node_modules/wechaty-puppet-wechat4u/dist/cjs/src/puppet-wechat4u.js — logout 不自动 start + 10. /app/.env — DISABLE_AUTO_LOGIN=true + +用法: + python3 wxbot_on_demand_login_patch.py # 应用补丁 + python3 wxbot_on_demand_login_patch.py --revert # 回滚 +""" +import subprocess, os, sys, shutil, time + +CONTAINER = "wxBotWebhook" +TMP = "/tmp/wxbot-on-demand-patch" +os.makedirs(TMP, exist_ok=True) + +def run(cmd, timeout=30): + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return (r.stdout + r.stderr).strip() + +def docker_exec(sh_cmd): + return run(["docker", "exec", CONTAINER, "sh", "-c", sh_cmd]) + +def docker_cp_out(path, local): + run(["docker", "cp", f"{CONTAINER}:{path}", local]) + +def docker_cp_in(local, path): + run(["docker", "cp", local, f"{CONTAINER}:{path}"]) + +# ══════════════════════════════════════════════════════════════ +# 1. init.js 补丁: 按需启动 + 导出控制函数 +# ══════════════════════════════════════════════════════════════ +def patch_init_js(): + path = "/app/src/wechaty/init.js" + docker_cp_out(path, f"{TMP}/init.js") + c = open(f"{TMP}/init.js", encoding='utf-8').read() + + # 备份 + docker_exec(f"cp {path} {path}.on-demand.bak") + + # 1a. 模块头加 autoLoginDisabled 判定 (含 .env 兜底) + old_module = """module.exports = function init() { + /** @type {import('wechaty').Contact} */ + let currentUser + let botLoginSuccessLastTime = false +""" + new_module = """module.exports = function init() { + /** @type {import('wechaty').Contact} */ + let currentUser + let botLoginSuccessLastTime = false + let botStarted = false + + // 按需登录控制: DISABLE_AUTO_LOGIN=true 时不自动启动 + // 兜底: docker run 可能注入 DISABLE_AUTO_LOGIN= (空值覆盖 .env), 这里同时读 .env 文件 + let _autoLoginFromFile = false + try { + const _fs = require('fs') + const _path = require('path') + const _envContent = _fs.readFileSync(_path.join(__dirname, '../../.env'), 'utf-8') + const _m = _envContent.match(/^DISABLE_AUTO_LOGIN=(\\S*)$/m) + if (_m && _m[1] === 'true') _autoLoginFromFile = true + } catch (e) { /* ignore */ } + const autoLoginDisabled = + process.env.DISABLE_AUTO_LOGIN === 'true' || _autoLoginFromFile +""" + if old_module in c: + c = c.replace(old_module, new_module) + print(" ✅ init.js: 按需启动判定") + else: + print(" ⚠️ init.js 模块头未匹配") + + # 1b. 末尾: 条件 start + 导出控制对象 + old_start = """ bot.start().catch((e) => { + Utils.logger.error('bot 初始化失败:', e) + }) + + return bot +}""" + new_start = """ // 自动登录模式: 容器启动即开始登录流程(获取二维码+轮询) + if (!autoLoginDisabled) { + botStarted = true + bot.start().catch((e) => { + Utils.logger.error('bot 初始化失败:', e) + }) + } else { + Utils.logger.info( + 'DISABLE_AUTO_LOGIN=true: 机器人已就绪但未启动, 等待 POST /api/bot/login 触发登录' + ) + } + + return { + bot, + /** + * 按需启动登录流程 (获取二维码 + 检测扫码) + * 幂等: 已启动/已登录时直接返回当前状态 + */ + start: async () => { + if (botStarted) { + Utils.logger.info('bot 已在运行中, 忽略重复 start') + return { started: true, alreadyRunning: true } + } + botStarted = true + Utils.logger.info('手动触发 bot.start() - 开始登录流程') + await bot.start() + return { started: true } + }, + /** + * 停止机器人 (登出后回到静默) + */ + stop: async () => { + if (!botStarted) return { stopped: true, alreadyStopped: true } + botStarted = false + try { + await bot.stop() + } catch (e) { + Utils.logger.error('bot.stop() 出错:', e) + } + Utils.logger.info('bot 已停止, 回到静默状态') + return { stopped: true } + }, + isRunning: () => botStarted + } +}""" + if old_start in c: + c = c.replace(old_start, new_start) + print(" ✅ init.js: 条件 start + 导出控制") + else: + print(" ⚠️ init.js 末尾未匹配") + + open(f"{TMP}/init.js", 'w', encoding='utf-8').write(c) + docker_cp_in(f"{TMP}/init.js", path) + +# ══════════════════════════════════════════════════════════════ +# 2. main.js 补丁 +# ══════════════════════════════════════════════════════════════ +def patch_main_js(): + path = "/app/main.js" + docker_cp_out(path, f"{TMP}/main.js") + c = open(f"{TMP}/main.js", encoding='utf-8').read() + + old = """const { bot, start: startBot, stop: stopBot } = wechatBotInit()""" + new = """const { bot, start: startBot, stop: stopBot, isRunning } = wechatBotInit()""" + if old in c: + c = c.replace(old, new) + print(" ✅ main.js: 解构 isRunning") + old2 = """registerRoute({ app, bot, startBot, stopBot })""" + new2 = """registerRoute({ app, bot, startBot, stopBot, isRunning })""" + if old2 in c: + c = c.replace(old2, new2) + print(" ✅ main.js: 传 isRunning") + + open(f"{TMP}/main.js", 'w', encoding='utf-8').write(c) + docker_cp_in(f"{TMP}/main.js", path) + +# ══════════════════════════════════════════════════════════════ +# 3. route/bot.js (新增) +# ══════════════════════════════════════════════════════════════ +BOT_ROUTE = """/** + * 按需登录控制路由 + * 仅当 DISABLE_AUTO_LOGIN=true 时生效: + * - POST /api/bot/login 手动触发登录(获取二维码+检测扫码) + * - POST /api/bot/logout 登出并回到静默 + * - GET /api/bot/status 查询当前状态 + */ +module.exports = function registerBotRoute({ app, startBot, stopBot, bot }) { + // 触发登录 (获取二维码) + app.post('/api/bot/login', async (c) => { + try { + const result = await startBot() + return c.json({ + success: true, + ...result, + loginUrl: `/login?token=${process.env.LOCAL_LOGIN_API_TOKEN || ''}` + }) + } catch (e) { + return c.json({ success: false, error: e.message }, 500) + } + }) + + // 登出回到静默 + app.post('/api/bot/logout', async (c) => { + try { + const result = await stopBot() + return c.json({ success: true, ...result }) + } catch (e) { + return c.json({ success: false, error: e.message }, 500) + } + }) + + // 状态查询 + app.get('/api/bot/status', async (c) => { + const isLoggedIn = !!(bot && bot.isLoggedIn) + const isRunning = startBot ? (typeof startBot.running === 'function' ? startBot.running() : false) : false + return c.json({ + isLoggedIn, + loginUrl: `/login?token=${process.env.LOCAL_LOGIN_API_TOKEN || ''}` + }) + }) +} +""" + +def patch_bot_route(): + path = "/app/src/route/bot.js" + with open(f"{TMP}/bot.js", 'w', encoding='utf-8') as f: + f.write(BOT_ROUTE) + docker_cp_in(f"{TMP}/bot.js", path) + print(" ✅ route/bot.js: 新增") + +# ══════════════════════════════════════════════════════════════ +# 4. route/index.js +# ══════════════════════════════════════════════════════════════ +def patch_route_index(): + path = "/app/src/route/index.js" + docker_cp_out(path, f"{TMP}/index.js") + c = open(f"{TMP}/index.js", encoding='utf-8').read() + + old = """module.exports = function registerRoute({ app, bot, startBot, stopBot }) {""" + new = """module.exports = function registerRoute({ app, bot, startBot, stopBot, isRunning }) {""" + if old in c: + c = c.replace(old, new) + old2 = """ require('./login')({ app, bot })""" + new2 = """ require('./login')({ app, bot, isRunning })""" + if old2 in c: + c = c.replace(old2, new2) + old3 = """ require('./resouces')({ app, bot })""" + new3 = """ require('./resouces')({ app, bot }) + if (startBot) { + require('./bot')({ app, startBot, stopBot, bot }) + }""" + if old3 in c: + c = c.replace(old3, new3) + print(" ✅ route/index.js: 注册 bot 路由 + 传参") + + open(f"{TMP}/index.js", 'w', encoding='utf-8').write(c) + docker_cp_in(f"{TMP}/index.js", path) + +# ══════════════════════════════════════════════════════════════ +# 5. route/login.js — 未运行不展示二维码 +# ══════════════════════════════════════════════════════════════ +def patch_login_js(): + path = "/app/src/route/login.js" + docker_cp_out(path, f"{TMP}/login.js") + c = open(f"{TMP}/login.js", encoding='utf-8').read() + + old_sig = """module.exports = function registerLoginCheck({ app, bot }) {""" + new_sig = """module.exports = function registerLoginCheck({ app, bot, isRunning }) { + // [按需登录补丁] bot 未运行时(未触发登录)不展示二维码 + const botActive = isRunning ? isRunning() : true + const token = process.env.LOCAL_LOGIN_API_TOKEN || ''""" + if old_sig in c: + c = c.replace(old_sig, new_sig) + print(" ✅ login.js: 签名+botActive+token") + + old_get = """ async (c) => { + // 登录成功的话,返回登录信息 + if (success) {""" + new_get = """ async (c) => { + // [按需登录补丁] bot 未运行 → 不展示二维码, 提示未启动 + if (!botActive) { + const html = ` + + +
+ +