wechat_reply_orchestrator.py
14.1 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
#!/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 = 25 # 轮询确认送达的总超时(秒)
VERIFY_POLL = 3 # 轮询间隔
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 发送队列要一次 onResume
# (后台→前台切换)才真正派发。真机验证:发送后 HOME 切后台再拉回前台,卡住的消息
# 全部 flush 到 status=2。(等同"重启微信才发出去",但轻得多。)
time.sleep(2) # 让消息先入库
try:
subprocess.run(["input", "keyevent", "KEYCODE_HOME"], capture_output=True, timeout=6)
time.sleep(1.5)
subprocess.run(["am", "start", "-n", "com.tencent.mm/.ui.LauncherUI"],
capture_output=True, timeout=8)
time.sleep(2)
except Exception: # noqa: BLE001
pass
deadline = time.time() + VERIFY_TIMEOUT
last = None
while time.time() < deadline:
time.sleep(VERIFY_POLL)
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(微信报失败)"}
# 超时判定:真机验证 status=1 是"已入队发送中",微信连接慢时会延迟但最终送达
# (曾观察到十几条 status=1 在微信重连后全部 flush 到 2)→ 视为已发,不误报失败。
# 仅 status=None(消息压根没创建,hook 没处理/微信没就绪)才算真失败 → 进审核。
if last == 1:
return {"ok": True, "reason": "queued(status=1,已入队将送达)"}
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
if action == "auto":
res = _send_text_verified(event.get("talker"), draft, key)
ok = res["ok"]
_queue_insert(event, draft, 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} → {draft!r}")
else: # review
_queue_insert(event, draft, sender_disp, "review", reason, "pending")
log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {draft!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()