You need to sign in before continuing.
Commit b56db049a27ea59dbab3995dc0de7344f452cf32
1 parent
ceb78d7f
feat: 串联微信自动回复全流程到 A11y UI 发送链路
把 6 步链路接到唯一可用的发送路径(HermesAccessibilityService):
1. termux-notification-list 监听通知(息屏兜底)
2. input keyevent 224 点亮屏幕 + wm dismiss-keyguard 解锁
3. am start LauncherUI 切微信前台
4. sqlcipher 轮询 EnMicroMsg.db 拉新消息 + 最近 N 条上下文
5. 调 hermes-agent /v1/chat/completions 生成回复
6. am broadcast com.hermes.wechat.SEND_MSG → A11y UI 自动化发送
关键改动:
- android-wechat-sender.js
- sendTextMessageBroadcast 增加 options.toUserDisplayName(A11y 搜索必传)
- 新增 checkAccessibilityServiceEnabled / enableAccessibilityService
- android-wechat-tracker-sqlcipher.js
- 新增 getContactNickname(wxid):查 rcontact 拿 conRemark/nickname,
带 filehelper/weixin 等内置 helper 的硬编码 fallback
- 新增 fetchHistory(talker, limit):拉最近 N 条文本消息正序返回
- auto-reply.js
- 移除已废弃的 sendTextMessageFrida
- 启动时校验 A11y service,未启用则尝试自动启用并打印 manual fix 命令
- handleMessage 中:先 getContactNickname 拿 displayName、再 fetchHistory
给 hermes 做多轮上下文,最后用 broadcast + displayName 发送
- buildHermesMessages 重写:自动把历史按 isSend 转 user/assistant 多轮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
3 changed files
with
199 additions
and
31 deletions
server/services/wechat/android-wechat-sender.js
| ... | ... | @@ -93,19 +93,31 @@ async function sendTextMessage(toUser, content) { |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | /** |
| 96 | - * 发送文本消息(通过广播,更直接,绕过 ContentProvider 进程) | |
| 97 | - * @param {string} toUser | |
| 98 | - * @param {string} content | |
| 96 | + * 发送文本消息(通过广播 → HermesAccessibilityService UI 自动化) | |
| 97 | + * | |
| 98 | + * 这是当前唯一可用的发送方式。A11y service 必须已启用(见 README)。 | |
| 99 | + * | |
| 100 | + * @param {string} toUser - wxid(用于校验,不参与 UI 搜索) | |
| 101 | + * @param {string} content - 消息内容 | |
| 102 | + * @param {object} [options] | |
| 103 | + * @param {string} [options.toUserDisplayName] - **必传**:A11y 在搜索框中输入的名字 | |
| 104 | + * 通常是 conRemark || nickname,例如 `火星人`。如果省略会 fallback 到 toUser, | |
| 105 | + * 但绝大多数情况下用 wxid 是搜不到的。 | |
| 99 | 106 | * @returns {Promise<{ok: boolean, error?: string}>} |
| 100 | 107 | */ |
| 101 | -async function sendTextMessageBroadcast(toUser, content) { | |
| 108 | +async function sendTextMessageBroadcast(toUser, content, options = {}) { | |
| 102 | 109 | if (!toUser || !content) { |
| 103 | 110 | return { ok: false, error: 'toUser and content are required' }; |
| 104 | 111 | } |
| 112 | + const displayName = options.toUserDisplayName || toUser; | |
| 105 | 113 | const safeToUser = escapeShellSingleQuote(toUser); |
| 114 | + const safeDisplayName = escapeShellSingleQuote(displayName); | |
| 106 | 115 | const safeContent = escapeShellSingleQuote(content); |
| 107 | 116 | |
| 108 | - const cmd = `am broadcast -a ${ACTION_SEND} --es toUser '${safeToUser}' --es content '${safeContent}'`; | |
| 117 | + const cmd = `am broadcast -a ${ACTION_SEND} ` | |
| 118 | + + `--es toUser '${safeToUser}' ` | |
| 119 | + + `--es toUserDisplayName '${safeDisplayName}' ` | |
| 120 | + + `--es content '${safeContent}'`; | |
| 109 | 121 | |
| 110 | 122 | try { |
| 111 | 123 | const out = await shell(cmd, 10000); |
| ... | ... | @@ -120,6 +132,37 @@ async function sendTextMessageBroadcast(toUser, content) { |
| 120 | 132 | } |
| 121 | 133 | |
| 122 | 134 | /** |
| 135 | + * 校验 HermesAccessibilityService 是否已启用。 | |
| 136 | + * 这是 broadcast 发送能否真正完成的最关键前提。 | |
| 137 | + * @returns {Promise<{enabled: boolean, raw: string}>} | |
| 138 | + */ | |
| 139 | +async function checkAccessibilityServiceEnabled() { | |
| 140 | + try { | |
| 141 | + const out = await shell('settings get secure enabled_accessibility_services', 5000); | |
| 142 | + const enabled = out.includes('com.hermes.wechat.xposed') && out.includes('HermesAccessibilityService'); | |
| 143 | + return { enabled, raw: out }; | |
| 144 | + } catch (e) { | |
| 145 | + return { enabled: false, raw: e.message }; | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +/** | |
| 150 | + * 启用 HermesAccessibilityService(root 下用 settings put secure 直接写入) | |
| 151 | + * @returns {Promise<{ok: boolean, error?: string}>} | |
| 152 | + */ | |
| 153 | +async function enableAccessibilityService() { | |
| 154 | + const svc = 'com.hermes.wechat.xposed/com.hermes.wechat.xposed.accessibility.HermesAccessibilityService'; | |
| 155 | + try { | |
| 156 | + await shell(`settings put secure enabled_accessibility_services ${svc}`, 5000); | |
| 157 | + await shell('settings put secure accessibility_enabled 1', 5000); | |
| 158 | + const check = await checkAccessibilityServiceEnabled(); | |
| 159 | + return { ok: check.enabled }; | |
| 160 | + } catch (e) { | |
| 161 | + return { ok: false, error: e.message }; | |
| 162 | + } | |
| 163 | +} | |
| 164 | + | |
| 165 | +/** | |
| 123 | 166 | * 查询最近一次发送结果 |
| 124 | 167 | * @returns {Promise<{status: string, error?: string}>} |
| 125 | 168 | */ |
| ... | ... | @@ -155,6 +198,8 @@ function sleep(ms) { |
| 155 | 198 | module.exports = { |
| 156 | 199 | sendTextMessage, |
| 157 | 200 | sendTextMessageBroadcast, |
| 201 | + checkAccessibilityServiceEnabled, | |
| 202 | + enableAccessibilityService, | |
| 158 | 203 | queryStatus, |
| 159 | 204 | sendBatch, |
| 160 | 205 | shell, | ... | ... |
server/services/wechat/android-wechat-tracker-sqlcipher.js
| ... | ... | @@ -104,6 +104,63 @@ class AndroidWechatTrackerSQLCipher extends EventEmitter { |
| 104 | 104 | return msgs; |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | + /** | |
| 108 | + * 查询 talker 最近 limit 条文本消息(按时间正序),用于喂给 hermes 做上下文。 | |
| 109 | + * 返回 [{ msgId, isSend, content, createTime }, ...] | |
| 110 | + */ | |
| 111 | + async fetchHistory(talker, limit = 10) { | |
| 112 | + const safeTalker = String(talker).replace(/'/g, "''"); | |
| 113 | + const sql = `SELECT msgId, isSend, content, createTime FROM message ` | |
| 114 | + + `WHERE talker='${safeTalker}' AND type=1 ` | |
| 115 | + + `ORDER BY msgId DESC LIMIT ${parseInt(limit, 10) || 10};`; | |
| 116 | + const out = await this._query(sql, 10000); | |
| 117 | + const rows = this._parseRows(out); | |
| 118 | + // _parseRows 期望 msgId/talker/content 等字段,这里 talker 没查所以补一下 | |
| 119 | + return rows | |
| 120 | + .map(r => ({ | |
| 121 | + msgId: r.msgId, | |
| 122 | + isSend: r.isSend, | |
| 123 | + content: r.content, | |
| 124 | + createTime: r.createTime, | |
| 125 | + })) | |
| 126 | + .reverse(); // 正序:旧 → 新 | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 根据 wxid 查询联系人的显示名(用于 A11y 搜索框输入)。 | |
| 131 | + * 优先级:conRemark(备注名)→ nickname → 内置 helper 映射 → wxid 本身 | |
| 132 | + * | |
| 133 | + * @param {string} wxid | |
| 134 | + * @returns {Promise<string>} | |
| 135 | + */ | |
| 136 | + async getContactNickname(wxid) { | |
| 137 | + if (!wxid) return ''; | |
| 138 | + | |
| 139 | + // 微信内置 helper 没有 rcontact 记录,直接硬编码 | |
| 140 | + const builtin = { | |
| 141 | + filehelper: '文件传输助手', | |
| 142 | + weixin: '微信团队', | |
| 143 | + newsapp: '腾讯新闻', | |
| 144 | + }; | |
| 145 | + if (builtin[wxid]) return builtin[wxid]; | |
| 146 | + | |
| 147 | + // 群聊 ID 走另一个表(chatroom),先简单返回 wxid 让上层处理 | |
| 148 | + if (wxid.endsWith('@chatroom')) { | |
| 149 | + return wxid; | |
| 150 | + } | |
| 151 | + | |
| 152 | + const safeWxid = String(wxid).replace(/'/g, "''"); | |
| 153 | + const sql = `SELECT conRemark, nickname FROM rcontact WHERE username='${safeWxid}' LIMIT 1;`; | |
| 154 | + try { | |
| 155 | + const out = await this._query(sql, 5000); | |
| 156 | + const conRemark = this._extractValue(out, 'conRemark'); | |
| 157 | + const nickname = this._extractValue(out, 'nickname'); | |
| 158 | + return conRemark || nickname || wxid; | |
| 159 | + } catch (_) { | |
| 160 | + return wxid; | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 107 | 164 | startPolling(intervalMs = 3000) { |
| 108 | 165 | if (this.polling) return; |
| 109 | 166 | this.polling = true; | ... | ... |
server/services/wechat/auto-reply.js
| 1 | 1 | /** |
| 2 | 2 | * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) |
| 3 | 3 | * |
| 4 | - * 架构: | |
| 5 | - * AndroidWechatTrackerSQLCipher (sqlcipher DB 轮询) ──→ 消息检测 | |
| 6 | - * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信 | |
| 7 | - * hermes API (POST /api/master/chat) ──→ AI 回复生成 | |
| 8 | - * android-wechat-sender-frida (frida-inject 调用微信内部 API) ──→ 发送回复 | |
| 4 | + * 完整链路: | |
| 5 | + * 1. termux-notification-list 监听通知(息屏兜底,定时轮询) | |
| 6 | + * 2. 检测到消息后 input keyevent 224 点亮屏幕 + wm dismiss-keyguard 解锁 | |
| 7 | + * 3. am start LauncherUI 把微信切到前台 | |
| 8 | + * 4. AndroidWechatTrackerSQLCipher 轮询 EnMicroMsg.db 拉新消息 + 历史上下文 | |
| 9 | + * 5. callHermesApi POST 到 hermes-agent (127.0.0.1:8642) 生成回复 | |
| 10 | + * 6. sendTextMessageBroadcast → am broadcast com.hermes.wechat.SEND_MSG | |
| 11 | + * → HermesAccessibilityService UI 自动化点击微信完成发送 | |
| 9 | 12 | * |
| 10 | 13 | * 运行方式: |
| 11 | 14 | * cd /data/data/com.termux/files/home/hermes-wechat |
| 12 | - * nohup node auto-reply.js & | |
| 15 | + * ./start-ar.sh (内部 nohup node auto-reply.js &) | |
| 13 | 16 | * |
| 14 | 17 | * 终止: |
| 15 | - * ps aux | grep auto-reply | |
| 16 | - * kill <pid> | |
| 18 | + * pkill -f auto-reply.js | |
| 17 | 19 | */ |
| 18 | 20 | |
| 19 | -const { AndroidWechatTracker } = require('./android-wechat-tracker'); | |
| 20 | 21 | const { AndroidWechatTrackerSQLCipher } = require('./android-wechat-tracker-sqlcipher'); |
| 21 | -const { sendTextMessageBroadcast } = require('./android-wechat-sender'); | |
| 22 | -const { sendTextMessageFrida } = require('./android-wechat-sender-frida'); | |
| 23 | -const { shell } = require('./android-wechat-sender'); | |
| 22 | +const { | |
| 23 | + sendTextMessageBroadcast, | |
| 24 | + checkAccessibilityServiceEnabled, | |
| 25 | + enableAccessibilityService, | |
| 26 | + shell, | |
| 27 | +} = require('./android-wechat-sender'); | |
| 24 | 28 | const fs = require('fs'); |
| 25 | 29 | const path = require('path'); |
| 26 | 30 | const http = require('http'); |
| ... | ... | @@ -47,6 +51,9 @@ const CONFIG = { |
| 47 | 51 | cooldownMs: 3000, |
| 48 | 52 | ignoreSelf: true, |
| 49 | 53 | |
| 54 | + // 上下文长度:每次调 hermes 时回看多少条最近消息(含本次触发的那条) | |
| 55 | + historyContextSize: parseInt(process.env.HISTORY_CONTEXT_SIZE, 10) || 8, | |
| 56 | + | |
| 50 | 57 | // 通知监听(息屏兜底) |
| 51 | 58 | notificationPollIntervalMs: 3000, |
| 52 | 59 | notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false', |
| ... | ... | @@ -194,25 +201,47 @@ async function callHermesApi(messages, sessionId) { |
| 194 | 201 | |
| 195 | 202 | /** |
| 196 | 203 | * 构造 OpenAI messages 数组发给 hermes-agent |
| 204 | + * | |
| 205 | + * 把 talker 的最近 N 条聊天记录作为多轮 messages(自己发的为 assistant、对方发的为 user)。 | |
| 206 | + * 再把本次触发消息作为最后一个 user turn,并在 system 中说明回复要求。 | |
| 197 | 207 | */ |
| 198 | 208 | function buildHermesMessages(ctx) { |
| 199 | - const { type, talker, content, senderName } = ctx; | |
| 209 | + const { type, talker, displayName, content, senderName, history } = ctx; | |
| 200 | 210 | |
| 201 | - let userContent = ''; | |
| 211 | + let role = ''; | |
| 202 | 212 | if (type === 'private') { |
| 203 | - userContent = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 213 | + role = `这是和「${displayName || talker}」的微信私聊。`; | |
| 204 | 214 | } else if (type === 'group-at') { |
| 205 | - userContent = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 215 | + role = `这是一个微信群聊,刚才有人 @ 我("${senderName}"),群名 ${displayName || talker}。`; | |
| 206 | 216 | } else if (type === 'group-quote') { |
| 207 | - userContent = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 217 | + role = `这是一个微信群聊,有人引用了我的消息(发送者 "${senderName}"),群名 ${displayName || talker}。`; | |
| 208 | 218 | } else { |
| 209 | - userContent = `收到微信消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 219 | + role = `这是来自「${displayName || talker}」的消息。`; | |
| 210 | 220 | } |
| 211 | 221 | |
| 212 | - return [ | |
| 213 | - { role: 'system', content: '你是 lemonAI 助手,一个智能、友好、简短的 AI 助手。' }, | |
| 214 | - { role: 'user', content: userContent }, | |
| 215 | - ]; | |
| 222 | + const systemMsg = { | |
| 223 | + role: 'system', | |
| 224 | + content: `你是 lemonAI 助手,正在帮我回复微信消息。${role}\n` | |
| 225 | + + '回复要自然、简短、得体,使用中文。只输出回复内容,不要加任何解释、不要带前缀如"回复:"。', | |
| 226 | + }; | |
| 227 | + | |
| 228 | + // history 是 [{ isSend, content }] 旧→新;本次消息已经在最后一条 | |
| 229 | + const msgs = [systemMsg]; | |
| 230 | + if (Array.isArray(history) && history.length > 0) { | |
| 231 | + for (const h of history) { | |
| 232 | + const r = h.isSend === 1 ? 'assistant' : 'user'; | |
| 233 | + // 群聊里对方发的 content 形如 "wxid_x:\n实际内容",去掉前缀 | |
| 234 | + let txt = h.content || ''; | |
| 235 | + if (r === 'user' && txt.includes(':\n')) { | |
| 236 | + txt = txt.substring(txt.indexOf(':\n') + 2); | |
| 237 | + } | |
| 238 | + msgs.push({ role: r, content: txt }); | |
| 239 | + } | |
| 240 | + } else { | |
| 241 | + // 没有历史时,直接把当前内容作为 user turn | |
| 242 | + msgs.push({ role: 'user', content }); | |
| 243 | + } | |
| 244 | + return msgs; | |
| 216 | 245 | } |
| 217 | 246 | |
| 218 | 247 | /** |
| ... | ... | @@ -223,7 +252,7 @@ async function hermesReplyGenerator(ctx) { |
| 223 | 252 | const sessionId = `wx-${talker}`; |
| 224 | 253 | const messages = buildHermesMessages(ctx); |
| 225 | 254 | |
| 226 | - log('[HERMES] Calling API for', talker, 'session:', sessionId); | |
| 255 | + log('[HERMES] Calling API for', talker, 'session:', sessionId, 'turns=' + messages.length); | |
| 227 | 256 | const reply = await callHermesApi(messages, sessionId); |
| 228 | 257 | |
| 229 | 258 | if (reply) { |
| ... | ... | @@ -479,13 +508,34 @@ class AutoReplyBot { |
| 479 | 508 | await sleep(2000); // 额外等待网络初始化 |
| 480 | 509 | } |
| 481 | 510 | |
| 511 | + // 查 displayName(A11y 用它来在搜索框里搜联系人) | |
| 512 | + let displayName; | |
| 513 | + try { | |
| 514 | + displayName = await this.tracker.getContactNickname(talker); | |
| 515 | + } catch (e) { | |
| 516 | + displayName = talker; | |
| 517 | + } | |
| 518 | + if (!displayName || displayName === talker) { | |
| 519 | + log('[WARN] No displayName resolved for', talker, '— A11y 搜索可能失败'); | |
| 520 | + } | |
| 521 | + | |
| 522 | + // 拉历史消息作为 hermes 上下文 | |
| 523 | + let history = []; | |
| 524 | + try { | |
| 525 | + history = await this.tracker.fetchHistory(talker, CONFIG.historyContextSize); | |
| 526 | + } catch (e) { | |
| 527 | + log('[HISTORY] fetch failed:', e.message); | |
| 528 | + } | |
| 529 | + | |
| 482 | 530 | let replyText; |
| 483 | 531 | try { |
| 484 | 532 | replyText = await this.replyGenerator({ |
| 485 | 533 | type: replyType, |
| 486 | 534 | talker, |
| 535 | + displayName, | |
| 487 | 536 | content, |
| 488 | 537 | senderName, |
| 538 | + history, | |
| 489 | 539 | raw: msg.raw, |
| 490 | 540 | }); |
| 491 | 541 | } catch (e) { |
| ... | ... | @@ -503,9 +553,9 @@ class AutoReplyBot { |
| 503 | 553 | } |
| 504 | 554 | |
| 505 | 555 | try { |
| 506 | - const result = await sendTextMessageFrida(talker, replyText); | |
| 556 | + const result = await sendTextMessageBroadcast(talker, replyText, { toUserDisplayName: displayName }); | |
| 507 | 557 | if (result.ok) { |
| 508 | - log('[SENT]', talker, replyText.substring(0, 60)); | |
| 558 | + log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60)); | |
| 509 | 559 | this.state.markReplied(msgId, talker); |
| 510 | 560 | } else { |
| 511 | 561 | log('[SEND FAILED]', talker, result.error); |
| ... | ... | @@ -527,10 +577,26 @@ class AutoReplyBot { |
| 527 | 577 | replyInPrivateChat: CONFIG.replyInPrivateChat, |
| 528 | 578 | replyAtInGroup: CONFIG.replyAtInGroup, |
| 529 | 579 | replyQuoteInGroup: CONFIG.replyQuoteInGroup, |
| 580 | + historyContextSize: CONFIG.historyContextSize, | |
| 530 | 581 | notificationEnabled: CONFIG.notificationEnabled, |
| 531 | 582 | })); |
| 532 | 583 | |
| 533 | - // 启动 tracker(sqlcipher DB 轮询,比 LSPosed hook 更可靠) | |
| 584 | + // A11y service 健康检查 — 发送 broadcast 必须依赖它 | |
| 585 | + const a11y = await checkAccessibilityServiceEnabled(); | |
| 586 | + if (!a11y.enabled) { | |
| 587 | + log('[A11Y] NOT enabled (raw:', a11y.raw, '), trying to enable...'); | |
| 588 | + const en = await enableAccessibilityService(); | |
| 589 | + if (en.ok) { | |
| 590 | + log('[A11Y] Enabled OK'); | |
| 591 | + } else { | |
| 592 | + log('[A11Y] FAILED to enable:', en.error || '(unknown)'); | |
| 593 | + log('[A11Y] Manual fix: adb shell "su -c \'settings put secure enabled_accessibility_services com.hermes.wechat.xposed/com.hermes.wechat.xposed.accessibility.HermesAccessibilityService\'"'); | |
| 594 | + } | |
| 595 | + } else { | |
| 596 | + log('[A11Y] HermesAccessibilityService is enabled'); | |
| 597 | + } | |
| 598 | + | |
| 599 | + // 启动 tracker(sqlcipher DB 轮询,唯一稳定的消息检测方式) | |
| 534 | 600 | this.tracker.on('message', msg => this.handleMessage(msg)); |
| 535 | 601 | this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); |
| 536 | 602 | await this.tracker.init(); | ... | ... |