- init.js: DISABLE_AUTO_LOGIN=true 时不自动 bot.start(), 导出 start/stop/isRunning - 新增 route/bot.js: POST /api/bot/login|logout, GET /api/bot/status (按需触发登录) - login.js: bot 未运行时不展示残留二维码, 显示开始登录按钮 - wechat4u wechat.js: checkLogin 忙循环加 3s 间隔+指数退避 (原来毫秒级轰炸 login.wx.qq.com) - puppet-wechat4u: logout 后不自动 start (原来登出即自动重登) - 补丁脚本: deploy/linux/wxbot_on_demand_login_patch.py (可重放, 容器重建后一键恢复) - 文档: docs/WXBOT_ON_DEMAND_LOGIN.md - agents_health_check/self_todo_executor: 禁用 wxBotWebhook 自动重启 (重启=重登触发风控)
460 lines
19 KiB
Python
460 lines
19 KiB
Python
#!/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 = `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>扫码登录</title>
|
|
<style>
|
|
body, html { display: flex; justify-content: center; align-items: center; height: 100%; margin: 0; font-family: sans-serif; }
|
|
.box { text-align: center; padding: 40px; border: 1px solid #ddd; border-radius: 12px; }
|
|
.btn { display: inline-block; margin-top: 20px; padding: 10px 24px; background: #07c160; color: #fff; border-radius: 8px; text-decoration: none; font-size: 16px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="box">
|
|
<h2>莫荷微信机器人</h2>
|
|
<p>当前未启动,未在请求微信服务器。</p>
|
|
<a class="btn" href="/api/bot/login?token=${token}">开始登录(获取二维码)</a>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
return c.html(html)
|
|
}
|
|
// 登录成功的话,返回登录信息
|
|
if (success) {"""
|
|
if old_get in c:
|
|
c = c.replace(old_get, new_get)
|
|
print(" ✅ login.js: 未启动分支")
|
|
|
|
open(f"{TMP}/login.js", 'w', encoding='utf-8').write(c)
|
|
docker_cp_in(f"{TMP}/login.js", path)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# 6. wechat4u wechat.js — checkLogin 忙循环加 3s+退避
|
|
# ══════════════════════════════════════════════════════════════
|
|
def patch_wechat_busyloop():
|
|
path = "/app/node_modules/wechat4u/lib/wechat.js"
|
|
docker_cp_out(path, f"{TMP}/wechat.js")
|
|
c = open(f"{TMP}/wechat.js", encoding='utf-8').read()
|
|
|
|
old = """ var checkLogin = function checkLogin() {
|
|
return _this6.checkLogin().then(function (res) {
|
|
if (res.code === 201 && res.userAvatar) {
|
|
_this6.emit('user-avatar', res.userAvatar);
|
|
}
|
|
if (res.code !== 200) {
|
|
debug('checkLogin: ', res.code);
|
|
return checkLogin();
|
|
} else {
|
|
return res;
|
|
}
|
|
});
|
|
};"""
|
|
new = """ var _loginCheckCount = 0;
|
|
var checkLogin = function checkLogin() {
|
|
return _this6.checkLogin().then(function (res) {
|
|
if (res.code === 201 && res.userAvatar) {
|
|
_this6.emit('user-avatar', res.userAvatar);
|
|
}
|
|
if (res.code !== 200) {
|
|
debug('checkLogin: ', res.code);
|
|
_loginCheckCount++;
|
|
// 防忙循环: 前10次3秒间隔, 之后指数退避到最长30秒
|
|
var delay = _loginCheckCount <= 10 ? 3000 : Math.min(3000 * Math.pow(2, _loginCheckCount - 10), 30000);
|
|
return new Promise(function (resolve) {
|
|
setTimeout(function () { resolve(checkLogin()); }, delay);
|
|
});
|
|
} else {
|
|
_loginCheckCount = 0;
|
|
return res;
|
|
}
|
|
});
|
|
};"""
|
|
if old in c:
|
|
c = c.replace(old, new)
|
|
print(" ✅ wechat.js: checkLogin 3s+退避")
|
|
else:
|
|
print(" ⚠️ wechat.js 忙循环未匹配(可能已打过)")
|
|
open(f"{TMP}/wechat.js", 'w', encoding='utf-8').write(c)
|
|
docker_cp_in(f"{TMP}/wechat.js", path)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# 7. puppet-wechat4u.js — logout 不自动 start
|
|
# ══════════════════════════════════════════════════════════════
|
|
def patch_puppet_logout():
|
|
path = "/app/node_modules/wechaty-puppet-wechat4u/dist/cjs/src/puppet-wechat4u.js"
|
|
docker_cp_out(path, f"{TMP}/puppet.js")
|
|
c = open(f"{TMP}/puppet.js", encoding='utf-8').read()
|
|
|
|
old = """ // 清除数据
|
|
await this.memory.delete(MEMORY_SLOT_NAME);
|
|
await this.memory.save();
|
|
this.wechat4u.start();
|
|
});"""
|
|
new = """ // 清除数据
|
|
await this.memory.delete(MEMORY_SLOT_NAME);
|
|
await this.memory.save();
|
|
// [按需登录补丁] 登出后不再自动 start (原代码会立即重新开始登录轮询)
|
|
// 需要重新登录时, 由上层 POST /api/bot/login 手动触发
|
|
if (!process.env.DISABLE_AUTO_LOGIN) {
|
|
this.wechat4u.start();
|
|
} else {
|
|
wechaty_puppet_1.log.info('PuppetWechat4u', 'logout后按需登录模式: 不自动重启, 等待手动触发');
|
|
}
|
|
});"""
|
|
if old in c:
|
|
c = c.replace(old, new)
|
|
print(" ✅ puppet: logout 不自动 start")
|
|
else:
|
|
print(" ⚠️ puppet logout 未匹配")
|
|
open(f"{TMP}/puppet.js", 'w', encoding='utf-8').write(c)
|
|
docker_cp_in(f"{TMP}/puppet.js", path)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# 8. .env — DISABLE_AUTO_LOGIN=true
|
|
# ══════════════════════════════════════════════════════════════
|
|
def patch_env():
|
|
r = docker_exec("grep '^DISABLE_AUTO_LOGIN' /app/.env")
|
|
if 'DISABLE_AUTO_LOGIN=true' in r:
|
|
print(" ✅ .env: DISABLE_AUTO_LOGIN=true (已设置)")
|
|
else:
|
|
docker_exec("sed -i 's/^DISABLE_AUTO_LOGIN=$/DISABLE_AUTO_LOGIN=true/' /app/.env")
|
|
print(" ✅ .env: DISABLE_AUTO_LOGIN=true")
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# 语法检查 + 重启
|
|
# ══════════════════════════════════════════════════════════════
|
|
def verify_and_restart():
|
|
print("\n=== 语法检查 ===")
|
|
files = [
|
|
"/app/src/wechaty/init.js",
|
|
"/app/main.js",
|
|
"/app/src/route/bot.js",
|
|
"/app/src/route/index.js",
|
|
"/app/src/route/login.js",
|
|
"/app/node_modules/wechat4u/lib/wechat.js",
|
|
"/app/node_modules/wechaty-puppet-wechat4u/dist/cjs/src/puppet-wechat4u.js",
|
|
]
|
|
ok = True
|
|
for f in files:
|
|
r = run(["docker", "exec", CONTAINER, "node", "--check", f])
|
|
if r:
|
|
ok = False
|
|
print(f" ✗ {f}: {r[:200]}")
|
|
else:
|
|
print(f" ✓ {f}")
|
|
return ok
|
|
|
|
def main():
|
|
print("=== 应用按需登录补丁 ===")
|
|
print("\n--- 1/8 init.js ---")
|
|
patch_init_js()
|
|
print("\n--- 2/8 main.js ---")
|
|
patch_main_js()
|
|
print("\n--- 3/8 route/bot.js ---")
|
|
patch_bot_route()
|
|
print("\n--- 4/8 route/index.js ---")
|
|
patch_route_index()
|
|
print("\n--- 5/8 route/login.js ---")
|
|
patch_login_js()
|
|
print("\n--- 6/8 wechat.js busy-loop ---")
|
|
patch_wechat_busyloop()
|
|
print("\n--- 7/8 puppet logout ---")
|
|
patch_puppet_logout()
|
|
print("\n--- 8/8 .env ---")
|
|
patch_env()
|
|
|
|
if verify_and_restart():
|
|
print("\n=== 语法全部通过, 重启容器 ===")
|
|
print(run(["docker", "restart", CONTAINER]))
|
|
time.sleep(8)
|
|
logs = run(["docker", "logs", CONTAINER, "--since", "1m", "2>&1"])
|
|
if '等待 POST /api/bot/login' in logs:
|
|
print("✅ 补丁生效: 静默等待触发登录")
|
|
else:
|
|
print("⚠️ 日志中未找到等待触发标记, 请检查:")
|
|
for l in logs.split('\n')[:15]:
|
|
print(f" {l[:120]}")
|
|
else:
|
|
print("\n!!! 语法检查失败, 未重启. 请手动修复")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|