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,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 * @returns {Promise<{ok: boolean, error?: string}>} 106 * @returns {Promise<{ok: boolean, error?: string}>}
100 */ 107 */
101 -async function sendTextMessageBroadcast(toUser, content) { 108 +async function sendTextMessageBroadcast(toUser, content, options = {}) {
102 if (!toUser || !content) { 109 if (!toUser || !content) {
103 return { ok: false, error: 'toUser and content are required' }; 110 return { ok: false, error: 'toUser and content are required' };
104 } 111 }
  112 + const displayName = options.toUserDisplayName || toUser;
105 const safeToUser = escapeShellSingleQuote(toUser); 113 const safeToUser = escapeShellSingleQuote(toUser);
  114 + const safeDisplayName = escapeShellSingleQuote(displayName);
106 const safeContent = escapeShellSingleQuote(content); 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 try { 122 try {
111 const out = await shell(cmd, 10000); 123 const out = await shell(cmd, 10000);
@@ -120,6 +132,37 @@ async function sendTextMessageBroadcast(toUser, content) { @@ -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 * @returns {Promise<{status: string, error?: string}>} 167 * @returns {Promise<{status: string, error?: string}>}
125 */ 168 */
@@ -155,6 +198,8 @@ function sleep(ms) { @@ -155,6 +198,8 @@ function sleep(ms) {
155 module.exports = { 198 module.exports = {
156 sendTextMessage, 199 sendTextMessage,
157 sendTextMessageBroadcast, 200 sendTextMessageBroadcast,
  201 + checkAccessibilityServiceEnabled,
  202 + enableAccessibilityService,
158 queryStatus, 203 queryStatus,
159 sendBatch, 204 sendBatch,
160 shell, 205 shell,
server/services/wechat/android-wechat-tracker-sqlcipher.js
@@ -104,6 +104,63 @@ class AndroidWechatTrackerSQLCipher extends EventEmitter { @@ -104,6 +104,63 @@ class AndroidWechatTrackerSQLCipher extends EventEmitter {
104 return msgs; 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 startPolling(intervalMs = 3000) { 164 startPolling(intervalMs = 3000) {
108 if (this.polling) return; 165 if (this.polling) return;
109 this.polling = true; 166 this.polling = true;
server/services/wechat/auto-reply.js
1 /** 1 /**
2 * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) 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 * cd /data/data/com.termux/files/home/hermes-wechat 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 const { AndroidWechatTrackerSQLCipher } = require('./android-wechat-tracker-sqlcipher'); 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 const fs = require('fs'); 28 const fs = require('fs');
25 const path = require('path'); 29 const path = require('path');
26 const http = require('http'); 30 const http = require('http');
@@ -47,6 +51,9 @@ const CONFIG = { @@ -47,6 +51,9 @@ const CONFIG = {
47 cooldownMs: 3000, 51 cooldownMs: 3000,
48 ignoreSelf: true, 52 ignoreSelf: true,
49 53
  54 + // 上下文长度:每次调 hermes 时回看多少条最近消息(含本次触发的那条)
  55 + historyContextSize: parseInt(process.env.HISTORY_CONTEXT_SIZE, 10) || 8,
  56 +
50 // 通知监听(息屏兜底) 57 // 通知监听(息屏兜底)
51 notificationPollIntervalMs: 3000, 58 notificationPollIntervalMs: 3000,
52 notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false', 59 notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false',
@@ -194,25 +201,47 @@ async function callHermesApi(messages, sessionId) { @@ -194,25 +201,47 @@ async function callHermesApi(messages, sessionId) {
194 201
195 /** 202 /**
196 * 构造 OpenAI messages 数组发给 hermes-agent 203 * 构造 OpenAI messages 数组发给 hermes-agent
  204 + *
  205 + * 把 talker 的最近 N 条聊天记录作为多轮 messages(自己发的为 assistant、对方发的为 user)。
  206 + * 再把本次触发消息作为最后一个 user turn,并在 system 中说明回复要求。
197 */ 207 */
198 function buildHermesMessages(ctx) { 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 if (type === 'private') { 212 if (type === 'private') {
203 - userContent = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; 213 + role = `这是和「${displayName || talker}」的微信私聊。`;
204 } else if (type === 'group-at') { 214 } else if (type === 'group-at') {
205 - userContent = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; 215 + role = `这是一个微信群聊,刚才有人 @ 我("${senderName}"),群名 ${displayName || talker}。`;
206 } else if (type === 'group-quote') { 216 } else if (type === 'group-quote') {
207 - userContent = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; 217 + role = `这是一个微信群聊,有人引用了我的消息(发送者 "${senderName}"),群名 ${displayName || talker}。`;
208 } else { 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,7 +252,7 @@ async function hermesReplyGenerator(ctx) {
223 const sessionId = `wx-${talker}`; 252 const sessionId = `wx-${talker}`;
224 const messages = buildHermesMessages(ctx); 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 const reply = await callHermesApi(messages, sessionId); 256 const reply = await callHermesApi(messages, sessionId);
228 257
229 if (reply) { 258 if (reply) {
@@ -479,13 +508,34 @@ class AutoReplyBot { @@ -479,13 +508,34 @@ class AutoReplyBot {
479 await sleep(2000); // 额外等待网络初始化 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 let replyText; 530 let replyText;
483 try { 531 try {
484 replyText = await this.replyGenerator({ 532 replyText = await this.replyGenerator({
485 type: replyType, 533 type: replyType,
486 talker, 534 talker,
  535 + displayName,
487 content, 536 content,
488 senderName, 537 senderName,
  538 + history,
489 raw: msg.raw, 539 raw: msg.raw,
490 }); 540 });
491 } catch (e) { 541 } catch (e) {
@@ -503,9 +553,9 @@ class AutoReplyBot { @@ -503,9 +553,9 @@ class AutoReplyBot {
503 } 553 }
504 554
505 try { 555 try {
506 - const result = await sendTextMessageFrida(talker, replyText); 556 + const result = await sendTextMessageBroadcast(talker, replyText, { toUserDisplayName: displayName });
507 if (result.ok) { 557 if (result.ok) {
508 - log('[SENT]', talker, replyText.substring(0, 60)); 558 + log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60));
509 this.state.markReplied(msgId, talker); 559 this.state.markReplied(msgId, talker);
510 } else { 560 } else {
511 log('[SEND FAILED]', talker, result.error); 561 log('[SEND FAILED]', talker, result.error);
@@ -527,10 +577,26 @@ class AutoReplyBot { @@ -527,10 +577,26 @@ class AutoReplyBot {
527 replyInPrivateChat: CONFIG.replyInPrivateChat, 577 replyInPrivateChat: CONFIG.replyInPrivateChat,
528 replyAtInGroup: CONFIG.replyAtInGroup, 578 replyAtInGroup: CONFIG.replyAtInGroup,
529 replyQuoteInGroup: CONFIG.replyQuoteInGroup, 579 replyQuoteInGroup: CONFIG.replyQuoteInGroup,
  580 + historyContextSize: CONFIG.historyContextSize,
530 notificationEnabled: CONFIG.notificationEnabled, 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 this.tracker.on('message', msg => this.handleMessage(msg)); 600 this.tracker.on('message', msg => this.handleMessage(msg));
535 this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); 601 this.tracker.on('error', err => log('[TRACKER ERROR]', err.message));
536 await this.tracker.init(); 602 await this.tracker.init();