wechatbridge-cli 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wechatbridge/__init__.py +2 -0
- wechatbridge/__main__.py +4 -0
- wechatbridge/agy.py +630 -0
- wechatbridge/config.py +266 -0
- wechatbridge/grok.py +682 -0
- wechatbridge/ilink.py +849 -0
- wechatbridge/main.py +755 -0
- wechatbridge/runner_common.py +1003 -0
- wechatbridge/update_check.py +175 -0
- wechatbridge_cli-1.3.0.dist-info/METADATA +29 -0
- wechatbridge_cli-1.3.0.dist-info/RECORD +15 -0
- wechatbridge_cli-1.3.0.dist-info/WHEEL +5 -0
- wechatbridge_cli-1.3.0.dist-info/entry_points.txt +2 -0
- wechatbridge_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- wechatbridge_cli-1.3.0.dist-info/top_level.txt +1 -0
wechatbridge/main.py
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wechatbridge Main Entry Point.
|
|
3
|
+
Active iLink client that receives WeChat messages and responds via CLI backends.
|
|
4
|
+
Architecture: WeChat ClawBot(iLink) <-> wechatbridge(Python) <-> agy/grok CLI
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from io import StringIO
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .config import config, ensure_runtime_dirs
|
|
19
|
+
from .ilink import ILinkClient
|
|
20
|
+
from .runner_common import (
|
|
21
|
+
clean_session_media,
|
|
22
|
+
format_error,
|
|
23
|
+
format_model_label,
|
|
24
|
+
get_session_dir,
|
|
25
|
+
is_dangerous,
|
|
26
|
+
load_prefs,
|
|
27
|
+
path_is_under,
|
|
28
|
+
save_prefs,
|
|
29
|
+
switch_backend_prefs,
|
|
30
|
+
)
|
|
31
|
+
from .update_check import update_check_loop, maybe_notify_admin, format_update_hint
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Logging
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
logging.basicConfig(
|
|
37
|
+
level=getattr(logging, config.log_level.upper(), logging.INFO),
|
|
38
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
39
|
+
)
|
|
40
|
+
logger = logging.getLogger("wechatbridge")
|
|
41
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
42
|
+
|
|
43
|
+
# Pending dangerous prompt confirmations (user_id -> {prompt, expire_at, context_token})
|
|
44
|
+
pending_confirms: dict = {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Backend dispatcher — routes to agy or grok based on per-user preference
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _get_backend(user_id: str) -> str:
|
|
52
|
+
"""Get the active backend for a user (from prefs, fallback to global config)."""
|
|
53
|
+
prefs = load_prefs(user_id)
|
|
54
|
+
return prefs.get("backend", config.backend)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _run_llm(prompt: str, user_id: str) -> tuple[str, list]:
|
|
58
|
+
"""Dispatch prompt to the active backend's run function."""
|
|
59
|
+
backend = _get_backend(user_id)
|
|
60
|
+
if backend == "grok":
|
|
61
|
+
from .grok import run_grok
|
|
62
|
+
return await run_grok(prompt, user_id)
|
|
63
|
+
else:
|
|
64
|
+
from .agy import run_agy
|
|
65
|
+
return await run_agy(prompt, user_id)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _handle_slash(text: str, user_id: str) -> str | None:
|
|
69
|
+
"""Dispatch slash command to the active backend's handler.
|
|
70
|
+
|
|
71
|
+
/backend is a meta-command handled here, not delegated to backends.
|
|
72
|
+
"""
|
|
73
|
+
parts = text.strip().split(maxsplit=1)
|
|
74
|
+
cmd = parts[0].lower() if parts else ""
|
|
75
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
76
|
+
|
|
77
|
+
# /backend is a meta-command — switch CLI backend per user
|
|
78
|
+
if cmd == "/backend":
|
|
79
|
+
return _cmd_backend(args, user_id)
|
|
80
|
+
|
|
81
|
+
if cmd == "/version":
|
|
82
|
+
return (
|
|
83
|
+
f"📦 **版本信息** 📦\n\n当前版本: `{__version__}`\n"
|
|
84
|
+
f"实例: `{config.instance}` 后端: `{_get_backend(user_id)}`"
|
|
85
|
+
) + format_update_hint()
|
|
86
|
+
|
|
87
|
+
backend = _get_backend(user_id)
|
|
88
|
+
if backend == "grok":
|
|
89
|
+
from .grok import handle_grok_slash_command
|
|
90
|
+
return await handle_grok_slash_command(text, user_id)
|
|
91
|
+
else:
|
|
92
|
+
from .agy import handle_slash_command
|
|
93
|
+
return await handle_slash_command(text, user_id)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cmd_backend(args: str, user_id: str) -> str:
|
|
97
|
+
"""Handle /backend <agy|grok> — switch CLI backend per user.
|
|
98
|
+
|
|
99
|
+
Switching restores that backend's remembered model/effort/mode (or empty
|
|
100
|
+
project default on first visit), and resets the conversation so the new
|
|
101
|
+
backend starts a fresh session.
|
|
102
|
+
"""
|
|
103
|
+
name = args.strip().lower()
|
|
104
|
+
if not name:
|
|
105
|
+
prefs = load_prefs(user_id)
|
|
106
|
+
current = prefs.get("backend", config.backend)
|
|
107
|
+
model_label = format_model_label(prefs.get("model", ""))
|
|
108
|
+
return (
|
|
109
|
+
f"📋 **当前后端** 📋\n\n`{current}`\n"
|
|
110
|
+
f"模型: `{model_label}`\n\n"
|
|
111
|
+
"用法: `/backend agy` 或 `/backend grok`"
|
|
112
|
+
)
|
|
113
|
+
if name not in ("agy", "grok"):
|
|
114
|
+
return "❌ **未知后端** ❌\n\n支持: `agy` / `grok`\n\n`/backend agy` 或 `/backend grok`"
|
|
115
|
+
prefs = load_prefs(user_id)
|
|
116
|
+
old, new = switch_backend_prefs(prefs, name)
|
|
117
|
+
save_prefs(user_id, prefs)
|
|
118
|
+
model_label = format_model_label(prefs.get("model", ""))
|
|
119
|
+
# Reset session so new backend starts fresh (only when actually changed)
|
|
120
|
+
if old != new:
|
|
121
|
+
session_dir = get_session_dir(user_id)
|
|
122
|
+
flag = os.path.join(session_dir, ".initialized")
|
|
123
|
+
if os.path.exists(flag):
|
|
124
|
+
os.remove(flag)
|
|
125
|
+
return (
|
|
126
|
+
f"✅ **后端已切换** ✅\n\n"
|
|
127
|
+
f"`{old}` → `{new}`\n"
|
|
128
|
+
f"模型: `{model_label}`\n\n"
|
|
129
|
+
"⚠️ 对话已重置,新后端将开始新会话。"
|
|
130
|
+
)
|
|
131
|
+
return (
|
|
132
|
+
f"📋 **当前后端** 📋\n\n`{name}`(未变化)\n"
|
|
133
|
+
f"模型: `{model_label}`"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Image file extension detection
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _detect_image_ext(data: bytes) -> str:
|
|
142
|
+
"""Detect image file extension from magic bytes."""
|
|
143
|
+
if data[:4] == b"\x89PNG":
|
|
144
|
+
return "png"
|
|
145
|
+
if data[:2] == b"\xff\xd8":
|
|
146
|
+
return "jpg"
|
|
147
|
+
if data[:4] in (b"GIF8",):
|
|
148
|
+
return "gif"
|
|
149
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
150
|
+
return "webp"
|
|
151
|
+
return "bin"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# QR code login flow
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
async def login_flow(client: ILinkClient) -> bool:
|
|
158
|
+
"""Perform QR code login flow. Returns True on success."""
|
|
159
|
+
qrcode_str, qrcode_url = await client.get_qrcode()
|
|
160
|
+
|
|
161
|
+
# Save QR code PNG from URL
|
|
162
|
+
if qrcode_url:
|
|
163
|
+
try:
|
|
164
|
+
import qrcode as qrcode_lib_png
|
|
165
|
+
|
|
166
|
+
qr = qrcode_lib_png.QRCode(border=2)
|
|
167
|
+
qr.add_data(qrcode_url)
|
|
168
|
+
qr.make(fit=True)
|
|
169
|
+
im = qr.make_image()
|
|
170
|
+
parent = os.path.dirname(os.path.abspath(config.qrcode_png_path))
|
|
171
|
+
if parent:
|
|
172
|
+
os.makedirs(parent, exist_ok=True)
|
|
173
|
+
im.save(config.qrcode_png_path)
|
|
174
|
+
try:
|
|
175
|
+
os.chmod(config.qrcode_png_path, 0o600)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
logger.info(
|
|
179
|
+
"二维码图片已保存到 %s", config.qrcode_png_path
|
|
180
|
+
)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.warning("保存二维码 PNG 失败: %s", e)
|
|
183
|
+
|
|
184
|
+
# Write URL to file for external access (restrict permissions)
|
|
185
|
+
try:
|
|
186
|
+
parent = os.path.dirname(os.path.abspath(config.qrcode_url_path))
|
|
187
|
+
if parent:
|
|
188
|
+
os.makedirs(parent, exist_ok=True)
|
|
189
|
+
with open(config.qrcode_url_path, "w") as f:
|
|
190
|
+
f.write(qrcode_url)
|
|
191
|
+
os.chmod(config.qrcode_url_path, 0o600)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.warning("写入二维码 URL 文件失败: %s", e)
|
|
194
|
+
|
|
195
|
+
logger.info(
|
|
196
|
+
"请用手机微信扫描 %s 或下方二维码完成绑定",
|
|
197
|
+
config.qrcode_png_path,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Render ASCII QR code for terminal scanning
|
|
201
|
+
try:
|
|
202
|
+
import qrcode as qrcode_lib
|
|
203
|
+
|
|
204
|
+
qr = qrcode_lib.QRCode(border=1)
|
|
205
|
+
qr.add_data(qrcode_url)
|
|
206
|
+
qr.make(fit=True)
|
|
207
|
+
buf = StringIO()
|
|
208
|
+
qr.print_ascii(out=buf)
|
|
209
|
+
ascii_art = buf.getvalue()
|
|
210
|
+
logger.info("ASCII 二维码:\n%s", ascii_art)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.debug("无法渲染 ASCII 二维码: %s", e)
|
|
213
|
+
|
|
214
|
+
logger.info("等待扫码...(超时 %d 秒)", config.qrcode_poll_timeout)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
bot_token, baseurl = await client.poll_qrcode_status(
|
|
218
|
+
qrcode_str,
|
|
219
|
+
timeout=config.qrcode_poll_timeout,
|
|
220
|
+
interval=config.qrcode_poll_interval,
|
|
221
|
+
)
|
|
222
|
+
client.state.bot_token = bot_token
|
|
223
|
+
client.state.baseurl = baseurl
|
|
224
|
+
client.state.bound_at = int(time.time())
|
|
225
|
+
client.state.save()
|
|
226
|
+
logger.info("绑定成功!bot_token 已持久化")
|
|
227
|
+
return True
|
|
228
|
+
except TimeoutError:
|
|
229
|
+
logger.error("扫码超时,退出")
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def gate_and_run(client, from_user, context_token, prompt) -> tuple[str, list] | None:
|
|
234
|
+
"""Check prompt with is_dangerous; if dangerous, ask for confirmation.
|
|
235
|
+
|
|
236
|
+
Returns (reply, artifacts) on safe prompt, None if confirmation asked.
|
|
237
|
+
"""
|
|
238
|
+
if is_dangerous(prompt):
|
|
239
|
+
expire_at = time.time() + config.pending_confirm_ttl
|
|
240
|
+
pending_confirms[from_user] = {
|
|
241
|
+
"prompt": prompt,
|
|
242
|
+
"expire_at": expire_at,
|
|
243
|
+
"context_token": context_token,
|
|
244
|
+
}
|
|
245
|
+
await client.send_message(
|
|
246
|
+
to_user_id=from_user,
|
|
247
|
+
text=(
|
|
248
|
+
f"⚠️ **危险操作确认** ⚠️\n\n```\n{prompt}\n```\n\n"
|
|
249
|
+
f"- 回复 **{config.confirm_token}** → 执行\n"
|
|
250
|
+
f"- 回复其他 → 取消"
|
|
251
|
+
),
|
|
252
|
+
context_token=context_token,
|
|
253
|
+
baseurl=client.state.baseurl,
|
|
254
|
+
bot_token=client.state.bot_token,
|
|
255
|
+
)
|
|
256
|
+
logger.warning("[AUDIT] dangerous prompt pending confirmation: user=%s prompt=%.200s", from_user, prompt)
|
|
257
|
+
return None
|
|
258
|
+
return await _run_llm(prompt, from_user)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def send_artifacts_back(client, from_user, context_token, artifacts) -> None:
|
|
262
|
+
"""Filter artifacts: only send back those under per-user session dir.
|
|
263
|
+
|
|
264
|
+
For agy: artifacts under .gemini/antigravity-cli/scratch
|
|
265
|
+
For grok: artifacts under session_dir (cwd where grok ran)
|
|
266
|
+
"""
|
|
267
|
+
session_dir = get_session_dir(from_user)
|
|
268
|
+
backend = _get_backend(from_user)
|
|
269
|
+
if backend == "grok":
|
|
270
|
+
# grok runs with cwd=session_dir, artifacts are under session_dir
|
|
271
|
+
allowed_root = session_dir
|
|
272
|
+
else:
|
|
273
|
+
# agy writes to .gemini/antigravity-cli/scratch under session_dir
|
|
274
|
+
allowed_root = os.path.join(session_dir, ".gemini", "antigravity-cli", "scratch")
|
|
275
|
+
for art_name, art_path in artifacts:
|
|
276
|
+
try:
|
|
277
|
+
# realpath check blocks symlink escape outside allowed root
|
|
278
|
+
if not path_is_under(art_path, allowed_root):
|
|
279
|
+
logger.debug("skip non-scratch artifact: %s", art_path)
|
|
280
|
+
continue
|
|
281
|
+
if not os.path.isfile(os.path.realpath(art_path)):
|
|
282
|
+
logger.warning("Artifact not found: %s", art_path)
|
|
283
|
+
continue
|
|
284
|
+
art_path = os.path.realpath(art_path)
|
|
285
|
+
file_size = os.path.getsize(art_path)
|
|
286
|
+
if file_size > config.max_outbound_file_bytes:
|
|
287
|
+
size_mb = file_size / (1024 * 1024)
|
|
288
|
+
await client.send_message(
|
|
289
|
+
to_user_id=from_user,
|
|
290
|
+
text=f"⚠️ **文件过大** ⚠️\n\n`{art_name}` {size_mb:.1f} MB\n已存:`{art_path}`",
|
|
291
|
+
context_token=context_token,
|
|
292
|
+
baseurl=client.state.baseurl,
|
|
293
|
+
bot_token=client.state.bot_token,
|
|
294
|
+
)
|
|
295
|
+
continue
|
|
296
|
+
ok = await client.send_media(
|
|
297
|
+
to_user_id=from_user,
|
|
298
|
+
baseurl=client.state.baseurl,
|
|
299
|
+
bot_token=client.state.bot_token,
|
|
300
|
+
context_token=context_token,
|
|
301
|
+
path=art_path,
|
|
302
|
+
caption="",
|
|
303
|
+
)
|
|
304
|
+
if ok:
|
|
305
|
+
logger.info("Artifact sent: %s -> %s", art_name, from_user)
|
|
306
|
+
else:
|
|
307
|
+
logger.warning("Failed to send artifact: %s", art_name)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.exception("Error sending artifact %s: %s", art_name, e)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# Message processing
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
316
|
+
"""Process a single WeChat message.
|
|
317
|
+
|
|
318
|
+
- Image messages (type==2 item): download, AES decrypt, detect ext, save,
|
|
319
|
+
then run CLI with ``prompt @path`` for image recognition.
|
|
320
|
+
- Text-only messages: original logic (slash interception + run_llm).
|
|
321
|
+
|
|
322
|
+
Image messages bypass slash command interception; the text caption (if any)
|
|
323
|
+
is used as the prompt, otherwise a default prompt is used.
|
|
324
|
+
"""
|
|
325
|
+
from_user = msg.get("from_user_id", "")
|
|
326
|
+
context_token = msg.get("context_token", "")
|
|
327
|
+
item_list = msg.get("item_list", [])
|
|
328
|
+
logger.debug(
|
|
329
|
+
"process_message: from=%s msg_type=%d items=%d",
|
|
330
|
+
from_user, msg.get("message_type", 0), len(item_list),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Extract image media and text from item_list
|
|
334
|
+
text = ""
|
|
335
|
+
image_media = None
|
|
336
|
+
file_media = None
|
|
337
|
+
file_name = ""
|
|
338
|
+
voice_text = ""
|
|
339
|
+
has_voice = False
|
|
340
|
+
for item in item_list:
|
|
341
|
+
item_type = item.get("type")
|
|
342
|
+
if item_type == 1 and not text:
|
|
343
|
+
text_item = item.get("text_item", {})
|
|
344
|
+
text = text_item.get("text", "")
|
|
345
|
+
elif item_type == 2 and image_media is None:
|
|
346
|
+
image_item = item.get("image_item", {})
|
|
347
|
+
media = image_item.get("media", {})
|
|
348
|
+
if media.get("encrypt_query_param") or media.get("full_url"):
|
|
349
|
+
image_media = media
|
|
350
|
+
elif item_type == 3 and not has_voice:
|
|
351
|
+
# Voice: WeChat transcribes to text server-side (voice_item.text).
|
|
352
|
+
# Only passthrough the text — no silk decode / no ASR (dmit 1c965Mi).
|
|
353
|
+
voice_item = item.get("voice_item", {})
|
|
354
|
+
voice_text = voice_item.get("text", "") or ""
|
|
355
|
+
has_voice = True
|
|
356
|
+
elif item_type == 4 and file_media is None:
|
|
357
|
+
fi = item.get("file_item", {})
|
|
358
|
+
media = fi.get("media", {})
|
|
359
|
+
if media.get("encrypt_query_param") or media.get("full_url"):
|
|
360
|
+
file_media = media
|
|
361
|
+
file_name = fi.get("file_name", "")
|
|
362
|
+
|
|
363
|
+
if not context_token:
|
|
364
|
+
logger.warning(
|
|
365
|
+
"Message from %s has no context_token, cannot reply", from_user
|
|
366
|
+
)
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# ---- Whitelist check (before any processing) ----
|
|
370
|
+
if config.allowed_senders and from_user not in config.allowed_senders:
|
|
371
|
+
await client.send_message(
|
|
372
|
+
to_user_id=from_user,
|
|
373
|
+
text="⛔ **未授权用户** ⛔\n联系管理员添加白名单。",
|
|
374
|
+
context_token=context_token,
|
|
375
|
+
baseurl=client.state.baseurl,
|
|
376
|
+
bot_token=client.state.bot_token,
|
|
377
|
+
)
|
|
378
|
+
logger.warning("拒绝非白名单用户: %s", from_user)
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# ---- Admin notification (update available, etc.) ----
|
|
382
|
+
await maybe_notify_admin(client, from_user, context_token)
|
|
383
|
+
|
|
384
|
+
# ---- Pending dangerous prompt confirmation ----
|
|
385
|
+
pending = pending_confirms.get(from_user)
|
|
386
|
+
if pending:
|
|
387
|
+
expired = time.time() >= pending["expire_at"]
|
|
388
|
+
if not expired and text.strip().lower() == config.confirm_token.lower():
|
|
389
|
+
# User confirmed → run pending prompt, send reply, return
|
|
390
|
+
logger.info("[AUDIT] user=%s confirmed dangerous prompt", from_user)
|
|
391
|
+
reply, artifacts = await _run_llm(pending["prompt"], from_user)
|
|
392
|
+
del pending_confirms[from_user]
|
|
393
|
+
# Send reply
|
|
394
|
+
success = await client.send_message(
|
|
395
|
+
to_user_id=from_user,
|
|
396
|
+
text=reply,
|
|
397
|
+
context_token=context_token,
|
|
398
|
+
baseurl=client.state.baseurl,
|
|
399
|
+
bot_token=client.state.bot_token,
|
|
400
|
+
)
|
|
401
|
+
if success:
|
|
402
|
+
logger.info("回复已发送到 %s", from_user)
|
|
403
|
+
else:
|
|
404
|
+
logger.warning("回复发送失败到 %s", from_user)
|
|
405
|
+
# Send artifacts
|
|
406
|
+
await send_artifacts_back(client, from_user, context_token, artifacts)
|
|
407
|
+
return
|
|
408
|
+
del pending_confirms[from_user]
|
|
409
|
+
if expired:
|
|
410
|
+
# Expired: don't reply, continue normal flow
|
|
411
|
+
logger.info("[AUDIT] user=%s pending expired, continue normal flow", from_user)
|
|
412
|
+
else:
|
|
413
|
+
# User explicitly cancelled: reply cancelled, return
|
|
414
|
+
logger.info("[AUDIT] user=%s cancelled dangerous prompt", from_user)
|
|
415
|
+
await client.send_message(
|
|
416
|
+
to_user_id=from_user,
|
|
417
|
+
text="🚫 **已取消** 🚫",
|
|
418
|
+
context_token=context_token,
|
|
419
|
+
baseurl=client.state.baseurl,
|
|
420
|
+
bot_token=client.state.bot_token,
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
# ---- Case 1: Message contains an image ----
|
|
425
|
+
artifacts = []
|
|
426
|
+
reply = ""
|
|
427
|
+
if image_media:
|
|
428
|
+
if not image_media.get("aes_key"):
|
|
429
|
+
reply = format_error("图片无法处理", "缺少解密密钥,请重新发送图片。")
|
|
430
|
+
logger.warning("图片缺少 aes_key from=%s", from_user)
|
|
431
|
+
else:
|
|
432
|
+
try:
|
|
433
|
+
# Download CDN image and AES decrypt → plaintext bytes
|
|
434
|
+
plain_bytes = await client.download_and_decrypt_media(image_media)
|
|
435
|
+
|
|
436
|
+
# Detect extension from magic bytes
|
|
437
|
+
ext = _detect_image_ext(plain_bytes)
|
|
438
|
+
|
|
439
|
+
# Save to per-user session images directory
|
|
440
|
+
images_dir = os.path.join(get_session_dir(from_user), "images")
|
|
441
|
+
os.makedirs(images_dir, exist_ok=True)
|
|
442
|
+
try:
|
|
443
|
+
os.chmod(images_dir, 0o700)
|
|
444
|
+
except OSError:
|
|
445
|
+
pass
|
|
446
|
+
save_path = os.path.join(images_dir, f"{uuid.uuid4().hex[:12]}.{ext}")
|
|
447
|
+
with open(save_path, "wb") as f:
|
|
448
|
+
f.write(plain_bytes)
|
|
449
|
+
|
|
450
|
+
logger.info(
|
|
451
|
+
"图片已保存 %s (%d bytes, ext=%s)",
|
|
452
|
+
save_path, len(plain_bytes), ext,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Build prompt: user's caption if present, else default
|
|
456
|
+
prompt = text.strip() if text.strip() else "请描述这张图片的内容"
|
|
457
|
+
logger.info("识图 from=%s: %s @%s", from_user, prompt, save_path)
|
|
458
|
+
result = await gate_and_run(client, from_user, context_token, f"{prompt} @{save_path}")
|
|
459
|
+
if result is None:
|
|
460
|
+
return
|
|
461
|
+
reply, artifacts = result
|
|
462
|
+
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.exception("图片下载/解密失败: %s", e)
|
|
465
|
+
reply = format_error("图片下载或解密失败", str(e))
|
|
466
|
+
|
|
467
|
+
# ---- Case 1.5: Message contains a file (non-image) ----
|
|
468
|
+
elif file_media:
|
|
469
|
+
if not file_media.get("aes_key"):
|
|
470
|
+
reply = format_error("文件无法处理", "缺少解密密钥,请重新发送文件。")
|
|
471
|
+
logger.warning("文件缺少 aes_key from=%s", from_user)
|
|
472
|
+
else:
|
|
473
|
+
try:
|
|
474
|
+
plain_bytes = await client.download_and_decrypt_media(file_media)
|
|
475
|
+
|
|
476
|
+
# Save to per-user session files directory
|
|
477
|
+
files_dir = os.path.join(get_session_dir(from_user), "files")
|
|
478
|
+
os.makedirs(files_dir, exist_ok=True)
|
|
479
|
+
try:
|
|
480
|
+
os.chmod(files_dir, 0o700)
|
|
481
|
+
except OSError:
|
|
482
|
+
pass
|
|
483
|
+
# Preserve original extension from file_name (basename only)
|
|
484
|
+
ext = os.path.splitext(os.path.basename(file_name or ""))[1]
|
|
485
|
+
save_name = f"{uuid.uuid4().hex[:12]}{ext}" if ext else uuid.uuid4().hex[:12]
|
|
486
|
+
save_path = os.path.join(files_dir, save_name)
|
|
487
|
+
with open(save_path, "wb") as f:
|
|
488
|
+
f.write(plain_bytes)
|
|
489
|
+
|
|
490
|
+
logger.info(
|
|
491
|
+
"文件已保存 %s (%d bytes)", save_path, len(plain_bytes),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
prompt = text.strip() if text.strip() else "请分析这个文件"
|
|
495
|
+
logger.info("文件分析 from=%s: %s @%s", from_user, prompt, save_path)
|
|
496
|
+
result = await gate_and_run(client, from_user, context_token, f"{prompt} @{save_path}")
|
|
497
|
+
if result is None:
|
|
498
|
+
return
|
|
499
|
+
reply, artifacts = result
|
|
500
|
+
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.exception("文件下载/解密失败: %s", e)
|
|
503
|
+
reply = format_error("文件下载或解密失败", str(e))
|
|
504
|
+
|
|
505
|
+
# ---- Case 1.6: Voice message (text transcription passthrough) ----
|
|
506
|
+
elif has_voice:
|
|
507
|
+
if voice_text.strip():
|
|
508
|
+
logger.info("语音转文字 from=%s: %.100s", from_user, voice_text.strip())
|
|
509
|
+
result = await gate_and_run(client, from_user, context_token, voice_text.strip())
|
|
510
|
+
if result is None:
|
|
511
|
+
return
|
|
512
|
+
reply, artifacts = result
|
|
513
|
+
else:
|
|
514
|
+
# WeChat failed to transcribe the voice → ask user to type.
|
|
515
|
+
reply = "🤔 **听不清,请打字** 🤔"
|
|
516
|
+
logger.info("语音未识别出文字 from=%s", from_user)
|
|
517
|
+
|
|
518
|
+
# ---- Case 2: Text-only message (original logic) ----
|
|
519
|
+
else:
|
|
520
|
+
if not text:
|
|
521
|
+
logger.debug("Skipping non-text message from %s", from_user)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
logger.info("收到消息 from=%s: %.100s", from_user, text)
|
|
525
|
+
|
|
526
|
+
# Slash command interception
|
|
527
|
+
if text.startswith("/"):
|
|
528
|
+
logger.info("Slash command from=%s: %.200s", from_user, text)
|
|
529
|
+
reply = await _handle_slash(text, from_user)
|
|
530
|
+
if reply is None:
|
|
531
|
+
# D class: passthrough — run CLI normally
|
|
532
|
+
result = await gate_and_run(client, from_user, context_token, text)
|
|
533
|
+
if result is None:
|
|
534
|
+
return
|
|
535
|
+
reply, artifacts = result
|
|
536
|
+
else:
|
|
537
|
+
result = await gate_and_run(client, from_user, context_token, text)
|
|
538
|
+
if result is None:
|
|
539
|
+
return
|
|
540
|
+
reply, artifacts = result
|
|
541
|
+
|
|
542
|
+
# ---- Send reply via iLink ----
|
|
543
|
+
success = await client.send_message(
|
|
544
|
+
to_user_id=from_user,
|
|
545
|
+
text=reply,
|
|
546
|
+
context_token=context_token,
|
|
547
|
+
baseurl=client.state.baseurl,
|
|
548
|
+
bot_token=client.state.bot_token,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if success:
|
|
552
|
+
logger.info("回复已发送到 %s", from_user)
|
|
553
|
+
else:
|
|
554
|
+
logger.warning("回复发送失败到 %s", from_user)
|
|
555
|
+
|
|
556
|
+
# ---- Send artifacts back to WeChat ----
|
|
557
|
+
await send_artifacts_back(client, from_user, context_token, artifacts)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ---------------------------------------------------------------------------
|
|
561
|
+
# Scratch TTL cleanup
|
|
562
|
+
# ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
def clean_scratch():
|
|
565
|
+
"""Remove old global scratch files and per-user session media."""
|
|
566
|
+
scratch_dir = config.agy_scratch_dir
|
|
567
|
+
if os.path.isdir(scratch_dir):
|
|
568
|
+
now = time.time()
|
|
569
|
+
cutoff = now - config.scratch_retention_days * 86400
|
|
570
|
+
try:
|
|
571
|
+
for name in os.listdir(scratch_dir):
|
|
572
|
+
path = os.path.join(scratch_dir, name)
|
|
573
|
+
if os.path.isfile(path):
|
|
574
|
+
mtime = os.path.getmtime(path)
|
|
575
|
+
if mtime < cutoff:
|
|
576
|
+
os.remove(path)
|
|
577
|
+
logger.info(
|
|
578
|
+
"Scratch cleanup: removed %s (age %.1f days)",
|
|
579
|
+
path, (now - mtime) / 86400,
|
|
580
|
+
)
|
|
581
|
+
except OSError as e:
|
|
582
|
+
logger.error("Scratch cleanup error: %s", e)
|
|
583
|
+
removed = clean_session_media() # images/files + safe session temps (A+C)
|
|
584
|
+
if removed:
|
|
585
|
+
logger.info("Session temp cleanup: removed %d files", removed)
|
|
586
|
+
_prune_user_locks()
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
# Main loop
|
|
591
|
+
# ---------------------------------------------------------------------------
|
|
592
|
+
async def periodic_clean_scratch():
|
|
593
|
+
"""Run clean_scratch every 3600 seconds as a background task."""
|
|
594
|
+
while True:
|
|
595
|
+
try:
|
|
596
|
+
await asyncio.sleep(3600)
|
|
597
|
+
clean_scratch()
|
|
598
|
+
except Exception as e:
|
|
599
|
+
logger.exception("periodic_clean_scratch error: %s", e)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
async def main_loop() -> None:
|
|
603
|
+
"""Main daemon loop: manages state, QR login, and message receiving."""
|
|
604
|
+
ensure_runtime_dirs()
|
|
605
|
+
clean_scratch()
|
|
606
|
+
if config.update_check_enabled:
|
|
607
|
+
asyncio.create_task(update_check_loop())
|
|
608
|
+
asyncio.create_task(periodic_clean_scratch())
|
|
609
|
+
while True:
|
|
610
|
+
client = ILinkClient()
|
|
611
|
+
try:
|
|
612
|
+
state_loaded = client.state.load()
|
|
613
|
+
|
|
614
|
+
if not state_loaded or not client.state.bot_token:
|
|
615
|
+
success = await login_flow(client)
|
|
616
|
+
if not success:
|
|
617
|
+
logger.warning("扫码超时,3 秒后重新获取二维码等待扫码")
|
|
618
|
+
await asyncio.sleep(3)
|
|
619
|
+
continue
|
|
620
|
+
|
|
621
|
+
baseurl = client.state.baseurl
|
|
622
|
+
bot_token = client.state.bot_token
|
|
623
|
+
logger.info("开始长轮询 iLink 消息 (baseurl=%s)", baseurl)
|
|
624
|
+
|
|
625
|
+
# Inner loop: long-poll for messages
|
|
626
|
+
get_updates_buf = ""
|
|
627
|
+
while True:
|
|
628
|
+
try:
|
|
629
|
+
msgs, new_buf = await client.get_updates(
|
|
630
|
+
get_updates_buf, baseurl, bot_token
|
|
631
|
+
)
|
|
632
|
+
except Exception as e:
|
|
633
|
+
# Token invalidated (401/403) → break for re-login
|
|
634
|
+
logger.exception("长轮询异常: %s", e)
|
|
635
|
+
if not client.state.bot_token:
|
|
636
|
+
logger.warning("Bot token 已失效,准备重新登录")
|
|
637
|
+
break
|
|
638
|
+
# Network hiccup → short delay and retry
|
|
639
|
+
await asyncio.sleep(0.5)
|
|
640
|
+
continue
|
|
641
|
+
|
|
642
|
+
# Always update cursor with the server-returned value
|
|
643
|
+
get_updates_buf = new_buf
|
|
644
|
+
|
|
645
|
+
for msg in msgs:
|
|
646
|
+
msg_type = msg.get("message_type", 0)
|
|
647
|
+
if msg_type == 1: # User message
|
|
648
|
+
# Non-blocking async task creation: process message in background
|
|
649
|
+
asyncio.create_task(_safe_process_message(client, msg))
|
|
650
|
+
else:
|
|
651
|
+
logger.debug(
|
|
652
|
+
"跳过 message_type=%s", msg_type
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
if not client.state.bot_token:
|
|
656
|
+
break
|
|
657
|
+
|
|
658
|
+
except KeyboardInterrupt:
|
|
659
|
+
logger.info("收到退出信号")
|
|
660
|
+
raise
|
|
661
|
+
finally:
|
|
662
|
+
await client.close()
|
|
663
|
+
|
|
664
|
+
# Decide whether to re-login or exit
|
|
665
|
+
if not client.state.bot_token:
|
|
666
|
+
logger.info("Bot token 已失效,重新执行登录流程...")
|
|
667
|
+
# Small delay before re-login to avoid tight loop
|
|
668
|
+
await asyncio.sleep(2)
|
|
669
|
+
continue # outer loop → re-login
|
|
670
|
+
else:
|
|
671
|
+
# Normal exit (should not happen in steady-state)
|
|
672
|
+
break
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# ---------------------------------------------------------------------------
|
|
676
|
+
# Per-user async execution lock, global concurrency gate, background spawner
|
|
677
|
+
# ---------------------------------------------------------------------------
|
|
678
|
+
user_locks: dict = {}
|
|
679
|
+
_global_task_sem: asyncio.Semaphore | None = None
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _get_global_sem() -> asyncio.Semaphore:
|
|
683
|
+
global _global_task_sem
|
|
684
|
+
if _global_task_sem is None:
|
|
685
|
+
n = max(int(config.max_concurrent_tasks), 1)
|
|
686
|
+
_global_task_sem = asyncio.Semaphore(n)
|
|
687
|
+
return _global_task_sem
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _prune_user_locks() -> None:
|
|
691
|
+
"""Drop idle per-user locks so the dict does not grow forever."""
|
|
692
|
+
idle = [uid for uid, lock in user_locks.items() if not lock.locked()]
|
|
693
|
+
for uid in idle:
|
|
694
|
+
user_locks.pop(uid, None)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
async def _safe_process_message(client: ILinkClient, msg: dict) -> None:
|
|
698
|
+
"""Run process_message inside global concurrency + per-user locks.
|
|
699
|
+
|
|
700
|
+
This ensures the main get_updates long-polling loop is NEVER blocked,
|
|
701
|
+
keeping WeChat heartbeats 100% active while ensuring per-user message ordering.
|
|
702
|
+
"""
|
|
703
|
+
from_user = msg.get("from_user_id", "")
|
|
704
|
+
context_token = msg.get("context_token", "")
|
|
705
|
+
sem = _get_global_sem()
|
|
706
|
+
|
|
707
|
+
# Fail fast when all slots are busy (do not queue unbounded work)
|
|
708
|
+
try:
|
|
709
|
+
await asyncio.wait_for(sem.acquire(), timeout=0.05)
|
|
710
|
+
except asyncio.TimeoutError:
|
|
711
|
+
logger.warning("并发已满,拒绝处理 from=%s", from_user)
|
|
712
|
+
if context_token and from_user:
|
|
713
|
+
try:
|
|
714
|
+
await client.send_message(
|
|
715
|
+
to_user_id=from_user,
|
|
716
|
+
text="⏳ **系统繁忙** ⏳\n\n当前处理人数已满,请稍后再试。",
|
|
717
|
+
context_token=context_token,
|
|
718
|
+
baseurl=client.state.baseurl,
|
|
719
|
+
bot_token=client.state.bot_token,
|
|
720
|
+
)
|
|
721
|
+
except Exception as e:
|
|
722
|
+
logger.warning("发送繁忙提示失败: %s", e)
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
if from_user not in user_locks:
|
|
727
|
+
user_locks[from_user] = asyncio.Lock()
|
|
728
|
+
async with user_locks[from_user]:
|
|
729
|
+
try:
|
|
730
|
+
await process_message(client, msg)
|
|
731
|
+
except Exception as e:
|
|
732
|
+
logger.exception("处理消息异常 (from=%s): %s", from_user, e)
|
|
733
|
+
finally:
|
|
734
|
+
sem.release()
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# ---------------------------------------------------------------------------
|
|
738
|
+
# Entry point
|
|
739
|
+
# ---------------------------------------------------------------------------
|
|
740
|
+
def main():
|
|
741
|
+
parser = argparse.ArgumentParser(prog="wechatbridge", description="Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.")
|
|
742
|
+
parser.add_argument("--version", action="version", version=f"wechatbridge {__version__}")
|
|
743
|
+
parser.parse_args()
|
|
744
|
+
logger.info("wechatbridge v%s 启动 (backend=%s, instance=%s)", __version__, config.backend, config.instance)
|
|
745
|
+
try:
|
|
746
|
+
asyncio.run(main_loop())
|
|
747
|
+
except KeyboardInterrupt:
|
|
748
|
+
logger.info("进程退出")
|
|
749
|
+
except Exception as e:
|
|
750
|
+
logger.exception("未预期错误: %s", e)
|
|
751
|
+
sys.exit(1)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
if __name__ == "__main__":
|
|
755
|
+
main()
|