wechat_reply_orchestrator.py
15.5 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
#!/usr/bin/env python3
"""环节 1→5 编排器 —— 把整条自动回复链串起来
消费 watcher 的 incoming.jsonl(cursor 增量)→ 对候选事件(私聊/群@我/群引用我):
env2 查上下文(发送者身份 + 最近历史)→ env3 hermes -z 生成草稿 →
env4 policy.decide()(白名单读 wechat-viewer 的 circle.db)→
env5 落地:auto 直接 send_text_via_hook + 入队(auto_sent) / review 入队(pending,在 /approvals 等人工)
作为 root 在 Termux 跑(同 watcher 上下文)。由 watcher 每次读到新消息后触发 --drain。
安全:policy 默认 auto_send_enabled=False → 一切进 review(不自动发)。生成超时/失败 →
无草稿 → policy skip → 不发(失败即静默,绝不误发)。
ponytail: 每次 drain 拷一次整库(~96MB)供该批所有事件查上下文;事件稀疏可接受。
升级:与 watcher 共享一次拷贝;incoming.jsonl 增长需按 offset 游标+轮转。
"""
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 = 160
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):
prompt = (
"【任务】下面是别人发到微信来的一条消息,请以你本人(火星人)的口吻,写一条你会真的发出去的"
"微信回复。只输出这条回复本身,不要解释、不要引号、不要用任何工具、不要真的发送。\n\n"
"【上下文】\n" + context_text)
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([HERMES, "-z", prompt], capture_output=True, text=True,
timeout=GEN_TIMEOUT, env=env)
out = (p.stdout or "").strip()
# 取最后一段非空文本(躲开可能的日志行)
return out.split("\n")[-1].strip() if out else ""
except subprocess.TimeoutExpired:
log("生成超时")
return ""
except Exception as e: # noqa: BLE001
log(f"生成异常: {e}")
return ""
# ── env4→5:落地 ─────────────────────────────────────
def _load_whitelist():
contacts, groups = [], []
try:
con = sqlite3.connect(CIRCLE_DB)
for kind, wxid in con.execute("SELECT kind, wxid FROM reply_whitelist"):
(groups if kind == "group" else contacts).append(wxid)
con.close()
except Exception:
pass
return contacts, groups
def _queue_insert(event, draft, sender_disp, action, reason, status):
con = sqlite3.connect(CIRCLE_DB)
con.execute(
"""INSERT INTO reply_queue
(msg_id, talker, sender, sender_name, kind, incoming, draft, action, reason, status, sent_at)
VALUES (?,?,?,?,?,?,?,?,?,?, CASE WHEN ?='auto_sent' THEN datetime('now','localtime') ELSE NULL END)""",
(event.get("msgId"), event.get("talker"), event.get("sender"), sender_disp,
event.get("kind"), event.get("body"), draft, 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 _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):
ctx, sender_disp = build_context(stage, key, event, self_wxid)
draft = generate(ctx)
d = policy.decide(event, draft, self_wxid=self_wxid, cfg=cfg)
action, reason = d["action"], d["reason"]
if action == "skip":
log(f"skip #{event.get('msgId')} {sender_disp}: {reason}")
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":
res = _send_text_verified(event.get("talker"), send_text, key)
ok = res["ok"]
_queue_insert(event, send_text, sender_disp, "auto",
reason, "auto_sent" if ok else "failed")
log(f"auto {'OK ' + res['reason'] if ok else 'FAILED ' + res['reason']} "
f"#{event.get('msgId')} {sender_disp} → {send_text!r}")
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_contacts"], cfg["whitelist_groups"] = _load_whitelist()
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()