bot.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """Bot 子进程入口:启动 WebSocket 长连接 + 限流 Worker。
  2. 被 supervisor.py 以子进程方式启动,stdout/stderr 被 supervisor 捕获用于日志和状态采集。
  3. 定期向 stdout 输出 __STATUS__{...} JSON 行,supervisor 解析后暴露给面板。
  4. 也可独立运行:python bot.py
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import asyncio
  9. import json
  10. import logging
  11. import signal
  12. import sys
  13. import threading
  14. import time
  15. from typing import Optional
  16. import lark_oapi as lark
  17. from config import Config, load_config
  18. from dedup import MessageDedup
  19. from forwarder import Forwarder, build_event_handler
  20. from ratelimit import RateLimitedWorker
  21. logger = logging.getLogger("lark2lark")
  22. # 状态行前缀,supervisor 据此识别
  23. STATUS_PREFIX = "__STATUS__"
  24. _STATUS_INTERVAL = 5.0 # 秒
  25. # 全局引用,供状态输出线程读取
  26. _dedup: Optional[MessageDedup] = None
  27. _worker: Optional[RateLimitedWorker] = None
  28. _ws_connected = threading.Event()
  29. def setup_logging(level: str) -> None:
  30. logging.basicConfig(
  31. level=getattr(logging, level, logging.INFO),
  32. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  33. datefmt="%Y-%m-%d %H:%M:%S",
  34. stream=sys.stdout, # 关键:输出到 stdout 供 supervisor 捕获
  35. )
  36. logging.getLogger("lark-oapi").setLevel(logging.WARNING)
  37. def parse_args() -> argparse.Namespace:
  38. p = argparse.ArgumentParser(description="飞书群聊自动转发机器人(子进程)")
  39. p.add_argument("--config", "-c", default=None, help="配置文件路径")
  40. return p.parse_args()
  41. def start_ws_client(cfg: Config, event_handler, ready: threading.Event) -> threading.Thread:
  42. """在子线程启动 lark WebSocket 客户端。"""
  43. def _run() -> None:
  44. cli = lark.ws.Client(
  45. cfg.app_id,
  46. cfg.app_secret,
  47. event_handler=event_handler,
  48. log_level=lark.LogLevel.INFO,
  49. )
  50. ready.set()
  51. _ws_connected.set()
  52. logger.info("WebSocket 客户端启动")
  53. try:
  54. cli.start()
  55. except Exception as e:
  56. logger.exception("WebSocket 客户端异常退出: %s", e)
  57. finally:
  58. _ws_connected.clear()
  59. t = threading.Thread(target=_run, name="lark-ws", daemon=True)
  60. t.start()
  61. return t
  62. def _status_loop(start_time: float, stop_event: threading.Event) -> None:
  63. """每 5 秒向 stdout 输出 __STATUS__{...} 行,供 supervisor 采集。"""
  64. while not stop_event.wait(_STATUS_INTERVAL):
  65. status = {
  66. "ts": int(time.time()),
  67. "uptime": int(time.time() - start_time),
  68. "ws_connected": _ws_connected.is_set(),
  69. "queue_size": 0,
  70. "dedup_size": 0,
  71. }
  72. if _worker is not None:
  73. # asyncio.Queue 没有线程安全的 qsize 跨线程读,但底层有 _queue
  74. try:
  75. status["queue_size"] = _worker._queue.qsize() # type: ignore[union-attr]
  76. except Exception:
  77. pass
  78. if _dedup is not None:
  79. stats = _dedup.stats()
  80. status["dedup_size"] = stats["size"]
  81. status["dedup_max"] = stats["max_size"]
  82. sys.stdout.write(STATUS_PREFIX + json.dumps(status, ensure_ascii=False) + "\n")
  83. sys.stdout.flush()
  84. async def main_async(cfg: Config) -> None:
  85. global _dedup, _worker
  86. loop = asyncio.get_running_loop()
  87. client = lark.Client.builder().app_id(cfg.app_id).app_secret(cfg.app_secret).build()
  88. _dedup = MessageDedup(max_size=cfg.dedup_cache_size)
  89. _worker = RateLimitedWorker(max_qps=cfg.max_qps, max_retry=cfg.max_retry)
  90. forwarder = Forwarder(cfg, client, _dedup, _worker, loop)
  91. _worker.set_forward_fn(forwarder.forward)
  92. stop_event = asyncio.Event()
  93. worker_task = asyncio.create_task(run_worker(_worker, stop_event))
  94. event_handler = build_event_handler(forwarder)
  95. ws_ready = threading.Event()
  96. ws_thread = start_ws_client(cfg, event_handler, ws_ready)
  97. ws_ready.wait(timeout=5)
  98. # 状态输出线程
  99. start_time = time.time()
  100. status_stop = threading.Event()
  101. status_thread = threading.Thread(
  102. target=_status_loop, args=(start_time, status_stop),
  103. name="status", daemon=True,
  104. )
  105. status_thread.start()
  106. def _on_signal(*_):
  107. logger.info("收到退出信号,开始关闭...")
  108. status_stop.set()
  109. stop_event.set()
  110. try:
  111. signal.signal(signal.SIGINT, _on_signal)
  112. signal.signal(signal.SIGTERM, _on_signal)
  113. except (ValueError, AttributeError):
  114. pass
  115. await worker_task
  116. status_stop.set()
  117. logger.info("Bot 已停止")
  118. async def run_worker(worker: RateLimitedWorker, stop_event: asyncio.Event) -> None:
  119. task = asyncio.create_task(worker.run())
  120. await stop_event.wait()
  121. task.cancel()
  122. try:
  123. await task
  124. except asyncio.CancelledError:
  125. pass
  126. def main() -> None:
  127. args = parse_args()
  128. cfg = load_config(args.config)
  129. setup_logging(cfg.log_level)
  130. logger.info("Bot 启动:源群 %d 个,目标群 %d 个,QPS=%d",
  131. len(cfg.source_chat_ids), len(cfg.target_chat_ids), cfg.max_qps)
  132. try:
  133. asyncio.run(main_async(cfg))
  134. except KeyboardInterrupt:
  135. logger.info("已退出")
  136. if __name__ == "__main__":
  137. main()