wechatbridge-cli 1.3.1__tar.gz → 1.3.2__tar.gz

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.
Files changed (20) hide show
  1. {wechatbridge_cli-1.3.1/wechatbridge_cli.egg-info → wechatbridge_cli-1.3.2}/PKG-INFO +1 -1
  2. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/__init__.py +1 -1
  3. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/agy.py +45 -19
  4. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/grok.py +45 -24
  5. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/ilink.py +18 -14
  6. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/main.py +166 -18
  7. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/runner_common.py +14 -5
  8. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
  9. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/LICENSE +0 -0
  10. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/README.md +0 -0
  11. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/pyproject.toml +0 -0
  12. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/setup.cfg +0 -0
  13. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/__main__.py +0 -0
  14. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/config.py +0 -0
  15. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge/update_check.py +0 -0
  16. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  17. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  18. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  19. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/requires.txt +0 -0
  20. {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy or Grok Build CLIs."""
2
- __version__ = "1.3.1"
2
+ __version__ = "1.3.2"
@@ -23,6 +23,9 @@ from .runner_common import (
23
23
 
24
24
  logger = logging.getLogger("agy_runner")
25
25
 
26
+ # execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
27
+ _MAX_ARG_BYTES = 120 * 1024
28
+
26
29
 
27
30
  def extract_artifacts(text: str) -> list[tuple[str, str]]:
28
31
  """Extract (name, absolute_path) tuples from markdown file:/// links.
@@ -109,6 +112,14 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
109
112
  if timeout is None:
110
113
  timeout = config.agy_timeout
111
114
 
115
+ # argv 单参数上限约 128KB(MAX_ARG_STRLEN),超长 prompt 直接拒绝,避免 E2BIG
116
+ if len(prompt.encode("utf-8", errors="replace")) > _MAX_ARG_BYTES:
117
+ logger.warning("Prompt too large for argv from user %s", user_id)
118
+ return format_error(
119
+ "消息过长",
120
+ f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
121
+ ), []
122
+
112
123
  t0 = time.time()
113
124
  session_dir = ensure_user_gemini(user_id)
114
125
 
@@ -228,10 +239,25 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
228
239
  env=env,
229
240
  preexec_fn=os.setsid if hasattr(os, "setsid") else None,
230
241
  )
231
- r_stdout, r_stderr = await asyncio.wait_for(
232
- retry_process.communicate(),
233
- timeout=float(timeout),
234
- )
242
+ try:
243
+ r_stdout, r_stderr = await asyncio.wait_for(
244
+ retry_process.communicate(),
245
+ timeout=float(timeout),
246
+ )
247
+ except asyncio.TimeoutError:
248
+ # 重试进程也必须回收,否则超时后成为孤儿进程
249
+ logger.warning(
250
+ "agy retry timed out after %ss for user %s, terminating retry process",
251
+ timeout, user_id,
252
+ )
253
+ await terminate_process(retry_process, graceful=True)
254
+ return format_error(
255
+ "级联超时",
256
+ "模型 API 级联推理超时,自动重试仍超时。请稍后重试或简化指令。",
257
+ ), []
258
+ except (asyncio.CancelledError, Exception):
259
+ await terminate_process(retry_process, graceful=False)
260
+ raise
235
261
  r_stdout_text = r_stdout.decode("utf-8", errors="replace").strip()
236
262
  r_stderr_text = r_stderr.decode("utf-8", errors="replace").strip()
237
263
  # Any useful stdout after retry counts as recovered (agy may exit non-zero)
@@ -271,10 +297,15 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
271
297
  await terminate_process(process, graceful=True)
272
298
  return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
273
299
 
300
+ except asyncio.CancelledError:
301
+ # 任务被取消(如重登录前排空):必须杀掉子进程再传递取消
302
+ await terminate_process(process, graceful=False)
303
+ raise
304
+
274
305
  except Exception as e:
275
306
  logger.exception("Unexpected error running agy: %s", e)
276
307
  await terminate_process(process, graceful=False)
277
- return format_error("执行出错", str(e)), []
308
+ return format_error("执行出错", "内部错误,详情见服务端日志。"), []
278
309
 
279
310
 
280
311
  # ---------------------------------------------------------------------------
@@ -290,6 +321,7 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
290
321
  """
291
322
  session_dir = ensure_user_gemini(user_id)
292
323
  cmd = [config.agy_binary_path] + subcmd_args
324
+ process = None
293
325
  try:
294
326
  env = sanitize_env(session_dir)
295
327
  process = await asyncio.create_subprocess_exec(
@@ -320,10 +352,16 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
320
352
  return clean_output(stdout_text) or EMPTY_REPLY
321
353
 
322
354
  except asyncio.TimeoutError:
355
+ # 超时必须回收子进程,否则挂死的子命令成为孤儿进程
356
+ await terminate_process(process, graceful=True)
323
357
  return format_error("指令超时", "子命令 30 秒内未完成。")
358
+ except asyncio.CancelledError:
359
+ await terminate_process(process, graceful=False)
360
+ raise
324
361
  except Exception as e:
325
362
  logger.exception("Subcommand error: %s", e)
326
- return format_error("执行出错", str(e))
363
+ await terminate_process(process, graceful=False)
364
+ return format_error("执行出错", "内部错误,详情见服务端日志。")
327
365
 
328
366
 
329
367
  def _cmd_help() -> str:
@@ -612,19 +650,7 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
612
650
  "> 用 codegraph 的 search 工具搜 ctxmode"
613
651
  )
614
652
 
615
- if cmd == "/agent":
616
- if not config.enable_subagent:
617
- return "ℹ️ **该功能已禁用** ℹ️"
618
- if not args:
619
- return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
620
- # Construct prompt and run through agy
621
- agent_parts = args.split(maxsplit=1)
622
- agent_name = agent_parts[0]
623
- agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
624
- crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
625
- logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
626
- result_text, _ = await run_agy(crafted, user_id)
627
- return result_text
653
+ # /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
628
654
 
629
655
  # --- D class: passthrough to agy (return None so caller runs run_agy) ---
630
656
  return None
@@ -23,6 +23,9 @@ from .runner_common import (
23
23
 
24
24
  logger = logging.getLogger("grok_runner")
25
25
 
26
+ # execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
27
+ _MAX_ARG_BYTES = 120 * 1024
28
+
26
29
 
27
30
  # ---------------------------------------------------------------------------
28
31
  # Per-user .grok directory setup
@@ -250,13 +253,16 @@ def _build_grok_command(prompt: str, prefs: dict, first: bool, persona_content:
250
253
  # Artifact extraction from chat_history.jsonl
251
254
  # ---------------------------------------------------------------------------
252
255
 
253
- def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
256
+ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.0) -> list:
254
257
  """Extract (name, abs_path) tuples from grok session chat_history.jsonl.
255
258
 
256
259
  grok stores sessions under $HOME/.grok/sessions/<url-encoded-cwd>/<session-id>/.
257
260
  The chat_history.jsonl contains structured tool_calls with file_path arguments
258
261
  from write/edit operations.
259
262
 
263
+ ``since``: 只收录 mtime >= since 的文件——chat_history.jsonl 跨轮累积,
264
+ 不过滤的话 --continue 会话每轮都会把历史文件重发一遍。
265
+
260
266
  Falls back to empty list on any error (never blocks text reply).
261
267
  """
262
268
  grok_sessions = os.path.join(session_dir, ".grok", "sessions")
@@ -292,6 +298,12 @@ def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
292
298
  if name in ("write", "edit", "str_replace") and isinstance(args, dict):
293
299
  fp = args.get("file_path", "")
294
300
  if fp and os.path.isabs(fp):
301
+ # 只收录本轮运行期间新写/修改的文件
302
+ try:
303
+ if since and os.path.getmtime(fp) < since - 2.0:
304
+ continue
305
+ except OSError:
306
+ continue # 文件已不存在,无需回传
295
307
  art_name = os.path.basename(fp)
296
308
  key = (art_name, fp)
297
309
  if key not in seen:
@@ -305,7 +317,7 @@ def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
305
317
  return artifacts
306
318
 
307
319
 
308
- def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
320
+ def _parse_grok_output(stdout_text: str, session_dir: str, since: float = 0.0) -> tuple:
309
321
  """Parse grok JSON output into (display_text, artifacts).
310
322
 
311
323
  Handles both success JSON ({text, sessionId, ...}) and error JSON
@@ -330,7 +342,7 @@ def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
330
342
 
331
343
  artifacts = []
332
344
  if session_id:
333
- artifacts = _extract_grok_artifacts(session_dir, session_id)
345
+ artifacts = _extract_grok_artifacts(session_dir, session_id, since=since)
334
346
 
335
347
  # Strip file:/// links from display (in case grok emits them)
336
348
  display = re.sub(
@@ -355,6 +367,14 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
355
367
  if timeout is None:
356
368
  timeout = config.agy_timeout
357
369
 
370
+ # argv 单参数上限约 128KB(MAX_ARG_STRLEN),超长 prompt 直接拒绝,避免 E2BIG
371
+ if len(prompt.encode("utf-8", errors="replace")) > _MAX_ARG_BYTES:
372
+ logger.warning("Prompt too large for argv from user %s", user_id)
373
+ return format_error(
374
+ "消息过长",
375
+ f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
376
+ ), []
377
+
358
378
  t0 = time.time()
359
379
  session_dir = ensure_user_grok(user_id)
360
380
 
@@ -398,22 +418,22 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
398
418
  stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
399
419
  stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
400
420
 
401
- display, artifacts = _parse_grok_output(stdout_text, session_dir)
421
+ display, artifacts = _parse_grok_output(stdout_text, session_dir, since=t0)
402
422
 
403
- # Failure detection: non-zero exit, or structured error already formatted
423
+ # Failure detection: 非零退出一律视为失败(与 agy 行为对齐),
424
+ # 零退出但 stdout 是结构化错误(已格式化为 ❌ 前缀)也算失败
404
425
  failed = process.returncode != 0 or (
405
426
  isinstance(display, str) and display.startswith("❌")
406
427
  )
407
- if process.returncode != 0 and (not display or display == EMPTY_REPLY):
428
+ if process.returncode != 0:
408
429
  logger.warning(
409
430
  "grok exited with code %s for user %s: %.200s",
410
431
  process.returncode, user_id, stderr_text,
411
432
  )
412
- display = format_cli_error(
413
- stderr_text or "grok 进程异常退出", backend="grok"
414
- )
415
433
  artifacts = []
416
- failed = True
434
+ if not (isinstance(display, str) and display.startswith("❌")):
435
+ raw_err = stderr_text or ("" if display == EMPTY_REPLY else display) or "grok 进程异常退出"
436
+ display = format_cli_error(raw_err, backend="grok")
417
437
 
418
438
  # Only mark session initialized on a real successful reply
419
439
  if first and not failed:
@@ -431,10 +451,15 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
431
451
  await terminate_process(process, graceful=True)
432
452
  return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
433
453
 
454
+ except asyncio.CancelledError:
455
+ # 任务被取消(如重登录前排空):必须杀掉子进程再传递取消
456
+ await terminate_process(process, graceful=False)
457
+ raise
458
+
434
459
  except Exception as e:
435
460
  logger.exception("Unexpected error running grok: %s", e)
436
461
  await terminate_process(process, graceful=False)
437
- return format_error("执行出错", str(e)), []
462
+ return format_error("执行出错", "内部错误,详情见服务端日志。"), []
438
463
 
439
464
 
440
465
  async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
@@ -445,6 +470,7 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
445
470
  """
446
471
  session_dir = ensure_user_grok(user_id)
447
472
  cmd = [config.grok_binary_path] + subcmd_args
473
+ process = None
448
474
  try:
449
475
  env = sanitize_env(session_dir)
450
476
  process = await asyncio.create_subprocess_exec(
@@ -476,10 +502,16 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
476
502
  return clean_output(stdout_text) or EMPTY_REPLY
477
503
 
478
504
  except asyncio.TimeoutError:
505
+ # 超时必须回收子进程,否则挂死的子命令成为孤儿进程
506
+ await terminate_process(process, graceful=True)
479
507
  return format_error("指令超时", "子命令 30 秒内未完成。")
508
+ except asyncio.CancelledError:
509
+ await terminate_process(process, graceful=False)
510
+ raise
480
511
  except Exception as e:
481
512
  logger.exception("Subcommand error: %s", e)
482
- return format_error("执行出错", str(e))
513
+ await terminate_process(process, graceful=False)
514
+ return format_error("执行出错", "内部错误,详情见服务端日志。")
483
515
 
484
516
 
485
517
  # ---------------------------------------------------------------------------
@@ -665,18 +697,7 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
665
697
  "> 用 codegraph 的 search 工具搜 ctxmode"
666
698
  )
667
699
 
668
- if cmd == "/agent":
669
- if not config.enable_subagent:
670
- return "ℹ️ **该功能已禁用** ℹ️"
671
- if not args:
672
- return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
673
- agent_parts = args.split(maxsplit=1)
674
- agent_name = agent_parts[0]
675
- agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
676
- crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
677
- logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
678
- result_text, _ = await run_grok(crafted, user_id)
679
- return result_text
700
+ # /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
680
701
 
681
702
  # --- D class: passthrough to grok (return None so caller runs run_grok) ---
682
703
  return None
@@ -209,9 +209,12 @@ class ILinkState:
209
209
  os.chmod(parent, 0o700)
210
210
  except OSError:
211
211
  pass
212
- with open(self.path, "w") as f:
212
+ # tmp + os.replace 原子写,避免崩溃留下半截 state 文件
213
+ tmp_path = self.path + ".tmp"
214
+ with open(tmp_path, "w") as f:
213
215
  json.dump(data, f)
214
- os.chmod(self.path, 0o600)
216
+ os.chmod(tmp_path, 0o600)
217
+ os.replace(tmp_path, self.path)
215
218
  except OSError as e:
216
219
  logger.error("Failed to save iLink state: %s", e)
217
220
 
@@ -270,7 +273,7 @@ class ILinkClient:
270
273
  return qrcode_str, qrcode_img_content
271
274
 
272
275
  async def poll_qrcode_status(
273
- self, qrcode_str: str, timeout: int = 180, interval: float = 1.5
276
+ self, qrcode_str: str, timeout: int = 180
274
277
  ) -> Tuple[str, str]:
275
278
  """
276
279
  Poll QR code status until confirmed or timeout.
@@ -369,11 +372,12 @@ class ILinkClient:
369
372
  logger.warning("HTTP error in get_updates: %s", e)
370
373
  return [], buf
371
374
  except httpx.RequestError as e:
375
+ # 网络层错误上抛,由主循环做指数退避(避免原地满速重试+日志洪泛)
372
376
  logger.warning("Network error in get_updates: %s", e)
373
- return [], buf
377
+ raise
374
378
  except Exception as e:
375
379
  logger.exception("Unexpected error in get_updates: %s", e)
376
- return [], buf
380
+ raise
377
381
 
378
382
  # ---- Media upload and sending ----
379
383
 
@@ -457,8 +461,8 @@ class ILinkClient:
457
461
  )
458
462
  elapsed = time.time() - t0
459
463
  logger.info(
460
- "CDN upload done: %d bytes in %.3fs x-encrypted-param=%.80s",
461
- len(ciphertext), elapsed, encrypted_param,
464
+ "CDN upload done: %d bytes in %.3fs x-encrypted-param(len=%d)",
465
+ len(ciphertext), elapsed, len(encrypted_param),
462
466
  )
463
467
  return encrypted_param
464
468
  except httpx.TimeoutException:
@@ -826,13 +830,13 @@ class ILinkClient:
826
830
  cl = resp.headers.get("content-length")
827
831
  if cl is not None:
828
832
  try:
829
- if int(cl) > max_in:
830
- raise ValueError(
831
- f"入站文件过大(Content-Length {cl} > {max_in} 字节)"
832
- )
833
- except ValueError as e:
834
- if "入站文件过大" in str(e):
835
- raise
833
+ declared = int(cl)
834
+ except ValueError:
835
+ declared = None # 非法 Content-Length 头忽略,由流式上限兜底
836
+ if declared is not None and declared > max_in:
837
+ raise ValueError(
838
+ f"入站文件过大(Content-Length {cl} > {max_in} 字节)"
839
+ )
836
840
  async for piece in resp.aiter_bytes():
837
841
  if not piece:
838
842
  continue
@@ -12,6 +12,7 @@ import os
12
12
  import sys
13
13
  import time
14
14
  import uuid
15
+ from collections import OrderedDict
15
16
  from io import StringIO
16
17
 
17
18
  from . import __version__
@@ -44,6 +45,71 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
44
45
  pending_confirms: dict = {}
45
46
 
46
47
 
48
+ # Slash 处理器内部信号:该指令已完整处理(如 /agent 进入确认流程),调用方直接 return
49
+ _HANDLED = object()
50
+
51
+ # 重登录/关闭前等待在途消息任务的最长时间
52
+ _DRAIN_TIMEOUT_S = 90.0
53
+
54
+ # 后台任务强引用集合(事件循环只持弱引用,防止任务被 GC 提前回收)
55
+ _background_tasks: set = set()
56
+
57
+
58
+ def _spawn_bg(coro) -> asyncio.Task:
59
+ """create_task + 强引用跟踪,任务结束自动移除。"""
60
+ t = asyncio.create_task(coro)
61
+ _background_tasks.add(t)
62
+ t.add_done_callback(_background_tasks.discard)
63
+ return t
64
+
65
+
66
+ # 已见消息 ID(LRU,上限 1000)——服务端重投/重启重放时跳过重复处理
67
+ _seen_msg_ids: "OrderedDict[str, None]" = OrderedDict()
68
+ _SEEN_MSG_IDS_CAP = 1000
69
+
70
+
71
+ # 去重键字段优先级,对齐官方 WeixinMessage schema(Tencent/openclaw-weixin types.ts):
72
+ # message_id(数值) > client_id > item_list[0].msg_id > seq
73
+ def _msg_dedup_key(msg: dict) -> str | None:
74
+ """从入站消息提取去重键;服务端不下发任何 id 字段时返回 None(无法安全去重)。"""
75
+ for field in ("message_id", "client_id"):
76
+ v = msg.get(field)
77
+ if v:
78
+ return f"{field}:{v}"
79
+ items = msg.get("item_list")
80
+ if isinstance(items, list) and items and isinstance(items[0], dict):
81
+ v = items[0].get("msg_id")
82
+ if v:
83
+ return f"item_msg_id:{v}"
84
+ v = msg.get("seq")
85
+ if v:
86
+ return f"seq:{v}"
87
+ return None
88
+
89
+
90
+ _dedup_field_announced = False
91
+
92
+
93
+ def _is_duplicate_msg(msg: dict) -> bool:
94
+ global _dedup_field_announced
95
+ key = _msg_dedup_key(msg)
96
+ if not _dedup_field_announced:
97
+ _dedup_field_announced = True
98
+ if key:
99
+ logger.info("消息去重已启用,使用字段: %s", key.split(":", 1)[0])
100
+ else:
101
+ logger.info("服务端未下发消息 id 字段,去重停用(依赖服务端游标)")
102
+ if key is None:
103
+ return False
104
+ if key in _seen_msg_ids:
105
+ return True
106
+ _seen_msg_ids[key] = None
107
+ _seen_msg_ids.move_to_end(key)
108
+ while len(_seen_msg_ids) > _SEEN_MSG_IDS_CAP:
109
+ _seen_msg_ids.popitem(last=False)
110
+ return False
111
+
112
+
47
113
  # ---------------------------------------------------------------------------
48
114
  # Backend dispatcher — routes to agy or grok based on per-user preference
49
115
  # ---------------------------------------------------------------------------
@@ -65,10 +131,16 @@ async def _run_llm(prompt: str, user_id: str) -> tuple[str, list]:
65
131
  return await run_agy(prompt, user_id)
66
132
 
67
133
 
68
- async def _handle_slash(text: str, user_id: str) -> str | None:
134
+ async def _handle_slash(client: ILinkClient, text: str, user_id: str, context_token: str):
69
135
  """Dispatch slash command to the active backend's handler.
70
136
 
71
- /backend is a meta-command handled here, not delegated to backends.
137
+ /backend /agent 是元指令,在这里处理,不下发给后端。
138
+
139
+ 返回值约定:
140
+ str — 直接作为回复文本
141
+ tuple — (reply, artifacts),经确认门后的执行结果
142
+ None — D 类透传,调用方应把原文交给 gate_and_run
143
+ _HANDLED — 已完整处理(如 /agent 进入确认流程),调用方直接 return
72
144
  """
73
145
  parts = text.strip().split(maxsplit=1)
74
146
  cmd = parts[0].lower() if parts else ""
@@ -78,6 +150,10 @@ async def _handle_slash(text: str, user_id: str) -> str | None:
78
150
  if cmd == "/backend":
79
151
  return _cmd_backend(args, user_id)
80
152
 
153
+ # /agent 也在这里处理:统一走 gate_and_run 确认门,不能绕开 is_dangerous
154
+ if cmd == "/agent":
155
+ return await _cmd_agent(client, args, user_id, context_token)
156
+
81
157
  if cmd == "/version":
82
158
  return (
83
159
  f"📦 **版本信息** 📦\n\n当前版本: `{__version__}`\n"
@@ -134,6 +210,26 @@ def _cmd_backend(args: str, user_id: str) -> str:
134
210
  )
135
211
 
136
212
 
213
+ async def _cmd_agent(client: ILinkClient, args: str, user_id: str, context_token: str):
214
+ """Handle /agent <名称> <任务> — 调用子代理执行任务。
215
+
216
+ 必须经过 gate_and_run 的危险确认门(历史上后端各自实现时绕过了该检查)。
217
+ """
218
+ if not config.enable_subagent:
219
+ return "ℹ️ **该功能已禁用** ℹ️"
220
+ if not args.strip():
221
+ return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
222
+ agent_parts = args.split(maxsplit=1)
223
+ agent_name = agent_parts[0]
224
+ agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
225
+ crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
226
+ logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
227
+ result = await gate_and_run(client, user_id, context_token, crafted)
228
+ if result is None:
229
+ return _HANDLED # 已进入危险确认流程
230
+ return result # (reply, artifacts)
231
+
232
+
137
233
  # ---------------------------------------------------------------------------
138
234
  # Image file extension detection
139
235
  # ---------------------------------------------------------------------------
@@ -217,7 +313,6 @@ async def login_flow(client: ILinkClient) -> bool:
217
313
  bot_token, baseurl = await client.poll_qrcode_status(
218
314
  qrcode_str,
219
315
  timeout=config.qrcode_poll_timeout,
220
- interval=config.qrcode_poll_interval,
221
316
  )
222
317
  client.state.bot_token = bot_token
223
318
  client.state.baseurl = baseurl
@@ -382,14 +477,20 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
382
477
  await maybe_notify_admin(client, from_user, context_token)
383
478
 
384
479
  # ---- Pending dangerous prompt confirmation ----
480
+ # 确认匹配用文本或语音转写(语音消息也可以回确认口令)
481
+ reply_text = text.strip() or voice_text.strip()
482
+ cancelled_notice = ""
385
483
  pending = pending_confirms.get(from_user)
386
484
  if pending:
387
485
  expired = time.time() >= pending["expire_at"]
388
- if not expired and text.strip().lower() == config.confirm_token.lower():
486
+ if not expired and reply_text.lower() == config.confirm_token.lower():
389
487
  # User confirmed → run pending prompt, send reply, return
390
488
  logger.info("[AUDIT] user=%s confirmed dangerous prompt", from_user)
391
- reply, artifacts = await _run_llm(pending["prompt"], from_user)
489
+ # 先删再执行:执行异常也不能让陈旧 pending 被后续消息误触发
392
490
  del pending_confirms[from_user]
491
+ if image_media or file_media:
492
+ logger.info("确认回复携带媒体,媒体部分被忽略 from=%s", from_user)
493
+ reply, artifacts = await _run_llm(pending["prompt"], from_user)
393
494
  # Send reply
394
495
  success = await client.send_message(
395
496
  to_user_id=from_user,
@@ -409,6 +510,10 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
409
510
  if expired:
410
511
  # Expired: don't reply, continue normal flow
411
512
  logger.info("[AUDIT] user=%s pending expired, continue normal flow", from_user)
513
+ elif image_media or file_media:
514
+ # 用户发来新媒体内容:取消待确认并继续处理本条消息(不静默丢弃)
515
+ logger.info("[AUDIT] user=%s cancelled pending by sending new media", from_user)
516
+ cancelled_notice = "🚫 已取消待确认的危险操作。\n\n"
412
517
  else:
413
518
  # User explicitly cancelled: reply cancelled, return
414
519
  logger.info("[AUDIT] user=%s cancelled dangerous prompt", from_user)
@@ -462,7 +567,9 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
462
567
 
463
568
  except Exception as e:
464
569
  logger.exception("图片下载/解密失败: %s", e)
465
- reply = format_error("图片下载或解密失败", str(e))
570
+ # ValueError 是自定义的中文可读原因;其他异常(httpx 等)含内部 URL,不外发
571
+ detail = str(e) if isinstance(e, ValueError) else "请重新发送图片。"
572
+ reply = format_error("图片下载或解密失败", detail)
466
573
 
467
574
  # ---- Case 1.5: Message contains a file (non-image) ----
468
575
  elif file_media:
@@ -500,7 +607,9 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
500
607
 
501
608
  except Exception as e:
502
609
  logger.exception("文件下载/解密失败: %s", e)
503
- reply = format_error("文件下载或解密失败", str(e))
610
+ # ValueError 是自定义的中文可读原因;其他异常(httpx 等)含内部 URL,不外发
611
+ detail = str(e) if isinstance(e, ValueError) else "请重新发送文件。"
612
+ reply = format_error("文件下载或解密失败", detail)
504
613
 
505
614
  # ---- Case 1.6: Voice message (text transcription passthrough) ----
506
615
  elif has_voice:
@@ -526,13 +635,20 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
526
635
  # Slash command interception
527
636
  if text.startswith("/"):
528
637
  logger.info("Slash command from=%s: %.200s", from_user, text)
529
- reply = await _handle_slash(text, from_user)
530
- if reply is None:
638
+ handled = await _handle_slash(client, text, from_user, context_token)
639
+ if handled is _HANDLED:
640
+ return
641
+ if handled is None:
531
642
  # D class: passthrough — run CLI normally
532
643
  result = await gate_and_run(client, from_user, context_token, text)
533
644
  if result is None:
534
645
  return
535
646
  reply, artifacts = result
647
+ elif isinstance(handled, tuple):
648
+ # /agent 等元指令经确认门后的执行结果
649
+ reply, artifacts = handled
650
+ else:
651
+ reply = handled
536
652
  else:
537
653
  result = await gate_and_run(client, from_user, context_token, text)
538
654
  if result is None:
@@ -542,7 +658,7 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
542
658
  # ---- Send reply via iLink ----
543
659
  success = await client.send_message(
544
660
  to_user_id=from_user,
545
- text=reply,
661
+ text=cancelled_notice + reply,
546
662
  context_token=context_token,
547
663
  baseurl=client.state.baseurl,
548
664
  bot_token=client.state.bot_token,
@@ -594,7 +710,8 @@ async def periodic_clean_scratch():
594
710
  while True:
595
711
  try:
596
712
  await asyncio.sleep(3600)
597
- clean_scratch()
713
+ # 同步文件遍历放到线程池,避免阻塞事件循环卡死长轮询心跳
714
+ await asyncio.to_thread(clean_scratch)
598
715
  except Exception as e:
599
716
  logger.exception("periodic_clean_scratch error: %s", e)
600
717
 
@@ -602,17 +719,26 @@ async def periodic_clean_scratch():
602
719
  async def main_loop() -> None:
603
720
  """Main daemon loop: manages state, QR login, and message receiving."""
604
721
  ensure_runtime_dirs()
605
- clean_scratch()
722
+ # 同步文件遍历放到线程池,避免阻塞事件循环
723
+ await asyncio.to_thread(clean_scratch)
606
724
  if config.update_check_enabled:
607
- asyncio.create_task(update_check_loop())
608
- asyncio.create_task(periodic_clean_scratch())
725
+ _spawn_bg(update_check_loop())
726
+ _spawn_bg(periodic_clean_scratch())
609
727
  while True:
610
728
  client = ILinkClient()
729
+ # 本轮 client 的在途消息任务(强引用持有,relogin 前排空,避免拿死连接发消息)
730
+ msg_tasks: set = set()
611
731
  try:
612
732
  state_loaded = client.state.load()
613
733
 
614
734
  if not state_loaded or not client.state.bot_token:
615
- success = await login_flow(client)
735
+ try:
736
+ success = await login_flow(client)
737
+ except Exception as e:
738
+ # 网络抖动/服务端异常不应直接杀死 daemon
739
+ logger.exception("登录流程异常,5 秒后重试: %s", e)
740
+ await asyncio.sleep(5)
741
+ continue
616
742
  if not success:
617
743
  logger.warning("扫码超时,3 秒后重新获取二维码等待扫码")
618
744
  await asyncio.sleep(3)
@@ -624,19 +750,22 @@ async def main_loop() -> None:
624
750
 
625
751
  # Inner loop: long-poll for messages
626
752
  get_updates_buf = ""
753
+ fail_delay = 0.5 # 网络异常指数退避,封顶 30s
627
754
  while True:
628
755
  try:
629
756
  msgs, new_buf = await client.get_updates(
630
757
  get_updates_buf, baseurl, bot_token
631
758
  )
759
+ fail_delay = 0.5 # 成功一次即重置退避
632
760
  except Exception as e:
633
761
  # Token invalidated (401/403) → break for re-login
634
762
  logger.exception("长轮询异常: %s", e)
635
763
  if not client.state.bot_token:
636
764
  logger.warning("Bot token 已失效,准备重新登录")
637
765
  break
638
- # Network hiccup → short delay and retry
639
- await asyncio.sleep(0.5)
766
+ # Network hiccup → 指数退避后重试
767
+ await asyncio.sleep(fail_delay)
768
+ fail_delay = min(fail_delay * 2, 30.0)
640
769
  continue
641
770
 
642
771
  # Always update cursor with the server-returned value
@@ -645,8 +774,14 @@ async def main_loop() -> None:
645
774
  for msg in msgs:
646
775
  msg_type = msg.get("message_type", 0)
647
776
  if msg_type == 1: # User message
777
+ if _is_duplicate_msg(msg):
778
+ logger.info("跳过重复投递的消息: %s", _msg_dedup_key(msg))
779
+ continue
780
+ logger.debug("inbound msg keys: %s", sorted(msg.keys()))
648
781
  # Non-blocking async task creation: process message in background
649
- asyncio.create_task(_safe_process_message(client, msg))
782
+ t = asyncio.create_task(_safe_process_message(client, msg))
783
+ msg_tasks.add(t)
784
+ t.add_done_callback(msg_tasks.discard)
650
785
  else:
651
786
  logger.debug(
652
787
  "跳过 message_type=%s", msg_type
@@ -659,6 +794,19 @@ async def main_loop() -> None:
659
794
  logger.info("收到退出信号")
660
795
  raise
661
796
  finally:
797
+ # 先排空/取消在途消息任务,再关连接,避免任务拿死 client 静默失败
798
+ if msg_tasks:
799
+ snapshot = set(msg_tasks)
800
+ logger.info(
801
+ "等待 %d 个在途消息任务完成(最长 %.0fs)...",
802
+ len(snapshot), _DRAIN_TIMEOUT_S,
803
+ )
804
+ _, still_pending = await asyncio.wait(snapshot, timeout=_DRAIN_TIMEOUT_S)
805
+ if still_pending:
806
+ logger.warning("强制取消 %d 个未完成的在途任务", len(still_pending))
807
+ for t in still_pending:
808
+ t.cancel()
809
+ await asyncio.gather(*still_pending, return_exceptions=True)
662
810
  await client.close()
663
811
 
664
812
  # Decide whether to re-login or exit
@@ -22,8 +22,11 @@ logger = logging.getLogger("wechatbridge.runner")
22
22
 
23
23
  # ANSI escape code pattern
24
24
  ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
25
- # HTML tag pattern
26
- HTML_TAG_RE = re.compile(r"<[^>]+>")
25
+ # HTML tag pattern — 白名单制,避免把代码输出里的 <String>、<T> 等泛型当标签误删
26
+ HTML_TAG_RE = re.compile(
27
+ r"</?(?:b|i|em|strong|code|pre|a|br|div|span|p|u|s|ul|ol|li|table|thead|tbody|tr|td|th|h[1-6]|blockquote|font|center|hr)(?:\s[^<>]*)?/?>",
28
+ re.IGNORECASE,
29
+ )
27
30
 
28
31
  # Sensitive env var prefixes to strip from child process environments
29
32
  _SENSITIVE_PREFIXES = (
@@ -32,7 +35,8 @@ _SENSITIVE_PREFIXES = (
32
35
  )
33
36
  # Segment names that mark a var as secret when they appear as a path part
34
37
  _SENSITIVE_SEGMENTS = frozenset({
35
- "TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD",
38
+ "TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD", "PASS", "PWD",
39
+ "AUTH", "CRED", "CREDS", "PRIVATE",
36
40
  "CREDENTIAL", "CREDENTIALS", "APIKEY", "API_KEY",
37
41
  })
38
42
  _SENSITIVE_SUFFIXES = (
@@ -482,8 +486,11 @@ def save_prefs(user_id: str, prefs: dict) -> None:
482
486
  payload["by_backend"].setdefault(b, _empty_backend_slot())
483
487
  # Current backend slot always mirrors active model/effort/mode
484
488
  sync_active_to_memory(payload)
485
- with open(prefs_path, "w") as f:
489
+ # tmp + os.replace 原子写,避免崩溃留下半截 prefs.json
490
+ tmp_path = prefs_path + ".tmp"
491
+ with open(tmp_path, "w") as f:
486
492
  json.dump(payload, f, ensure_ascii=False, indent=2)
493
+ os.replace(tmp_path, prefs_path)
487
494
  # Keep caller's dict in sync with what was written
488
495
  prefs.update(payload)
489
496
  except OSError as e:
@@ -625,7 +632,9 @@ def _remove_old_files_under(root: str, cutoff: float) -> int:
625
632
  try:
626
633
  if not os.path.isfile(path) and not os.path.islink(path):
627
634
  continue
628
- if os.path.getmtime(path) < cutoff:
635
+ # lstat 不跟随符号链接:悬空链接(如旧 venv 的 bin/python)
636
+ # 也能按链接自身 mtime 正常过期,不再每次刷 warning
637
+ if os.lstat(path).st_mtime < cutoff:
629
638
  os.remove(path)
630
639
  removed += 1
631
640
  logger.info("Session cleanup: removed %s", path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT