diff --git a/wechat_plain/scripts/auto-reply/README.md b/wechat_plain/scripts/auto-reply/README.md index 69fcd13..0d93b09 100644 --- a/wechat_plain/scripts/auto-reply/README.md +++ b/wechat_plain/scripts/auto-reply/README.md @@ -92,8 +92,8 @@ python wechat_reply_orchestrator.py --drain # 手动跑一轮编排 ## 待收尾(都非阻塞,系统已可用) -1. **群回复 @ 触发者**:✅ **文字前缀已做(2026-07-12)**——群消息回复自动加 `@昵称 `(U+2005=微信原生分隔符),`process_event` 里确定性拼接(不靠 LLM)。⚠️ 仅**文字 @**(看得见),**不带 `atuserlist`** → 未必真 ping 对方红点「有人@我」;真通知要给 `SEND_MSG_INTERNAL` hook 加 at 列表 + 逆向微信发送 API 的 at 字段(大工程,待定)。 -2. **原生引用气泡**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。 +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 会强退微信=要重登。 +2. **原生引用气泡**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。**跟上面红点@一起做**(都涉及微信发送链路逆向)。 3. **日志轮转**:watcher/orchestrator log 24/7 会长(ponytail 已标)。 4. **lifelog 人物增强**(二期):回链 `com.lemon.lifelog` 的 Person 库让回复带人物记忆。 5. ~~auto_send 开关搬进 viewer UI~~ ✅ **已做(2026-07-12)**:`/whitelist` 页顶部开关卡片,`GET/POST /api/auto_send` 读写 `policy_config.json`。 diff --git a/xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java b/xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java index 7f2e6c3..c27bc7a 100644 --- a/xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java +++ b/xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java @@ -74,6 +74,11 @@ public class WeChatHook implements IXposedHookLoadPackage { private static volatile String sPendingText = null; private static volatile String sPendingProc = null; + // ── 群 @ 自学习(atuserlist):从微信自己发的真 @ 消息学到 r0 里 msgSource 存哪个字段 ── + private static volatile String sAtFieldPath = null; // "field" 或 "field.subField" + private static volatile boolean sAtLoaded = false; + private static final String AT_FIELD_FILE = "/data/data/com.tencent.mm/files/.at_field"; + @Override public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { if (!"com.tencent.mm".equals(lpparam.packageName)) return; @@ -3059,6 +3064,7 @@ public class WeChatHook implements IXposedHookLoadPackage { hookF9ConvertTo(sWeChatCL, procName); hookF9ImgPathInjector(sWeChatCL, procName); registerSendReceiver(ctx, procName); + hookAtDiscovery(sWeChatCL, procName); // 群 @ 自学习 } }); XposedBridge.log("[WeChatHook] Send hook registered in " + procName); @@ -3294,6 +3300,12 @@ public class WeChatHook implements IXposedHookLoadPackage { } private static void trySendWithRetry(String talker, String text, int attempt, String procName) { + trySendWithRetry(talker, text, null, attempt, procName); + } + + // msgSource 非空 → 群 @(atuserlist)走 HighLevel 路径注入。Factory/R0 兜底路径不带 @ + // (降级:@ 丢失但消息仍发出),因为 @ 通知只在主路径实现。 + private static void trySendWithRetry(String talker, String text, String msgSource, int attempt, String procName) { // Non-main WeChat processes (:push, :tools, etc.) lack storage init — fail immediately. if (procName != null && procName.contains(":")) { if (attempt == 0) XposedBridge.log("[WeChatHook] Skip send in non-main proc=" + procName); @@ -3301,7 +3313,7 @@ public class WeChatHook implements IXposedHookLoadPackage { } // Route A2: tg3.t1 high-level service locator try { - sendViaHighLevel(talker, text); + sendViaHighLevel(talker, text, msgSource); XposedBridge.log("[WeChatHook] HighLevel send OK to=" + talker + " proc=" + procName); return; } catch (Throwable t) { @@ -3339,7 +3351,7 @@ public class WeChatHook implements IXposedHookLoadPackage { XposedBridge.log("[WeChatHook] All paths not ready, polling proc=" + procName + " to=" + talker); new Handler(Looper.getMainLooper()).postDelayed( - () -> trySendWithRetry(talker, text, attempt + 1, procName), 1000); + () -> trySendWithRetry(talker, text, msgSource, attempt + 1, procName), 1000); } else { XposedBridge.log("[WeChatHook] GIVE UP after 60s proc=" + procName + " to=" + talker); } @@ -3364,9 +3376,122 @@ public class WeChatHook implements IXposedHookLoadPackage { } } + // ── 群 @ 自学习:辅助 ───────────────────────────────────────── + private static void loadAtField() { + if (sAtLoaded) return; + sAtLoaded = true; + try { + java.io.File f = new java.io.File(AT_FIELD_FILE); + if (f.exists()) { + java.io.FileInputStream in = new java.io.FileInputStream(f); + byte[] b = new byte[(int) f.length()]; + int n = in.read(b); in.close(); + String p = new String(b, 0, Math.max(n, 0), "UTF-8").trim(); + if (!p.isEmpty()) { sAtFieldPath = p; XposedBridge.log("[WeChatHook] AT loaded field=" + p); } + } + } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT load failed: " + t); } + } + + private static void recordAt(String path, String xml) { + sAtFieldPath = path; + XposedBridge.log("[WeChatHook] AT-LEARNED field=" + path + " xml=" + xml); + try { + java.io.FileOutputStream o = new java.io.FileOutputStream(AT_FIELD_FILE); + o.write(path.getBytes("UTF-8")); o.close(); + } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT save failed: " + t); } + } + + private static boolean isSimpleVal(Object v) { + return v instanceof String || v instanceof Number || v instanceof Boolean + || v instanceof Character || v.getClass().isArray() || v.getClass().isPrimitive(); + } + + // 把 value 写进 root 的 path 字段("f" 直接 / "f.sub" 一层嵌套)。 + private static boolean setFieldByPath(Object root, String path, String value) { + try { + String[] parts = path.split("\\."); + Object obj = root; + for (int i = 0; i < parts.length - 1; i++) { + java.lang.reflect.Field f = obj.getClass().getDeclaredField(parts[i]); + f.setAccessible(true); + obj = f.get(obj); + if (obj == null) return false; + } + java.lang.reflect.Field last = obj.getClass().getDeclaredField(parts[parts.length - 1]); + last.setAccessible(true); + last.set(obj, value); + return true; + } catch (Throwable t) { + XposedBridge.log("[WeChatHook] setFieldByPath(" + path + ") failed: " + t); + return false; + } + } + + // 扫 r0 找含 atuserlist 的字段(直接 String + 一层嵌套 String);同时全量 dump 兜底。 + private static void scanForAtuserlist(Object r0) { + try { + if (!"w11.r0".equals(r0.getClass().getName())) return; + StringBuilder dump = new StringBuilder("[WeChatHook] AT-DUMP r0 fields:\n"); + for (java.lang.reflect.Field f : r0.getClass().getDeclaredFields()) { + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; + f.setAccessible(true); + Object v; + try { v = f.get(r0); } catch (Throwable e) { continue; } + String vs = v == null ? "null" : v.toString(); + if (vs.length() > 180) vs = vs.substring(0, 180); + dump.append(" ").append(f.getName()).append(":") + .append(f.getType().getSimpleName()).append(" = ").append(vs).append("\n"); + if (v instanceof String && ((String) v).contains("atuserlist")) { + recordAt(f.getName(), (String) v); + } else if (v != null && !isSimpleVal(v)) { + for (java.lang.reflect.Field sf : v.getClass().getDeclaredFields()) { + if (java.lang.reflect.Modifier.isStatic(sf.getModifiers())) continue; + if (sf.getType() != String.class) continue; + sf.setAccessible(true); + Object sv; + try { sv = sf.get(v); } catch (Throwable e) { continue; } + if (sv instanceof String && ((String) sv).contains("atuserlist")) { + recordAt(f.getName() + "." + sf.getName(), (String) sv); + } + } + } + } + XposedBridge.log(dump.toString()); + } catch (Throwable t) { + XposedBridge.log("[WeChatHook] scanForAtuserlist failed: " + t); + } + } + + // hook 发送派发 z2.b(scene):真实发送(非本 hook)时扫 r0 学 atuserlist 字段,学一次即止。 + private void hookAtDiscovery(ClassLoader cl, String procName) { + try { + loadAtField(); + Class z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); + int hooked = 0; + // hook z2 所有"1 个引用参数"的方法(UI enqueue 方法名不确定,不只 b);扫描内部按 + // scene 是不是 w11.r0 过滤,不会误伤别的 scene。学会后全部 short-circuit。 + for (java.lang.reflect.Method m : z2c.getDeclaredMethods()) { + Class[] pt = m.getParameterTypes(); + if (pt.length != 1 || pt[0].isPrimitive()) continue; + XposedBridge.hookMethod(m, new XC_MethodHook() { + @Override protected void beforeHookedMethod(MethodHookParam p) { + if (sAtFieldPath != null || sCalledByUs) return; // 已学会 / 我们自己的发送 → 跳过 + Object scene = (p.args != null && p.args.length > 0) ? p.args[0] : null; + if (scene != null) scanForAtuserlist(scene); + } + }); + hooked++; + } + XposedBridge.log("[WeChatHook] hookAtDiscovery OK in " + procName + + " (" + hooked + " z2 methods, learned=" + sAtFieldPath + ")"); + } catch (Throwable t) { + XposedBridge.log("[WeChatHook] hookAtDiscovery failed in " + procName + ": " + t); + } + } + // Route A2: high-level API via tg3.t1 service locator → dk5.s5.Ni(). // This path is what WeChat UI uses — internally handles DI, account context, etc. - private static void sendViaHighLevel(String talker, String text) throws Throwable { + private static void sendViaHighLevel(String talker, String text, String msgSource) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); // Text = same PROVEN path as voice sendVoiceViaAPI: w11.r0 ctor (type=1) inserts @@ -3374,18 +3499,34 @@ public class WeChatHook implements IXposedHookLoadPackage { // Text carries no CDN attachment, so unlike voice it completes fully. (The s1.a / // s1.b / dk5.s5.Ni builder paths either produced name-cards or silently failed to // persist — they need hidden r1 fields + MsgRetransmitUI.P6 that we can't set robustly.) + // 群 @ 通知:atuserlist 不进 message 行(cv 里没有),而是当 r0 第5参数 obj 传给 + // 发送 scene(newsendmsg CGI)。msgSource=wxid,.. + // 时对方才收到「有人@我」红点。无 @ 时 obj=null(纯文本原路径,逐字节不变)。 Class r0Class = XposedHelpers.findClass("w11.r0", cl); Object r0; sCalledByUs = true; try { - // Object-ctor is the one that actually inserts the f9 (confirmed by - // sendVoiceViaAPI). obj=null for text (no audio/msgsource extras). + // obj 恒为 null:obj 非 null 时 r0 把它当"消息 key 查已有 f9"(g9.q5)→ NPE,不是 msgsource。 r0 = XposedHelpers.newInstance(r0Class, new Class[]{String.class, String.class, int.class, int.class, Object.class}, talker, text, 1, 0, null); // (talker, content, type=1 TEXT, flag=0, null) } finally { sCalledByUs = false; } + // 群 @:把 msgSource 塞进"学到"的字段(sAtFieldPath),对方才收到红点。 + // 字段由 hookAtDiscovery 从微信自己的真 @ 发送学得;没学会则只发文字(降级)。 + if (msgSource != null && !msgSource.isEmpty()) { + loadAtField(); + if (sAtFieldPath != null) { + boolean ok = setFieldByPath(r0, sAtFieldPath, msgSource); + XposedBridge.log("[WeChatHook] @ apply msgsource → " + sAtFieldPath + " ok=" + ok); + } else { + XposedBridge.log("[WeChatHook] @ field 未学会 — 先用微信手动发一条真 @ 教它"); + } + } Class z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); - XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg + sCalledByUs = true; // 别让 discovery hook 扫我们自己的 dispatch + try { + XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg + } finally { sCalledByUs = false; } XposedBridge.log("[WeChatHook] sendViaHighLevel(TEXT via r0-obj+z2.b): OK to=" + talker); } @@ -3412,10 +3553,16 @@ public class WeChatHook implements IXposedHookLoadPackage { XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'/'text_b64'"); return; } + // --es at "wxid1,wxid2" → 群 @ 通知(atuserlist)。构造 msgSource,让对方收到红点。 + String atList = intent.getStringExtra("at"); + final String msgSource = (atList != null && !atList.trim().isEmpty()) + ? "" + atList.trim() + "" + : null; XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName + " pid=" + android.os.Process.myPid() - + " to=" + talker + " len=" + text.length()); - new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 0, procName)); + + " to=" + talker + " len=" + text.length() + + (msgSource != null ? " at=" + atList : "")); + new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, msgSource, 0, procName)); } }; ctx.registerReceiver(sendRecv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED);