Commit 0e8f419d800a9f0cf6342acbde95c7b0dd7422c0
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>
Showing
2 changed files
with
101 additions
and
10 deletions
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 | ... | ... |