wechat_reply_orchestrator.py
28.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
#!/usr/bin/env python3
"""环节 1→5 编排器 —— 把整条自动回复链串起来
消费 watcher 的 incoming.jsonl(cursor 增量)→ 对候选事件(私聊/群@我/群引用我):
env2 查上下文(发送者身份 + 最近历史)→ env3 hermes 生成草稿 →
env4 policy.decide()(白名单读 wechat-viewer 的 circle.db)→
env5 落地:auto 直接 send_text_via_hook + 入队(auto_sent) / review 入队(pending)
策略权威参考:wechat-auto-reply skill (v3.0)。代码是参考实现,skill 定义逻辑。
"""
import json
import os
import sqlite3
import subprocess
import sys
import time
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
import wechat_reply_watcher as w # 复用解密/查询/账号推导
import wechat_reply_policy as policy
WORK_DIR = w.WORK_DIR
QUEUE_FILE = f"{WORK_DIR}/incoming.jsonl"
CURSOR_FILE = f"{WORK_DIR}/orch_state.json"
ORCH_LOG = f"{WORK_DIR}/orchestrator.log"
CIRCLE_DB = "/data/data/com.termux/files/home/wechat-viewer/circle.db"
EVENT_BUS = "/data/data/com.termux/files/home/.hermes/event_bus"
HERMES = "/data/data/com.termux/files/usr/bin/hermes"
CANDIDATE_KINDS = {"private", "group_at_me", "group_quote_me"}
HISTORY_N = 12
GEN_TIMEOUT = 600 # admin 任务超时(10分钟),GFW 下 GitHub 访问慢
PROCESSED_FILE = f"{WORK_DIR}/processed_ids.json"
# 去重:已处理的 msgId 集合。drain 时加载,处理后持久化
_processed_ids = None
def _load_processed():
global _processed_ids
if _processed_ids is not None:
return _processed_ids
try:
with open(PROCESSED_FILE) as f:
_processed_ids = set(json.load(f))
except (OSError, ValueError):
_processed_ids = set()
return _processed_ids
def _save_processed():
try:
with open(PROCESSED_FILE, "w") as f:
json.dump(list(_processed_ids or set()), f)
except OSError:
pass
def log(msg):
line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}"
print(line, flush=True)
try:
with open(ORCH_LOG, "a") as f:
f.write(line + "\n")
except OSError:
pass
# ── env2:查上下文 ─────────────────────────────────────
def _display_name(stage, key, wxid):
if not wxid:
return ""
rows = w.sqlcipher_query(
stage, key,
f"SELECT conRemark, nickname, alias FROM rcontact WHERE username='{wxid}';")
if rows and rows[0]:
r = rows[0] + ["", "", ""]
return r[0] or r[1] or r[2] or wxid
return wxid
def _placeholder(mtype, text):
if mtype == 1:
return text
return {3: "[图片]", 34: "[语音]", 43: "[视频]", 47: "[表情]",
42: "[名片]", 49: "[链接/文件]", 48: "[位置]"}.get(mtype, text[:40] if text else "[消息]")
def build_context(stage, key, event, self_wxid):
talker = event.get("talker", "")
is_group = event.get("isGroup") or talker.endswith("@chatroom")
sender_wxid = event.get("sender") or talker
sender_disp = _display_name(stage, key, sender_wxid)
lines = [f"对方:{sender_disp}"]
if is_group:
gname = _display_name(stage, key, talker)
lines.insert(0, f"[群] {gname}")
# 最近历史
rows = w.sqlcipher_query(
stage, key,
"SELECT isSend, type, createTime, hex(content) FROM message "
f"WHERE talker='{talker}' ORDER BY msgId DESC LIMIT {HISTORY_N};")
hist = []
for r in reversed(rows):
if len(r) < 4:
continue
is_send, mtype, _, chex = r
try:
raw = bytes.fromhex(chex).decode("utf-8", "ignore")
except ValueError:
raw = ""
if is_send == "1":
who, body = "我", raw
else:
s, body = w.parse_group_content(raw) if is_group else (sender_wxid, raw)
who = _display_name(stage, key, s) if is_group and s else sender_disp
txt = _placeholder(int(mtype) if mtype.isdigit() else 0, body).replace("\n", " ").strip()
if txt:
hist.append(f" {who}: {txt}")
ctx = "\n".join(lines)
if hist:
ctx += "\n最近对话:\n" + "\n".join(hist[-HISTORY_N:])
ctx += f"\n本条:{sender_disp}: {(event.get('body') or '').strip()[:200]}"
return ctx, sender_disp
# ── env3:hermes 生成 ─────────────────────────────────
def generate(context_text, event=None, role="member"):
"""调用 Hermes 生成回复。
role='admin' → hermes chat(完整 CLI,可调工具/skill/API,执行复杂任务)
role='member' → hermes -z(零工具纯文本补全,简单闲聊回复)
"""
talker = (event or {}).get("talker", "")
body = (event or {}).get("body", "") or ""
is_group = talker.endswith("@chatroom")
if role == "admin":
is_task = _is_task_request(body)
result_file = f"{WORK_DIR}/_reply_result_{int(time.time())}.txt"
if is_task:
prompt = (
f"你是火星人的微信助手。用 reply-task-executor skill 执行任务。\n\n"
f"任务:{body}\n\n"
f"上下文:\n{context_text}\n\n"
f"重要:先执行任务(调工具、写文件、调API),不要先写报告。\n"
f"任务完成后,把结果报告写入 {result_file}。"
)
else:
prompt = (
f"你是火星人的微信助手。有人发了一条消息,分析内容,以火星人口吻回复。\n\n"
f"【消息内容】{body}\n\n"
f"【上下文】\n{context_text}\n\n"
f"把你要发送的回复文本写入文件 {result_file}。只写回复本身,不要解释。"
)
cmd = [HERMES, "chat", "-q", prompt, "-s", "reply-task-executor", "--yolo", "-t", "terminal,file,web"]
else:
# 简单模式:零工具,纯粹文本补全
prompt = (
"【任务】下面是别人发到微信来的一条消息,请以你本人(火星人)的口吻,写一条你会真的发出去的"
"微信回复。只输出这条回复本身,不要解释、不要引号、不要用任何工具、不要真的发送。\n\n"
"【上下文】\n" + context_text)
cmd = [HERMES, "-z", prompt]
env = dict(os.environ)
env.update({
"HOME": "/data/data/com.termux/files/home",
"PREFIX": "/data/data/com.termux/files/usr",
"PATH": "/data/data/com.termux/files/usr/bin:/system/bin",
"LD_LIBRARY_PATH": "/data/data/com.termux/files/usr/lib",
})
try:
p = subprocess.run(cmd, capture_output=True, text=True,
timeout=GEN_TIMEOUT, env=env)
# admin 模式从文件读结果,不用 stdout
if role == "admin" and result_file:
try:
with open(result_file) as f:
draft = f.read().strip()
try: os.remove(result_file)
except: pass
except OSError:
draft = ""
else:
# member 模式:从 stdout 取最后一行
out = (p.stdout or "").strip()
draft = out.split("\n")[-1].strip() if out else ""
stderr = (p.stderr or "")[:200]
return draft, p.returncode, stderr, result_file if role == "admin" else ""
except subprocess.TimeoutExpired:
log("生成超时")
return "", -1, "timeout", result_file if role == "admin" else ""
except Exception as e:
log(f"生成异常: {e}")
return "", -1, str(e)[:200], result_file if role == "admin" else ""
# ── V 层:独立验证 ──────────────────────────────────
def _verify_draft(draft, role="member"):
"""独立验证回复质量。不能由 agent 自己宣布完成。
admin 生成的回复需要更严格检查(因为可能调了错误工具/产生幻觉)。
member 的回复比较简单,主要防胡话。
返回 (ok: bool, reason: str)
"""
if not draft or not draft.strip():
return False, "empty_draft"
text = draft.strip()
# 噪音行——hermes 输出里的元数据/分隔线
if all(c in "─═━═─ \t" for c in text):
return False, "noise_line"
for noise_start in ["Messages:", "Session:", "Duration:", "Tool:",
"Exit code:", "📱", "Model:", "Provider:",
"Tokens:", "Cost:", "$", "hermes ",
"Resume this", "Session saved"]:
if text.startswith(noise_start):
return False, f"noise:{noise_start}"
# 错误标记——hermes 调用失败时可能输出的内容
for marker in ["[Hermes", "[调用失败", "[超时", "[错误", "[Error",
"抱歉,我无法", "I cannot", "I'm unable"]:
if marker in text:
return False, f"error_marker:{marker}"
# 过短——不太可能是有效回复(admin 尤甚,它应该产出实质性内容)
if role == "admin" and len(text) < 5:
return False, "admin_too_short"
if len(text) < 2:
return False, "too_short"
# admin 回复不应该只是 emoji/单字(说明 hermes 没认真执行)
if role == "admin" and len(text) < 10 and not any('\u4e00' <= c <= '\u9fff' for c in text):
return False, "admin_no_chinese"
return True, "ok"
# ── L 层:Task Contract ──────────────────────────────
def _write_contract(event, role, exit_code, output, err):
"""admin 的 hermes chat 失败时写 task contract——包含复现所需全部信息。"""
import __main__
contract_dir = os.path.join(WORK_DIR, "_q")
os.makedirs(contract_dir, exist_ok=True)
contract = {
"ts": time.time(),
"msgId": event.get("msgId"),
"talker": event.get("talker"),
"sender": event.get("sender"),
"body": (event.get("body") or "")[:300],
"role": role,
"exit_code": exit_code,
"output": (output or "")[:500],
"stderr": (err or "")[:200],
}
fname = os.path.join(contract_dir, f"ct_{int(time.time())}_{event.get('msgId')}.json")
try:
with open(fname, "w") as f:
json.dump(contract, f, ensure_ascii=False, indent=2)
w.chown_to_hermes(fname)
log(f"contract written: {fname}")
except Exception as e: # noqa: BLE001
log(f"contract write failed: {e}")
def _load_whitelist():
"""返回 {wxid: role} 映射。role: 'admin' | 'member'。"""
roles = {}
try:
con = sqlite3.connect(CIRCLE_DB)
for wxid, role in con.execute("SELECT wxid, COALESCE(role,'member') FROM reply_whitelist"):
roles[wxid] = role or "member"
con.close()
except Exception:
pass
return roles
def _queue_insert(event, draft, sender_disp, action, reason, status, result_file="", override_talker=""):
talker = override_talker or event.get("talker")
con = sqlite3.connect(CIRCLE_DB)
con.execute(
"""INSERT INTO reply_queue
(msg_id, talker, sender, sender_name, kind, incoming, draft, result_file, action, reason, status, sent_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?, CASE WHEN ?='auto_sent' THEN datetime('now','localtime') ELSE NULL END)""",
(event.get("msgId"), talker, event.get("sender"), sender_disp,
event.get("kind"), event.get("body"), draft, result_file, action, reason, status, status))
con.commit()
con.close()
VERIFY_TIMEOUT = 60 # 送达校验总超时(秒)
VERIFY_POLL = 3 # 前台停留期每次查 status 的间隔
DWELL_POLLS = 4 # 一次 onResume 后停留前台查几次(给网络发送时间,不立刻再背景化打断)
def _onresume_cycle():
"""一次真实的 onPause→onResume:HOME 背景化 → 停顿 → 拉回微信前台。
触发 WeChat NetSceneQueue flush 卡在 status=1 的发送。切换要给足时间——太快
(背景/前台 <2s)微信来不及走完 onPause/onResume+网络 tick,flush 不了。"""
try:
subprocess.run(["input", "keyevent", "KEYCODE_HOME"], capture_output=True, timeout=6)
time.sleep(2.5) # 让 onPause 走完、微信真的进后台
subprocess.run(["am", "start", "-n", "com.tencent.mm/.ui.LauncherUI"],
capture_output=True, timeout=8)
time.sleep(3) # onResume 后停一下,让网络 scene 开始派发
except Exception: # noqa: BLE001
pass
def _cleanup_verify():
for p in __import__("glob").glob(f"{WORK_DIR}/_stage_verify/en.db*"):
try:
os.remove(p)
except OSError:
pass
def _sent_status(db, key, talker, base_msgid, text):
"""查我方(isSend=1)在 base 之后、内容==text 的那条消息的 status。
微信 status: 1=发送中(转圈) / 2=已发送 / 5=失败。查不到返回 None。"""
st = w.copy_db(db, "_stage_verify")
try:
rows = w.sqlcipher_query(
st, key,
f"SELECT status, hex(content) FROM message WHERE talker='{talker}' "
f"AND isSend=1 AND msgId>{base_msgid} ORDER BY msgId DESC LIMIT 8;")
finally:
_cleanup_verify()
for r in rows:
if len(r) < 2:
continue
try:
body = bytes.fromhex(r[1]).decode("utf-8", "ignore")
except ValueError:
body = ""
if body == text:
return int(r[0]) if str(r[0]).lstrip("-").isdigit() else None
return None
def _run_hermes_z(prompt):
"""快速 hermes -z 调用(零工具,纯文本补全),用于生成失败报告"""
cmd = [HERMES, "-z", prompt]
env = dict(os.environ)
env.update({
"HOME": "/data/data/com.termux/files/home",
"PREFIX": "/data/data/com.termux/files/usr",
"PATH": "/data/data/com.termux/files/usr/bin:/system/bin",
"LD_LIBRARY_PATH": "/data/data/com.termux/files/usr/lib",
})
try:
p = subprocess.run(cmd, capture_output=True, text=True,
timeout=60, env=env)
return (p.stdout or "").strip(), p.returncode, (p.stderr or "")[:200]
except subprocess.TimeoutExpired:
return "", -1, "timeout"
except Exception as e:
return "", -1, str(e)[:200]
# ── 两段式回复:任务识别 + ack 生成 ──────────────
TASK_KEYWORDS = ["研究", "学习", "查一下", "分析", "帮我", "看看",
"安装", "配置", "搭建", "写一个", "改一下",
"怎么", "什么是", "解释", "总结", "调查",
"research", "install", "setup", "analyze"]
def _is_task_request(body):
"""判断是否是需要后台执行的复杂任务(vs 简单闲聊)"""
text = body.lower()
score = sum(1 for kw in TASK_KEYWORDS if kw in text)
return score >= 1
def _gen_ack(ctx, body, sender_disp):
"""生成确认收到消息,根据任务内容个性化"""
if "研究" in body or "学习" in body or "看看" in body:
return "收到,我来研究,搞定了给你报告"
if "安装" in body or "配置" in body or "搭建" in body:
return "好的,我来处理,完了给你结果"
if "查" in body or "分析" in body:
return "收到,我查一下,稍等给你报告"
if "写" in body or "改" in body or "帮" in body:
return "好的,我来弄,弄好给你报告"
return "收到,我来处理,有结果跟你说"
def _is_complete_report(draft):
"""检查 admin 任务报告是否完整(不只是简短回复)"""
if not draft:
return False
text = draft.strip()
# 太短 → 不完整
if len(text) < 80:
return False
# 只有一句话没有结构 → 不完整
lines = [l for l in text.split("\n") if l.strip()]
if len(lines) < 2:
return False
# 至少含一项具体信息(数字/URL/skill名/文件路径/工具名)
has_specific = any(
kw in text
for kw in ["http", "github.com", "skill", "memory", "创建",
"安装", "pip", "npm", "实现了", "测试", "验证",
"Star", "star", "⭐"]
)
return has_specific
def _send_text_verified(to_wxid, text, key):
"""可靠发送:切前台唤醒微信 → 广播发送 → 轮询 status 直到 =2(确认送达)。
超时仍卡在发送中/失败 → 返回 ok=False,由调用方标 failed 进审核(不假装成功)。
ponytail: 校验靠反复拷库(~96MB/次)读 status;发送稀疏可接受,首轮多半即达。
上限=拷库 I/O;升级=让 hook 直接回报送达 / 常驻前台。"""
_, db, _ = w.account_paths()
st = w.copy_db(db, "_stage_verify")
try:
r = w.sqlcipher_query(
st, key, f"SELECT COALESCE(MAX(msgId),0) FROM message WHERE talker='{to_wxid}';")
base = int(r[0][0]) if r and r[0] else 0
finally:
_cleanup_verify()
# 微信在后台被 Android 冻结→NetScene 队列不提交,消息卡"发送中"。先切前台唤醒。
try:
subprocess.run(["am", "start", "-n", "com.tencent.mm/.ui.LauncherUI"],
capture_output=True, timeout=8)
time.sleep(3)
except Exception: # noqa: BLE001
pass
sys.path.insert(0, EVENT_BUS)
from wechat_hook_send import send_text_via_hook
res = send_text_via_hook(to_wxid, text)
if res.get("status") != "sent":
return {"ok": False, "reason": "broadcast失败:" + str(res.get("error", ""))[:40]}
# 关键机制:z2.b 只把消息入队(status=1 转圈),WeChat 发送队列要一次真实的 app 切换
# (切走→切回微信 = onPause→onResume)才真正派发。真机确认:发送后等几秒 → 切到别的 app
# → 切回微信,卡住的消息就 flush 到 status=2。这里:先等消息落库,再做完整切换 cycle,
# 切回后停在前台多轮查(不立刻再切走,否则会打断正在进行的网络发送)。
time.sleep(4) # 让消息先稳稳落库(太快切换时微信还没登记这条,flush 不到它)
last = None
deadline = time.time() + VERIFY_TIMEOUT
while time.time() < deadline:
_onresume_cycle() # 一次真实切走→切回
for _ in range(DWELL_POLLS): # 切回后停留前台查几次,给网络发送时间
last = _sent_status(db, key, to_wxid, base, text)
if last == 2:
return {"ok": True, "reason": "verified(status=2)"}
if last == 5:
return {"ok": False, "reason": "status=5(微信报失败)"}
if time.time() >= deadline:
break
time.sleep(VERIFY_POLL)
# 超时判定:多轮切换仍卡 status=1 = 微信连接慢/离线,最终多半会 flush(网络恢复时批量
# 派发)→ 视为已入队不误报失败。仅 status=None(消息没创建,hook 没处理)才算真失败。
if last == 1:
return {"ok": True, "reason": "queued(status=1,多轮切换仍未flush,网络恢复后送达)"}
return {"ok": False, "reason": f"消息未创建(status={last})"}
def process_event(event, stage, key, self_wxid, cfg):
# ═══ Step 0: 去重 ═══
msg_id = event.get("msgId")
processed = _load_processed()
if msg_id in processed:
return # 已处理过,跳过
ctx, sender_disp = build_context(stage, key, event, self_wxid)
# 查 sender 的 role:admin → 完整 Hermes CLI,member → 简单 hermes -z
sender_wxid = event.get("sender") or event.get("talker") or ""
role = cfg.get("whitelist_roles", {}).get(sender_wxid, "member")
body = (event.get("body") or "").strip()
talker = event.get("talker", "")
# ═══ 两段式回复:admin 复杂任务先回 ack ═══
if role == "admin" and _is_task_request(body):
ack = _gen_ack(ctx, body, sender_disp)
ack_text = f"@{sender_disp} {ack}" if event.get("isGroup") and sender_disp else ack
_send_text_verified(talker, ack_text, key)
_queue_insert(event, ack_text, sender_disp, "auto", "ack", "auto_sent")
log(f"ack #{event.get('msgId')} {sender_disp}: {ack!r}")
# ── 生成回复(V 层:独立验证 + 回退)──
draft, exit_code, stderr, result_file = generate(ctx, event=event, role=role)
ok, vreason = _verify_draft(draft, role)
# ═══ 报告完整性检查(admin 任务必须产出完整报告) ═══
if ok and role == "admin" and _is_task_request(body):
if not _is_complete_report(draft):
ok = False
vreason = f"incomplete_report(len={len(draft)})"
if not ok:
log(f"V-verify FAIL #{event.get('msgId')} role={role}: {vreason} draft={draft!r}")
_write_contract(event, role, exit_code, draft, stderr)
if role == "admin":
is_task = _is_task_request(body)
if is_task:
# admin 任务失败 → 调 reply-failure-report skill 生成报告,写入队列
fail_prompt = (
f"任务内容:{body}\\n"
f"失败原因:{vreason}\\n"
f"对方:{sender_disp}\\n\\n"
f"按 reply-failure-report skill 生成一条要发给对方的失败报告。只输出消息本身。"
)
fail_draft, _, _ = _run_hermes_z(fail_prompt)
fail_report = (fail_draft or "").strip()
if not fail_report or len(fail_report) < 5:
fail_report = f"这个任务没跑成({vreason}),我加到 to-do list 了。"
fail_text = f"@{sender_disp} {fail_report}" if event.get("isGroup") and sender_disp else fail_report
# 写入队列,cron 会发送
_queue_insert(event, fail_text, sender_disp, "auto",
f"task_failed:{vreason}", "failed")
log(f"task failed queued #{event.get('msgId')}: {vreason}")
if msg_id:
loaded = _load_processed()
loaded.add(msg_id)
_save_processed()
return
# admin 闲聊失败 → 回退到 member 模式
log(f"admin fallback → member mode for #{event.get('msgId')}")
draft, exit_code, stderr, _ = generate(ctx, event=event, role="member")
ok, vreason = _verify_draft(draft, "member")
if not ok:
log(f"V-verify FAIL (member fallback) #{event.get('msgId')}: {vreason}")
_queue_insert(event, draft or "(验证失败)", sender_disp, "auto",
f"verify_failed:{vreason}", "failed")
return
else:
_queue_insert(event, draft or "(验证失败)", sender_disp, "auto",
f"verify_failed:{vreason}", "failed")
return
d = policy.decide(event, draft, self_wxid=self_wxid, cfg=cfg, sender_role=role)
action, reason = d["action"], d["reason"]
if action == "skip":
log(f"skip #{event.get('msgId')} {sender_disp}: {reason}")
# ═══ 去重持久化(skip 也记录) ═══
if msg_id:
loaded = _load_processed()
loaded.add(msg_id)
_save_processed()
return
# 群里回复自动 @ 触发者:昵称前缀 + U+2005(微信原生 @ 分隔符)。安全判定(decide)已在
# 原始 draft 上跑过,前缀只影响发送/展示的文本。
# ponytail: 只是文字 @(看得见),不带 atuserlist → 未必真 ping 到对方红点;真通知要改
# SEND_MSG_INTERNAL hook 加 at 列表(见 README 环节5 待收尾)。
send_text = draft
if event.get("isGroup") and sender_disp:
send_text = f"@{sender_disp} {draft}"
if action == "auto":
is_task = role == "admin" and _is_task_request(body)
if is_task:
# admin 任务结果 → 私聊发 sender,直接发送
task_talker = sender_wxid if event.get("isGroup") else talker
res = _send_text_verified(task_talker, send_text, key)
ok_send = res["ok"]
_queue_insert(event, send_text, sender_disp, "auto",
reason, "auto_sent" if ok_send else "failed",
result_file=result_file, override_talker=task_talker)
log(f"task result sent #{event.get('msgId')} → {task_talker}: {'OK' if ok_send else 'FAILED'}")
else:
# 闲聊 / member → 直接发送
res = _send_text_verified(event.get("talker"), send_text, key)
ok_send = res["ok"]
_queue_insert(event, send_text, sender_disp, "auto",
reason, "auto_sent" if ok_send else "failed")
log(f"auto {'OK ' + res['reason'] if ok_send else 'FAILED ' + res['reason']} "
f"#{event.get('msgId')} {sender_disp} → {send_text!r}")
if msg_id:
loaded = _load_processed()
loaded.add(msg_id)
_save_processed()
else: # review
_queue_insert(event, send_text, sender_disp, "review", reason, "pending")
log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {send_text!r}")
# ── drain:消费 incoming.jsonl ─────────────────────────
def _read_events():
evs = []
try:
with open(QUEUE_FILE) as f:
for line in f:
line = line.strip()
if line:
try:
evs.append(json.loads(line))
except ValueError:
pass
except OSError:
pass
return evs
def drain():
"""非阻塞单实例:已有 drain 在跑就跳过(后续消息的 inotify 会再触发)。"""
lock = f"{WORK_DIR}/orch.lock"
try:
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
except FileExistsError:
try: # 5min 以上视为残留锁,抢占
if time.time() - os.path.getmtime(lock) > 300:
os.remove(lock)
os.close(os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
else:
return
except OSError:
return
try:
_drain_inner()
finally:
try:
os.remove(lock)
except OSError:
pass
def _drain_inner():
evs = _read_events()
if not evs:
return
try:
with open(CURSOR_FILE) as f:
cursor = json.load(f).get("last_msgId", 0)
except (OSError, ValueError):
cursor = None
max_id = max((int(e.get("msgId", 0)) for e in evs), default=0)
if cursor is None: # 首跑:基线,不回放
with open(CURSOR_FILE, "w") as f:
json.dump({"last_msgId": max_id}, f)
w.chown_to_hermes(CURSOR_FILE)
log(f"编排器首跑基线 last_msgId={max_id}")
return
todo = [e for e in evs if int(e.get("msgId", 0)) > cursor
and e.get("kind") in CANDIDATE_KINDS]
if todo:
_, db, key = w.account_paths()
stage = w.copy_db(db, subdir="_stage_orch") # 独立暂存,避开与 watcher 的 _stage 竞态
try:
self_wxid, _ = w.detect_self(stage, key)
cfg = policy.load_config()
cfg["whitelist_roles"] = _load_whitelist()
# backward compat: policy.decide still checks these lists for whitelist membership
cfg["whitelist_contacts"] = [w for w, r in cfg["whitelist_roles"].items() if not w.endswith("@chatroom")]
cfg["whitelist_groups"] = [w for w, r in cfg["whitelist_roles"].items() if w.endswith("@chatroom")]
log(f"drain:{len(todo)} 条候选(auto_send={cfg['auto_send_enabled']})")
for e in todo:
try:
process_event(e, stage, key, self_wxid, cfg)
except Exception as ex: # noqa: BLE001
log(f"处理 #{e.get('msgId')} 失败: {ex}")
finally:
for p in __import__("glob").glob(f"{WORK_DIR}/_stage_orch/en.db*"):
try:
os.remove(p)
except OSError:
pass
with open(CURSOR_FILE, "w") as f:
json.dump({"last_msgId": max_id}, f)
w.chown_to_hermes(CURSOR_FILE)
def main():
args = sys.argv[1:]
if "--drain" in args:
drain()
else:
print("用法: wechat_reply_orchestrator.py --drain")
if __name__ == "__main__":
main()