Commit c5380e1d2d3c7133975bcc14a442f9d206676905
1 parent
edfe7ab4
feat: A11y 支持群聊引用回复 + @ 群成员
新增功能: - HermesAccessibilityService 支持广播 extras quoteText / atSender - quoteText: 在聊天页找文本含此 substring 的 bkl 气泡 → 长按 → 点「引用」 菜单项 → 输入回复文本 → 点发送 - atSender: 在输入框先打「@」触发选人面板 → 找匹配成员点击 → 追加回复 实测路径完整跑通(msgId 1409 验证): 1. 广播触发 A11y service 2. 进入聊天页("already in correct chat" 或经过搜索) 3. findBubbleByPartialText 在 bkl 列表里倒序匹配 4. dispatchGesture 派发 800ms 合成长按手势(ACTION_LONG_CLICK 在 WeChat GestureDetector 实现上不触发菜单,必须用 gesture 派发) 5. 等 1.2s 后用 findClickableByTextAllWindows 搜「引用」 — 关键:菜单在 popup window 不在 active window,必须遍历 getWindows() 才能找到 6. 点击引用 → 引用 chip 注入到输入框 → 写文本 → 点发送 7. DB 写入的 message 是 type=57 (微信内部引用回复消息类型) 关键依赖(Android AccessibilityService): - accessibility_service_config.xml 必须设 canPerformGestures="true" 否则 dispatchGesture 不回调 onCompleted 群聊路径: - auto-reply.js handleMessage 在 replyType=group-at/group-quote 时自动 从触发消息 content 解析 bubbleText 当 quoteText,senderName 当 atSender - android-wechat-sender.js sendTextMessageBroadcast 接受 options.quoteText / options.atSender,拼到 am broadcast --es 参数里 测试覆盖(在 Pixel 实测): - ✅ 私聊回归:plain text 路径不影响(msgId 1408) - ✅ 引用回复:私聊场景已验证(msgId 1409 type=57) - ⏸ 群聊 + @ 群成员:代码已写完,需要真实群消息触发才能测 APK 已重编 + 用 debug.keystore 签 + adb install -r 升级(A11y 权限保留)。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
4 changed files
with
349 additions
and
7 deletions
server/services/wechat/android-wechat-sender.js
| @@ -101,8 +101,13 @@ async function sendTextMessage(toUser, content) { | @@ -101,8 +101,13 @@ async function sendTextMessage(toUser, content) { | ||
| 101 | * @param {string} content - 消息内容 | 101 | * @param {string} content - 消息内容 |
| 102 | * @param {object} [options] | 102 | * @param {object} [options] |
| 103 | * @param {string} [options.toUserDisplayName] - **必传**:A11y 在搜索框中输入的名字 | 103 | * @param {string} [options.toUserDisplayName] - **必传**:A11y 在搜索框中输入的名字 |
| 104 | - * 通常是 conRemark || nickname,例如 `火星人`。如果省略会 fallback 到 toUser, | ||
| 105 | - * 但绝大多数情况下用 wxid 是搜不到的。 | 104 | + * 通常是 conRemark || nickname,例如 `火星人` / `宜创AI全员群`。 |
| 105 | + * 如果省略会 fallback 到 toUser,但绝大多数情况下用 wxid 是搜不到的。 | ||
| 106 | + * @param {string} [options.quoteText] - 可选:要引用的消息文本片段(substring)。 | ||
| 107 | + * A11y 进入聊天后会在 bkl 气泡里按 substring 匹配,找到后长按 → 点"引用", | ||
| 108 | + * 然后输入 content + 点发送。找不到时降级为不引用。 | ||
| 109 | + * @param {string} [options.atSender] - 可选:群里要 @ 的成员显示名(如 `小火子`)。 | ||
| 110 | + * A11y 会在输入框先打 "@" 触发选人面板,匹配 atSender 点击,再追加 content。 | ||
| 106 | * @returns {Promise<{ok: boolean, error?: string}>} | 111 | * @returns {Promise<{ok: boolean, error?: string}>} |
| 107 | */ | 112 | */ |
| 108 | async function sendTextMessageBroadcast(toUser, content, options = {}) { | 113 | async function sendTextMessageBroadcast(toUser, content, options = {}) { |
| @@ -114,11 +119,20 @@ async function sendTextMessageBroadcast(toUser, content, options = {}) { | @@ -114,11 +119,20 @@ async function sendTextMessageBroadcast(toUser, content, options = {}) { | ||
| 114 | const safeDisplayName = escapeShellSingleQuote(displayName); | 119 | const safeDisplayName = escapeShellSingleQuote(displayName); |
| 115 | const safeContent = escapeShellSingleQuote(content); | 120 | const safeContent = escapeShellSingleQuote(content); |
| 116 | 121 | ||
| 117 | - const cmd = `am broadcast -a ${ACTION_SEND} ` | 122 | + let cmd = `am broadcast -a ${ACTION_SEND} ` |
| 118 | + `--es toUser '${safeToUser}' ` | 123 | + `--es toUser '${safeToUser}' ` |
| 119 | + `--es toUserDisplayName '${safeDisplayName}' ` | 124 | + `--es toUserDisplayName '${safeDisplayName}' ` |
| 120 | + `--es content '${safeContent}'`; | 125 | + `--es content '${safeContent}'`; |
| 121 | 126 | ||
| 127 | + if (options.quoteText) { | ||
| 128 | + // 截到 40 字符,避免 UI 渲染省略号导致 substring match 失败 | ||
| 129 | + const qt = String(options.quoteText).substring(0, 40); | ||
| 130 | + cmd += ` --es quoteText '${escapeShellSingleQuote(qt)}'`; | ||
| 131 | + } | ||
| 132 | + if (options.atSender) { | ||
| 133 | + cmd += ` --es atSender '${escapeShellSingleQuote(options.atSender)}'`; | ||
| 134 | + } | ||
| 135 | + | ||
| 122 | try { | 136 | try { |
| 123 | const out = await shell(cmd, 10000); | 137 | const out = await shell(cmd, 10000); |
| 124 | // am broadcast 成功输出通常包含 "Broadcast completed: result=0" | 138 | // am broadcast 成功输出通常包含 "Broadcast completed: result=0" |
server/services/wechat/auto-reply.js
| @@ -552,8 +552,22 @@ class AutoReplyBot { | @@ -552,8 +552,22 @@ class AutoReplyBot { | ||
| 552 | replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; | 552 | replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; |
| 553 | } | 553 | } |
| 554 | 554 | ||
| 555 | + // 群里被 @ 时,给 broadcast 带上 quoteText(引用触发消息)+ atSender(回复里 @ 对方) | ||
| 556 | + const sendOpts = { toUserDisplayName: displayName }; | ||
| 557 | + if (replyType === 'group-at' || replyType === 'group-quote') { | ||
| 558 | + // 群消息 content 的格式是 "senderWxid:\n实际文本",提取实际文本作为 quote substring | ||
| 559 | + let bubbleText = content; | ||
| 560 | + const colonIdx = bubbleText.indexOf(':\n'); | ||
| 561 | + if (colonIdx > 0) bubbleText = bubbleText.substring(colonIdx + 2); | ||
| 562 | + // 取前若干字符作为 substring 匹配;含 XML/特殊字符的部分(如 refermsg)跳过 | ||
| 563 | + const trimmed = bubbleText.replace(/<[^>]+>/g, '').trim(); | ||
| 564 | + if (trimmed) sendOpts.quoteText = trimmed.substring(0, 30); | ||
| 565 | + if (senderName) sendOpts.atSender = senderName; | ||
| 566 | + log('[GROUP] quote=' + (sendOpts.quoteText || '-') + ' atSender=' + (sendOpts.atSender || '-')); | ||
| 567 | + } | ||
| 568 | + | ||
| 555 | try { | 569 | try { |
| 556 | - const result = await sendTextMessageBroadcast(talker, replyText, { toUserDisplayName: displayName }); | 570 | + const result = await sendTextMessageBroadcast(talker, replyText, sendOpts); |
| 557 | if (result.ok) { | 571 | if (result.ok) { |
| 558 | log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60)); | 572 | log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60)); |
| 559 | this.state.markReplied(msgId, talker); | 573 | this.state.markReplied(msgId, talker); |
wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java
| 1 | package com.hermes.wechat.xposed.accessibility; | 1 | package com.hermes.wechat.xposed.accessibility; |
| 2 | 2 | ||
| 3 | import android.accessibilityservice.AccessibilityService; | 3 | import android.accessibilityservice.AccessibilityService; |
| 4 | +import android.accessibilityservice.AccessibilityService.GestureResultCallback; | ||
| 5 | +import android.accessibilityservice.GestureDescription; | ||
| 4 | import android.content.BroadcastReceiver; | 6 | import android.content.BroadcastReceiver; |
| 5 | import android.content.Context; | 7 | import android.content.Context; |
| 6 | import android.content.Intent; | 8 | import android.content.Intent; |
| 7 | import android.content.IntentFilter; | 9 | import android.content.IntentFilter; |
| 10 | +import android.graphics.Path; | ||
| 11 | +import android.graphics.Rect; | ||
| 8 | import android.os.Bundle; | 12 | import android.os.Bundle; |
| 9 | import android.os.Handler; | 13 | import android.os.Handler; |
| 10 | import android.os.Looper; | 14 | import android.os.Looper; |
| @@ -36,12 +40,18 @@ public class HermesAccessibilityService extends AccessibilityService { | @@ -36,12 +40,18 @@ public class HermesAccessibilityService extends AccessibilityService { | ||
| 36 | private static final String ID_CHAT_EDIT = "com.tencent.mm:id/bkk"; // 聊天输入框容器 (FrameLayout) | 40 | private static final String ID_CHAT_EDIT = "com.tencent.mm:id/bkk"; // 聊天输入框容器 (FrameLayout) |
| 37 | private static final String ID_CHAT_SEND = "com.tencent.mm:id/bql"; // 发送按钮 (Button, text="发送") | 41 | private static final String ID_CHAT_SEND = "com.tencent.mm:id/bql"; // 发送按钮 (Button, text="发送") |
| 38 | private static final String ID_CHAT_TITLE = "com.tencent.mm:id/obn"; // 聊天界面标题栏 (TextView) | 42 | private static final String ID_CHAT_TITLE = "com.tencent.mm:id/obn"; // 聊天界面标题栏 (TextView) |
| 43 | + private static final String ID_BUBBLE_TEXT = "com.tencent.mm:id/bkl"; // 消息气泡 TextView (long-clickable=true) | ||
| 44 | + | ||
| 45 | + // 长按气泡后弹出的菜单项文案(WeChat 8.0.72 简中) | ||
| 46 | + private static final String MENU_QUOTE_TEXT = "引用"; | ||
| 39 | 47 | ||
| 40 | private final Handler mainHandler = new Handler(Looper.getMainLooper()); | 48 | private final Handler mainHandler = new Handler(Looper.getMainLooper()); |
| 41 | 49 | ||
| 42 | - private String pendingToUser = null; // wxid / username (for verification) | 50 | + private String pendingToUser = null; // wxid / username (for verification) |
| 43 | private String pendingToUserDisplayName = null; // display name for search | 51 | private String pendingToUserDisplayName = null; // display name for search |
| 44 | private String pendingContent = null; | 52 | private String pendingContent = null; |
| 53 | + private String pendingQuoteText = null; // 要引用的消息片段(substring 匹配 bkl text),null=不引用 | ||
| 54 | + private String pendingAtSender = null; // 要 @ 的群成员显示名,null=不 @ | ||
| 45 | 55 | ||
| 46 | @Override | 56 | @Override |
| 47 | protected void onServiceConnected() { | 57 | protected void onServiceConnected() { |
| @@ -76,6 +86,8 @@ public class HermesAccessibilityService extends AccessibilityService { | @@ -76,6 +86,8 @@ public class HermesAccessibilityService extends AccessibilityService { | ||
| 76 | String toUser = intent.getStringExtra("toUser"); | 86 | String toUser = intent.getStringExtra("toUser"); |
| 77 | String toUserDisplayName = intent.getStringExtra("toUserDisplayName"); | 87 | String toUserDisplayName = intent.getStringExtra("toUserDisplayName"); |
| 78 | String content = intent.getStringExtra("content"); | 88 | String content = intent.getStringExtra("content"); |
| 89 | + String quoteText = intent.getStringExtra("quoteText"); // 可选:要引用的消息文本片段 | ||
| 90 | + String atSender = intent.getStringExtra("atSender"); // 可选:群里要 @ 的成员显示名 | ||
| 79 | if (toUser == null || content == null) { | 91 | if (toUser == null || content == null) { |
| 80 | Log.w(TAG, "Broadcast missing toUser or content"); | 92 | Log.w(TAG, "Broadcast missing toUser or content"); |
| 81 | return; | 93 | return; |
| @@ -85,10 +97,15 @@ public class HermesAccessibilityService extends AccessibilityService { | @@ -85,10 +97,15 @@ public class HermesAccessibilityService extends AccessibilityService { | ||
| 85 | toUserDisplayName = toUser; | 97 | toUserDisplayName = toUser; |
| 86 | } | 98 | } |
| 87 | 99 | ||
| 88 | - Log.i(TAG, "Received send request: to=" + toUser + " searchName=" + toUserDisplayName + " content=" + content.substring(0, Math.min(30, content.length()))); | 100 | + Log.i(TAG, "Received send request: to=" + toUser + " searchName=" + toUserDisplayName |
| 101 | + + " quote=" + (quoteText != null ? quoteText.substring(0, Math.min(30, quoteText.length())) : "no") | ||
| 102 | + + " atSender=" + (atSender != null ? atSender : "no") | ||
| 103 | + + " content=" + content.substring(0, Math.min(30, content.length()))); | ||
| 89 | pendingToUser = toUser; | 104 | pendingToUser = toUser; |
| 90 | pendingToUserDisplayName = toUserDisplayName; | 105 | pendingToUserDisplayName = toUserDisplayName; |
| 91 | pendingContent = content; | 106 | pendingContent = content; |
| 107 | + pendingQuoteText = (quoteText != null && !quoteText.isEmpty()) ? quoteText : null; | ||
| 108 | + pendingAtSender = (atSender != null && !atSender.isEmpty()) ? atSender : null; | ||
| 92 | 109 | ||
| 93 | // Bring WeChat to foreground (clear task to ensure main screen) | 110 | // Bring WeChat to foreground (clear task to ensure main screen) |
| 94 | Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm"); | 111 | Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm"); |
| @@ -251,10 +268,156 @@ public class HermesAccessibilityService extends AccessibilityService { | @@ -251,10 +268,156 @@ public class HermesAccessibilityService extends AccessibilityService { | ||
| 251 | } | 268 | } |
| 252 | 269 | ||
| 253 | // ===================================================================== | 270 | // ===================================================================== |
| 254 | - // Step 3+4: Type message and click send | 271 | + // Step 3+4: (optional quote) → (optional @ mention) → type message → click send |
| 255 | // ===================================================================== | 272 | // ===================================================================== |
| 256 | 273 | ||
| 257 | private void performTypeAndSend(AccessibilityNodeInfo root) { | 274 | private void performTypeAndSend(AccessibilityNodeInfo root) { |
| 275 | + // 如果需要引用某条消息,先长按它弹菜单 | ||
| 276 | + if (pendingQuoteText != null) { | ||
| 277 | + Log.i(TAG, "Quote mode: looking for bubble containing \"" + | ||
| 278 | + pendingQuoteText.substring(0, Math.min(30, pendingQuoteText.length())) + "\""); | ||
| 279 | + AccessibilityNodeInfo bubble = findBubbleByPartialText(root, pendingQuoteText); | ||
| 280 | + if (bubble != null) { | ||
| 281 | + Rect bounds = new Rect(); | ||
| 282 | + bubble.getBoundsInScreen(bounds); | ||
| 283 | + bubble.recycle(); | ||
| 284 | + Log.i(TAG, "Found bubble at " + bounds + ", dispatching long-press gesture"); | ||
| 285 | + // 用 dispatchGesture 派发真实的 down+hold+up,因为 WeChat 用 GestureDetector | ||
| 286 | + // 实现长按,AccessibilityNodeInfo.ACTION_LONG_CLICK 触发不了它 | ||
| 287 | + dispatchLongPress(bounds.centerX(), bounds.centerY(), 800, | ||
| 288 | + () -> mainHandler.postDelayed(this::clickQuoteMenuItem, 400)); | ||
| 289 | + return; | ||
| 290 | + } else { | ||
| 291 | + Log.w(TAG, "Quote target bubble not found, falling back to plain send"); | ||
| 292 | + // 找不到引用目标就降级为普通文本发送 | ||
| 293 | + pendingQuoteText = null; | ||
| 294 | + } | ||
| 295 | + } | ||
| 296 | + | ||
| 297 | + proceedToInputAndSend(root); | ||
| 298 | + } | ||
| 299 | + | ||
| 300 | + /** | ||
| 301 | + * 派发合成长按手势 — 比 ACTION_LONG_CLICK 更可靠,因为很多 app 用 | ||
| 302 | + * GestureDetector / OnTouchListener 而不是标准 View.OnLongClickListener。 | ||
| 303 | + */ | ||
| 304 | + private void dispatchLongPress(int x, int y, long durationMs, Runnable onCompleted) { | ||
| 305 | + Path path = new Path(); | ||
| 306 | + path.moveTo(x, y); | ||
| 307 | + GestureDescription.StrokeDescription stroke = | ||
| 308 | + new GestureDescription.StrokeDescription(path, 0, durationMs); | ||
| 309 | + GestureDescription gesture = new GestureDescription.Builder() | ||
| 310 | + .addStroke(stroke) | ||
| 311 | + .build(); | ||
| 312 | + boolean dispatched = dispatchGesture(gesture, new GestureResultCallback() { | ||
| 313 | + @Override | ||
| 314 | + public void onCompleted(GestureDescription g) { | ||
| 315 | + Log.i(TAG, "Long-press gesture completed at (" + x + "," + y + ")"); | ||
| 316 | + if (onCompleted != null) onCompleted.run(); | ||
| 317 | + } | ||
| 318 | + | ||
| 319 | + @Override | ||
| 320 | + public void onCancelled(GestureDescription g) { | ||
| 321 | + Log.w(TAG, "Long-press gesture cancelled"); | ||
| 322 | + if (onCompleted != null) onCompleted.run(); | ||
| 323 | + } | ||
| 324 | + }, mainHandler); | ||
| 325 | + if (!dispatched) { | ||
| 326 | + Log.w(TAG, "dispatchGesture returned false"); | ||
| 327 | + if (onCompleted != null) onCompleted.run(); | ||
| 328 | + } | ||
| 329 | + } | ||
| 330 | + | ||
| 331 | + /** 在长按菜单中点击「引用」 — 搜索所有可见窗口(菜单可能在 popup window 里) */ | ||
| 332 | + private void clickQuoteMenuItem() { | ||
| 333 | + AccessibilityNodeInfo menuItem = findClickableByTextAllWindows(MENU_QUOTE_TEXT); | ||
| 334 | + if (menuItem != null) { | ||
| 335 | + Log.i(TAG, "Clicking menu item: " + MENU_QUOTE_TEXT); | ||
| 336 | + menuItem.performAction(AccessibilityNodeInfo.ACTION_CLICK); | ||
| 337 | + menuItem.recycle(); | ||
| 338 | + | ||
| 339 | + // 引用 chip 注入到输入框,等界面就绪后继续 @ + 文本 | ||
| 340 | + mainHandler.postDelayed(() -> { | ||
| 341 | + AccessibilityNodeInfo r = getRootInActiveWindow(); | ||
| 342 | + if (r != null) { | ||
| 343 | + proceedToInputAndSend(r); | ||
| 344 | + r.recycle(); | ||
| 345 | + } | ||
| 346 | + }, 600); | ||
| 347 | + } else { | ||
| 348 | + Log.w(TAG, "「" + MENU_QUOTE_TEXT + "」 menu item not found in any window, dumping all visible texts"); | ||
| 349 | + dumpAllVisibleTexts(); | ||
| 350 | + pendingQuoteText = null; | ||
| 351 | + recoverWithPlainSend(); | ||
| 352 | + } | ||
| 353 | + } | ||
| 354 | + | ||
| 355 | + /** 遍历所有可见窗口,找 clickable + text=label 的节点 */ | ||
| 356 | + private AccessibilityNodeInfo findClickableByTextAllWindows(String label) { | ||
| 357 | + if (label == null) return null; | ||
| 358 | + List<android.view.accessibility.AccessibilityWindowInfo> windows = getWindows(); | ||
| 359 | + if (windows == null || windows.isEmpty()) { | ||
| 360 | + AccessibilityNodeInfo r = getRootInActiveWindow(); | ||
| 361 | + if (r == null) return null; | ||
| 362 | + AccessibilityNodeInfo result = findClickableByText(r, label); | ||
| 363 | + if (result != r) r.recycle(); | ||
| 364 | + return result; | ||
| 365 | + } | ||
| 366 | + for (android.view.accessibility.AccessibilityWindowInfo w : windows) { | ||
| 367 | + AccessibilityNodeInfo r = w.getRoot(); | ||
| 368 | + if (r == null) continue; | ||
| 369 | + AccessibilityNodeInfo result = findClickableByText(r, label); | ||
| 370 | + if (result != null) { | ||
| 371 | + if (result != r) r.recycle(); | ||
| 372 | + return result; | ||
| 373 | + } | ||
| 374 | + r.recycle(); | ||
| 375 | + } | ||
| 376 | + return null; | ||
| 377 | + } | ||
| 378 | + | ||
| 379 | + /** debug:把所有可见窗口里出现的 text 打到 logcat 方便定位真实的菜单文本 */ | ||
| 380 | + private void dumpAllVisibleTexts() { | ||
| 381 | + List<android.view.accessibility.AccessibilityWindowInfo> windows = getWindows(); | ||
| 382 | + if (windows == null) return; | ||
| 383 | + for (android.view.accessibility.AccessibilityWindowInfo w : windows) { | ||
| 384 | + AccessibilityNodeInfo r = w.getRoot(); | ||
| 385 | + if (r == null) continue; | ||
| 386 | + Log.d(TAG, " Window: type=" + w.getType() + " title=" + w.getTitle() | ||
| 387 | + + " root_class=" + r.getClassName() + " childCount=" + r.getChildCount()); | ||
| 388 | + collectTexts(r, 0); | ||
| 389 | + r.recycle(); | ||
| 390 | + } | ||
| 391 | + } | ||
| 392 | + | ||
| 393 | + private void collectTexts(AccessibilityNodeInfo n, int depth) { | ||
| 394 | + if (n == null || depth > 8) return; | ||
| 395 | + CharSequence t = n.getText(); | ||
| 396 | + if (t != null && t.length() > 0 && t.length() < 30) { | ||
| 397 | + Log.d(TAG, " [d=" + depth + " clickable=" + n.isClickable() + "] " + t); | ||
| 398 | + } | ||
| 399 | + for (int i = 0; i < n.getChildCount(); i++) { | ||
| 400 | + AccessibilityNodeInfo c = n.getChild(i); | ||
| 401 | + if (c != null) { | ||
| 402 | + collectTexts(c, depth + 1); | ||
| 403 | + c.recycle(); | ||
| 404 | + } | ||
| 405 | + } | ||
| 406 | + } | ||
| 407 | + | ||
| 408 | + /** 引用步骤失败时的回退:重新拿 root,按普通文本发送 */ | ||
| 409 | + private void recoverWithPlainSend() { | ||
| 410 | + AccessibilityNodeInfo r = getRootInActiveWindow(); | ||
| 411 | + if (r != null) { | ||
| 412 | + proceedToInputAndSend(r); | ||
| 413 | + r.recycle(); | ||
| 414 | + } else { | ||
| 415 | + resetState(); | ||
| 416 | + } | ||
| 417 | + } | ||
| 418 | + | ||
| 419 | + /** Step 3+4 真正写入文本 + 点发送(必要时先 @ 群成员) */ | ||
| 420 | + private void proceedToInputAndSend(AccessibilityNodeInfo root) { | ||
| 258 | AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT); | 421 | AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT); |
| 259 | if (edit == null) { | 422 | if (edit == null) { |
| 260 | Log.w(TAG, "Chat input not found"); | 423 | Log.w(TAG, "Chat input not found"); |
| @@ -288,6 +451,68 @@ public class HermesAccessibilityService extends AccessibilityService { | @@ -288,6 +451,68 @@ public class HermesAccessibilityService extends AccessibilityService { | ||
| 288 | edit.performAction(AccessibilityNodeInfo.ACTION_FOCUS); | 451 | edit.performAction(AccessibilityNodeInfo.ACTION_FOCUS); |
| 289 | } | 452 | } |
| 290 | 453 | ||
| 454 | + // 群里 @ 某成员:输入 "@" 触发选人面板,找名字点击 | ||
| 455 | + if (pendingAtSender != null) { | ||
| 456 | + Log.i(TAG, "@ mention: typing @ to trigger picker for " + pendingAtSender); | ||
| 457 | + Bundle atArgs = new Bundle(); | ||
| 458 | + atArgs.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, "@"); | ||
| 459 | + edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, atArgs); | ||
| 460 | + // 把 edit 暂存供 typeFinalContent 用 | ||
| 461 | + final AccessibilityNodeInfo editRef = edit; | ||
| 462 | + mainHandler.postDelayed(() -> pickMentionMemberAndContinue(editRef), 600); | ||
| 463 | + return; | ||
| 464 | + } | ||
| 465 | + | ||
| 466 | + typeFinalContent(edit); | ||
| 467 | + } | ||
| 468 | + | ||
| 469 | + /** 在 @ 选人面板中找到对应成员并点击;失败则降级(不 @ 只发文本) */ | ||
| 470 | + private void pickMentionMemberAndContinue(AccessibilityNodeInfo edit) { | ||
| 471 | + AccessibilityNodeInfo root = getRootInActiveWindow(); | ||
| 472 | + if (root == null) { | ||
| 473 | + Log.w(TAG, "@ picker: root null, fallback to plain content"); | ||
| 474 | + pendingAtSender = null; | ||
| 475 | + typeFinalContent(edit); | ||
| 476 | + return; | ||
| 477 | + } | ||
| 478 | + | ||
| 479 | + AccessibilityNodeInfo memberNode = findClickableByText(root, pendingAtSender); | ||
| 480 | + if (memberNode != null) { | ||
| 481 | + Log.i(TAG, "Clicking @ member: " + pendingAtSender); | ||
| 482 | + memberNode.performAction(AccessibilityNodeInfo.ACTION_CLICK); | ||
| 483 | + memberNode.recycle(); | ||
| 484 | + root.recycle(); | ||
| 485 | + | ||
| 486 | + // 点击后 WeChat 会把 @xxx 文本插入到 EditText 末尾,等一下再继续 | ||
| 487 | + mainHandler.postDelayed(() -> { | ||
| 488 | + AccessibilityNodeInfo r = getRootInActiveWindow(); | ||
| 489 | + AccessibilityNodeInfo freshEdit = (r != null) ? findInnerEditText(r) : null; | ||
| 490 | + if (freshEdit != null) { | ||
| 491 | + // 拼接 pendingContent 到现有 @xx 后面(不要覆盖 @ chip) | ||
| 492 | + appendToEditText(freshEdit, " " + pendingContent); | ||
| 493 | + freshEdit.recycle(); | ||
| 494 | + } else if (edit != null) { | ||
| 495 | + appendToEditText(edit, " " + pendingContent); | ||
| 496 | + } | ||
| 497 | + if (r != null) r.recycle(); | ||
| 498 | + if (edit != null) edit.recycle(); | ||
| 499 | + mainHandler.postDelayed(() -> { | ||
| 500 | + AccessibilityNodeInfo sendRoot = getRootInActiveWindow(); | ||
| 501 | + if (sendRoot != null) { | ||
| 502 | + clickSendButton(sendRoot); | ||
| 503 | + sendRoot.recycle(); | ||
| 504 | + } | ||
| 505 | + }, 400); | ||
| 506 | + }, 500); | ||
| 507 | + } else { | ||
| 508 | + Log.w(TAG, "@ member not found in picker: " + pendingAtSender + " — fallback to plain content"); | ||
| 509 | + root.recycle(); | ||
| 510 | + pendingAtSender = null; | ||
| 511 | + typeFinalContent(edit); | ||
| 512 | + } | ||
| 513 | + } | ||
| 514 | + | ||
| 515 | + private void typeFinalContent(AccessibilityNodeInfo edit) { | ||
| 291 | Log.i(TAG, "Setting chat text: " + pendingContent.substring(0, Math.min(30, pendingContent.length()))); | 516 | Log.i(TAG, "Setting chat text: " + pendingContent.substring(0, Math.min(30, pendingContent.length()))); |
| 292 | Bundle args = new Bundle(); | 517 | Bundle args = new Bundle(); |
| 293 | args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, pendingContent); | 518 | args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, pendingContent); |
| @@ -310,6 +535,33 @@ public class HermesAccessibilityService extends AccessibilityService { | @@ -310,6 +535,33 @@ public class HermesAccessibilityService extends AccessibilityService { | ||
| 310 | }, 400); | 535 | }, 400); |
| 311 | } | 536 | } |
| 312 | 537 | ||
| 538 | + /** 在已有 @chip 的 EditText 末尾追加内容(保持 chip 不被覆盖) */ | ||
| 539 | + private void appendToEditText(AccessibilityNodeInfo edit, String append) { | ||
| 540 | + CharSequence cur = edit.getText(); | ||
| 541 | + String combined = (cur != null ? cur.toString() : "") + append; | ||
| 542 | + Log.i(TAG, "Append to EditText: " + append); | ||
| 543 | + Bundle args = new Bundle(); | ||
| 544 | + args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, combined); | ||
| 545 | + edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args); | ||
| 546 | + } | ||
| 547 | + | ||
| 548 | + /** 在 bkk 容器里找内部 EditText(同样的解包逻辑封装一份方便复用) */ | ||
| 549 | + private AccessibilityNodeInfo findInnerEditText(AccessibilityNodeInfo root) { | ||
| 550 | + AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT); | ||
| 551 | + if (edit == null) return null; | ||
| 552 | + if ("android.widget.EditText".equals(edit.getClassName())) return edit; | ||
| 553 | + for (int i = 0; i < edit.getChildCount(); i++) { | ||
| 554 | + AccessibilityNodeInfo child = edit.getChild(i); | ||
| 555 | + if (child != null && "android.widget.EditText".equals(child.getClassName())) { | ||
| 556 | + edit.recycle(); | ||
| 557 | + return child; | ||
| 558 | + } | ||
| 559 | + if (child != null) child.recycle(); | ||
| 560 | + } | ||
| 561 | + edit.recycle(); | ||
| 562 | + return null; | ||
| 563 | + } | ||
| 564 | + | ||
| 313 | private void clickSendButton(AccessibilityNodeInfo root) { | 565 | private void clickSendButton(AccessibilityNodeInfo root) { |
| 314 | // Primary: find by exact ID | 566 | // Primary: find by exact ID |
| 315 | AccessibilityNodeInfo sendBtn = findNodeById(root, ID_CHAT_SEND); | 567 | AccessibilityNodeInfo sendBtn = findNodeById(root, ID_CHAT_SEND); |
| @@ -354,10 +606,71 @@ public class HermesAccessibilityService extends AccessibilityService { | @@ -354,10 +606,71 @@ public class HermesAccessibilityService extends AccessibilityService { | ||
| 354 | return null; | 606 | return null; |
| 355 | } | 607 | } |
| 356 | 608 | ||
| 609 | + /** | ||
| 610 | + * 在所有 bkl 消息气泡里找文本包含 partial(子串)的最后一条(最近收到的那条优先)。 | ||
| 611 | + */ | ||
| 612 | + private AccessibilityNodeInfo findBubbleByPartialText(AccessibilityNodeInfo root, String partial) { | ||
| 613 | + if (root == null || partial == null || partial.isEmpty()) return null; | ||
| 614 | + List<AccessibilityNodeInfo> bubbles = root.findAccessibilityNodeInfosByViewId(ID_BUBBLE_TEXT); | ||
| 615 | + Log.d(TAG, "findBubbleByPartialText: found " + (bubbles == null ? 0 : bubbles.size()) + " bkl bubbles, looking for: " + partial); | ||
| 616 | + if (bubbles == null || bubbles.isEmpty()) return null; | ||
| 617 | + AccessibilityNodeInfo match = null; | ||
| 618 | + // 后面的 bubble 在列表里通常更靠后(也更新),倒序优先 | ||
| 619 | + for (int i = bubbles.size() - 1; i >= 0; i--) { | ||
| 620 | + AccessibilityNodeInfo b = bubbles.get(i); | ||
| 621 | + CharSequence t = b.getText(); | ||
| 622 | + String tStr = (t != null) ? t.toString() : "(null)"; | ||
| 623 | + String preview = tStr.length() > 40 ? tStr.substring(0, 40) + "…" : tStr; | ||
| 624 | + Log.d(TAG, " bubble[" + i + "] text=" + preview); | ||
| 625 | + if (match == null && t != null && t.toString().contains(partial)) { | ||
| 626 | + Log.i(TAG, " matched at index " + i); | ||
| 627 | + match = b; | ||
| 628 | + continue; | ||
| 629 | + } | ||
| 630 | + b.recycle(); | ||
| 631 | + } | ||
| 632 | + return match; | ||
| 633 | + } | ||
| 634 | + | ||
| 635 | + /** | ||
| 636 | + * 在 root 子树里找一个 text 等于 label 且 clickable(自身或最近祖先)的节点。 | ||
| 637 | + * 用于点击弹出菜单 / 选人面板里的项。 | ||
| 638 | + */ | ||
| 639 | + private AccessibilityNodeInfo findClickableByText(AccessibilityNodeInfo root, String label) { | ||
| 640 | + if (root == null || label == null) return null; | ||
| 641 | + List<AccessibilityNodeInfo> nodes = root.findAccessibilityNodeInfosByText(label); | ||
| 642 | + if (nodes == null || nodes.isEmpty()) return null; | ||
| 643 | + for (AccessibilityNodeInfo n : nodes) { | ||
| 644 | + CharSequence t = n.getText(); | ||
| 645 | + if (t == null) { n.recycle(); continue; } | ||
| 646 | + // 严格匹配避免误命中("引用复制删除…" 这类长 text) | ||
| 647 | + if (!label.equals(t.toString())) { n.recycle(); continue; } | ||
| 648 | + // 自身可点就用自身 | ||
| 649 | + if (n.isClickable()) return n; | ||
| 650 | + // 否则向上找 clickable 祖先 | ||
| 651 | + AccessibilityNodeInfo cur = n; | ||
| 652 | + AccessibilityNodeInfo result = null; | ||
| 653 | + while (cur != null) { | ||
| 654 | + if (cur.isClickable()) { result = cur; break; } | ||
| 655 | + AccessibilityNodeInfo parent = cur.getParent(); | ||
| 656 | + if (cur != n) cur.recycle(); | ||
| 657 | + cur = parent; | ||
| 658 | + } | ||
| 659 | + if (result != null) { | ||
| 660 | + if (result != n) n.recycle(); | ||
| 661 | + return result; | ||
| 662 | + } | ||
| 663 | + n.recycle(); | ||
| 664 | + } | ||
| 665 | + return null; | ||
| 666 | + } | ||
| 667 | + | ||
| 357 | private void resetState() { | 668 | private void resetState() { |
| 358 | pendingToUser = null; | 669 | pendingToUser = null; |
| 359 | pendingToUserDisplayName = null; | 670 | pendingToUserDisplayName = null; |
| 360 | pendingContent = null; | 671 | pendingContent = null; |
| 672 | + pendingQuoteText = null; | ||
| 673 | + pendingAtSender = null; | ||
| 361 | Log.i(TAG, "State reset"); | 674 | Log.i(TAG, "State reset"); |
| 362 | } | 675 | } |
| 363 | } | 676 | } |
wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/res/xml/accessibility_service_config.xml
| @@ -5,4 +5,5 @@ | @@ -5,4 +5,5 @@ | ||
| 5 | android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewFocused" | 5 | android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewFocused" |
| 6 | android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagReportViewIds" | 6 | android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagReportViewIds" |
| 7 | android:canRetrieveWindowContent="true" | 7 | android:canRetrieveWindowContent="true" |
| 8 | + android:canPerformGestures="true" | ||
| 8 | android:notificationTimeout="100" /> | 9 | android:notificationTimeout="100" /> |