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/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()