bot_config.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  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. from wbb.services.bot_permissions import (
  12. ALL_BOT_PERMISSIONS,
  13. builtin_roles,
  14. normalize_permissions,
  15. permission_catalog,
  16. )
  17. BOT_TOKEN_PATTERN = re.compile(r"^\d{5,}:[A-Za-z0-9_-]{20,}$")
  18. API_HASH_PATTERN = re.compile(r"^[A-Fa-f0-9]{32}$")
  19. PROFILE_FIELDS = {
  20. "sudo_users_id",
  21. "log_group_id",
  22. "gban_log_group_id",
  23. "message_dump_chat",
  24. }
  25. DEFAULT_ASSISTANT_PROVIDER = {
  26. "base_url": "",
  27. "api_key": "",
  28. "model": "",
  29. "timeout_seconds": 30,
  30. "max_output_tokens": 600,
  31. }
  32. _STORE_LOCK = threading.RLock()
  33. class BotConfigError(ValueError):
  34. def __init__(self, code: str, message: str):
  35. super().__init__(message)
  36. self.code = code
  37. def _empty_document() -> dict[str, Any]:
  38. return {
  39. "version": 2,
  40. "telegram": {"api_id": 0, "api_hash": ""},
  41. "roles": [],
  42. "bots": [],
  43. }
  44. def _read_document(path: str | Path) -> dict[str, Any]:
  45. config_path = Path(path)
  46. if not config_path.exists():
  47. return _empty_document()
  48. try:
  49. data = json.loads(config_path.read_text(encoding="utf-8"))
  50. except (OSError, json.JSONDecodeError) as exc:
  51. raise BotConfigError(
  52. "bot_config_corrupt",
  53. "机器人配置文件无法读取。",
  54. ) from exc
  55. if not isinstance(data, dict) or not isinstance(data.get("bots", []), list):
  56. raise BotConfigError("bot_config_corrupt", "机器人配置文件结构无效。")
  57. telegram = data.get("telegram")
  58. if not isinstance(telegram, dict):
  59. telegram = {}
  60. return {
  61. "version": 2,
  62. "telegram": {
  63. "api_id": int(telegram.get("api_id") or 0),
  64. "api_hash": str(telegram.get("api_hash") or ""),
  65. },
  66. "roles": [item for item in data.get("roles", []) if isinstance(item, dict)],
  67. "bots": [item for item in data.get("bots", []) if isinstance(item, dict)],
  68. }
  69. def _write_document(path: str | Path, data: dict[str, Any]) -> None:
  70. config_path = Path(path).resolve()
  71. config_path.parent.mkdir(parents=True, exist_ok=True)
  72. with tempfile.NamedTemporaryFile(
  73. mode="w",
  74. encoding="utf-8",
  75. dir=config_path.parent,
  76. delete=False,
  77. ) as handle:
  78. temporary_path = Path(handle.name)
  79. json.dump(data, handle, ensure_ascii=True, indent=2)
  80. handle.write("\n")
  81. try:
  82. temporary_path.chmod(0o600)
  83. temporary_path.replace(config_path)
  84. config_path.chmod(0o600)
  85. finally:
  86. temporary_path.unlink(missing_ok=True)
  87. def _integer(value: Any, name: str, *, positive: bool = False) -> int:
  88. try:
  89. parsed = int(value)
  90. except (TypeError, ValueError) as exc:
  91. raise BotConfigError("invalid_bot_config", f"{name} 必须是整数。") from exc
  92. if positive and parsed <= 0:
  93. raise BotConfigError("invalid_bot_config", f"{name} 必须大于 0。")
  94. return parsed
  95. def _normalize_sudoers(value: Any) -> list[int]:
  96. items = value if isinstance(value, list) else str(value or "").replace(",", " ").split()
  97. normalized = [_integer(item, "SUDOERS 用户 ID", positive=True) for item in items]
  98. return list(dict.fromkeys(normalized))
  99. def _normalize_assistant_provider(
  100. value: Any,
  101. *,
  102. previous: dict[str, Any] | None = None,
  103. ) -> dict[str, Any]:
  104. if not isinstance(value, dict):
  105. raise BotConfigError("invalid_assistant_provider", "智能接待模型配置格式无效。")
  106. current = {**DEFAULT_ASSISTANT_PROVIDER, **(previous or {})}
  107. if "base_url" in value:
  108. base_url = str(value.get("base_url") or "").strip().rstrip("/")
  109. if base_url and not re.fullmatch(r"https?://[^\s]{1,500}", base_url):
  110. raise BotConfigError(
  111. "invalid_assistant_provider", "OpenAI 兼容 Base URL 必须是 HTTP(S) 地址。"
  112. )
  113. current["base_url"] = base_url
  114. api_key = str(value.get("api_key") or "").strip()
  115. if api_key:
  116. if len(api_key) > 1000:
  117. raise BotConfigError("invalid_assistant_provider", "OpenAI API Key 过长。")
  118. current["api_key"] = api_key
  119. if value.get("clear_api_key") is True:
  120. current["api_key"] = ""
  121. if "model" in value:
  122. model = str(value.get("model") or "").strip()
  123. if len(model) > 200:
  124. raise BotConfigError("invalid_assistant_provider", "模型名称不能超过 200 个字符。")
  125. current["model"] = model
  126. for key, label, minimum, maximum in (
  127. ("timeout_seconds", "模型超时秒数", 5, 120),
  128. ("max_output_tokens", "模型输出 Token 上限", 100, 2000),
  129. ):
  130. if key not in value:
  131. continue
  132. parsed = _integer(value.get(key), label, positive=True)
  133. if not minimum <= parsed <= maximum:
  134. raise BotConfigError(
  135. "invalid_assistant_provider",
  136. f"{label}必须在 {minimum} 到 {maximum} 之间。",
  137. )
  138. current[key] = parsed
  139. return current
  140. def _find_profile(data: dict[str, Any], bot_id: str) -> dict[str, Any]:
  141. profile = next(
  142. (item for item in data["bots"] if str(item.get("bot_id")) == str(bot_id)),
  143. None,
  144. )
  145. if profile is None:
  146. raise BotConfigError("bot_not_found", "未找到该机器人配置。")
  147. return profile
  148. def _all_roles(data: dict[str, Any]) -> list[dict[str, Any]]:
  149. roles = builtin_roles()
  150. builtin_ids = {str(item["role_id"]) for item in roles}
  151. roles.extend(
  152. {
  153. "role_id": str(item.get("role_id") or ""),
  154. "name": str(item.get("name") or ""),
  155. "description": str(item.get("description") or ""),
  156. "permissions": [
  157. permission
  158. for permission in item.get("permissions", [])
  159. if permission in ALL_BOT_PERMISSIONS
  160. ],
  161. "builtin": False,
  162. "created_at": item.get("created_at"),
  163. "updated_at": item.get("updated_at"),
  164. }
  165. for item in data.get("roles", [])
  166. if str(item.get("role_id") or "") not in builtin_ids
  167. )
  168. return roles
  169. def _find_role(data: dict[str, Any], role_id: str) -> dict[str, Any]:
  170. role = next(
  171. (item for item in _all_roles(data) if item["role_id"] == str(role_id)),
  172. None,
  173. )
  174. if role is None:
  175. raise BotConfigError("role_not_found", "未找到该机器人角色。")
  176. return role
  177. def _normalize_role_ids(
  178. value: Any,
  179. data: dict[str, Any],
  180. *,
  181. legacy_default: bool = False,
  182. ) -> list[str]:
  183. if value is None and legacy_default:
  184. return ["full_access"]
  185. items = value if isinstance(value, list) else str(value or "").replace(",", " ").split()
  186. normalized = list(dict.fromkeys(str(item).strip() for item in items if str(item).strip()))
  187. known = {str(item["role_id"]) for item in _all_roles(data)}
  188. unknown = sorted(set(normalized) - known)
  189. if unknown:
  190. raise BotConfigError(
  191. "invalid_bot_roles",
  192. f"包含不存在的机器人角色:{', '.join(unknown)}。",
  193. )
  194. return normalized
  195. def _profile_role_ids(profile: dict[str, Any], data: dict[str, Any]) -> list[str]:
  196. raw = profile.get("role_ids")
  197. if raw is None:
  198. return ["full_access"]
  199. values = raw if isinstance(raw, list) else str(raw or "").replace(",", " ").split()
  200. known = {str(item["role_id"]) for item in _all_roles(data)}
  201. return [
  202. item
  203. for item in dict.fromkeys(str(value) for value in values)
  204. if item in known
  205. ]
  206. def _effective_permissions(
  207. profile: dict[str, Any],
  208. data: dict[str, Any],
  209. ) -> list[str]:
  210. roles = {str(item["role_id"]): item for item in _all_roles(data)}
  211. granted: set[str] = set()
  212. for role_id in _profile_role_ids(profile, data):
  213. granted.update(roles[role_id].get("permissions", []))
  214. return [
  215. item["key"]
  216. for item in permission_catalog()
  217. if item["key"] in granted
  218. ]
  219. def _public_profile(
  220. profile: dict[str, Any],
  221. *,
  222. data: dict[str, Any],
  223. api_ready: bool,
  224. runtime: dict[str, Any] | None = None,
  225. ) -> dict[str, Any]:
  226. token_configured = bool(BOT_TOKEN_PATTERN.fullmatch(str(profile.get("bot_token") or "")))
  227. identity = profile.get("identity") if isinstance(profile.get("identity"), dict) else None
  228. assistant = {
  229. **DEFAULT_ASSISTANT_PROVIDER,
  230. **(
  231. profile.get("business_assistant")
  232. if isinstance(profile.get("business_assistant"), dict)
  233. else {}
  234. ),
  235. }
  236. return {
  237. "bot_id": str(profile.get("bot_id") or ""),
  238. "label": str(profile.get("label") or ""),
  239. "enabled": bool(profile.get("enabled", True)),
  240. "bot_token_configured": token_configured,
  241. "sudo_users_id": [str(item) for item in profile.get("sudo_users_id", [])],
  242. "log_group_id": str(profile.get("log_group_id") or 0),
  243. "gban_log_group_id": str(profile.get("gban_log_group_id") or 0),
  244. "message_dump_chat": str(profile.get("message_dump_chat") or 0),
  245. "role_ids": _profile_role_ids(profile, data),
  246. "permissions": _effective_permissions(profile, data),
  247. "identity": identity,
  248. "business_assistant": {
  249. "base_url": str(assistant.get("base_url") or ""),
  250. "api_key_configured": bool(assistant.get("api_key")),
  251. "model": str(assistant.get("model") or ""),
  252. "timeout_seconds": int(assistant.get("timeout_seconds") or 30),
  253. "max_output_tokens": int(assistant.get("max_output_tokens") or 600),
  254. "configured": bool(
  255. assistant.get("base_url")
  256. and assistant.get("api_key")
  257. and assistant.get("model")
  258. ),
  259. },
  260. "ready_to_connect": api_ready and token_configured,
  261. "created_at": profile.get("created_at"),
  262. "updated_at": profile.get("updated_at"),
  263. "runtime": runtime or {"state": "stopped"},
  264. }
  265. def telegram_config_status(
  266. path: str | Path,
  267. *,
  268. runtimes: dict[str, dict[str, Any]] | None = None,
  269. ) -> dict[str, Any]:
  270. with _STORE_LOCK:
  271. data = _read_document(path)
  272. telegram = data["telegram"]
  273. api_id = int(telegram.get("api_id") or 0)
  274. api_hash_configured = bool(API_HASH_PATTERN.fullmatch(str(telegram.get("api_hash") or "")))
  275. api_ready = api_id > 0 and api_hash_configured
  276. runtime_map = runtimes or {}
  277. return {
  278. "api_id": api_id or None,
  279. "api_hash_configured": api_hash_configured,
  280. "api_ready": api_ready,
  281. "roles": _all_roles(data),
  282. "permission_catalog": permission_catalog(),
  283. "bots": [
  284. _public_profile(
  285. profile,
  286. data=data,
  287. api_ready=api_ready,
  288. runtime=runtime_map.get(str(profile.get("bot_id"))),
  289. )
  290. for profile in data["bots"]
  291. ],
  292. }
  293. def update_telegram_config(path: str | Path, body: dict[str, Any]) -> dict[str, Any]:
  294. with _STORE_LOCK:
  295. data = _read_document(path)
  296. telegram = data["telegram"]
  297. changed = False
  298. if "api_id" in body:
  299. telegram["api_id"] = _integer(
  300. body.get("api_id"), "Telegram App API ID", positive=True
  301. )
  302. changed = True
  303. api_hash = str(body.get("api_hash") or "").strip()
  304. if api_hash:
  305. if not API_HASH_PATTERN.fullmatch(api_hash):
  306. raise BotConfigError(
  307. "invalid_api_hash",
  308. "Telegram 应用密钥必须是 my.telegram.org/apps 提供的 "
  309. "32 位十六进制字符串,不能使用机器人令牌。",
  310. )
  311. telegram["api_hash"] = api_hash
  312. changed = True
  313. if not changed:
  314. raise BotConfigError(
  315. "bot_config_unchanged",
  316. "没有可保存的 Telegram 应用接口配置。",
  317. )
  318. _write_document(path, data)
  319. return telegram_config_status(path)
  320. def create_bot_profile(path: str | Path, body: dict[str, Any]) -> dict[str, Any]:
  321. label = str(body.get("label") or "").strip()
  322. token = str(body.get("bot_token") or "").strip()
  323. if not label or len(label) > 60:
  324. raise BotConfigError(
  325. "invalid_bot_label",
  326. "机器人名称长度需要在 1 到 60 个字符之间。",
  327. )
  328. if not BOT_TOKEN_PATTERN.fullmatch(token):
  329. raise BotConfigError("invalid_bot_token", "机器人令牌格式无效。")
  330. with _STORE_LOCK:
  331. data = _read_document(path)
  332. now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  333. profile = {
  334. "bot_id": uuid4().hex,
  335. "label": label,
  336. "bot_token": token,
  337. "enabled": bool(body.get("enabled", True)),
  338. "sudo_users_id": _normalize_sudoers(body.get("sudo_users_id", [])),
  339. "log_group_id": _integer(body.get("log_group_id", 0), "日志群 ID"),
  340. "gban_log_group_id": _integer(
  341. body.get("gban_log_group_id", 0), "全局封禁日志群 ID"
  342. ),
  343. "message_dump_chat": _integer(
  344. body.get("message_dump_chat", 0), "媒体中转群 ID"
  345. ),
  346. "role_ids": _normalize_role_ids(
  347. body.get("role_ids"),
  348. data,
  349. legacy_default="role_ids" not in body,
  350. ),
  351. "identity": None,
  352. "business_assistant": _normalize_assistant_provider(
  353. body.get("business_assistant", {})
  354. ),
  355. "created_at": now,
  356. "updated_at": now,
  357. }
  358. data["bots"].append(profile)
  359. _write_document(path, data)
  360. api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
  361. return _public_profile(profile, data=data, api_ready=api_ready)
  362. def update_bot_profile(path: str | Path, bot_id: str, body: dict[str, Any]) -> dict[str, Any]:
  363. with _STORE_LOCK:
  364. data = _read_document(path)
  365. profile = _find_profile(data, bot_id)
  366. if "label" in body:
  367. label = str(body.get("label") or "").strip()
  368. if not label or len(label) > 60:
  369. raise BotConfigError(
  370. "invalid_bot_label",
  371. "机器人名称长度需要在 1 到 60 个字符之间。",
  372. )
  373. profile["label"] = label
  374. token = str(body.get("bot_token") or "").strip()
  375. if token:
  376. if not BOT_TOKEN_PATTERN.fullmatch(token):
  377. raise BotConfigError("invalid_bot_token", "机器人令牌格式无效。")
  378. profile["bot_token"] = token
  379. profile["identity"] = None
  380. if "enabled" in body:
  381. profile["enabled"] = bool(body.get("enabled"))
  382. if "sudo_users_id" in body:
  383. profile["sudo_users_id"] = _normalize_sudoers(body.get("sudo_users_id"))
  384. if "role_ids" in body:
  385. profile["role_ids"] = _normalize_role_ids(body.get("role_ids"), data)
  386. if "business_assistant" in body:
  387. previous_assistant = (
  388. profile.get("business_assistant")
  389. if isinstance(profile.get("business_assistant"), dict)
  390. else {}
  391. )
  392. profile["business_assistant"] = _normalize_assistant_provider(
  393. body.get("business_assistant"), previous=previous_assistant
  394. )
  395. for field in PROFILE_FIELDS - {"sudo_users_id"}:
  396. if field in body:
  397. profile[field] = _integer(body.get(field), field)
  398. profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  399. _write_document(path, data)
  400. api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
  401. return _public_profile(profile, data=data, api_ready=api_ready)
  402. def delete_bot_profile(path: str | Path, bot_id: str) -> None:
  403. with _STORE_LOCK:
  404. data = _read_document(path)
  405. _find_profile(data, bot_id)
  406. data["bots"] = [
  407. item for item in data["bots"] if str(item.get("bot_id")) != str(bot_id)
  408. ]
  409. _write_document(path, data)
  410. def get_bot_profile_secrets(path: str | Path, bot_id: str) -> dict[str, Any]:
  411. with _STORE_LOCK:
  412. data = _read_document(path)
  413. profile = dict(_find_profile(data, bot_id))
  414. profile["role_ids"] = _profile_role_ids(profile, data)
  415. profile["permissions"] = _effective_permissions(profile, data)
  416. telegram = dict(data["telegram"])
  417. return {**profile, **telegram}
  418. def create_bot_role(path: str | Path, body: dict[str, Any]) -> dict[str, Any]:
  419. name = str(body.get("name") or "").strip()
  420. description = str(body.get("description") or "").strip()
  421. if not name or len(name) > 60:
  422. raise BotConfigError(
  423. "invalid_role_name",
  424. "角色名称长度需要在 1 到 60 个字符之间。",
  425. )
  426. if len(description) > 200:
  427. raise BotConfigError("invalid_role_description", "角色说明不能超过 200 个字符。")
  428. try:
  429. permissions = normalize_permissions(body.get("permissions", []))
  430. except ValueError as exc:
  431. raise BotConfigError("invalid_role_permissions", str(exc)) from exc
  432. now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  433. role = {
  434. "role_id": uuid4().hex,
  435. "name": name,
  436. "description": description,
  437. "permissions": permissions,
  438. "created_at": now,
  439. "updated_at": now,
  440. }
  441. with _STORE_LOCK:
  442. data = _read_document(path)
  443. data["roles"].append(role)
  444. _write_document(path, data)
  445. return {**role, "builtin": False}
  446. def update_bot_role(
  447. path: str | Path,
  448. role_id: str,
  449. body: dict[str, Any],
  450. ) -> dict[str, Any]:
  451. with _STORE_LOCK:
  452. data = _read_document(path)
  453. current = _find_role(data, role_id)
  454. if current.get("builtin"):
  455. raise BotConfigError("builtin_role_immutable", "内置角色不能修改。")
  456. role = next(
  457. item
  458. for item in data["roles"]
  459. if str(item.get("role_id")) == str(role_id)
  460. )
  461. if "name" in body:
  462. name = str(body.get("name") or "").strip()
  463. if not name or len(name) > 60:
  464. raise BotConfigError(
  465. "invalid_role_name",
  466. "角色名称长度需要在 1 到 60 个字符之间。",
  467. )
  468. role["name"] = name
  469. if "description" in body:
  470. description = str(body.get("description") or "").strip()
  471. if len(description) > 200:
  472. raise BotConfigError(
  473. "invalid_role_description",
  474. "角色说明不能超过 200 个字符。",
  475. )
  476. role["description"] = description
  477. if "permissions" in body:
  478. try:
  479. role["permissions"] = normalize_permissions(body.get("permissions"))
  480. except ValueError as exc:
  481. raise BotConfigError("invalid_role_permissions", str(exc)) from exc
  482. role["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  483. _write_document(path, data)
  484. return {**role, "builtin": False}
  485. def delete_bot_role(path: str | Path, role_id: str) -> None:
  486. with _STORE_LOCK:
  487. data = _read_document(path)
  488. role = _find_role(data, role_id)
  489. if role.get("builtin"):
  490. raise BotConfigError("builtin_role_immutable", "内置角色不能删除。")
  491. assigned = [
  492. str(profile.get("label") or profile.get("bot_id"))
  493. for profile in data["bots"]
  494. if str(role_id) in _profile_role_ids(profile, data)
  495. ]
  496. if assigned:
  497. raise BotConfigError(
  498. "role_in_use",
  499. f"角色仍分配给以下机器人:{', '.join(assigned)}。",
  500. )
  501. data["roles"] = [
  502. item
  503. for item in data["roles"]
  504. if str(item.get("role_id")) != str(role_id)
  505. ]
  506. _write_document(path, data)
  507. def store_bot_identity(path: str | Path, bot_id: str, identity: dict[str, Any]) -> None:
  508. with _STORE_LOCK:
  509. data = _read_document(path)
  510. profile = _find_profile(data, bot_id)
  511. profile["identity"] = {
  512. "id": str(identity.get("id") or ""),
  513. "username": str(identity.get("username") or ""),
  514. "name": str(identity.get("name") or ""),
  515. "can_connect_to_business": bool(identity.get("can_connect_to_business")),
  516. }
  517. profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
  518. _write_document(path, data)
  519. async def test_bot_token(token: str) -> dict[str, Any]:
  520. if not BOT_TOKEN_PATTERN.fullmatch(token):
  521. raise BotConfigError("invalid_bot_token", "机器人令牌格式无效。")
  522. timeout = ClientTimeout(total=12)
  523. try:
  524. async with ClientSession(timeout=timeout) as session:
  525. async with session.post(f"https://api.telegram.org/bot{token}/getMe") as response:
  526. payload = await response.json(content_type=None)
  527. except (ClientError, TimeoutError, ValueError) as exc:
  528. raise BotConfigError(
  529. "telegram_unreachable",
  530. "无法连接 Telegram 机器人接口。",
  531. ) from exc
  532. if response.status != 200 or not payload.get("ok"):
  533. raise BotConfigError("telegram_auth_failed", "机器人令牌验证失败。")
  534. result = payload.get("result") or {}
  535. return {
  536. "id": str(result.get("id") or ""),
  537. "username": result.get("username") or "",
  538. "name": " ".join(
  539. part for part in (result.get("first_name"), result.get("last_name")) if part
  540. ),
  541. "can_connect_to_business": bool(result.get("can_connect_to_business")),
  542. }