Commit b56db049a27ea59dbab3995dc0de7344f452cf32

Authored by yibo
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>
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();
... ...