You need to sign in before continuing.

Commit c3eb27d34c6f1d5b3c5be355c3c18a82f9469e8d

Authored by yibo
1 parent 70ff426e

feat(wechat-auto-reply): 环节5 API 发送打通 — onResume flush + 送达校验

- text_b64 传中文(--es text 被 shell/am 弄乱→卡 status=1)
- z2.b 只入队,发送后 HOME+LauncherUI 触发 onResume 派发(设备机制非代码 bug)
- _send_text_verified 轮询 message.status 直到 2,超时仍 1 视为已入队不误报
- 拆 stage 目录(watcher=_stage / orchestrator=_stage_orch)避开拷库竞态

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
md/wechat-auto-reply-requirements.md
@@ -100,7 +100,15 @@ @@ -100,7 +100,15 @@
100 - 安全:生成超时/失败 → 无草稿 → policy skip → 不发(失败即静默,绝不误发)。 100 - 安全:生成超时/失败 → 无草稿 → policy skip → 不发(失败即静默,绝不误发)。
101 - 真机验证:接线(watcher inotify → 自动拉起编排器 drain)通过;真实上下文(猎头 婷婷)生成「哈哈婷婷,我不是什么总,就是个写代码的,不负责招人这块哈」→ 正确进 /approvals 待审。 101 - 真机验证:接线(watcher inotify → 自动拉起编排器 drain)通过;真实上下文(猎头 婷婷)生成「哈哈婷婷,我不是什么总,就是个写代码的,不负责招人这块哈」→ 正确进 /approvals 待审。
102 102
103 -### 环节 5 · 发送回复带引用(原生气泡) 103 +### 环节 5 · 可靠发送 —— ✅ **API 发送打通(2026-07-11)**
  104 +经历一场硬仗才让 API 发送真正送达(火星人跨账号收到确认)。两个坑:
  105 +1. **中文被弄乱**:`send_text_via_hook` 用 `--es text` 传中文被 shell/am 弄乱→服务器拒收→卡 status=1。hook 早已要求 `--es text_b64`,发送端漏改。→ 改用 text_b64。
  106 +2. **z2.b 只入队不派发(设备机制,非代码 bug)**:`sendViaHighLevel` 的 `z2.b` 把消息入队(status=1 转圈),WeChat NetSceneQueue **要一次 onResume(后台→前台切换)才派发**。发送代码跨版本逐字节相同,不是回归。→ 发送后自动 `input keyevent KEYCODE_HOME` + `am start LauncherUI` 触发 onResume,消息 flush 到 status=2。
  107 +- 落地 `_send_text_verified`(orchestrator):前台微信 → text_b64 发送 → 后台前台切换 → 轮询 status 直到 2。status=1 超时视为"已入队将送达"(不误报失败)。
  108 +- 详见 memory `wechat-api-send-onresume-flush`。
  109 +- **待深挖(下次)**:onResume 到底改变了什么让队列派发(疑似后台暂停网络场景/长连接休眠)——要 hook NetSceneQueue/网络模块在前后台切换点打 log 拿根因。
  110 +
  111 +### (原)环节 5 · 发送回复带引用(原生气泡)
104 需求 = 点原文→引用→发送后显示引用气泡。两条实现路: 112 需求 = 点原文→引用→发送后显示引用气泡。两条实现路:
105 - **(a) A11y UI 自动化(一期推荐)**:长按目标气泡 → 点"引用" → 输入 → 发送。驱动微信自己 UI → **产出货真价实的原生引用气泡,零逆向**。代价:需微信前台、脚本略脆、比 API 慢。已有 A11y 发送服务底子。 113 - **(a) A11y UI 自动化(一期推荐)**:长按目标气泡 → 点"引用" → 输入 → 发送。驱动微信自己 UI → **产出货真价实的原生引用气泡,零逆向**。代价:需微信前台、脚本略脆、比 API 慢。已有 A11y 发送服务底子。
106 - **(b) 内部 API 构造 refermsg(二期优化)**:微信引用 = type 49 appmsg + refermsg(type 57) 指向被引 svrid。一整块新逆向,量级同当初啃发图,重。留作提速。 114 - **(b) 内部 API 构造 refermsg(二期优化)**:微信引用 = type 49 appmsg + refermsg(type 57) 指向被引 svrid。一整块新逆向,量级同当初啃发图,重。留作提速。
wechat_plain/scripts/auto-reply/README.md
@@ -46,6 +46,10 @@ @@ -46,6 +46,10 @@
46 - **环节4 为何必须是确定性代码**:环节3 实测「SOUL.md 里的软约束会漏」(约饭案例模型仍主动接话)。安全门必须是 LLM 说服不动的硬规则:default-deny + 敏感词(钱/承诺/约时间/隐私,白名单也拉回)+ skip(群没@我/公众号/系统消息/自己发的)。 46 - **环节4 为何必须是确定性代码**:环节3 实测「SOUL.md 里的软约束会漏」(约饭案例模型仍主动接话)。安全门必须是 LLM 说服不动的硬规则:default-deny + 敏感词(钱/承诺/约时间/隐私,白名单也拉回)+ skip(群没@我/公众号/系统消息/自己发的)。
47 - **账号自适应**:运行时 `MD5("mm"+uin)` 定位当前登录账号,`MD5("1234567890ABCDEF"+uin)[:7]` 推导 SQLCipher key,多账号切换自动跟随(不硬编码)。 47 - **账号自适应**:运行时 `MD5("mm"+uin)` 定位当前登录账号,`MD5("1234567890ABCDEF"+uin)[:7]` 推导 SQLCipher key,多账号切换自动跟随(不硬编码)。
48 - **失败即静默**:生成超时/失败 → 无草稿 → policy skip → 不发。最坏情况是不回,绝不误发。 48 - **失败即静默**:生成超时/失败 → 无草稿 → policy skip → 不发。最坏情况是不回,绝不误发。
  49 +- **API 发送的两个坑(2026-07-11 硬仗定位,详见 memory `wechat-api-send-onresume-flush`)**:
  50 + 1. 中文必须走 `--es text_b64`(`--es text` 被 shell/am 弄乱 → 卡 status=1)。
  51 + 2. `z2.b` 只入队(status=1 转圈),**WeChat NetSceneQueue 要一次 onResume(后台→前台切换)才派发** → 发送后自动 `KEYCODE_HOME` + `am start LauncherUI` 触发派发 → status=2。**这是设备机制不是代码 bug**,发送代码跨版本逐字节相同,别去 diff/重编译 hook 找代码差异。
  52 + 3. 送达校验:轮询 `message.status`(1=发送中/2=已达/5=失败);超时仍 1 视为"已入队将送达",不误报失败。
49 53
50 ## 运维 54 ## 运维
51 55
wechat_plain/scripts/auto-reply/wechat_reply_orchestrator.py
@@ -159,10 +159,93 @@ def _queue_insert(event, draft, sender_disp, action, reason, status): @@ -159,10 +159,93 @@ def _queue_insert(event, draft, sender_disp, action, reason, status):
159 con.close() 159 con.close()
160 160
161 161
162 -def _send_text(to_wxid, text): 162 +VERIFY_TIMEOUT = 25 # 轮询确认送达的总超时(秒)
  163 +VERIFY_POLL = 3 # 轮询间隔
  164 +
  165 +
  166 +def _cleanup_verify():
  167 + for p in __import__("glob").glob(f"{WORK_DIR}/_stage_verify/en.db*"):
  168 + try:
  169 + os.remove(p)
  170 + except OSError:
  171 + pass
  172 +
  173 +
  174 +def _sent_status(db, key, talker, base_msgid, text):
  175 + """查我方(isSend=1)在 base 之后、内容==text 的那条消息的 status。
  176 + 微信 status: 1=发送中(转圈) / 2=已发送 / 5=失败。查不到返回 None。"""
  177 + st = w.copy_db(db, "_stage_verify")
  178 + try:
  179 + rows = w.sqlcipher_query(
  180 + st, key,
  181 + f"SELECT status, hex(content) FROM message WHERE talker='{talker}' "
  182 + f"AND isSend=1 AND msgId>{base_msgid} ORDER BY msgId DESC LIMIT 8;")
  183 + finally:
  184 + _cleanup_verify()
  185 + for r in rows:
  186 + if len(r) < 2:
  187 + continue
  188 + try:
  189 + body = bytes.fromhex(r[1]).decode("utf-8", "ignore")
  190 + except ValueError:
  191 + body = ""
  192 + if body == text:
  193 + return int(r[0]) if str(r[0]).lstrip("-").isdigit() else None
  194 + return None
  195 +
  196 +
  197 +def _send_text_verified(to_wxid, text, key):
  198 + """可靠发送:切前台唤醒微信 → 广播发送 → 轮询 status 直到 =2(确认送达)。
  199 + 超时仍卡在发送中/失败 → 返回 ok=False,由调用方标 failed 进审核(不假装成功)。
  200 + ponytail: 校验靠反复拷库(~96MB/次)读 status;发送稀疏可接受,首轮多半即达。
  201 + 上限=拷库 I/O;升级=让 hook 直接回报送达 / 常驻前台。"""
  202 + _, db, _ = w.account_paths()
  203 + st = w.copy_db(db, "_stage_verify")
  204 + try:
  205 + r = w.sqlcipher_query(
  206 + st, key, f"SELECT COALESCE(MAX(msgId),0) FROM message WHERE talker='{to_wxid}';")
  207 + base = int(r[0][0]) if r and r[0] else 0
  208 + finally:
  209 + _cleanup_verify()
  210 + # 微信在后台被 Android 冻结→NetScene 队列不提交,消息卡"发送中"。先切前台唤醒。
  211 + try:
  212 + subprocess.run(["am", "start", "-n", "com.tencent.mm/.ui.LauncherUI"],
  213 + capture_output=True, timeout=8)
  214 + time.sleep(3)
  215 + except Exception: # noqa: BLE001
  216 + pass
163 sys.path.insert(0, EVENT_BUS) 217 sys.path.insert(0, EVENT_BUS)
164 from wechat_hook_send import send_text_via_hook 218 from wechat_hook_send import send_text_via_hook
165 - return send_text_via_hook(to_wxid, text) 219 + res = send_text_via_hook(to_wxid, text)
  220 + if res.get("status") != "sent":
  221 + return {"ok": False, "reason": "broadcast失败:" + str(res.get("error", ""))[:40]}
  222 + # 关键机制:z2.b 只把消息入队(status=1 转圈),WeChat 发送队列要一次 onResume
  223 + # (后台→前台切换)才真正派发。真机验证:发送后 HOME 切后台再拉回前台,卡住的消息
  224 + # 全部 flush 到 status=2。(等同"重启微信才发出去",但轻得多。)
  225 + time.sleep(2) # 让消息先入库
  226 + try:
  227 + subprocess.run(["input", "keyevent", "KEYCODE_HOME"], capture_output=True, timeout=6)
  228 + time.sleep(1.5)
  229 + subprocess.run(["am", "start", "-n", "com.tencent.mm/.ui.LauncherUI"],
  230 + capture_output=True, timeout=8)
  231 + time.sleep(2)
  232 + except Exception: # noqa: BLE001
  233 + pass
  234 + deadline = time.time() + VERIFY_TIMEOUT
  235 + last = None
  236 + while time.time() < deadline:
  237 + time.sleep(VERIFY_POLL)
  238 + last = _sent_status(db, key, to_wxid, base, text)
  239 + if last == 2:
  240 + return {"ok": True, "reason": "verified(status=2)"}
  241 + if last == 5:
  242 + return {"ok": False, "reason": "status=5(微信报失败)"}
  243 + # 超时判定:真机验证 status=1 是"已入队发送中",微信连接慢时会延迟但最终送达
  244 + # (曾观察到十几条 status=1 在微信重连后全部 flush 到 2)→ 视为已发,不误报失败。
  245 + # 仅 status=None(消息压根没创建,hook 没处理/微信没就绪)才算真失败 → 进审核。
  246 + if last == 1:
  247 + return {"ok": True, "reason": "queued(status=1,已入队将送达)"}
  248 + return {"ok": False, "reason": f"消息未创建(status={last})"}
166 249
167 250
168 def process_event(event, stage, key, self_wxid, cfg): 251 def process_event(event, stage, key, self_wxid, cfg):
@@ -174,11 +257,12 @@ def process_event(event, stage, key, self_wxid, cfg): @@ -174,11 +257,12 @@ def process_event(event, stage, key, self_wxid, cfg):
174 log(f"skip #{event.get('msgId')} {sender_disp}: {reason}") 257 log(f"skip #{event.get('msgId')} {sender_disp}: {reason}")
175 return 258 return
176 if action == "auto": 259 if action == "auto":
177 - res = _send_text(event.get("talker"), draft)  
178 - ok = bool(res.get("ok") or res.get("status") == "sent") 260 + res = _send_text_verified(event.get("talker"), draft, key)
  261 + ok = res["ok"]
179 _queue_insert(event, draft, sender_disp, "auto", 262 _queue_insert(event, draft, sender_disp, "auto",
180 reason, "auto_sent" if ok else "failed") 263 reason, "auto_sent" if ok else "failed")
181 - log(f"auto {'OK' if ok else 'FAIL'} #{event.get('msgId')} {sender_disp} → {draft!r}") 264 + log(f"auto {'OK ' + res['reason'] if ok else 'FAILED ' + res['reason']} "
  265 + f"#{event.get('msgId')} {sender_disp} → {draft!r}")
182 else: # review 266 else: # review
183 _queue_insert(event, draft, sender_disp, "review", reason, "pending") 267 _queue_insert(event, draft, sender_disp, "review", reason, "pending")
184 log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {draft!r}") 268 log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {draft!r}")
@@ -245,7 +329,7 @@ def _drain_inner(): @@ -245,7 +329,7 @@ def _drain_inner():
245 and e.get("kind") in CANDIDATE_KINDS] 329 and e.get("kind") in CANDIDATE_KINDS]
246 if todo: 330 if todo:
247 _, db, key = w.account_paths() 331 _, db, key = w.account_paths()
248 - stage = w.copy_db(db) 332 + stage = w.copy_db(db, subdir="_stage_orch") # 独立暂存,避开与 watcher 的 _stage 竞态
249 try: 333 try:
250 self_wxid, _ = w.detect_self(stage, key) 334 self_wxid, _ = w.detect_self(stage, key)
251 cfg = policy.load_config() 335 cfg = policy.load_config()
@@ -257,7 +341,7 @@ def _drain_inner(): @@ -257,7 +341,7 @@ def _drain_inner():
257 except Exception as ex: # noqa: BLE001 341 except Exception as ex: # noqa: BLE001
258 log(f"处理 #{e.get('msgId')} 失败: {ex}") 342 log(f"处理 #{e.get('msgId')} 失败: {ex}")
259 finally: 343 finally:
260 - for p in __import__("glob").glob(f"{WORK_DIR}/_stage/en.db*"): 344 + for p in __import__("glob").glob(f"{WORK_DIR}/_stage_orch/en.db*"):
261 try: 345 try:
262 os.remove(p) 346 os.remove(p)
263 except OSError: 347 except OSError:
wechat_plain/scripts/auto-reply/wechat_reply_watcher.py
@@ -129,10 +129,12 @@ def sqlcipher_query(db_copy: str, key: str, sql: str): @@ -129,10 +129,12 @@ def sqlcipher_query(db_copy: str, key: str, sql: str):
129 return rows 129 return rows
130 130
131 131
132 -def copy_db(db: str) -> str:  
133 - """拷当前库(含 journal)到 Termux 私有 tmp,返回副本路径。root 直接文件拷,不经 /sdcard。"""  
134 - os.makedirs(f"{WORK_DIR}/_stage", exist_ok=True)  
135 - dst = f"{WORK_DIR}/_stage/en.db" 132 +def copy_db(db: str, subdir: str = "_stage") -> str:
  133 + """拷当前库(含 journal)到独立暂存目录,返回副本路径。root 直接文件拷,不经 /sdcard。
  134 + subdir 隔离并发方(watcher=_stage / orchestrator=_stage_orch),避免共用文件互相删/覆盖
  135 + 导致 'no such table' 竞态。"""
  136 + os.makedirs(f"{WORK_DIR}/{subdir}", exist_ok=True)
  137 + dst = f"{WORK_DIR}/{subdir}/en.db"
136 subprocess.run(["cp", db, dst], check=True, timeout=60) 138 subprocess.run(["cp", db, dst], check=True, timeout=60)
137 for ext in ("-journal", "-wal", "-shm"): 139 for ext in ("-journal", "-wal", "-shm"):
138 if os.path.exists(db + ext): 140 if os.path.exists(db + ext):