supervisor.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. from __future__ import annotations
  2. import asyncio
  3. import os
  4. import re
  5. import socket
  6. import sys
  7. from dataclasses import dataclass
  8. from datetime import UTC, datetime
  9. from pathlib import Path
  10. from typing import Any
  11. from aiohttp import ClientError, ClientSession, ClientTimeout
  12. from wbb.admin.bot_config import (
  13. BotConfigError,
  14. get_bot_profile_secrets,
  15. telegram_config_status,
  16. )
  17. @dataclass
  18. class BotProcess:
  19. bot_id: str
  20. port: int
  21. process: asyncio.subprocess.Process
  22. state: str = "starting"
  23. started_at: str = ""
  24. error: str = ""
  25. expected_stop: bool = False
  26. monitor_task: asyncio.Task[None] | None = None
  27. readiness_task: asyncio.Task[None] | None = None
  28. class BotSupervisor:
  29. def __init__(self, config_path: str | Path, *, project_root: str | Path) -> None:
  30. self.config_path = Path(config_path).resolve()
  31. self.project_root = Path(project_root).resolve()
  32. self._processes: dict[str, BotProcess] = {}
  33. self._lock = asyncio.Lock()
  34. def runtimes(self) -> dict[str, dict[str, Any]]:
  35. values: dict[str, dict[str, Any]] = {}
  36. for bot_id, item in self._processes.items():
  37. state = item.state
  38. if item.process.returncode is not None and state not in {"failed", "stopped"}:
  39. state = "stopped" if item.expected_stop else "failed"
  40. values[bot_id] = {
  41. "state": state,
  42. "pid": item.process.pid,
  43. "port": item.port,
  44. "started_at": item.started_at,
  45. "error": item.error,
  46. }
  47. return values
  48. def endpoint_for(self, bot_id: str) -> str | None:
  49. item = self._processes.get(str(bot_id))
  50. if not item or item.state != "running" or item.process.returncode is not None:
  51. return None
  52. return f"http://127.0.0.1:{item.port}"
  53. async def start_enabled(self) -> None:
  54. status = telegram_config_status(self.config_path)
  55. for profile in status["bots"]:
  56. if profile["enabled"] and profile["ready_to_connect"]:
  57. await self.start(profile["bot_id"])
  58. async def start(self, bot_id: str) -> dict[str, Any]:
  59. async with self._lock:
  60. existing = self._processes.get(str(bot_id))
  61. if existing and existing.process.returncode is None:
  62. return self.runtimes()[str(bot_id)]
  63. profile = get_bot_profile_secrets(self.config_path, bot_id)
  64. if not profile.get("enabled", True):
  65. raise BotConfigError("bot_disabled", "该机器人已停用。")
  66. if not profile.get("api_id") or not profile.get("api_hash"):
  67. raise BotConfigError(
  68. "telegram_api_required",
  69. "请先配置 Telegram 应用编号和应用密钥。",
  70. )
  71. port = self._available_port()
  72. environment = self._worker_environment(profile, port)
  73. process = await asyncio.create_subprocess_exec(
  74. sys.executable,
  75. str(self.project_root / "launcher.py"),
  76. "--worker",
  77. str(bot_id),
  78. "--port",
  79. str(port),
  80. cwd=self.project_root,
  81. env=environment,
  82. )
  83. item = BotProcess(
  84. bot_id=str(bot_id),
  85. port=port,
  86. process=process,
  87. started_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
  88. )
  89. self._processes[str(bot_id)] = item
  90. item.monitor_task = asyncio.create_task(self._monitor(item))
  91. item.readiness_task = asyncio.create_task(self._wait_until_ready(item))
  92. return self.runtimes()[str(bot_id)]
  93. async def stop(self, bot_id: str) -> dict[str, Any]:
  94. async with self._lock:
  95. item = self._processes.get(str(bot_id))
  96. if not item or item.process.returncode is not None:
  97. return {"state": "stopped"}
  98. item.expected_stop = True
  99. item.state = "stopping"
  100. item.process.terminate()
  101. try:
  102. await asyncio.wait_for(item.process.wait(), timeout=10)
  103. except TimeoutError:
  104. item.process.kill()
  105. await item.process.wait()
  106. item.state = "stopped"
  107. return self.runtimes()[str(bot_id)]
  108. async def restart(self, bot_id: str) -> dict[str, Any]:
  109. await self.stop(bot_id)
  110. return await self.start(bot_id)
  111. async def reconcile(self, bot_id: str) -> dict[str, Any]:
  112. profile = get_bot_profile_secrets(self.config_path, bot_id)
  113. if profile.get("enabled", True) and profile.get("api_id") and profile.get("api_hash"):
  114. return await self.restart(bot_id)
  115. return await self.stop(bot_id)
  116. async def close(self) -> None:
  117. for bot_id in list(self._processes):
  118. await self.stop(bot_id)
  119. async def _monitor(self, item: BotProcess) -> None:
  120. return_code = await item.process.wait()
  121. if item.readiness_task and not item.readiness_task.done():
  122. item.readiness_task.cancel()
  123. if item.expected_stop:
  124. item.state = "stopped"
  125. return
  126. item.state = "failed"
  127. item.error = f"Bot 工作进程退出,状态码 {return_code}。"
  128. async def _wait_until_ready(self, item: BotProcess) -> None:
  129. timeout = ClientTimeout(total=2)
  130. async with ClientSession(timeout=timeout) as session:
  131. for _ in range(45):
  132. if item.process.returncode is not None:
  133. return
  134. try:
  135. async with session.get(
  136. f"http://127.0.0.1:{item.port}/api/admin/v1/health"
  137. ) as response:
  138. if response.status == 200:
  139. item.state = "running"
  140. item.error = ""
  141. return
  142. except (ClientError, TimeoutError):
  143. pass
  144. await asyncio.sleep(1)
  145. if item.process.returncode is None:
  146. item.state = "failed"
  147. item.error = "Bot 已连接,但内部管理接口未在限定时间内就绪。"
  148. def _worker_environment(self, profile: dict[str, Any], port: int) -> dict[str, str]:
  149. environment = os.environ.copy()
  150. environment.update(
  151. {
  152. "WBB_ADMIN_BOOTSTRAP": "0",
  153. "WBB_SUPERVISOR_MODE": "0",
  154. "WBB_BOT_WORKER": "1",
  155. "WBB_BOT_PROFILE_ID": str(profile["bot_id"]),
  156. "WBB_BOT_DATABASE": self._worker_database_name(profile["bot_id"]),
  157. "BOT_TOKEN": str(profile["bot_token"]),
  158. "API_ID": str(profile["api_id"]),
  159. "API_HASH": str(profile["api_hash"]),
  160. "USERBOT_ENABLED": "0",
  161. "SESSION_STRING": "",
  162. "PHONE_NUMBER": "",
  163. "SUDO_USERS_ID": " ".join(str(item) for item in profile["sudo_users_id"]),
  164. "LOG_GROUP_ID": str(profile["log_group_id"]),
  165. "GBAN_LOG_GROUP_ID": str(profile["gban_log_group_id"]),
  166. "MESSAGE_DUMP_CHAT": str(profile["message_dump_chat"]),
  167. "ADMIN_WEB_ENABLED": "1",
  168. "ADMIN_WEB_HOST": "127.0.0.1",
  169. "ADMIN_WEB_PORT": str(port),
  170. "BOT_PROFILES_PATH": str(self.config_path),
  171. }
  172. )
  173. return environment
  174. @staticmethod
  175. def _worker_database_name(bot_id: str) -> str:
  176. storage_key = re.sub(r"[^A-Za-z0-9_-]", "_", str(bot_id))[:48]
  177. if not storage_key:
  178. raise BotConfigError(
  179. "invalid_bot_id",
  180. "机器人编号无法用于创建独立数据空间。",
  181. )
  182. return f"wbb_bot_{storage_key}"
  183. @staticmethod
  184. def _available_port() -> int:
  185. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
  186. sock.bind(("127.0.0.1", 0))
  187. return int(sock.getsockname()[1])