Commit 1c2bff36740cba716dad3d9b5e5bb2f93f0d4267

Authored by yibo
1 parent 7f20b54e

wip(wechat-spoof): 群 @ 红点(atuserlist)逆向脚手架 — 未成,搁置

红点通知需 atuserlist,深逆向未到底,跟原生引用气泡一起后面做。已确认:
- 纯文字@不触发红点;atuserlist 不在 message 行(convertTo cv 无此列)
- r0 第5参 obj≠msgSource(当它传会 NPE g9.q5,obj 是"消息key查f9")
- 微信 UI 发送链路≠我们合成的 r0+z2.b:hook z2 自学习扫 atuserlist 字段零命中

已部署脚手架(WIP,生产不传 at 无影响,自动回复照常):
- SEND_MSG_INTERNAL 支持 --es at → 构造 msgsource
- trySendWithRetry/sendViaHighLevel 加 msgSource 参数
- hookAtDiscovery 自学习 z2 派发点 + sAtFieldPath 持久化(目前学不到)

详见 memory wechat-group-at-atuserlist。⚠️ 重编译 hook 会强退微信=要重登,
继续挖前一次想全要抓什么。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wechat_plain/scripts/auto-reply/README.md
... ... @@ -92,8 +92,8 @@ python wechat_reply_orchestrator.py --drain # 手动跑一轮编排
92 92  
93 93 ## 待收尾(都非阻塞,系统已可用)
94 94  
95   -1. **群回复 @ 触发者**:✅ **文字前缀已做(2026-07-12)**——群消息回复自动加 `@昵称 `(U+2005=微信原生分隔符),`process_event` 里确定性拼接(不靠 LLM)。⚠️ 仅**文字 @**(看得见),**不带 `atuserlist`** → 未必真 ping 对方红点「有人@我」;真通知要给 `SEND_MSG_INTERNAL` hook 加 at 列表 + 逆向微信发送 API 的 at 字段(大工程,待定)。
96   -2. **原生引用气泡**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。
  95 +1. **群回复 @ 触发者**:✅ **文字前缀已做(2026-07-12)**——群消息回复自动加 `@昵称 `(U+2005=微信原生分隔符),`process_event` 里确定性拼接(不靠 LLM)。⚠️ 仅**文字 @**(看得见),**红点通知(atuserlist)未成,搁置**(跟下面引用气泡一起后面做)。逆向进度/坑见 memory `wechat-group-at-atuserlist`:atuserlist 不在 message 行、在微信 UI 发送链路(≠我们合成的 r0+z2.b),self-learning z2.b hook 没抓到;WeChatHook 里已有 `--es at` + 自学习脚手架(WIP,生产不传 at 无影响)。⚠️ 重编译 hook 会强退微信=要重登。
  96 +2. **原生引用气泡**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。**跟上面红点@一起做**(都涉及微信发送链路逆向)。
97 97 3. **日志轮转**:watcher/orchestrator log 24/7 会长(ponytail 已标)。
98 98 4. **lifelog 人物增强**(二期):回链 `com.lemon.lifelog` 的 Person 库让回复带人物记忆。
99 99 5. ~~auto_send 开关搬进 viewer UI~~ ✅ **已做(2026-07-12)**:`/whitelist` 页顶部开关卡片,`GET/POST /api/auto_send` 读写 `policy_config.json`。
... ...
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
... ... @@ -74,6 +74,11 @@ public class WeChatHook implements IXposedHookLoadPackage {
74 74 private static volatile String sPendingText = null;
75 75 private static volatile String sPendingProc = null;
76 76  
  77 + // ── 群 @ 自学习(atuserlist):从微信自己发的真 @ 消息学到 r0 里 msgSource 存哪个字段 ──
  78 + private static volatile String sAtFieldPath = null; // "field" 或 "field.subField"
  79 + private static volatile boolean sAtLoaded = false;
  80 + private static final String AT_FIELD_FILE = "/data/data/com.tencent.mm/files/.at_field";
  81 +
77 82 @Override
78 83 public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
79 84 if (!"com.tencent.mm".equals(lpparam.packageName)) return;
... ... @@ -3059,6 +3064,7 @@ public class WeChatHook implements IXposedHookLoadPackage {
3059 3064 hookF9ConvertTo(sWeChatCL, procName);
3060 3065 hookF9ImgPathInjector(sWeChatCL, procName);
3061 3066 registerSendReceiver(ctx, procName);
  3067 + hookAtDiscovery(sWeChatCL, procName); // 群 @ 自学习
3062 3068 }
3063 3069 });
3064 3070 XposedBridge.log("[WeChatHook] Send hook registered in " + procName);
... ... @@ -3294,6 +3300,12 @@ public class WeChatHook implements IXposedHookLoadPackage {
3294 3300 }
3295 3301  
3296 3302 private static void trySendWithRetry(String talker, String text, int attempt, String procName) {
  3303 + trySendWithRetry(talker, text, null, attempt, procName);
  3304 + }
  3305 +
  3306 + // msgSource 非空 → 群 @(atuserlist)走 HighLevel 路径注入。Factory/R0 兜底路径不带 @
  3307 + // (降级:@ 丢失但消息仍发出),因为 @ 通知只在主路径实现。
  3308 + private static void trySendWithRetry(String talker, String text, String msgSource, int attempt, String procName) {
3297 3309 // Non-main WeChat processes (:push, :tools, etc.) lack storage init — fail immediately.
3298 3310 if (procName != null && procName.contains(":")) {
3299 3311 if (attempt == 0) XposedBridge.log("[WeChatHook] Skip send in non-main proc=" + procName);
... ... @@ -3301,7 +3313,7 @@ public class WeChatHook implements IXposedHookLoadPackage {
3301 3313 }
3302 3314 // Route A2: tg3.t1 high-level service locator
3303 3315 try {
3304   - sendViaHighLevel(talker, text);
  3316 + sendViaHighLevel(talker, text, msgSource);
3305 3317 XposedBridge.log("[WeChatHook] HighLevel send OK to=" + talker + " proc=" + procName);
3306 3318 return;
3307 3319 } catch (Throwable t) {
... ... @@ -3339,7 +3351,7 @@ public class WeChatHook implements IXposedHookLoadPackage {
3339 3351 XposedBridge.log("[WeChatHook] All paths not ready, polling proc=" + procName
3340 3352 + " to=" + talker);
3341 3353 new Handler(Looper.getMainLooper()).postDelayed(
3342   - () -> trySendWithRetry(talker, text, attempt + 1, procName), 1000);
  3354 + () -> trySendWithRetry(talker, text, msgSource, attempt + 1, procName), 1000);
3343 3355 } else {
3344 3356 XposedBridge.log("[WeChatHook] GIVE UP after 60s proc=" + procName + " to=" + talker);
3345 3357 }
... ... @@ -3364,9 +3376,122 @@ public class WeChatHook implements IXposedHookLoadPackage {
3364 3376 }
3365 3377 }
3366 3378  
  3379 + // ── 群 @ 自学习:辅助 ─────────────────────────────────────────
  3380 + private static void loadAtField() {
  3381 + if (sAtLoaded) return;
  3382 + sAtLoaded = true;
  3383 + try {
  3384 + java.io.File f = new java.io.File(AT_FIELD_FILE);
  3385 + if (f.exists()) {
  3386 + java.io.FileInputStream in = new java.io.FileInputStream(f);
  3387 + byte[] b = new byte[(int) f.length()];
  3388 + int n = in.read(b); in.close();
  3389 + String p = new String(b, 0, Math.max(n, 0), "UTF-8").trim();
  3390 + if (!p.isEmpty()) { sAtFieldPath = p; XposedBridge.log("[WeChatHook] AT loaded field=" + p); }
  3391 + }
  3392 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT load failed: " + t); }
  3393 + }
  3394 +
  3395 + private static void recordAt(String path, String xml) {
  3396 + sAtFieldPath = path;
  3397 + XposedBridge.log("[WeChatHook] AT-LEARNED field=" + path + " xml=" + xml);
  3398 + try {
  3399 + java.io.FileOutputStream o = new java.io.FileOutputStream(AT_FIELD_FILE);
  3400 + o.write(path.getBytes("UTF-8")); o.close();
  3401 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT save failed: " + t); }
  3402 + }
  3403 +
  3404 + private static boolean isSimpleVal(Object v) {
  3405 + return v instanceof String || v instanceof Number || v instanceof Boolean
  3406 + || v instanceof Character || v.getClass().isArray() || v.getClass().isPrimitive();
  3407 + }
  3408 +
  3409 + // 把 value 写进 root 的 path 字段("f" 直接 / "f.sub" 一层嵌套)。
  3410 + private static boolean setFieldByPath(Object root, String path, String value) {
  3411 + try {
  3412 + String[] parts = path.split("\\.");
  3413 + Object obj = root;
  3414 + for (int i = 0; i < parts.length - 1; i++) {
  3415 + java.lang.reflect.Field f = obj.getClass().getDeclaredField(parts[i]);
  3416 + f.setAccessible(true);
  3417 + obj = f.get(obj);
  3418 + if (obj == null) return false;
  3419 + }
  3420 + java.lang.reflect.Field last = obj.getClass().getDeclaredField(parts[parts.length - 1]);
  3421 + last.setAccessible(true);
  3422 + last.set(obj, value);
  3423 + return true;
  3424 + } catch (Throwable t) {
  3425 + XposedBridge.log("[WeChatHook] setFieldByPath(" + path + ") failed: " + t);
  3426 + return false;
  3427 + }
  3428 + }
  3429 +
  3430 + // 扫 r0 找含 atuserlist 的字段(直接 String + 一层嵌套 String);同时全量 dump 兜底。
  3431 + private static void scanForAtuserlist(Object r0) {
  3432 + try {
  3433 + if (!"w11.r0".equals(r0.getClass().getName())) return;
  3434 + StringBuilder dump = new StringBuilder("[WeChatHook] AT-DUMP r0 fields:\n");
  3435 + for (java.lang.reflect.Field f : r0.getClass().getDeclaredFields()) {
  3436 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  3437 + f.setAccessible(true);
  3438 + Object v;
  3439 + try { v = f.get(r0); } catch (Throwable e) { continue; }
  3440 + String vs = v == null ? "null" : v.toString();
  3441 + if (vs.length() > 180) vs = vs.substring(0, 180);
  3442 + dump.append(" ").append(f.getName()).append(":")
  3443 + .append(f.getType().getSimpleName()).append(" = ").append(vs).append("\n");
  3444 + if (v instanceof String && ((String) v).contains("atuserlist")) {
  3445 + recordAt(f.getName(), (String) v);
  3446 + } else if (v != null && !isSimpleVal(v)) {
  3447 + for (java.lang.reflect.Field sf : v.getClass().getDeclaredFields()) {
  3448 + if (java.lang.reflect.Modifier.isStatic(sf.getModifiers())) continue;
  3449 + if (sf.getType() != String.class) continue;
  3450 + sf.setAccessible(true);
  3451 + Object sv;
  3452 + try { sv = sf.get(v); } catch (Throwable e) { continue; }
  3453 + if (sv instanceof String && ((String) sv).contains("atuserlist")) {
  3454 + recordAt(f.getName() + "." + sf.getName(), (String) sv);
  3455 + }
  3456 + }
  3457 + }
  3458 + }
  3459 + XposedBridge.log(dump.toString());
  3460 + } catch (Throwable t) {
  3461 + XposedBridge.log("[WeChatHook] scanForAtuserlist failed: " + t);
  3462 + }
  3463 + }
  3464 +
  3465 + // hook 发送派发 z2.b(scene):真实发送(非本 hook)时扫 r0 学 atuserlist 字段,学一次即止。
  3466 + private void hookAtDiscovery(ClassLoader cl, String procName) {
  3467 + try {
  3468 + loadAtField();
  3469 + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl);
  3470 + int hooked = 0;
  3471 + // hook z2 所有"1 个引用参数"的方法(UI enqueue 方法名不确定,不只 b);扫描内部按
  3472 + // scene 是不是 w11.r0 过滤,不会误伤别的 scene。学会后全部 short-circuit。
  3473 + for (java.lang.reflect.Method m : z2c.getDeclaredMethods()) {
  3474 + Class<?>[] pt = m.getParameterTypes();
  3475 + if (pt.length != 1 || pt[0].isPrimitive()) continue;
  3476 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  3477 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  3478 + if (sAtFieldPath != null || sCalledByUs) return; // 已学会 / 我们自己的发送 → 跳过
  3479 + Object scene = (p.args != null && p.args.length > 0) ? p.args[0] : null;
  3480 + if (scene != null) scanForAtuserlist(scene);
  3481 + }
  3482 + });
  3483 + hooked++;
  3484 + }
  3485 + XposedBridge.log("[WeChatHook] hookAtDiscovery OK in " + procName
  3486 + + " (" + hooked + " z2 methods, learned=" + sAtFieldPath + ")");
  3487 + } catch (Throwable t) {
  3488 + XposedBridge.log("[WeChatHook] hookAtDiscovery failed in " + procName + ": " + t);
  3489 + }
  3490 + }
  3491 +
3367 3492 // Route A2: high-level API via tg3.t1 service locator → dk5.s5.Ni().
3368 3493 // This path is what WeChat UI uses — internally handles DI, account context, etc.
3369   - private static void sendViaHighLevel(String talker, String text) throws Throwable {
  3494 + private static void sendViaHighLevel(String talker, String text, String msgSource) throws Throwable {
3370 3495 ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
3371 3496 if (cl == null) throw new IllegalStateException("classloader null");
3372 3497 // Text = same PROVEN path as voice sendVoiceViaAPI: w11.r0 ctor (type=1) inserts
... ... @@ -3374,18 +3499,34 @@ public class WeChatHook implements IXposedHookLoadPackage {
3374 3499 // Text carries no CDN attachment, so unlike voice it completes fully. (The s1.a /
3375 3500 // s1.b / dk5.s5.Ni builder paths either produced name-cards or silently failed to
3376 3501 // persist — they need hidden r1 fields + MsgRetransmitUI.P6 that we can't set robustly.)
  3502 + // 群 @ 通知:atuserlist 不进 message 行(cv 里没有),而是当 r0 第5参数 obj 传给
  3503 + // 发送 scene(newsendmsg CGI)。msgSource=<msgsource><atuserlist>wxid,..</atuserlist></msgsource>
  3504 + // 时对方才收到「有人@我」红点。无 @ 时 obj=null(纯文本原路径,逐字节不变)。
3377 3505 Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
3378 3506 Object r0;
3379 3507 sCalledByUs = true;
3380 3508 try {
3381   - // Object-ctor is the one that actually inserts the f9 (confirmed by
3382   - // sendVoiceViaAPI). obj=null for text (no audio/msgsource extras).
  3509 + // obj 恒为 null:obj 非 null 时 r0 把它当"消息 key 查已有 f9"(g9.q5)→ NPE,不是 msgsource。
3383 3510 r0 = XposedHelpers.newInstance(r0Class,
3384 3511 new Class<?>[]{String.class, String.class, int.class, int.class, Object.class},
3385 3512 talker, text, 1, 0, null); // (talker, content, type=1 TEXT, flag=0, null)
3386 3513 } finally { sCalledByUs = false; }
  3514 + // 群 @:把 msgSource 塞进"学到"的字段(sAtFieldPath),对方才收到红点。
  3515 + // 字段由 hookAtDiscovery 从微信自己的真 @ 发送学得;没学会则只发文字(降级)。
  3516 + if (msgSource != null && !msgSource.isEmpty()) {
  3517 + loadAtField();
  3518 + if (sAtFieldPath != null) {
  3519 + boolean ok = setFieldByPath(r0, sAtFieldPath, msgSource);
  3520 + XposedBridge.log("[WeChatHook] @ apply msgsource → " + sAtFieldPath + " ok=" + ok);
  3521 + } else {
  3522 + XposedBridge.log("[WeChatHook] @ field 未学会 — 先用微信手动发一条真 @ 教它");
  3523 + }
  3524 + }
3387 3525 Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl);
3388   - XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg
  3526 + sCalledByUs = true; // 别让 discovery hook 扫我们自己的 dispatch
  3527 + try {
  3528 + XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg
  3529 + } finally { sCalledByUs = false; }
3389 3530 XposedBridge.log("[WeChatHook] sendViaHighLevel(TEXT via r0-obj+z2.b): OK to=" + talker);
3390 3531 }
3391 3532  
... ... @@ -3412,10 +3553,16 @@ public class WeChatHook implements IXposedHookLoadPackage {
3412 3553 XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'/'text_b64'");
3413 3554 return;
3414 3555 }
  3556 + // --es at "wxid1,wxid2" → 群 @ 通知(atuserlist)。构造 msgSource,让对方收到红点。
  3557 + String atList = intent.getStringExtra("at");
  3558 + final String msgSource = (atList != null && !atList.trim().isEmpty())
  3559 + ? "<msgsource><atuserlist>" + atList.trim() + "</atuserlist></msgsource>"
  3560 + : null;
3415 3561 XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName
3416 3562 + " pid=" + android.os.Process.myPid()
3417   - + " to=" + talker + " len=" + text.length());
3418   - new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 0, procName));
  3563 + + " to=" + talker + " len=" + text.length()
  3564 + + (msgSource != null ? " at=" + atList : ""));
  3565 + new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, msgSource, 0, procName));
3419 3566 }
3420 3567 };
3421 3568 ctx.registerReceiver(sendRecv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED);
... ...