bot_config.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. from __future__ import annotations
  2. import json
  3. import re
  4. import tempfile
  5. import threading
  6. from datetime import UTC, datetime
  7. from pathlib import Path
  8. from typing import Any
  9. from uuid import uuid4
  10. from aiohttp import ClientError, ClientSession, ClientTimeout
  11. BOT_TOKEN_PATTERN = re.compile(r"^\d{5,}:[A-Za-z0-9_-]{20,}$")
  12. API_HASH_PATTERN = re.compile(r"^[A-Fa-f0-9]{32}$")
  13. PROFILE_FIELDS = {
  14. "sudo_users_id",
  15. "log_group_id",
  16. "gban_log_group_id",
  17. "message_dump_chat",
  18. }
  19. _STORE_LOCK = threading.RLock()
  20. class BotConfigError(ValueError):
  21. def __init__(self, code: str, message: str):
  22. super().__init__(message)
  23. self.code = code
  24. def _empty_document() -> dict[str, Any]:
  25. return {
  26. "version": 1,
  27. "telegram": {"api_id": 0, "api_hash": ""},
  28. "bots": [],
  29. }
  30. def _read_document(path: str | Path) -> dict[str, Any]:
  31. config_path = Path(path)
  32. if not config_path.exists():
  33. return _empty_document()
  34. try:
  35. data = json.loads(config_path.read_text(encoding="utf-8"))
  36. except (OSError, json.JSONDecodeError) as exc:
  37. raise BotConfigError(
  38. "bot_config_corrupt",
  39. "机器人配置文件无法读取。",
  40. ) from exc
  41. if not isinstance(data, dict) or not isinstance(data.get("bots", []), list):
  42. raise BotConfigError("bot_config_corrupt", "机器人配置文件结构无效。")
  43. telegram = data.get("telegram")
  44. if not isinstance(telegram, dict):
  45. telegram = {}
  46. return {
  47. "version": 1,
  48. "telegram": {
  49. "api_id": int(telegram.get("api_id") or 0),
  50. "api_hash": str(telegram.get("api_hash") or ""),
  51. },
  52. "bots": [item for item in data.get("bots", []) if isinstance(item, dict)],
  53. }
  54. def _write_document(path: str | Path, data: dict[str, Any]) -> None:
  55. config_path = Path(path).resolve()
  56. config_path.parent.mkdir(parents=True, exist_ok=True)
  57. with tempfile.NamedTemporaryFile(
  58. mode="w",
  59. encoding="utf-8",
  60. dir=config_path.parent,
  61. delete=False,
  62. ) as handle:
  63. temporary_path = Path(handle.name)
  64. json.dump(data, handle, ensure_ascii=True, indent=2)
  65. handle.write("\n")
  66. try:
  67. temporary_path.chmod(0o600)
  68. temporary_path.replace(config_path)
  69. config_path.chmod(0o600)
  70. finally:
  71. temporary_path.unlink(missing_ok=True)
  72. def _integer(value: Any, name: str, *, positive: bool = False) -> int:
  73. try:
  74. parsed = int(value)
  75. except (TypeError, ValueError) as exc:
  76. raise BotConfigError("invalid_bot_config", f"{name} 必须是整数。") from exc
  77. if positive and parsed <= 0:
  78. raise BotConfigError("invalid_bot_config", f"{name} 必须大于 0。")
  79. return parsed
  80. def _normalize_sudoers(value: Any) -> list[int]:
  81. items = value if isinstance(value, list) else str(value or "").replace(",", " ").split()
  82. normalized = [_integer(item, "SUDOERS 用户 ID", positive=True) for item in items]
  83. return list(dict.fromkeys(normalized))
  84. def _find_profile(data: dict[str, Any], bot_id: str) -> dict[str, Any]:
  85. profile = next(
  86. (item for item in data["bots"] if str(item.get("bot_id")) == str(bot_id)),
  87. None,
  88. )
  89. if profile is None:
  90. raise BotConfigError("bot_not_found", "未找到该机器人配置。")
  91. return profile
  92. def _public_profile(
  93. profile: dict[str, Any],
  94. *,
  95. api_ready: bool,
  96. runtime: dict[str, Any] | None = None,
  97. ) -> dict[str, Any]:
  98. token_configured = bool(BOT_TOKEN_PATTERN.fullmatch(str(profile.get("bot_token") or "")))
  99. identity = profile.get("identity") if isinstance(profile.get("identity"), dict) else None
  100. return {
  101. "bot_id": str(profile.get("bot_id") or ""),
  102. "label": str(profile.get("label") or ""),
  103. "enabled": bool(profile.get("enabled", True)),
  104. "bot_token_configured": token_configured,
  105. "sudo_users_id": [str(item) for item in profile.get("sudo_users_id", [])],
  106. "log_group_id": str(profile.get("log_group_id") or 0),
  107. "gban_log_group_id": str(profile.get("gban_log_group_id") or 0),
  108. "message_dump_chat": str(profile.get("message_dump_chat") or 0),
  109. "identity": identity,
  110. "ready_to_connect": api_ready and token_configured,
  111. "created_at": profile.get("created_at"),
  112. "updated_at": profile.get("updated_at"),
  113. "runtime": runtime or {"state": "stopped"},
  114. }
  115. def telegram_config_status(
  116. path: str | Path,
  117. *,
  118. runtimes: dict[str, dict[str, Any]] | None = None,
  119. ) -> dict[str, Any]:
  120. with _STORE_LOCK:
  121. data = _read_document(path)
  122. telegram = data["telegram"]
  123. api_id = int(telegram.get("api_id") or 0)
  124. api_hash_configured = bool(API_HASH_PATTERN.fullmatch(str(telegram.get("api_hash") or "")))
  125. api_ready = api_id > 0 and api_hash_configured
  126. runtime_map = runtimes or {}
  127. return {
  128. "api_id": api_id or None,
  129. "api_hash_configured": api_hash_configured,
  130. "api_ready": api_ready,
  131. "bots": [
  132. _public_profile(
  133. profile,
  134. api_ready=api_ready,
  135. runtime=runtime_map.get(str(profile.get("bot_id"))),
  136. )
  137. for profile in data["bots"]
  138. ],
  139. }
  140. def update_telegram_config(path: str | Path, body: dict[str, Any]) -> dict[str, Any]:
  141. with _STORE_LOCK:
  142. data = _read_document(path)
  143. telegram = data["telegram"]
  144. changed = False
  145. if "api_id" in body:
  146. telegram["api_id"] = _integer(
  147. body.get("api_id"), "Telegram App API ID", positive=True
  148. )
  149. changed = True
  150. api_hash = str(body.get("api_hash") or "").strip()
  151. if api_hash:
  152. if not API_HASH_PATTERN.fullmatch(api_hash):
  153. raise BotConfigError(
  154. "invalid_api_hash",
  155. "Telegram 应用密钥必须是 my.telegram.org/apps 提供的 "
  156. "32 位十六进制字符串,不能使用机器人令牌。",
  157. )
  158. telegram["api_hash"] = api_hash
  159. changed = True
  160. if not changed:
  161. raise BotConfigError(
  162. "bot_config_unchanged",
  163. "没有可保存的 Telegram 应用接口配置。",
  164. )
  165. _write_document(path, data)
  166. return telegram_config_status(path)
  167. def create_bot_profile(path: str | Path, body: dict[str, Any]) -> dict[str, Any]:
  168. label = str(body.get("label") or "").strip()
  169. token = str(body.get("bot_token") or "").strip()
  170. if not label or len(label) > 60:
  171. raise BotConfigError(
  172. "invalid_bot_label",
  173. "机器人名称长度需要在 1 到 60 个字符之间。",
  174. )
  175. if not BOT_TOKEN_PATTERN.fullmatch(token):
  176. raise BotConfigError("invalid_bot_token", "机器人令牌格式无效。")
  177. now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  178. profile = {
  179. "bot_id": uuid4().hex,
  180. "label": label,
  181. "bot_token": token,
  182. "enabled": bool(body.get("enabled", True)),
  183. "sudo_users_id": _normalize_sudoers(body.get("sudo_users_id", [])),
  184. "log_group_id": _integer(body.get("log_group_id", 0), "日志群 ID"),
  185. "gban_log_group_id": _integer(body.get("gban_log_group_id", 0), "全局封禁日志群 ID"),
  186. "message_dump_chat": _integer(body.get("message_dump_chat", 0), "媒体中转群 ID"),
  187. "identity": None,
  188. "created_at": now,
  189. "updated_at": now,
  190. }
  191. with _STORE_LOCK:
  192. data = _read_document(path)
  193. data["bots"].append(profile)
  194. _write_document(path, data)
  195. api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
  196. return _public_profile(profile, api_ready=api_ready)
  197. def update_bot_profile(path: str | Path, bot_id: str, body: dict[str, Any]) -> dict[str, Any]:
  198. with _STORE_LOCK:
  199. data = _read_document(path)
  200. profile = _find_profile(data, bot_id)
  201. if "label" in body:
  202. label = str(body.get("label") or "").strip()
  203. if not label or len(label) > 60:
  204. raise BotConfigError(
  205. "invalid_bot_label",
  206. "机器人名称长度需要在 1 到 60 个字符之间。",
  207. )
  208. profile["label"] = label
  209. token = str(body.get("bot_token") or "").strip()
  210. if token:
  211. if not BOT_TOKEN_PATTERN.fullmatch(token):
  212. raise BotConfigError("invalid_bot_token", "机器人令牌格式无效。")
  213. profile["bot_token"] = token
  214. profile["identity"] = None
  215. if "enabled" in body:
  216. profile["enabled"] = bool(body.get("enabled"))
  217. if "sudo_users_id" in body:
  218. profile["sudo_users_id"] = _normalize_sudoers(body.get("sudo_users_id"))
  219. for field in PROFILE_FIELDS - {"sudo_users_id"}:
  220. if field in body:
  221. profile[field] = _integer(body.get(field), field)
  222. profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  223. _write_document(path, data)
  224. api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
  225. return _public_profile(profile, api_ready=api_ready)
  226. def delete_bot_profile(path: str | Path, bot_id: str) -> None:
  227. with _STORE_LOCK:
  228. data = _read_document(path)
  229. _find_profile(data, bot_id)
  230. data["bots"] = [
  231. item for item in data["bots"] if str(item.get("bot_id")) != str(bot_id)
  232. ]
  233. _write_document(path, data)
  234. def get_bot_profile_secrets(path: str | Path, bot_id: str) -> dict[str, Any]:
  235. with _STORE_LOCK:
  236. data = _read_document(path)
  237. profile = dict(_find_profile(data, bot_id))
  238. telegram = dict(data["telegram"])
  239. return {**profile, **telegram}
  240. def store_bot_identity(path: str | Path, bot_id: str, identity: dict[str, Any]) -> None:
  241. with _STORE_LOCK:
  242. data = _read_document(path)
  243. profile = _find_profile(data, bot_id)
  244. profile["identity"] = {
  245. "id": str(identity.get("id") or ""),
  246. "username": str(identity.get("username") or ""),
  247. "name": str(identity.get("name") or ""),
  248. }
  249. profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  250. _write_document(path, data)
  251. async def test_bot_token(token: str) -> dict[str, Any]:
  252. if not BOT_TOKEN_PATTERN.fullmatch(token):
  253. raise BotConfigError("invalid_bot_token", "机器人令牌格式无效。")
  254. timeout = ClientTimeout(total=12)
  255. try:
  256. async with ClientSession(timeout=timeout) as session:
  257. async with session.post(f"https://api.telegram.org/bot{token}/getMe") as response:
  258. payload = await response.json(content_type=None)
  259. except (ClientError, TimeoutError, ValueError) as exc:
  260. raise BotConfigError(
  261. "telegram_unreachable",
  262. "无法连接 Telegram 机器人接口。",
  263. ) from exc
  264. if response.status != 200 or not payload.get("ok"):
  265. raise BotConfigError("telegram_auth_failed", "机器人令牌验证失败。")
  266. result = payload.get("result") or {}
  267. return {
  268. "id": str(result.get("id") or ""),
  269. "username": result.get("username") or "",
  270. "name": " ".join(
  271. part for part in (result.get("first_name"), result.get("last_name")) if part
  272. ),
  273. }