blacklist_enforcement.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762
  1. from __future__ import annotations
  2. import re
  3. import secrets
  4. import unicodedata
  5. from collections.abc import Iterable, Mapping, Sequence
  6. from dataclasses import dataclass
  7. from datetime import UTC, datetime, timedelta
  8. from typing import Any
  9. from pyrogram.enums import ChatMemberStatus, MessageEntityType
  10. from pyrogram.types import ChatPermissions
  11. from wbb import BOT_ID, SUDOERS, app, log
  12. from wbb.utils.dbadmin import (
  13. get_managed_chat_settings,
  14. update_managed_chat_settings,
  15. )
  16. from wbb.utils.dbfunctions import (
  17. add_warn,
  18. delete_blacklist_filter,
  19. get_blacklisted_words,
  20. get_warn,
  21. int_to_alpha,
  22. remove_warns,
  23. save_blacklist_filter,
  24. )
  25. RISK_ACTIONS = {"delete", "warn", "mute", "kick", "ban"}
  26. MEMBER_ACTIONS = {"warn", "mute", "kick", "ban"}
  27. MEMBER_ACTION_PRIORITY = ("ban", "kick", "mute", "warn")
  28. WARNING_LIMIT = 3
  29. DEFAULT_BAN_DURATION_SECONDS = 3600
  30. MIN_BAN_DURATION_SECONDS = 60
  31. MAX_BAN_DURATION_SECONDS = 365 * 24 * 60 * 60
  32. MAX_RISK_RULES = 50
  33. LEGACY_POLICY_RULE_ID = "legacy-risk-control"
  34. LEGACY_KEYWORD_RULE_ID = "legacy-blacklist"
  35. COMMAND_KEYWORD_RULE_ID = "command-blacklist"
  36. LEGACY_RULE_IDS = {
  37. LEGACY_POLICY_RULE_ID,
  38. LEGACY_KEYWORD_RULE_ID,
  39. COMMAND_KEYWORD_RULE_ID,
  40. }
  41. RULE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
  42. LINK_PATTERN = re.compile(
  43. r"(?:https?://|ftp://|www\.|t\.me/|telegram\.me/)\S+",
  44. flags=re.IGNORECASE,
  45. )
  46. class RiskControlValidationError(ValueError):
  47. pass
  48. @dataclass(frozen=True)
  49. class RiskMatch:
  50. rule_id: str
  51. rule_name: str
  52. trigger_types: tuple[str, ...]
  53. keyword: str | None = None
  54. @dataclass(frozen=True)
  55. class RiskEnforcementResult:
  56. matches: tuple[RiskMatch, ...]
  57. deleted: bool | None
  58. requested_member_action: str
  59. applied_member_action: str
  60. warning_count: int | None = None
  61. duration_seconds: int | None = None
  62. @property
  63. def match(self) -> RiskMatch:
  64. return self.matches[0]
  65. def _normalize(value: str) -> str:
  66. return unicodedata.normalize("NFKC", value).casefold().strip()
  67. def _normalized_keywords(words: Iterable[Any]) -> list[str]:
  68. values = {_normalize(str(word))[:100] for word in words}
  69. return sorted(value for value in values if value)[:200]
  70. def _normalize_actions(value: Any, *, strict: bool) -> list[str]:
  71. raw_actions = value if value is not None else ["delete"]
  72. if isinstance(raw_actions, str):
  73. raw_actions = [raw_actions]
  74. if not isinstance(raw_actions, (list, tuple, set)):
  75. if strict:
  76. raise RiskControlValidationError("风控操作格式不正确。")
  77. raw_actions = ["delete"]
  78. requested_actions = [str(action).strip().lower() for action in raw_actions]
  79. invalid_actions = sorted(set(requested_actions) - RISK_ACTIONS)
  80. if invalid_actions and strict:
  81. raise RiskControlValidationError("包含不支持的风控操作。")
  82. member_actions = [
  83. action for action in MEMBER_ACTION_PRIORITY if action in requested_actions
  84. ]
  85. if len(member_actions) > 1 and strict:
  86. raise RiskControlValidationError("警告、禁言、踢出和封禁只能选择一种。")
  87. actions: list[str] = []
  88. if "delete" in requested_actions:
  89. actions.append("delete")
  90. if member_actions:
  91. actions.append(member_actions[0])
  92. return actions
  93. def _normalize_action_duration(value: Any, *, strict: bool) -> int:
  94. if value is None or value == "":
  95. return DEFAULT_BAN_DURATION_SECONDS
  96. try:
  97. duration = int(value)
  98. except (TypeError, ValueError):
  99. if strict:
  100. raise RiskControlValidationError("处罚时长必须是整数秒。") from None
  101. return DEFAULT_BAN_DURATION_SECONDS
  102. if MIN_BAN_DURATION_SECONDS <= duration <= MAX_BAN_DURATION_SECONDS:
  103. return duration
  104. if strict:
  105. raise RiskControlValidationError("处罚时长需要在 1 分钟到 365 天之间。")
  106. return DEFAULT_BAN_DURATION_SECONDS
  107. def _normalize_rule(
  108. value: Mapping[str, Any],
  109. *,
  110. fallback_keywords: Iterable[Any] = (),
  111. strict: bool = False,
  112. default_rule_id: str | None = None,
  113. default_name: str | None = None,
  114. ) -> dict[str, Any]:
  115. raw_rule_id = str(value.get("rule_id") or default_rule_id or "").strip()
  116. if raw_rule_id and not RULE_ID_PATTERN.fullmatch(raw_rule_id):
  117. if strict:
  118. raise RiskControlValidationError("规则 ID 格式不正确。")
  119. raw_rule_id = ""
  120. rule_id = raw_rule_id or secrets.token_hex(8)
  121. name = str(value.get("name") or default_name or "").strip()
  122. if not name:
  123. if strict:
  124. raise RiskControlValidationError("规则名称不能为空。")
  125. name = "未命名规则"
  126. if len(name) > 60:
  127. if strict:
  128. raise RiskControlValidationError("规则名称不能超过 60 个字符。")
  129. name = name[:60]
  130. keywords = _normalized_keywords(
  131. value["keywords"] if "keywords" in value else fallback_keywords
  132. )
  133. match_images = bool(value.get("match_images", False))
  134. match_links = bool(value.get("match_links", False))
  135. actions = _normalize_actions(value.get("actions"), strict=strict)
  136. enabled = bool(
  137. value.get("enabled", bool(keywords or match_images or match_links))
  138. )
  139. if strict and enabled and not (keywords or match_images or match_links):
  140. raise RiskControlValidationError("启用规则时至少配置一种触发条件。")
  141. if strict and enabled and not actions:
  142. raise RiskControlValidationError("启用规则时至少选择一种处置操作。")
  143. return {
  144. "rule_id": rule_id,
  145. "name": name,
  146. "enabled": enabled,
  147. "keywords": keywords,
  148. "match_images": match_images,
  149. "match_links": match_links,
  150. "actions": actions,
  151. "duration_seconds": _normalize_action_duration(
  152. value.get("duration_seconds", value.get("ban_duration_seconds")),
  153. strict=strict,
  154. ),
  155. }
  156. def normalize_risk_control(
  157. value: Mapping[str, Any] | None,
  158. *,
  159. fallback_keywords: Iterable[Any] = (),
  160. strict: bool = False,
  161. ) -> dict[str, Any]:
  162. raw = value or {}
  163. rule = _normalize_rule(
  164. raw,
  165. fallback_keywords=fallback_keywords,
  166. strict=strict,
  167. default_rule_id=LEGACY_POLICY_RULE_ID,
  168. default_name="原风控策略",
  169. )
  170. return {
  171. key: rule[key]
  172. for key in (
  173. "enabled",
  174. "keywords",
  175. "match_images",
  176. "match_links",
  177. "actions",
  178. "duration_seconds",
  179. )
  180. }
  181. def normalize_risk_rules(
  182. value: Sequence[Mapping[str, Any]] | None,
  183. *,
  184. legacy_policy: Mapping[str, Any] | None = None,
  185. fallback_keywords: Iterable[Any] = (),
  186. strict: bool = False,
  187. ) -> list[dict[str, Any]]:
  188. fallback = _normalized_keywords(fallback_keywords)
  189. if value is None:
  190. if legacy_policy is not None:
  191. legacy = dict(legacy_policy)
  192. legacy["keywords"] = _normalized_keywords(
  193. [*(legacy.get("keywords") or []), *fallback]
  194. )
  195. return [
  196. _normalize_rule(
  197. legacy,
  198. strict=strict,
  199. default_rule_id=LEGACY_POLICY_RULE_ID,
  200. default_name="原风控策略",
  201. )
  202. ]
  203. if fallback:
  204. return [
  205. _normalize_rule(
  206. {
  207. "enabled": True,
  208. "keywords": fallback,
  209. "actions": ["delete"],
  210. },
  211. strict=strict,
  212. default_rule_id=LEGACY_KEYWORD_RULE_ID,
  213. default_name="原关键词黑名单",
  214. )
  215. ]
  216. return []
  217. if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
  218. if strict:
  219. raise RiskControlValidationError("风控规则必须是列表。")
  220. return []
  221. if len(value) > MAX_RISK_RULES and strict:
  222. raise RiskControlValidationError(f"每个群最多配置 {MAX_RISK_RULES} 条风控规则。")
  223. rules: list[dict[str, Any]] = []
  224. rule_ids: set[str] = set()
  225. for index, item in enumerate(value[:MAX_RISK_RULES]):
  226. if not isinstance(item, Mapping):
  227. if strict:
  228. raise RiskControlValidationError("风控规则格式不正确。")
  229. continue
  230. rule = _normalize_rule(
  231. item,
  232. strict=strict,
  233. default_name=f"规则 {index + 1}",
  234. )
  235. if rule["rule_id"] in rule_ids:
  236. if strict:
  237. raise RiskControlValidationError("规则 ID 不能重复。")
  238. rule["rule_id"] = f"{rule['rule_id'][:30]}-{index + 1}"
  239. rule_ids.add(rule["rule_id"])
  240. rules.append(rule)
  241. return rules
  242. def risk_rule_keywords(
  243. rules: Iterable[Mapping[str, Any]], *, enabled_only: bool = False
  244. ) -> list[str]:
  245. return _normalized_keywords(
  246. keyword
  247. for rule in rules
  248. if not enabled_only or bool(rule.get("enabled"))
  249. for keyword in rule.get("keywords", [])
  250. )
  251. def find_blacklist_match(content: str, words: Iterable[str]) -> str | None:
  252. normalized_content = _normalize(content)
  253. if not normalized_content:
  254. return None
  255. for word in words:
  256. normalized_word = _normalize(str(word))
  257. if normalized_word and normalized_word in normalized_content:
  258. return str(word).strip()
  259. return None
  260. def _is_link_entity(entity: Any) -> bool:
  261. entity_type = getattr(entity, "type", None)
  262. if entity_type in {MessageEntityType.URL, MessageEntityType.TEXT_LINK}:
  263. return True
  264. label = str(entity_type).rsplit(".", 1)[-1].lower()
  265. return label in {"url", "text_link"}
  266. def _message_has_link(message: Any, content: str) -> bool:
  267. for name in ("entities", "caption_entities"):
  268. if any(_is_link_entity(entity) for entity in (getattr(message, name, None) or [])):
  269. return True
  270. return bool(LINK_PATTERN.search(content))
  271. def _message_has_image(message: Any) -> bool:
  272. if getattr(message, "photo", None) or getattr(message, "animation", None):
  273. return True
  274. document = getattr(message, "document", None)
  275. return bool(
  276. document
  277. and str(getattr(document, "mime_type", "")).lower().startswith("image/")
  278. )
  279. def detect_risk_match(
  280. message: Any, policy: Mapping[str, Any]
  281. ) -> RiskMatch | None:
  282. if not policy.get("enabled"):
  283. return None
  284. content = str(
  285. getattr(message, "text", None) or getattr(message, "caption", None) or ""
  286. )
  287. keyword = find_blacklist_match(content, policy.get("keywords", []))
  288. triggers: list[str] = []
  289. if keyword:
  290. triggers.append("keyword")
  291. if policy.get("match_images") and _message_has_image(message):
  292. triggers.append("image")
  293. if policy.get("match_links") and _message_has_link(message, content):
  294. triggers.append("link")
  295. if not triggers:
  296. return None
  297. return RiskMatch(
  298. rule_id=str(policy.get("rule_id") or LEGACY_POLICY_RULE_ID),
  299. rule_name=str(policy.get("name") or "风控规则"),
  300. trigger_types=tuple(triggers),
  301. keyword=keyword,
  302. )
  303. def detect_risk_matches(
  304. message: Any, rules: Iterable[Mapping[str, Any]]
  305. ) -> list[tuple[Mapping[str, Any], RiskMatch]]:
  306. matches: list[tuple[Mapping[str, Any], RiskMatch]] = []
  307. for rule in rules:
  308. match = detect_risk_match(message, rule)
  309. if match:
  310. matches.append((rule, match))
  311. return matches
  312. async def get_effective_risk_rules(chat_id: int) -> list[dict[str, Any]]:
  313. settings = await get_managed_chat_settings(chat_id)
  314. legacy_words = await get_blacklisted_words(chat_id)
  315. if "risk_rules" in settings:
  316. return normalize_risk_rules(settings.get("risk_rules"))
  317. return normalize_risk_rules(
  318. None,
  319. legacy_policy=settings.get("risk_control"),
  320. fallback_keywords=legacy_words or settings.get("blacklist_words", []),
  321. )
  322. async def get_effective_risk_control(chat_id: int) -> dict[str, Any]:
  323. rules = await get_effective_risk_rules(chat_id)
  324. active = [rule for rule in rules if rule["enabled"]]
  325. actions = {action for rule in active for action in rule["actions"]}
  326. member_action = next(
  327. (action for action in MEMBER_ACTION_PRIORITY if action in actions), None
  328. )
  329. normalized_actions = ["delete"] if "delete" in actions else []
  330. if member_action:
  331. normalized_actions.append(member_action)
  332. return {
  333. "enabled": bool(active),
  334. "keywords": risk_rule_keywords(active),
  335. "match_images": any(rule["match_images"] for rule in active),
  336. "match_links": any(rule["match_links"] for rule in active),
  337. "actions": normalized_actions,
  338. "duration_seconds": max(
  339. (int(rule["duration_seconds"]) for rule in active),
  340. default=DEFAULT_BAN_DURATION_SECONDS,
  341. ),
  342. }
  343. async def sync_legacy_blacklist(chat_id: int, keywords: Iterable[Any]) -> list[str]:
  344. normalized = _normalized_keywords(keywords)
  345. current = await get_blacklisted_words(chat_id)
  346. current_by_normalized = {_normalize(str(word)): str(word) for word in current}
  347. target = set(normalized)
  348. for normalized_word, stored_word in current_by_normalized.items():
  349. if normalized_word not in target:
  350. while await delete_blacklist_filter(chat_id, stored_word):
  351. pass
  352. current_normalized = {
  353. _normalize(str(word)) for word in await get_blacklisted_words(chat_id)
  354. }
  355. for word in normalized:
  356. if word not in current_normalized:
  357. await save_blacklist_filter(chat_id, word)
  358. return normalized
  359. def _keyword_rule_index(rules: list[dict[str, Any]]) -> int | None:
  360. for index, rule in enumerate(rules):
  361. if rule["rule_id"] in LEGACY_RULE_IDS:
  362. return index
  363. return None
  364. def replace_legacy_rule_keywords(
  365. rules: Sequence[Mapping[str, Any]], keywords: Iterable[Any]
  366. ) -> list[dict[str, Any]]:
  367. normalized_rules = normalize_risk_rules(rules)
  368. index = _keyword_rule_index(normalized_rules)
  369. if index is None:
  370. normalized_rules.append(
  371. _normalize_rule(
  372. {
  373. "enabled": True,
  374. "keywords": [],
  375. "actions": ["delete"],
  376. },
  377. default_rule_id=COMMAND_KEYWORD_RULE_ID,
  378. default_name="命令关键词黑名单",
  379. )
  380. )
  381. index = len(normalized_rules) - 1
  382. rule = normalized_rules[index]
  383. rule["keywords"] = _normalized_keywords(keywords)
  384. if rule["keywords"]:
  385. rule["enabled"] = True
  386. elif not rule["match_images"] and not rule["match_links"]:
  387. rule["enabled"] = False
  388. return normalized_rules
  389. async def _store_risk_rules(chat_id: int, rules: list[dict[str, Any]]) -> None:
  390. keywords = await sync_legacy_blacklist(
  391. chat_id, risk_rule_keywords(rules, enabled_only=True)
  392. )
  393. await update_managed_chat_settings(
  394. chat_id,
  395. {"risk_rules": rules, "blacklist_words": keywords},
  396. )
  397. async def add_risk_keyword(chat_id: int, word: str) -> tuple[bool, dict[str, Any]]:
  398. normalized_word = _normalize(word)[:100]
  399. if not normalized_word:
  400. raise RiskControlValidationError("关键词不能为空。")
  401. rules = await get_effective_risk_rules(chat_id)
  402. index = _keyword_rule_index(rules)
  403. existing_keywords = rules[index]["keywords"] if index is not None else []
  404. rules = replace_legacy_rule_keywords(
  405. rules, [*existing_keywords, normalized_word]
  406. )
  407. index = _keyword_rule_index(rules)
  408. assert index is not None
  409. rule = rules[index]
  410. changed = normalized_word not in existing_keywords
  411. await _store_risk_rules(chat_id, rules)
  412. return changed, rule
  413. async def remove_risk_keyword(chat_id: int, word: str) -> tuple[bool, dict[str, Any]]:
  414. normalized_word = _normalize(word)[:100]
  415. rules = await get_effective_risk_rules(chat_id)
  416. index = _keyword_rule_index(rules)
  417. if index is None:
  418. return False, normalize_risk_control(None)
  419. existing_keywords = rules[index]["keywords"]
  420. filtered = [
  421. item for item in existing_keywords if _normalize(item) != normalized_word
  422. ]
  423. changed = len(filtered) != len(existing_keywords)
  424. rules = replace_legacy_rule_keywords(rules, filtered)
  425. index = _keyword_rule_index(rules)
  426. assert index is not None
  427. rule = rules[index]
  428. await _store_risk_rules(chat_id, rules)
  429. return changed, rule
  430. async def _is_privileged_member(chat_id: int, user_id: int) -> bool:
  431. if user_id in SUDOERS or user_id == BOT_ID:
  432. return True
  433. try:
  434. member = await app.get_chat_member(chat_id, user_id)
  435. except Exception as exc:
  436. log.error(
  437. f"无法确认群 {chat_id} 成员 {user_id} 的管理员身份,已跳过自动处罚:{exc}"
  438. )
  439. return True
  440. return member.status in {
  441. ChatMemberStatus.OWNER,
  442. ChatMemberStatus.ADMINISTRATOR,
  443. }
  444. async def _bot_capabilities(chat_id: int) -> dict[str, bool | None]:
  445. try:
  446. member = await app.get_chat_member(chat_id, BOT_ID)
  447. except Exception as exc:
  448. log.error(f"无法读取机器人在群 {chat_id} 的实时权限,将直接尝试执行:{exc}")
  449. return {"delete": None, "restrict": None}
  450. if member.status == ChatMemberStatus.OWNER:
  451. return {"delete": True, "restrict": True}
  452. if member.status != ChatMemberStatus.ADMINISTRATOR:
  453. return {"delete": False, "restrict": False}
  454. privileges = getattr(member, "privileges", None)
  455. return {
  456. "delete": bool(getattr(privileges, "can_delete_messages", False)),
  457. "restrict": bool(getattr(privileges, "can_restrict_members", False)),
  458. }
  459. def _ban_until(duration_seconds: int) -> datetime:
  460. return datetime.now(UTC) + timedelta(seconds=duration_seconds)
  461. async def _apply_warning(
  462. chat_id: int,
  463. user_id: int,
  464. *,
  465. can_restrict: bool | None,
  466. duration_seconds: int,
  467. ) -> tuple[str, int]:
  468. key = await int_to_alpha(user_id)
  469. current = await get_warn(chat_id, key)
  470. count = int(current.get("warns", 0)) + 1 if current else 1
  471. if count < WARNING_LIMIT:
  472. await add_warn(chat_id, key, {"warns": count})
  473. return "warn", count
  474. if can_restrict is False:
  475. await add_warn(chat_id, key, {"warns": count})
  476. log.error(
  477. f"群 {chat_id} 成员 {user_id} 已达到警告上限,但机器人没有封禁权限"
  478. )
  479. return "warn", count
  480. try:
  481. await app.ban_chat_member(
  482. chat_id,
  483. user_id,
  484. until_date=_ban_until(duration_seconds),
  485. )
  486. except Exception as exc:
  487. await add_warn(chat_id, key, {"warns": count})
  488. log.error(f"群 {chat_id} 成员 {user_id} 已达到警告上限,但封禁失败:{exc}")
  489. return "warn", count
  490. await remove_warns(chat_id, key)
  491. return "ban", count
  492. async def _apply_member_action(
  493. chat_id: int,
  494. user_id: int,
  495. action: str,
  496. *,
  497. can_restrict: bool | None,
  498. duration_seconds: int,
  499. ) -> tuple[str, int | None]:
  500. if action == "warn":
  501. return await _apply_warning(
  502. chat_id,
  503. user_id,
  504. can_restrict=can_restrict,
  505. duration_seconds=duration_seconds,
  506. )
  507. if can_restrict is False:
  508. log.error(f"群 {chat_id} 无法执行风控{action}:机器人没有限制成员权限")
  509. return "none", None
  510. try:
  511. if action == "kick":
  512. await app.ban_chat_member(chat_id, user_id)
  513. await app.unban_chat_member(chat_id, user_id)
  514. elif action == "mute":
  515. await app.restrict_chat_member(
  516. chat_id,
  517. user_id,
  518. ChatPermissions(),
  519. until_date=_ban_until(duration_seconds),
  520. )
  521. elif action == "ban":
  522. await app.ban_chat_member(
  523. chat_id,
  524. user_id,
  525. until_date=_ban_until(duration_seconds),
  526. )
  527. else:
  528. return "none", None
  529. except Exception as exc:
  530. log.error(f"群 {chat_id} 风控处置成员 {user_id} 失败:{exc}")
  531. return "none", None
  532. return action, None
  533. def _trigger_description(match: RiskMatch) -> str:
  534. labels = {"keyword": "关键词", "image": "图片", "link": "链接"}
  535. values = [labels[item] for item in match.trigger_types]
  536. if match.keyword:
  537. values[values.index("关键词")] = f"关键词“{match.keyword}”"
  538. return "、".join(values)
  539. def _duration_description(duration_seconds: int) -> str:
  540. if duration_seconds % 86400 == 0:
  541. return f"{duration_seconds // 86400} 天"
  542. if duration_seconds % 3600 == 0:
  543. return f"{duration_seconds // 3600} 小时"
  544. return f"{max(1, duration_seconds // 60)} 分钟"
  545. async def _send_action_notice(
  546. chat_id: int,
  547. user: Any,
  548. matches: tuple[RiskMatch, ...],
  549. action: str,
  550. warning_count: int | None,
  551. duration_seconds: int,
  552. ) -> None:
  553. mention = getattr(user, "mention", None) or f"成员 {user.id}"
  554. if action == "warn":
  555. outcome = f"已警告({warning_count}/{WARNING_LIMIT})"
  556. elif action == "ban" and warning_count:
  557. outcome = (
  558. f"累计 {WARNING_LIMIT} 次警告,已封禁 "
  559. f"{_duration_description(duration_seconds)}"
  560. )
  561. elif action == "ban":
  562. outcome = f"已封禁 {_duration_description(duration_seconds)}"
  563. elif action == "mute":
  564. outcome = f"已禁言 {_duration_description(duration_seconds)}"
  565. else:
  566. outcome = {"kick": "已踢出"}.get(action, action)
  567. matched = ";".join(
  568. f"{match.rule_name}:{_trigger_description(match)}" for match in matches
  569. )
  570. try:
  571. await app.send_message(
  572. chat_id,
  573. f"{mention} 触发风控规则({matched}),{outcome}。",
  574. )
  575. except Exception as exc:
  576. log.error(f"群 {chat_id} 风控处置通知发送失败:{exc}")
  577. async def enforce_risk_message(
  578. message: Any,
  579. policy: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
  580. ) -> RiskEnforcementResult | None:
  581. user = getattr(message, "from_user", None)
  582. if user is not None and getattr(user, "is_bot", False):
  583. return None
  584. chat_id = int(message.chat.id)
  585. if policy is None:
  586. rules = await get_effective_risk_rules(chat_id)
  587. elif isinstance(policy, Mapping):
  588. rules = normalize_risk_rules(None, legacy_policy=policy)
  589. else:
  590. rules = normalize_risk_rules(policy)
  591. matched_rules = detect_risk_matches(message, rules)
  592. if not matched_rules:
  593. return None
  594. matches = tuple(match for _, match in matched_rules)
  595. delete_requested = any("delete" in rule["actions"] for rule, _ in matched_rules)
  596. member_action = "none"
  597. action_rules: list[Mapping[str, Any]] = []
  598. for candidate in MEMBER_ACTION_PRIORITY:
  599. action_rules = [
  600. rule for rule, _ in matched_rules if candidate in rule["actions"]
  601. ]
  602. if action_rules:
  603. member_action = candidate
  604. break
  605. duration_seconds = max(
  606. (int(rule["duration_seconds"]) for rule in action_rules),
  607. default=DEFAULT_BAN_DURATION_SECONDS,
  608. )
  609. capabilities = await _bot_capabilities(chat_id)
  610. deleted: bool | None = None
  611. if delete_requested:
  612. deleted = False
  613. if capabilities["delete"] is False:
  614. log.error(f"群 {chat_id} 命中风控规则,但机器人没有删除消息权限")
  615. else:
  616. try:
  617. await message.delete()
  618. deleted = True
  619. except Exception as exc:
  620. log.error(f"群 {chat_id} 命中风控规则但删除消息失败:{exc}")
  621. applied_action = "none"
  622. warning_count: int | None = None
  623. if (
  624. member_action != "none"
  625. and user is not None
  626. and not await _is_privileged_member(chat_id, int(user.id))
  627. ):
  628. applied_action, warning_count = await _apply_member_action(
  629. chat_id,
  630. int(user.id),
  631. member_action,
  632. can_restrict=capabilities["restrict"],
  633. duration_seconds=duration_seconds,
  634. )
  635. if applied_action != "none":
  636. await _send_action_notice(
  637. chat_id,
  638. user,
  639. matches,
  640. applied_action,
  641. warning_count,
  642. duration_seconds,
  643. )
  644. return RiskEnforcementResult(
  645. matches=matches,
  646. deleted=deleted,
  647. requested_member_action=member_action,
  648. applied_member_action=applied_action,
  649. warning_count=warning_count,
  650. duration_seconds=(
  651. duration_seconds if member_action in {"ban", "mute", "warn"} else None
  652. ),
  653. )
  654. async def enforce_blacklist_message(
  655. message: Any, words: Iterable[str]
  656. ) -> RiskEnforcementResult | None:
  657. keyword_list = list(words)
  658. return await enforce_risk_message(
  659. message,
  660. [
  661. {
  662. "rule_id": LEGACY_KEYWORD_RULE_ID,
  663. "name": "关键词黑名单",
  664. "enabled": bool(keyword_list),
  665. "keywords": keyword_list,
  666. "match_images": False,
  667. "match_links": False,
  668. "actions": ["delete"],
  669. "duration_seconds": DEFAULT_BAN_DURATION_SECONDS,
  670. }
  671. ],
  672. )