from __future__ import annotations from dataclasses import dataclass, field ChatMode = str MODE_ONCALL: ChatMode = "oncall" MODE_ONMESSAGE: ChatMode = "onmessage" MODE_ONCHAR: ChatMode = "onchar" CHAT_MODES = frozenset({MODE_ONCALL, MODE_ONMESSAGE, MODE_ONCHAR}) DEFAULT_ONCHAR_PREFIXES = frozenset({">", "!"}) @dataclass class MentionGateConfig: chat_mode: ChatMode = MODE_ONMESSAGE require_mention: bool = True onchar_prefixes: set[str] = field(default_factory=lambda: set(DEFAULT_ONCHAR_PREFIXES)) @classmethod def from_config(cls, config: dict) -> MentionGateConfig: chat_mode = config.get("chatmode", MODE_ONMESSAGE) if chat_mode not in CHAT_MODES: chat_mode = MODE_ONMESSAGE prefixes_raw = config.get("onchar_prefixes", []) if not prefixes_raw: prefixes_raw = list(DEFAULT_ONCHAR_PREFIXES) prefixes = {str(p).strip() for p in prefixes_raw if str(p).strip()} if not prefixes: prefixes = set(DEFAULT_ONCHAR_PREFIXES) require_mention = bool(config.get("require_mention", True)) return cls( chat_mode=chat_mode, require_mention=require_mention, onchar_prefixes=frozenset(prefixes), ) @dataclass class MentionGateResult: should_respond: bool reason: str = "" class MentionGate: """消息响应门控 — 决定是否响应入站消息。 支持三种模式: - oncall: 仅响应明确呼叫(不自动触发) - onmessage: 响应所有消息(结合 requireMention 过滤) - onchar: 仅响应以特定前缀开头的消息 """ def __init__(self, config: dict | None = None): self._config = MentionGateConfig.from_config(config or {}) @property def chat_mode(self) -> str: return self._config.chat_mode @property def require_mention(self) -> bool: return self._config.require_mention def reload_config(self, key: str, value) -> None: from dataclasses import replace if key == "chatmode" and value in CHAT_MODES: self._config = replace(self._config, chat_mode=value) elif key == "require_mention": self._config = replace(self._config, require_mention=bool(value)) elif key == "onchar_prefixes": prefixes = {str(p).strip() for p in (value if isinstance(value, list) else []) if str(p).strip()} if prefixes: self._config = replace(self._config, onchar_prefixes=frozenset(prefixes)) def check( self, chat_type: str, text: str, bot_mentioned: bool, ) -> MentionGateResult: mode = self._config.chat_mode if chat_type == "direct": return self._check_dm(text, mode) return self._check_group_or_channel(text, bot_mentioned, mode) def _check_dm(self, text: str, mode: ChatMode) -> MentionGateResult: if mode == MODE_ONCALL: return MentionGateResult(True) if mode == MODE_ONCHAR: if text and any(text.lstrip().startswith(p) for p in self._config.onchar_prefixes): return MentionGateResult(True) return MentionGateResult(False, "DM message does not match onchar prefix") return MentionGateResult(True) def _check_group_or_channel(self, text: str, bot_mentioned: bool, mode: ChatMode) -> MentionGateResult: if mode == MODE_ONCALL: return MentionGateResult(False, "oncall mode does not respond in groups without explicit mention") if mode == MODE_ONMESSAGE: if self._config.require_mention and not bot_mentioned: return MentionGateResult(False, "Bot not mentioned and requireMention is enabled") return MentionGateResult(True) if mode == MODE_ONCHAR: if text and any(text.lstrip().startswith(p) for p in self._config.onchar_prefixes): return MentionGateResult(True) return MentionGateResult(False, "Message does not match onchar prefix") return MentionGateResult(False, f"Unknown chat mode: {mode}") CONTROL_COMMAND_MARKERS = frozenset({"/restart", "/sudo", "/exec", "/delete", "/config", "/approve", "/deny"}) def is_control_command(text: str) -> bool: text_stripped = text.strip() return any(text_stripped.startswith(cmd) for cmd in CONTROL_COMMAND_MARKERS) def resolve_control_command_gate( text: str, chat_type: str, bot_mentioned: bool = False, ) -> bool: """控制命令门控 — 控制命令总是通过(仅 DM/mention 场景), 由后续的 authorize_command_invocation 做进一步授权检查。 """ if not is_control_command(text): return False if chat_type == "direct": return True return bot_mentioned