From b56db049a27ea59dbab3995dc0de7344f452cf32 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 24 May 2026 01:02:57 +0800 Subject: [PATCH] feat: 串联微信自动回复全流程到 A11y UI 发送链路 --- server/services/wechat/android-wechat-sender.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++----- server/services/wechat/android-wechat-tracker-sqlcipher.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/services/wechat/auto-reply.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- 3 files changed, 199 insertions(+), 31 deletions(-) diff --git a/server/services/wechat/android-wechat-sender.js b/server/services/wechat/android-wechat-sender.js index 01075a2..ecda243 100644 --- a/server/services/wechat/android-wechat-sender.js +++ b/server/services/wechat/android-wechat-sender.js @@ -93,19 +93,31 @@ async function sendTextMessage(toUser, content) { } /** - * 发送文本消息(通过广播,更直接,绕过 ContentProvider 进程) - * @param {string} toUser - * @param {string} content + * 发送文本消息(通过广播 → HermesAccessibilityService UI 自动化) + * + * 这是当前唯一可用的发送方式。A11y service 必须已启用(见 README)。 + * + * @param {string} toUser - wxid(用于校验,不参与 UI 搜索) + * @param {string} content - 消息内容 + * @param {object} [options] + * @param {string} [options.toUserDisplayName] - **必传**:A11y 在搜索框中输入的名字 + * 通常是 conRemark || nickname,例如 `火星人`。如果省略会 fallback 到 toUser, + * 但绝大多数情况下用 wxid 是搜不到的。 * @returns {Promise<{ok: boolean, error?: string}>} */ -async function sendTextMessageBroadcast(toUser, content) { +async function sendTextMessageBroadcast(toUser, content, options = {}) { if (!toUser || !content) { return { ok: false, error: 'toUser and content are required' }; } + const displayName = options.toUserDisplayName || toUser; const safeToUser = escapeShellSingleQuote(toUser); + const safeDisplayName = escapeShellSingleQuote(displayName); const safeContent = escapeShellSingleQuote(content); - const cmd = `am broadcast -a ${ACTION_SEND} --es toUser '${safeToUser}' --es content '${safeContent}'`; + const cmd = `am broadcast -a ${ACTION_SEND} ` + + `--es toUser '${safeToUser}' ` + + `--es toUserDisplayName '${safeDisplayName}' ` + + `--es content '${safeContent}'`; try { const out = await shell(cmd, 10000); @@ -120,6 +132,37 @@ async function sendTextMessageBroadcast(toUser, content) { } /** + * 校验 HermesAccessibilityService 是否已启用。 + * 这是 broadcast 发送能否真正完成的最关键前提。 + * @returns {Promise<{enabled: boolean, raw: string}>} + */ +async function checkAccessibilityServiceEnabled() { + try { + const out = await shell('settings get secure enabled_accessibility_services', 5000); + const enabled = out.includes('com.hermes.wechat.xposed') && out.includes('HermesAccessibilityService'); + return { enabled, raw: out }; + } catch (e) { + return { enabled: false, raw: e.message }; + } +} + +/** + * 启用 HermesAccessibilityService(root 下用 settings put secure 直接写入) + * @returns {Promise<{ok: boolean, error?: string}>} + */ +async function enableAccessibilityService() { + const svc = 'com.hermes.wechat.xposed/com.hermes.wechat.xposed.accessibility.HermesAccessibilityService'; + try { + await shell(`settings put secure enabled_accessibility_services ${svc}`, 5000); + await shell('settings put secure accessibility_enabled 1', 5000); + const check = await checkAccessibilityServiceEnabled(); + return { ok: check.enabled }; + } catch (e) { + return { ok: false, error: e.message }; + } +} + +/** * 查询最近一次发送结果 * @returns {Promise<{status: string, error?: string}>} */ @@ -155,6 +198,8 @@ function sleep(ms) { module.exports = { sendTextMessage, sendTextMessageBroadcast, + checkAccessibilityServiceEnabled, + enableAccessibilityService, queryStatus, sendBatch, shell, diff --git a/server/services/wechat/android-wechat-tracker-sqlcipher.js b/server/services/wechat/android-wechat-tracker-sqlcipher.js index c2f07f6..789077e 100644 --- a/server/services/wechat/android-wechat-tracker-sqlcipher.js +++ b/server/services/wechat/android-wechat-tracker-sqlcipher.js @@ -104,6 +104,63 @@ class AndroidWechatTrackerSQLCipher extends EventEmitter { return msgs; } + /** + * 查询 talker 最近 limit 条文本消息(按时间正序),用于喂给 hermes 做上下文。 + * 返回 [{ msgId, isSend, content, createTime }, ...] + */ + async fetchHistory(talker, limit = 10) { + const safeTalker = String(talker).replace(/'/g, "''"); + const sql = `SELECT msgId, isSend, content, createTime FROM message ` + + `WHERE talker='${safeTalker}' AND type=1 ` + + `ORDER BY msgId DESC LIMIT ${parseInt(limit, 10) || 10};`; + const out = await this._query(sql, 10000); + const rows = this._parseRows(out); + // _parseRows 期望 msgId/talker/content 等字段,这里 talker 没查所以补一下 + return rows + .map(r => ({ + msgId: r.msgId, + isSend: r.isSend, + content: r.content, + createTime: r.createTime, + })) + .reverse(); // 正序:旧 → 新 + } + + /** + * 根据 wxid 查询联系人的显示名(用于 A11y 搜索框输入)。 + * 优先级:conRemark(备注名)→ nickname → 内置 helper 映射 → wxid 本身 + * + * @param {string} wxid + * @returns {Promise} + */ + async getContactNickname(wxid) { + if (!wxid) return ''; + + // 微信内置 helper 没有 rcontact 记录,直接硬编码 + const builtin = { + filehelper: '文件传输助手', + weixin: '微信团队', + newsapp: '腾讯新闻', + }; + if (builtin[wxid]) return builtin[wxid]; + + // 群聊 ID 走另一个表(chatroom),先简单返回 wxid 让上层处理 + if (wxid.endsWith('@chatroom')) { + return wxid; + } + + const safeWxid = String(wxid).replace(/'/g, "''"); + const sql = `SELECT conRemark, nickname FROM rcontact WHERE username='${safeWxid}' LIMIT 1;`; + try { + const out = await this._query(sql, 5000); + const conRemark = this._extractValue(out, 'conRemark'); + const nickname = this._extractValue(out, 'nickname'); + return conRemark || nickname || wxid; + } catch (_) { + return wxid; + } + } + startPolling(intervalMs = 3000) { if (this.polling) return; this.polling = true; diff --git a/server/services/wechat/auto-reply.js b/server/services/wechat/auto-reply.js index 6036d81..e192e97 100644 --- a/server/services/wechat/auto-reply.js +++ b/server/services/wechat/auto-reply.js @@ -1,26 +1,30 @@ /** * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) * - * 架构: - * AndroidWechatTrackerSQLCipher (sqlcipher DB 轮询) ──→ 消息检测 - * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信 - * hermes API (POST /api/master/chat) ──→ AI 回复生成 - * android-wechat-sender-frida (frida-inject 调用微信内部 API) ──→ 发送回复 + * 完整链路: + * 1. termux-notification-list 监听通知(息屏兜底,定时轮询) + * 2. 检测到消息后 input keyevent 224 点亮屏幕 + wm dismiss-keyguard 解锁 + * 3. am start LauncherUI 把微信切到前台 + * 4. AndroidWechatTrackerSQLCipher 轮询 EnMicroMsg.db 拉新消息 + 历史上下文 + * 5. callHermesApi POST 到 hermes-agent (127.0.0.1:8642) 生成回复 + * 6. sendTextMessageBroadcast → am broadcast com.hermes.wechat.SEND_MSG + * → HermesAccessibilityService UI 自动化点击微信完成发送 * * 运行方式: * cd /data/data/com.termux/files/home/hermes-wechat - * nohup node auto-reply.js & + * ./start-ar.sh (内部 nohup node auto-reply.js &) * * 终止: - * ps aux | grep auto-reply - * kill + * pkill -f auto-reply.js */ -const { AndroidWechatTracker } = require('./android-wechat-tracker'); const { AndroidWechatTrackerSQLCipher } = require('./android-wechat-tracker-sqlcipher'); -const { sendTextMessageBroadcast } = require('./android-wechat-sender'); -const { sendTextMessageFrida } = require('./android-wechat-sender-frida'); -const { shell } = require('./android-wechat-sender'); +const { + sendTextMessageBroadcast, + checkAccessibilityServiceEnabled, + enableAccessibilityService, + shell, +} = require('./android-wechat-sender'); const fs = require('fs'); const path = require('path'); const http = require('http'); @@ -47,6 +51,9 @@ const CONFIG = { cooldownMs: 3000, ignoreSelf: true, + // 上下文长度:每次调 hermes 时回看多少条最近消息(含本次触发的那条) + historyContextSize: parseInt(process.env.HISTORY_CONTEXT_SIZE, 10) || 8, + // 通知监听(息屏兜底) notificationPollIntervalMs: 3000, notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false', @@ -194,25 +201,47 @@ async function callHermesApi(messages, sessionId) { /** * 构造 OpenAI messages 数组发给 hermes-agent + * + * 把 talker 的最近 N 条聊天记录作为多轮 messages(自己发的为 assistant、对方发的为 user)。 + * 再把本次触发消息作为最后一个 user turn,并在 system 中说明回复要求。 */ function buildHermesMessages(ctx) { - const { type, talker, content, senderName } = ctx; + const { type, talker, displayName, content, senderName, history } = ctx; - let userContent = ''; + let role = ''; if (type === 'private') { - userContent = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + role = `这是和「${displayName || talker}」的微信私聊。`; } else if (type === 'group-at') { - userContent = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + role = `这是一个微信群聊,刚才有人 @ 我("${senderName}"),群名 ${displayName || talker}。`; } else if (type === 'group-quote') { - userContent = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + role = `这是一个微信群聊,有人引用了我的消息(发送者 "${senderName}"),群名 ${displayName || talker}。`; } else { - userContent = `收到微信消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + role = `这是来自「${displayName || talker}」的消息。`; } - return [ - { role: 'system', content: '你是 lemonAI 助手,一个智能、友好、简短的 AI 助手。' }, - { role: 'user', content: userContent }, - ]; + const systemMsg = { + role: 'system', + content: `你是 lemonAI 助手,正在帮我回复微信消息。${role}\n` + + '回复要自然、简短、得体,使用中文。只输出回复内容,不要加任何解释、不要带前缀如"回复:"。', + }; + + // history 是 [{ isSend, content }] 旧→新;本次消息已经在最后一条 + const msgs = [systemMsg]; + if (Array.isArray(history) && history.length > 0) { + for (const h of history) { + const r = h.isSend === 1 ? 'assistant' : 'user'; + // 群聊里对方发的 content 形如 "wxid_x:\n实际内容",去掉前缀 + let txt = h.content || ''; + if (r === 'user' && txt.includes(':\n')) { + txt = txt.substring(txt.indexOf(':\n') + 2); + } + msgs.push({ role: r, content: txt }); + } + } else { + // 没有历史时,直接把当前内容作为 user turn + msgs.push({ role: 'user', content }); + } + return msgs; } /** @@ -223,7 +252,7 @@ async function hermesReplyGenerator(ctx) { const sessionId = `wx-${talker}`; const messages = buildHermesMessages(ctx); - log('[HERMES] Calling API for', talker, 'session:', sessionId); + log('[HERMES] Calling API for', talker, 'session:', sessionId, 'turns=' + messages.length); const reply = await callHermesApi(messages, sessionId); if (reply) { @@ -479,13 +508,34 @@ class AutoReplyBot { await sleep(2000); // 额外等待网络初始化 } + // 查 displayName(A11y 用它来在搜索框里搜联系人) + let displayName; + try { + displayName = await this.tracker.getContactNickname(talker); + } catch (e) { + displayName = talker; + } + if (!displayName || displayName === talker) { + log('[WARN] No displayName resolved for', talker, '— A11y 搜索可能失败'); + } + + // 拉历史消息作为 hermes 上下文 + let history = []; + try { + history = await this.tracker.fetchHistory(talker, CONFIG.historyContextSize); + } catch (e) { + log('[HISTORY] fetch failed:', e.message); + } + let replyText; try { replyText = await this.replyGenerator({ type: replyType, talker, + displayName, content, senderName, + history, raw: msg.raw, }); } catch (e) { @@ -503,9 +553,9 @@ class AutoReplyBot { } try { - const result = await sendTextMessageFrida(talker, replyText); + const result = await sendTextMessageBroadcast(talker, replyText, { toUserDisplayName: displayName }); if (result.ok) { - log('[SENT]', talker, replyText.substring(0, 60)); + log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60)); this.state.markReplied(msgId, talker); } else { log('[SEND FAILED]', talker, result.error); @@ -527,10 +577,26 @@ class AutoReplyBot { replyInPrivateChat: CONFIG.replyInPrivateChat, replyAtInGroup: CONFIG.replyAtInGroup, replyQuoteInGroup: CONFIG.replyQuoteInGroup, + historyContextSize: CONFIG.historyContextSize, notificationEnabled: CONFIG.notificationEnabled, })); - // 启动 tracker(sqlcipher DB 轮询,比 LSPosed hook 更可靠) + // A11y service 健康检查 — 发送 broadcast 必须依赖它 + const a11y = await checkAccessibilityServiceEnabled(); + if (!a11y.enabled) { + log('[A11Y] NOT enabled (raw:', a11y.raw, '), trying to enable...'); + const en = await enableAccessibilityService(); + if (en.ok) { + log('[A11Y] Enabled OK'); + } else { + log('[A11Y] FAILED to enable:', en.error || '(unknown)'); + log('[A11Y] Manual fix: adb shell "su -c \'settings put secure enabled_accessibility_services com.hermes.wechat.xposed/com.hermes.wechat.xposed.accessibility.HermesAccessibilityService\'"'); + } + } else { + log('[A11Y] HermesAccessibilityService is enabled'); + } + + // 启动 tracker(sqlcipher DB 轮询,唯一稳定的消息检测方式) this.tracker.on('message', msg => this.handleMessage(msg)); this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); await this.tracker.init(); -- libgit2 0.21.4