Commit 0e8f419d800a9f0cf6342acbde95c7b0dd7422c0

Authored by yibo
1 parent ceea6636

feat: A11y 走 chat list 优先路径 + 群 @ 用昵称定位选人面板

#1 主进入路径改造(用户提议)— A11y 在不在目标聊天页时,
   优先扫主界面 chat list 直接点击进入,而不是走"搜索 → 点结果"。
   理由:搜索 "丁鹏、🍋火星人、火星人" 这种群名会被 WeChat 解读成
   "创建包含这 3 人的新群"建议,clickFirstSearchResult 误点进了
   MMCreateChatroomUI,然后 bkk 不出现,整条发送链路挂掉。

   新流程:
   - HermesAccessibilityService.enterChatViaListOrSearch:
     扫 root 下所有 ID_CHATLIST_NAME (kbq) 节点
     文本严格 equals pendingToUserDisplayName 时
     向上找 ID_CHATLIST_ROW (cj1) clickable 容器并 ACTION_CLICK
     等 2.2s 后检查 bkk 出现就 performTypeAndSend
   - 找不到匹配(chat 不在可见列表 / 不在 LauncherUI)回退到 performSearch
   - 实测:群在 chat list 第一/二行可定位,整段比搜索快 ~3s

#2 @ 选人面板修复
   - auto-reply.js: senderName 是 wxid("yibooo"),但群 @ 面板显示
     的是昵称("火星人"),传 wxid 进 picker 永远找不到。改成
     getContactNickname(senderName) 翻译后再传给 broadcast 的 atSender。
   - HermesAccessibilityService.pickMentionMemberAndContinue:
     @ 失败 fallback 时旧 edit 节点引用已过期(IPC backed,600ms
     postDelayed 后失效),SET_TEXT 静默 false 整条发送挂掉。
     fallback 路径重新 getRootInActiveWindow + findInnerEditText
     拿 fresh edit 节点再 type 内容。

实测(msgId 1556 type=57):
  ChatList scan: 10 kbq names → matched 丁鹏、小火子、火星人、延墨...
  Clicking row → Chat UI loaded
  Quote bubble @小火子 测试一下 → long-press → 引用
  @ mention typing → Clicking @ member: 火星人
  Append "延墨好!欢迎欢迎..." → Clicking send button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
server/services/wechat/auto-reply.js
... ... @@ -562,7 +562,16 @@ class AutoReplyBot {
562 562 // 取前若干字符作为 substring 匹配;含 XML/特殊字符的部分(如 refermsg)跳过
563 563 const trimmed = bubbleText.replace(/<[^>]+>/g, '').trim();
564 564 if (trimmed) sendOpts.quoteText = trimmed.substring(0, 30);
565   - if (senderName) sendOpts.atSender = senderName;
  565 + // senderName 是 wxid(如 'yibooo'),群里 @ 面板显示的是昵称(如 '火星人'),
  566 + // 必须查 rcontact 拿到昵称,否则 A11y 在选人面板里搜不到
  567 + if (senderName) {
  568 + try {
  569 + const senderNick = await this.tracker.getContactNickname(senderName);
  570 + sendOpts.atSender = senderNick || senderName;
  571 + } catch (_) {
  572 + sendOpts.atSender = senderName;
  573 + }
  574 + }
566 575 log('[GROUP] quote=' + (sendOpts.quoteText || '-') + ' atSender=' + (sendOpts.atSender || '-'));
567 576 }
568 577  
... ...
wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java
... ... @@ -41,6 +41,8 @@ public class HermesAccessibilityService extends AccessibilityService {
41 41 private static final String ID_CHAT_SEND = "com.tencent.mm:id/bql"; // 发送按钮 (Button, text="发送")
42 42 private static final String ID_CHAT_TITLE = "com.tencent.mm:id/obn"; // 聊天界面标题栏 (TextView)
43 43 private static final String ID_BUBBLE_TEXT = "com.tencent.mm:id/bkl"; // 消息气泡 TextView (long-clickable=true)
  44 + private static final String ID_CHATLIST_ROW = "com.tencent.mm:id/cj1"; // 主界面 chat list 每行的 clickable 容器 (LinearLayout)
  45 + private static final String ID_CHATLIST_NAME = "com.tencent.mm:id/kbq"; // chat list 行里显示会话名 / 群名 的 TextView
44 46  
45 47 // 长按气泡后弹出的菜单项文案(WeChat 8.0.72 简中)
46 48 private static final String MENU_QUOTE_TEXT = "引用";
... ... @@ -140,11 +142,6 @@ public class HermesAccessibilityService extends AccessibilityService {
140 142 } else {
141 143 if (edit != null) {
142 144 Log.i(TAG, "In wrong chat (title mismatch), restarting WeChat main screen");
143   - } else {
144   - Log.i(TAG, "Not in chat, starting search");
145   - }
146   - // If in wrong chat, relaunch WeChat main screen first
147   - if (edit != null) {
148 145 Intent relaunch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm");
149 146 if (relaunch != null) {
150 147 relaunch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
... ... @@ -153,12 +150,12 @@ public class HermesAccessibilityService extends AccessibilityService {
153 150 mainHandler.postDelayed(() -> {
154 151 AccessibilityNodeInfo mainRoot = getRootInActiveWindow();
155 152 if (mainRoot != null) {
156   - performSearch(mainRoot);
  153 + enterChatViaListOrSearch(mainRoot);
157 154 mainRoot.recycle();
158 155 }
159 156 }, 1500);
160 157 } else {
161   - performSearch(root);
  158 + enterChatViaListOrSearch(root);
162 159 }
163 160 }
164 161 root.recycle();
... ... @@ -167,7 +164,81 @@ public class HermesAccessibilityService extends AccessibilityService {
167 164 };
168 165  
169 166 // =====================================================================
170   - // Search for contact
  167 + // Enter chat via chat list (preferred) — fallback to search
  168 + // =====================================================================
  169 +
  170 + /**
  171 + * 优先方案:在 LauncherUI 的 chat list 里找显示名匹配的行,直接点击进入。
  172 + * 比搜索路径稳得多 — 不会触发"创建群聊"歧义(搜索"丁鹏、🍋火星人、火星人"
  173 + * 会被 WeChat 解读成"创建包含这 3 人的新群"建议)。
  174 + *
  175 + * 失败时(chat 不在可见列表里 / 不在 LauncherUI 等)回退到搜索路径。
  176 + */
  177 + private void enterChatViaListOrSearch(AccessibilityNodeInfo root) {
  178 + // 1) 找 chat list 里所有 kbq TextView,按 text 严格匹配 displayName
  179 + List<AccessibilityNodeInfo> names = root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME);
  180 + Log.d(TAG, "ChatList scan: found " + (names == null ? 0 : names.size()) + " kbq names, target=" + pendingToUserDisplayName);
  181 + AccessibilityNodeInfo matchedRow = null;
  182 + if (names != null) {
  183 + for (AccessibilityNodeInfo n : names) {
  184 + CharSequence t = n.getText();
  185 + if (matchedRow == null && t != null && pendingToUserDisplayName.equals(t.toString())) {
  186 + // 向上找 cj1 (clickable row container)
  187 + AccessibilityNodeInfo cur = n;
  188 + while (cur != null) {
  189 + CharSequence id = cur.getViewIdResourceName();
  190 + if (id != null && ID_CHATLIST_ROW.equals(id.toString())) {
  191 + matchedRow = cur;
  192 + break;
  193 + }
  194 + AccessibilityNodeInfo parent = cur.getParent();
  195 + if (cur != n) cur.recycle();
  196 + cur = parent;
  197 + }
  198 + // fallback: 没找到 cj1 就用任意 clickable 祖先
  199 + if (matchedRow == null) {
  200 + AccessibilityNodeInfo c = n;
  201 + while (c != null) {
  202 + if (c.isClickable()) { matchedRow = c; break; }
  203 + AccessibilityNodeInfo p = c.getParent();
  204 + if (c != n) c.recycle();
  205 + c = p;
  206 + }
  207 + }
  208 + continue; // matched, don't recycle n
  209 + }
  210 + n.recycle();
  211 + }
  212 + }
  213 +
  214 + if (matchedRow != null) {
  215 + Log.i(TAG, "ChatList: clicking row for " + pendingToUserDisplayName);
  216 + matchedRow.performAction(AccessibilityNodeInfo.ACTION_CLICK);
  217 + matchedRow.recycle();
  218 +
  219 + // 等聊天页加载,bkk 出现后开始 quote/@/text 流程
  220 + mainHandler.postDelayed(() -> {
  221 + AccessibilityNodeInfo chatRoot = getRootInActiveWindow();
  222 + if (chatRoot != null) {
  223 + if (findNodeById(chatRoot, ID_CHAT_EDIT) != null) {
  224 + Log.i(TAG, "Chat UI loaded from list, proceeding to type/send");
  225 + performTypeAndSend(chatRoot);
  226 + } else {
  227 + Log.w(TAG, "Chat UI not loaded after list click, falling back to search");
  228 + performSearch(chatRoot);
  229 + }
  230 + chatRoot.recycle();
  231 + }
  232 + }, 2200);
  233 + return;
  234 + }
  235 +
  236 + Log.i(TAG, "ChatList: no match for " + pendingToUserDisplayName + " in visible list, falling back to search");
  237 + performSearch(root);
  238 + }
  239 +
  240 + // =====================================================================
  241 + // Search for contact (fallback path)
171 242 // =====================================================================
172 243  
173 244 private void performSearch(AccessibilityNodeInfo root) {
... ... @@ -508,7 +579,18 @@ public class HermesAccessibilityService extends AccessibilityService {
508 579 Log.w(TAG, "@ member not found in picker: " + pendingAtSender + " — fallback to plain content");
509 580 root.recycle();
510 581 pendingAtSender = null;
511   - typeFinalContent(edit);
  582 + // edit 引用可能在 ACTION_SET_TEXT("@") + postDelayed 后过期,
  583 + // 重新从当前 root 拿 EditText 再 set text
  584 + if (edit != null) edit.recycle();
  585 + AccessibilityNodeInfo r = getRootInActiveWindow();
  586 + AccessibilityNodeInfo freshEdit = (r != null) ? findInnerEditText(r) : null;
  587 + if (r != null) r.recycle();
  588 + if (freshEdit != null) {
  589 + typeFinalContent(freshEdit);
  590 + } else {
  591 + Log.w(TAG, "Fresh edit not found after @ fallback, abort");
  592 + resetState();
  593 + }
512 594 }
513 595 }
514 596  
... ...