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);