You need to sign in before continuing.
Commit c3eb27d34c6f1d5b3c5be355c3c18a82f9469e8d
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>
Showing
4 changed files
with
110 additions
and
12 deletions
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): |