From c5380e1d2d3c7133975bcc14a442f9d206676905 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 24 May 2026 02:06:12 +0800 Subject: [PATCH] feat: A11y 支持群聊引用回复 + @ 群成员 --- server/services/wechat/android-wechat-sender.js | 20 +++++++++++++++++--- server/services/wechat/auto-reply.js | 16 +++++++++++++++- wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java | 319 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/res/xml/accessibility_service_config.xml | 1 + 4 files changed, 349 insertions(+), 7 deletions(-) diff --git a/server/services/wechat/android-wechat-sender.js b/server/services/wechat/android-wechat-sender.js index ecda243..ba52795 100644 --- a/server/services/wechat/android-wechat-sender.js +++ b/server/services/wechat/android-wechat-sender.js @@ -101,8 +101,13 @@ async function sendTextMessage(toUser, content) { * @param {string} content - 消息内容 * @param {object} [options] * @param {string} [options.toUserDisplayName] - **必传**:A11y 在搜索框中输入的名字 - * 通常是 conRemark || nickname,例如 `火星人`。如果省略会 fallback 到 toUser, - * 但绝大多数情况下用 wxid 是搜不到的。 + * 通常是 conRemark || nickname,例如 `火星人` / `宜创AI全员群`。 + * 如果省略会 fallback 到 toUser,但绝大多数情况下用 wxid 是搜不到的。 + * @param {string} [options.quoteText] - 可选:要引用的消息文本片段(substring)。 + * A11y 进入聊天后会在 bkl 气泡里按 substring 匹配,找到后长按 → 点"引用", + * 然后输入 content + 点发送。找不到时降级为不引用。 + * @param {string} [options.atSender] - 可选:群里要 @ 的成员显示名(如 `小火子`)。 + * A11y 会在输入框先打 "@" 触发选人面板,匹配 atSender 点击,再追加 content。 * @returns {Promise<{ok: boolean, error?: string}>} */ async function sendTextMessageBroadcast(toUser, content, options = {}) { @@ -114,11 +119,20 @@ async function sendTextMessageBroadcast(toUser, content, options = {}) { const safeDisplayName = escapeShellSingleQuote(displayName); const safeContent = escapeShellSingleQuote(content); - const cmd = `am broadcast -a ${ACTION_SEND} ` + let cmd = `am broadcast -a ${ACTION_SEND} ` + `--es toUser '${safeToUser}' ` + `--es toUserDisplayName '${safeDisplayName}' ` + `--es content '${safeContent}'`; + if (options.quoteText) { + // 截到 40 字符,避免 UI 渲染省略号导致 substring match 失败 + const qt = String(options.quoteText).substring(0, 40); + cmd += ` --es quoteText '${escapeShellSingleQuote(qt)}'`; + } + if (options.atSender) { + cmd += ` --es atSender '${escapeShellSingleQuote(options.atSender)}'`; + } + try { const out = await shell(cmd, 10000); // am broadcast 成功输出通常包含 "Broadcast completed: result=0" diff --git a/server/services/wechat/auto-reply.js b/server/services/wechat/auto-reply.js index e192e97..bf9b8d3 100644 --- a/server/services/wechat/auto-reply.js +++ b/server/services/wechat/auto-reply.js @@ -552,8 +552,22 @@ class AutoReplyBot { replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; } + // 群里被 @ 时,给 broadcast 带上 quoteText(引用触发消息)+ atSender(回复里 @ 对方) + const sendOpts = { toUserDisplayName: displayName }; + if (replyType === 'group-at' || replyType === 'group-quote') { + // 群消息 content 的格式是 "senderWxid:\n实际文本",提取实际文本作为 quote substring + let bubbleText = content; + const colonIdx = bubbleText.indexOf(':\n'); + if (colonIdx > 0) bubbleText = bubbleText.substring(colonIdx + 2); + // 取前若干字符作为 substring 匹配;含 XML/特殊字符的部分(如 refermsg)跳过 + const trimmed = bubbleText.replace(/<[^>]+>/g, '').trim(); + if (trimmed) sendOpts.quoteText = trimmed.substring(0, 30); + if (senderName) sendOpts.atSender = senderName; + log('[GROUP] quote=' + (sendOpts.quoteText || '-') + ' atSender=' + (sendOpts.atSender || '-')); + } + try { - const result = await sendTextMessageBroadcast(talker, replyText, { toUserDisplayName: displayName }); + const result = await sendTextMessageBroadcast(talker, replyText, sendOpts); if (result.ok) { log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60)); this.state.markReplied(msgId, talker); diff --git a/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java b/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java index e48a634..e8301c6 100644 --- a/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java +++ b/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java @@ -1,10 +1,14 @@ package com.hermes.wechat.xposed.accessibility; import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.AccessibilityService.GestureResultCallback; +import android.accessibilityservice.GestureDescription; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.graphics.Path; +import android.graphics.Rect; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -36,12 +40,18 @@ public class HermesAccessibilityService extends AccessibilityService { private static final String ID_CHAT_EDIT = "com.tencent.mm:id/bkk"; // 聊天输入框容器 (FrameLayout) private static final String ID_CHAT_SEND = "com.tencent.mm:id/bql"; // 发送按钮 (Button, text="发送") private static final String ID_CHAT_TITLE = "com.tencent.mm:id/obn"; // 聊天界面标题栏 (TextView) + private static final String ID_BUBBLE_TEXT = "com.tencent.mm:id/bkl"; // 消息气泡 TextView (long-clickable=true) + + // 长按气泡后弹出的菜单项文案(WeChat 8.0.72 简中) + private static final String MENU_QUOTE_TEXT = "引用"; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private String pendingToUser = null; // wxid / username (for verification) + private String pendingToUser = null; // wxid / username (for verification) private String pendingToUserDisplayName = null; // display name for search private String pendingContent = null; + private String pendingQuoteText = null; // 要引用的消息片段(substring 匹配 bkl text),null=不引用 + private String pendingAtSender = null; // 要 @ 的群成员显示名,null=不 @ @Override protected void onServiceConnected() { @@ -76,6 +86,8 @@ public class HermesAccessibilityService extends AccessibilityService { String toUser = intent.getStringExtra("toUser"); String toUserDisplayName = intent.getStringExtra("toUserDisplayName"); String content = intent.getStringExtra("content"); + String quoteText = intent.getStringExtra("quoteText"); // 可选:要引用的消息文本片段 + String atSender = intent.getStringExtra("atSender"); // 可选:群里要 @ 的成员显示名 if (toUser == null || content == null) { Log.w(TAG, "Broadcast missing toUser or content"); return; @@ -85,10 +97,15 @@ public class HermesAccessibilityService extends AccessibilityService { toUserDisplayName = toUser; } - Log.i(TAG, "Received send request: to=" + toUser + " searchName=" + toUserDisplayName + " content=" + content.substring(0, Math.min(30, content.length()))); + Log.i(TAG, "Received send request: to=" + toUser + " searchName=" + toUserDisplayName + + " quote=" + (quoteText != null ? quoteText.substring(0, Math.min(30, quoteText.length())) : "no") + + " atSender=" + (atSender != null ? atSender : "no") + + " content=" + content.substring(0, Math.min(30, content.length()))); pendingToUser = toUser; pendingToUserDisplayName = toUserDisplayName; pendingContent = content; + pendingQuoteText = (quoteText != null && !quoteText.isEmpty()) ? quoteText : null; + pendingAtSender = (atSender != null && !atSender.isEmpty()) ? atSender : null; // Bring WeChat to foreground (clear task to ensure main screen) Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm"); @@ -251,10 +268,156 @@ public class HermesAccessibilityService extends AccessibilityService { } // ===================================================================== - // Step 3+4: Type message and click send + // Step 3+4: (optional quote) → (optional @ mention) → type message → click send // ===================================================================== private void performTypeAndSend(AccessibilityNodeInfo root) { + // 如果需要引用某条消息,先长按它弹菜单 + if (pendingQuoteText != null) { + Log.i(TAG, "Quote mode: looking for bubble containing \"" + + pendingQuoteText.substring(0, Math.min(30, pendingQuoteText.length())) + "\""); + AccessibilityNodeInfo bubble = findBubbleByPartialText(root, pendingQuoteText); + if (bubble != null) { + Rect bounds = new Rect(); + bubble.getBoundsInScreen(bounds); + bubble.recycle(); + Log.i(TAG, "Found bubble at " + bounds + ", dispatching long-press gesture"); + // 用 dispatchGesture 派发真实的 down+hold+up,因为 WeChat 用 GestureDetector + // 实现长按,AccessibilityNodeInfo.ACTION_LONG_CLICK 触发不了它 + dispatchLongPress(bounds.centerX(), bounds.centerY(), 800, + () -> mainHandler.postDelayed(this::clickQuoteMenuItem, 400)); + return; + } else { + Log.w(TAG, "Quote target bubble not found, falling back to plain send"); + // 找不到引用目标就降级为普通文本发送 + pendingQuoteText = null; + } + } + + proceedToInputAndSend(root); + } + + /** + * 派发合成长按手势 — 比 ACTION_LONG_CLICK 更可靠,因为很多 app 用 + * GestureDetector / OnTouchListener 而不是标准 View.OnLongClickListener。 + */ + private void dispatchLongPress(int x, int y, long durationMs, Runnable onCompleted) { + Path path = new Path(); + path.moveTo(x, y); + GestureDescription.StrokeDescription stroke = + new GestureDescription.StrokeDescription(path, 0, durationMs); + GestureDescription gesture = new GestureDescription.Builder() + .addStroke(stroke) + .build(); + boolean dispatched = dispatchGesture(gesture, new GestureResultCallback() { + @Override + public void onCompleted(GestureDescription g) { + Log.i(TAG, "Long-press gesture completed at (" + x + "," + y + ")"); + if (onCompleted != null) onCompleted.run(); + } + + @Override + public void onCancelled(GestureDescription g) { + Log.w(TAG, "Long-press gesture cancelled"); + if (onCompleted != null) onCompleted.run(); + } + }, mainHandler); + if (!dispatched) { + Log.w(TAG, "dispatchGesture returned false"); + if (onCompleted != null) onCompleted.run(); + } + } + + /** 在长按菜单中点击「引用」 — 搜索所有可见窗口(菜单可能在 popup window 里) */ + private void clickQuoteMenuItem() { + AccessibilityNodeInfo menuItem = findClickableByTextAllWindows(MENU_QUOTE_TEXT); + if (menuItem != null) { + Log.i(TAG, "Clicking menu item: " + MENU_QUOTE_TEXT); + menuItem.performAction(AccessibilityNodeInfo.ACTION_CLICK); + menuItem.recycle(); + + // 引用 chip 注入到输入框,等界面就绪后继续 @ + 文本 + mainHandler.postDelayed(() -> { + AccessibilityNodeInfo r = getRootInActiveWindow(); + if (r != null) { + proceedToInputAndSend(r); + r.recycle(); + } + }, 600); + } else { + Log.w(TAG, "「" + MENU_QUOTE_TEXT + "」 menu item not found in any window, dumping all visible texts"); + dumpAllVisibleTexts(); + pendingQuoteText = null; + recoverWithPlainSend(); + } + } + + /** 遍历所有可见窗口,找 clickable + text=label 的节点 */ + private AccessibilityNodeInfo findClickableByTextAllWindows(String label) { + if (label == null) return null; + List windows = getWindows(); + if (windows == null || windows.isEmpty()) { + AccessibilityNodeInfo r = getRootInActiveWindow(); + if (r == null) return null; + AccessibilityNodeInfo result = findClickableByText(r, label); + if (result != r) r.recycle(); + return result; + } + for (android.view.accessibility.AccessibilityWindowInfo w : windows) { + AccessibilityNodeInfo r = w.getRoot(); + if (r == null) continue; + AccessibilityNodeInfo result = findClickableByText(r, label); + if (result != null) { + if (result != r) r.recycle(); + return result; + } + r.recycle(); + } + return null; + } + + /** debug:把所有可见窗口里出现的 text 打到 logcat 方便定位真实的菜单文本 */ + private void dumpAllVisibleTexts() { + List windows = getWindows(); + if (windows == null) return; + for (android.view.accessibility.AccessibilityWindowInfo w : windows) { + AccessibilityNodeInfo r = w.getRoot(); + if (r == null) continue; + Log.d(TAG, " Window: type=" + w.getType() + " title=" + w.getTitle() + + " root_class=" + r.getClassName() + " childCount=" + r.getChildCount()); + collectTexts(r, 0); + r.recycle(); + } + } + + private void collectTexts(AccessibilityNodeInfo n, int depth) { + if (n == null || depth > 8) return; + CharSequence t = n.getText(); + if (t != null && t.length() > 0 && t.length() < 30) { + Log.d(TAG, " [d=" + depth + " clickable=" + n.isClickable() + "] " + t); + } + for (int i = 0; i < n.getChildCount(); i++) { + AccessibilityNodeInfo c = n.getChild(i); + if (c != null) { + collectTexts(c, depth + 1); + c.recycle(); + } + } + } + + /** 引用步骤失败时的回退:重新拿 root,按普通文本发送 */ + private void recoverWithPlainSend() { + AccessibilityNodeInfo r = getRootInActiveWindow(); + if (r != null) { + proceedToInputAndSend(r); + r.recycle(); + } else { + resetState(); + } + } + + /** Step 3+4 真正写入文本 + 点发送(必要时先 @ 群成员) */ + private void proceedToInputAndSend(AccessibilityNodeInfo root) { AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT); if (edit == null) { Log.w(TAG, "Chat input not found"); @@ -288,6 +451,68 @@ public class HermesAccessibilityService extends AccessibilityService { edit.performAction(AccessibilityNodeInfo.ACTION_FOCUS); } + // 群里 @ 某成员:输入 "@" 触发选人面板,找名字点击 + if (pendingAtSender != null) { + Log.i(TAG, "@ mention: typing @ to trigger picker for " + pendingAtSender); + Bundle atArgs = new Bundle(); + atArgs.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, "@"); + edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, atArgs); + // 把 edit 暂存供 typeFinalContent 用 + final AccessibilityNodeInfo editRef = edit; + mainHandler.postDelayed(() -> pickMentionMemberAndContinue(editRef), 600); + return; + } + + typeFinalContent(edit); + } + + /** 在 @ 选人面板中找到对应成员并点击;失败则降级(不 @ 只发文本) */ + private void pickMentionMemberAndContinue(AccessibilityNodeInfo edit) { + AccessibilityNodeInfo root = getRootInActiveWindow(); + if (root == null) { + Log.w(TAG, "@ picker: root null, fallback to plain content"); + pendingAtSender = null; + typeFinalContent(edit); + return; + } + + AccessibilityNodeInfo memberNode = findClickableByText(root, pendingAtSender); + if (memberNode != null) { + Log.i(TAG, "Clicking @ member: " + pendingAtSender); + memberNode.performAction(AccessibilityNodeInfo.ACTION_CLICK); + memberNode.recycle(); + root.recycle(); + + // 点击后 WeChat 会把 @xxx 文本插入到 EditText 末尾,等一下再继续 + mainHandler.postDelayed(() -> { + AccessibilityNodeInfo r = getRootInActiveWindow(); + AccessibilityNodeInfo freshEdit = (r != null) ? findInnerEditText(r) : null; + if (freshEdit != null) { + // 拼接 pendingContent 到现有 @xx 后面(不要覆盖 @ chip) + appendToEditText(freshEdit, " " + pendingContent); + freshEdit.recycle(); + } else if (edit != null) { + appendToEditText(edit, " " + pendingContent); + } + if (r != null) r.recycle(); + if (edit != null) edit.recycle(); + mainHandler.postDelayed(() -> { + AccessibilityNodeInfo sendRoot = getRootInActiveWindow(); + if (sendRoot != null) { + clickSendButton(sendRoot); + sendRoot.recycle(); + } + }, 400); + }, 500); + } else { + Log.w(TAG, "@ member not found in picker: " + pendingAtSender + " — fallback to plain content"); + root.recycle(); + pendingAtSender = null; + typeFinalContent(edit); + } + } + + private void typeFinalContent(AccessibilityNodeInfo edit) { Log.i(TAG, "Setting chat text: " + pendingContent.substring(0, Math.min(30, pendingContent.length()))); Bundle args = new Bundle(); args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, pendingContent); @@ -310,6 +535,33 @@ public class HermesAccessibilityService extends AccessibilityService { }, 400); } + /** 在已有 @chip 的 EditText 末尾追加内容(保持 chip 不被覆盖) */ + private void appendToEditText(AccessibilityNodeInfo edit, String append) { + CharSequence cur = edit.getText(); + String combined = (cur != null ? cur.toString() : "") + append; + Log.i(TAG, "Append to EditText: " + append); + Bundle args = new Bundle(); + args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, combined); + edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args); + } + + /** 在 bkk 容器里找内部 EditText(同样的解包逻辑封装一份方便复用) */ + private AccessibilityNodeInfo findInnerEditText(AccessibilityNodeInfo root) { + AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT); + if (edit == null) return null; + if ("android.widget.EditText".equals(edit.getClassName())) return edit; + for (int i = 0; i < edit.getChildCount(); i++) { + AccessibilityNodeInfo child = edit.getChild(i); + if (child != null && "android.widget.EditText".equals(child.getClassName())) { + edit.recycle(); + return child; + } + if (child != null) child.recycle(); + } + edit.recycle(); + return null; + } + private void clickSendButton(AccessibilityNodeInfo root) { // Primary: find by exact ID AccessibilityNodeInfo sendBtn = findNodeById(root, ID_CHAT_SEND); @@ -354,10 +606,71 @@ public class HermesAccessibilityService extends AccessibilityService { return null; } + /** + * 在所有 bkl 消息气泡里找文本包含 partial(子串)的最后一条(最近收到的那条优先)。 + */ + private AccessibilityNodeInfo findBubbleByPartialText(AccessibilityNodeInfo root, String partial) { + if (root == null || partial == null || partial.isEmpty()) return null; + List bubbles = root.findAccessibilityNodeInfosByViewId(ID_BUBBLE_TEXT); + Log.d(TAG, "findBubbleByPartialText: found " + (bubbles == null ? 0 : bubbles.size()) + " bkl bubbles, looking for: " + partial); + if (bubbles == null || bubbles.isEmpty()) return null; + AccessibilityNodeInfo match = null; + // 后面的 bubble 在列表里通常更靠后(也更新),倒序优先 + for (int i = bubbles.size() - 1; i >= 0; i--) { + AccessibilityNodeInfo b = bubbles.get(i); + CharSequence t = b.getText(); + String tStr = (t != null) ? t.toString() : "(null)"; + String preview = tStr.length() > 40 ? tStr.substring(0, 40) + "…" : tStr; + Log.d(TAG, " bubble[" + i + "] text=" + preview); + if (match == null && t != null && t.toString().contains(partial)) { + Log.i(TAG, " matched at index " + i); + match = b; + continue; + } + b.recycle(); + } + return match; + } + + /** + * 在 root 子树里找一个 text 等于 label 且 clickable(自身或最近祖先)的节点。 + * 用于点击弹出菜单 / 选人面板里的项。 + */ + private AccessibilityNodeInfo findClickableByText(AccessibilityNodeInfo root, String label) { + if (root == null || label == null) return null; + List nodes = root.findAccessibilityNodeInfosByText(label); + if (nodes == null || nodes.isEmpty()) return null; + for (AccessibilityNodeInfo n : nodes) { + CharSequence t = n.getText(); + if (t == null) { n.recycle(); continue; } + // 严格匹配避免误命中("引用复制删除…" 这类长 text) + if (!label.equals(t.toString())) { n.recycle(); continue; } + // 自身可点就用自身 + if (n.isClickable()) return n; + // 否则向上找 clickable 祖先 + AccessibilityNodeInfo cur = n; + AccessibilityNodeInfo result = null; + while (cur != null) { + if (cur.isClickable()) { result = cur; break; } + AccessibilityNodeInfo parent = cur.getParent(); + if (cur != n) cur.recycle(); + cur = parent; + } + if (result != null) { + if (result != n) n.recycle(); + return result; + } + n.recycle(); + } + return null; + } + private void resetState() { pendingToUser = null; pendingToUserDisplayName = null; pendingContent = null; + pendingQuoteText = null; + pendingAtSender = null; Log.i(TAG, "State reset"); } } diff --git a/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/res/xml/accessibility_service_config.xml b/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/res/xml/accessibility_service_config.xml index ee1818a..fae425e 100644 --- a/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/res/xml/accessibility_service_config.xml +++ b/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/res/xml/accessibility_service_config.xml @@ -5,4 +5,5 @@ android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewFocused" android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagReportViewIds" android:canRetrieveWindowContent="true" + android:canPerformGestures="true" android:notificationTimeout="100" /> -- libgit2 0.21.4