Commit 266b0b95662995256f7930c90b446ca9fe74e928

Authored by yibo
1 parent 4f519e39

fix(wechat-auto-reply): 加固发送 flush — 延迟从分钟级降到 ~15s

用户实测发现:触发 NetSceneQueue 派发的是一次真实 app 切换(切走→切回微信),
且要给足时间。旧版单次切换+被动等 25s 常卡几十秒~分钟。三个经验值:
- 发送后 sleep(4) 让消息稳稳落库(切太快微信还没登记这条,flush 不到)
- _onresume_cycle: HOME 背景化 2.5s → am start LauncherUI 切回停 3s(切换给足时间)
- 切回后停前台连查 DWELL_POLLS 轮,不立刻再切走(否则打断正在进行的网络发送);
  没到 2 再来一轮,总超时 60s

真机实测:屏幕亮/灭(Dozing)都 ~15s flush 到 status=2。memory
wechat-api-send-onresume-flush 已补加固经验。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wechat_plain/scripts/auto-reply/README.md
... ... @@ -48,8 +48,9 @@
48 48 - **失败即静默**:生成超时/失败 → 无草稿 → policy skip → 不发。最坏情况是不回,绝不误发。
49 49 - **API 发送的两个坑(2026-07-11 硬仗定位,详见 memory `wechat-api-send-onresume-flush`)**:
50 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 视为"已入队将送达",不误报失败。
  51 + 2. `z2.b` 只入队(status=1 转圈),**WeChat NetSceneQueue 要一次真实的 app 切换(切走→切回微信 = onPause→onResume)才派发**。**这是设备机制不是代码 bug**,别去 diff/重编译 hook。
  52 + 3. **flush 加固(2026-07-12,`_send_text_verified`)**:发送后 `time.sleep(4)` 让消息稳稳落库(太快切换微信还没登记这条)→ `_onresume_cycle()`(`KEYCODE_HOME` 背景化 2.5s → `am start LauncherUI` 切回停 3s)→ **切回后停在前台多轮查 status,不立刻再切走**(否则打断正在进行的网络发送)→ 没到 2 再来一轮。真机实测:屏幕亮/灭(Dozing)都 **~15s flush 到 status=2**(旧版单次切换+被动等常卡几十秒~分钟)。
  53 + 4. 送达校验:轮询 `message.status`(1=发送中/2=已达/5=失败);超时仍 1 视为"已入队将送达",不误报失败。
53 54  
54 55 ## 运维
55 56  
... ...
wechat_plain/scripts/auto-reply/wechat_reply_orchestrator.py
... ... @@ -159,8 +159,23 @@ def _queue_insert(event, draft, sender_disp, action, reason, status):
159 159 con.close()
160 160  
161 161  
162   -VERIFY_TIMEOUT = 25 # 轮询确认送达的总超时(秒)
163   -VERIFY_POLL = 3 # 轮询间隔
  162 +VERIFY_TIMEOUT = 60 # 送达校验总超时(秒)
  163 +VERIFY_POLL = 3 # 前台停留期每次查 status 的间隔
  164 +DWELL_POLLS = 4 # 一次 onResume 后停留前台查几次(给网络发送时间,不立刻再背景化打断)
  165 +
  166 +
  167 +def _onresume_cycle():
  168 + """一次真实的 onPause→onResume:HOME 背景化 → 停顿 → 拉回微信前台。
  169 + 触发 WeChat NetSceneQueue flush 卡在 status=1 的发送。切换要给足时间——太快
  170 + (背景/前台 <2s)微信来不及走完 onPause/onResume+网络 tick,flush 不了。"""
  171 + try:
  172 + subprocess.run(["input", "keyevent", "KEYCODE_HOME"], capture_output=True, timeout=6)
  173 + time.sleep(2.5) # 让 onPause 走完、微信真的进后台
  174 + subprocess.run(["am", "start", "-n", "com.tencent.mm/.ui.LauncherUI"],
  175 + capture_output=True, timeout=8)
  176 + time.sleep(3) # onResume 后停一下,让网络 scene 开始派发
  177 + except Exception: # noqa: BLE001
  178 + pass
164 179  
165 180  
166 181 def _cleanup_verify():
... ... @@ -219,32 +234,28 @@ def _send_text_verified(to_wxid, text, key):
219 234 res = send_text_via_hook(to_wxid, text)
220 235 if res.get("status") != "sent":
221 236 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
  237 + # 关键机制:z2.b 只把消息入队(status=1 转圈),WeChat 发送队列要一次真实的 app 切换
  238 + # (切走→切回微信 = onPause→onResume)才真正派发。真机确认:发送后等几秒 → 切到别的 app
  239 + # → 切回微信,卡住的消息就 flush 到 status=2。这里:先等消息落库,再做完整切换 cycle,
  240 + # 切回后停在前台多轮查(不立刻再切走,否则会打断正在进行的网络发送)。
  241 + time.sleep(4) # 让消息先稳稳落库(太快切换时微信还没登记这条,flush 不到它)
235 242 last = None
  243 + deadline = time.time() + VERIFY_TIMEOUT
236 244 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 没处理/微信没就绪)才算真失败 → 进审核。
  245 + _onresume_cycle() # 一次真实切走→切回
  246 + for _ in range(DWELL_POLLS): # 切回后停留前台查几次,给网络发送时间
  247 + last = _sent_status(db, key, to_wxid, base, text)
  248 + if last == 2:
  249 + return {"ok": True, "reason": "verified(status=2)"}
  250 + if last == 5:
  251 + return {"ok": False, "reason": "status=5(微信报失败)"}
  252 + if time.time() >= deadline:
  253 + break
  254 + time.sleep(VERIFY_POLL)
  255 + # 超时判定:多轮切换仍卡 status=1 = 微信连接慢/离线,最终多半会 flush(网络恢复时批量
  256 + # 派发)→ 视为已入队不误报失败。仅 status=None(消息没创建,hook 没处理)才算真失败。
246 257 if last == 1:
247   - return {"ok": True, "reason": "queued(status=1,已入队将送达)"}
  258 + return {"ok": True, "reason": "queued(status=1,多轮切换仍未flush,网络恢复后送达)"}
248 259 return {"ok": False, "reason": f"消息未创建(status={last})"}
249 260  
250 261  
... ...