from __future__ import annotations import logging import os logger = logging.getLogger(__name__) class MattermostConfigAdapter: def __init__(self): self._config: dict = {} def list_account_ids(self, config: dict) -> list[str]: self._config = config accounts = config.get("accounts", {}) if not accounts: return ["default"] if self._env_token_exists("default") else [] return list(accounts.keys()) def default_account_id(self, config: dict) -> str: self._config = config return config.get("default_account", "default") async def resolve_account(self, account_id: str) -> dict: raw = self._load_config(account_id) return self._build_account(account_id, raw) def _load_config(self, account_id: str) -> dict: accounts = self._config.get("accounts", {}) return accounts.get(account_id, {}) def _build_account(self, account_id: str, raw: dict) -> dict: env_token = self._env_bot_token(account_id) env_url = os.environ.get("MATTERMOST_URL", "") account = { "account_id": account_id, "name": raw.get("name", account_id), "enabled": raw.get("enabled", True), "bot_token": env_token or raw.get("bot_token", ""), "base_url": env_url or raw.get("base_url", ""), "chatmode": raw.get("chatmode", "oncall"), "onchar_prefixes": raw.get("onchar_prefixes", [">", "!"]), "require_mention": raw.get("require_mention", True), "dm_policy": raw.get("dm_policy", "pairing"), "allow_from": raw.get("allow_from", []), "group_policy": raw.get("group_policy", "allowlist"), "group_allow_from": raw.get("group_allow_from", []), "text_chunk_limit": raw.get("text_chunk_limit", 4000), "chunk_mode": raw.get("chunk_mode", "length"), "reply_to_mode": raw.get("reply_to_mode", "off"), "response_prefix": raw.get("response_prefix"), "block_streaming": raw.get("block_streaming", True), "block_streaming_coalesce": raw.get( "block_streaming_coalesce", {"min_chars": 1500, "idle_ms": 1000}, ), "reactions": raw.get("actions", {}).get("reactions", True), "callback_base_url": raw.get("interactions", {}).get("callback_base_url"), "allowed_source_ips": raw.get("interactions", {}).get("allowed_source_ips", []), "native_commands": raw.get("commands", {}).get("native", False), "native_skills": raw.get("commands", {}).get("native_skills", False), "dangerously_allow_private_network": raw.get("network", {}).get("dangerously_allow_private_network", False), "dangerously_allow_name_matching": raw.get("dangerously_allow_name_matching", False), "config_writes": raw.get("config_writes", True), "dm_channel_retry": raw.get( "dm_channel_retry", { "max_retries": 3, "initial_delay_ms": 1000, "max_delay_ms": 10000, "timeout_ms": 30000, }, ), } coalesce = account["block_streaming_coalesce"] account["block_streaming_coalesce_min_chars"] = coalesce.get("min_chars", 1500) account["block_streaming_coalesce_idle_ms"] = coalesce.get("idle_ms", 1000) retry = account["dm_channel_retry"] account["dm_channel_retry_max_retries"] = retry.get("max_retries", 3) account["dm_channel_retry_initial_delay_ms"] = retry.get("initial_delay_ms", 1000) account["dm_channel_retry_max_delay_ms"] = retry.get("max_delay_ms", 10000) account["dm_channel_retry_timeout_ms"] = retry.get("timeout_ms", 30000) return account def is_configured(self, account: dict) -> bool: return bool(account.get("bot_token") and account.get("base_url")) def is_enabled(self, account: dict) -> bool: return account.get("enabled", True) def disabled_reason(self, account: dict) -> str: if not account.get("enabled", True): return "Account disabled" return "" def describe_account(self, account: dict) -> dict: return { "account_id": account.get("account_id", ""), "name": account.get("name", ""), "base_url": account.get("base_url", ""), "dm_policy": account.get("dm_policy", "pairing"), "configured": self.is_configured(account), "enabled": self.is_enabled(account), } def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None: self._config = config aid = account_id or "default" accounts = config.get("accounts", {}) acct = accounts.get(aid, {}) if accounts else {} allow = acct.get("allow_from", config.get("allow_from", [])) if not allow: return None return [str(e).strip() for e in allow] def config_schema(self) -> dict: return { "$schema": "https://json-schema.org/draft-07/schema#", "type": "object", "title": "Mattermost 渠道配置", "properties": { "bot_token": { "type": "string", "title": "Bot Token", "description": "Mattermost Personal Access Token", "x-ui-password": True, }, "base_url": { "type": "string", "title": "Base URL", "description": "Mattermost 服务器 URL (如 https://mattermost.example.com)", }, "dm_policy": { "type": "string", "title": "DM 策略", "enum": ["pairing", "allowlist", "open", "disabled"], "default": "pairing", }, "group_policy": { "type": "string", "title": "群组策略", "enum": ["open", "allowlist", "disabled"], "default": "allowlist", }, "chatmode": { "type": "string", "title": "聊天模式", "enum": ["oncall", "onmessage", "onchar"], "default": "oncall", }, "require_mention": { "type": "boolean", "title": "要求 @提及", "default": True, }, "text_chunk_limit": { "type": "integer", "title": "文本分块上限", "default": 4000, "minimum": 100, "maximum": 4000, }, }, } @staticmethod def _env_bot_token(account_id: str) -> str: key = f"MATTERMOST_BOT_TOKEN_{account_id.upper()}" return os.environ.get(key, "") or os.environ.get("MATTERMOST_BOT_TOKEN", "") @staticmethod def _env_token_exists(account_id: str) -> bool: return bool(MattermostConfigAdapter._env_bot_token(account_id)) def normalize_mattermost_base_url(url: str) -> str: url = url.rstrip("/") if url.endswith("/api/v4"): url = url[:-7] return url def validate_mattermost_config(account: dict) -> list[str]: errors: list[str] = [] dm_policy = account.get("dm_policy", "pairing") if dm_policy == "open": allow_from = account.get("allow_from", []) if "*" not in [str(e).strip() for e in allow_from]: errors.append( "dm_policy='open' requires allow_from to include '*' (open to all senders)" ) retry = account.get("dm_channel_retry", {}) initial = retry.get("initial_delay_ms", 1000) max_delay = retry.get("max_delay_ms", 10000) if initial > max_delay: errors.append( f"dm_channel_retry.initial_delay_ms ({initial}) must be <= max_delay_ms ({max_delay})" ) return errors