import os import hashlib import logging from yuxi.channel.extensions.slack.types import ( SlackDmPolicy, SlackGroupPolicy, SlackMode, SlackStreamingMode, SlackReactionLevel, SlackReactionNotifications, SlackTokenSource, ) logger = logging.getLogger(__name__) class SlackConfigAdapter: def list_account_ids(self, config: dict) -> list[str]: accounts = config.get("channels", {}).get("slack", {}).get("accounts", {}) if not accounts: return ["default"] if self._env_bot_token() else [] return list(accounts.keys()) async def resolve_account(self, account_id: str, config: dict | None = None) -> dict: account_config = self._get_account_config(config or {}, account_id) bot_token, bot_token_source = self._resolve_token(account_config.get("botToken"), "SLACK_BOT_TOKEN") app_token, app_token_source = self._resolve_token(account_config.get("appToken"), "SLACK_APP_TOKEN") user_token, user_token_source = self._resolve_token(account_config.get("userToken"), "SLACK_USER_TOKEN") signing_secret = account_config.get("signingSecret") or os.environ.get("SLACK_SIGNING_SECRET", "") mode = SlackMode.SOCKET if account_config.get("mode"): mode = SlackMode(account_config["mode"]) elif not app_token: mode = SlackMode.HTTP dm_policy = SlackDmPolicy.PAIRING if account_config.get("dmPolicy"): dm_policy = SlackDmPolicy(account_config["dmPolicy"]) group_policy = SlackGroupPolicy.OPEN if account_config.get("groupPolicy"): group_policy = SlackGroupPolicy(account_config["groupPolicy"]) return { "account_id": account_id, "name": account_config.get("name", account_id), "enabled": account_config.get("enabled", True), "bot_token": bot_token, "bot_token_source": bot_token_source, "app_token": app_token, "app_token_source": app_token_source, "user_token": user_token, "user_token_source": user_token_source, "signing_secret": signing_secret, "mode": mode, "dm_policy": dm_policy, "group_policy": group_policy, "allow_from": account_config.get("allowFrom", []), "group_allow_from": account_config.get("groupAllowFrom", []), "channels": account_config.get("channels", {}), "text_chunk_limit": account_config.get("textChunkLimit", 40000), "media_max_mb": account_config.get("mediaMaxMb", 50), "streaming_mode": SlackStreamingMode(account_config.get("streamingMode", "native")), "streaming_min_chars": account_config.get("streamingMinChars", 256), "reaction_level": SlackReactionLevel(account_config.get("reactionLevel", "minimal")), "reaction_notifications": SlackReactionNotifications(account_config.get("reactionNotifications", "own")), "send_read_receipts": account_config.get("sendReadReceipts", True), } @staticmethod def _resolve_token(account_value: str | None, env_key: str) -> tuple[str, SlackTokenSource]: if account_value is not None: if account_value == "none": return "", SlackTokenSource.NONE if account_value.startswith("$"): env_val = os.environ.get(account_value.strip("${}"), "") return env_val, SlackTokenSource.CONFIG if env_val else SlackTokenSource.NONE return account_value, SlackTokenSource.CONFIG env_val = os.environ.get(env_key, "") if env_val: return env_val, SlackTokenSource.ENV return "", SlackTokenSource.NONE def is_configured(self, account: dict) -> bool: bot_token = account.get("bot_token", "") app_token = account.get("app_token", "") signing_secret = account.get("signing_secret", "") return bool(bot_token and (app_token or signing_secret)) def is_enabled(self, account: dict, config: dict) -> bool: account_id = account.get("account_id", "") account_config = self._get_account_config(config, account_id) return account_config.get("enabled", account.get("enabled", True)) def disabled_reason(self, account: dict, config: dict) -> str: if self.is_enabled(account, config): return "" account_id = account.get("account_id", "") account_config = self._get_account_config(config, account_id) return account_config.get("disabled_reason", "Account is disabled in config") def unconfigured_reason(self, account: dict, config: dict) -> str: if self.is_configured(account): return "" bot_token = account.get("bot_token", "") app_token = account.get("app_token", "") signing_secret = account.get("signing_secret", "") mode = account.get("mode", SlackMode.SOCKET) missing = [] if not bot_token: missing.append("bot_token") if mode == SlackMode.SOCKET and not app_token: missing.append("app_token") if mode == SlackMode.HTTP and not signing_secret: missing.append("signing_secret") if missing: return f"Missing required credentials: {', '.join(missing)}" return "Account is not fully configured" def describe_account(self, account: dict, config: dict) -> dict: bot_token = account.get("bot_token", "") app_token = account.get("app_token", "") return { "account_id": account.get("account_id", ""), "name": account.get("name", account.get("account_id", "")), "mode": account.get("mode", SlackMode.SOCKET), "configured": self.is_configured(account), "enabled": self.is_enabled(account, config), "bot_token_fingerprint": self._token_fingerprint(bot_token), "app_token_fingerprint": self._token_fingerprint(app_token), "dm_policy": account.get("dm_policy", SlackDmPolicy.PAIRING), "group_policy": account.get("group_policy", SlackGroupPolicy.OPEN), } def default_account_id(self, config: dict) -> str: return config.get("channels", {}).get("slack", {}).get("default_account", "default") def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None: account_config = self._get_account_config(config, account_id) allow_from = account_config.get("allow_from") if allow_from is None: return None return [str(entry) for entry in allow_from] def format_allow_from(self, config: dict, account_id: str | None, allow_from: list[str | int]) -> list[str]: return [str(entry) for entry in allow_from] def has_configured_state(self, config: dict) -> bool: accounts = config.get("channels", {}).get("slack", {}).get("accounts", {}) if not accounts: return bool(self._env_bot_token()) for account_config in accounts.values(): if account_config.get("bot_token") or self._env_bot_token(): return True return False def has_persisted_auth_state(self, config: dict) -> bool: accounts = config.get("channels", {}).get("slack", {}).get("accounts", {}) if not accounts: return False for account_config in accounts.values(): if account_config.get("bot_token"): return True return False def inspect_account(self, config: dict, account_id: str | None = None) -> dict: aid = account_id or "default" account_config = self._get_account_config(config, aid) return { "account_id": aid, "name": account_config.get("name", aid), "enabled": account_config.get("enabled", True), "mode": account_config.get("mode", "socket"), "dm_policy": account_config.get("dm_policy", "pairing"), "group_policy": account_config.get("group_policy", "open"), "allow_from": account_config.get("allow_from", []), "group_allow_from": account_config.get("group_allow_from", []), "bot_token_configured": bool(account_config.get("bot_token") or self._env_bot_token()), "app_token_configured": bool(account_config.get("app_token") or self._env_app_token()), } def set_account_enabled(self, config: dict, account_id: str, enabled: bool) -> dict: config.setdefault("channels", {}).setdefault("slack", {}).setdefault("accounts", {}) config["channels"]["slack"]["accounts"].setdefault(account_id, {}) config["channels"]["slack"]["accounts"][account_id]["enabled"] = enabled return config def delete_account(self, config: dict, account_id: str) -> dict: accounts = config.get("channels", {}).get("slack", {}).get("accounts", {}) if account_id in accounts: del accounts[account_id] return config def resolve_default_to(self, config: dict, account_id: str | None = None) -> str | None: accounts = config.get("channels", {}).get("slack", {}).get("accounts", {}) if not accounts: return None if account_id and account_id in accounts: return account_id return next(iter(accounts.keys())) def config_schema(self) -> dict: return { "$schema": "https://json-schema.org/draft-07/schema#", "type": "object", "title": "Slack 渠道配置", "properties": { "accounts": { "type": "object", "title": "账户列表", "description": "Slack 账户配置,key 为账户 ID", "additionalProperties": { "type": "object", "properties": { "name": { "type": "string", "title": "账户名称", }, "enabled": { "type": "boolean", "title": "启用", "default": True, }, "bot_token": { "type": "string", "title": "Bot Token", "description": "Slack Bot User OAuth Token (xoxb-),也可设为环境变量 SLACK_BOT_TOKEN", "x-ui-password": True, }, "app_token": { "type": "string", "title": "App-Level Token", "description": "Slack App-Level Token (xapp-),Socket 模式必需,也可用环境变量", "x-ui-password": True, }, "user_token": { "type": "string", "title": "User Token", "description": "Slack User OAuth Token (xoxp-),也可通过 SLACK_USER_TOKEN 环境变量注入", "x-ui-password": True, }, "signing_secret": { "type": "string", "title": "Signing Secret", "description": "Slack Signing Secret(HTTP 模式用于验证请求签名)", "x-ui-password": True, }, "mode": { "type": "string", "title": "连接模式", "enum": ["socket", "http"], "default": "socket", }, "dm_policy": { "type": "string", "title": "DM 策略", "enum": ["pairing", "allowlist", "open", "disabled"], "default": "pairing", }, "group_policy": { "type": "string", "title": "群组策略", "enum": ["open", "allowlist", "disabled"], "default": "open", }, "allow_from": { "type": "array", "title": "DM 白名单", "items": {"type": "string"}, }, "group_allow_from": { "type": "array", "title": "群组白名单", "items": {"type": "string"}, }, "channels": { "type": "object", "title": "频道配置", "additionalProperties": { "type": "object", "properties": { "require_mention": { "type": "boolean", "title": "需要 @提及", "default": True, }, "enabled": { "type": "boolean", "title": "启用", "default": True, }, "users": { "type": "array", "title": "授权用户", "items": {"type": "string"}, }, }, }, }, "text_chunk_limit": { "type": "integer", "title": "文本分块上限", "default": 40000, "minimum": 1000, "maximum": 40000, }, "media_max_mb": { "type": "integer", "title": "媒体大小上限 (MB)", "default": 50, "minimum": 1, "maximum": 100, }, "streaming_mode": { "type": "string", "title": "流式输出模式", "enum": ["native", "draft", "off"], "default": "native", }, "streaming_min_chars": { "type": "integer", "title": "流式最小字符数", "default": 256, "minimum": 64, "maximum": 40000, }, "reaction_level": { "type": "string", "title": "反应级别", "enum": ["off", "ack", "minimal"], "default": "minimal", }, "reaction_notifications": { "type": "string", "title": "反应通知", "enum": ["off", "own", "all"], "default": "own", }, "send_read_receipts": { "type": "boolean", "title": "发送已读回执", "default": True, }, }, }, }, "default_account": { "type": "string", "title": "默认账户", "description": "默认使用的 Slack 账户 ID", "default": "default", }, }, } def _token_fingerprint(self, token: str) -> str: if not token: return "" return hashlib.sha256(token.encode()).hexdigest()[:8] @staticmethod def _env_bot_token() -> str: return os.environ.get("SLACK_BOT_TOKEN", "") @staticmethod def _env_app_token() -> str: return os.environ.get("SLACK_APP_TOKEN", "") @staticmethod def _env_user_token() -> str: return os.environ.get("SLACK_USER_TOKEN", "") @staticmethod def _get_account_config(config: dict, account_id: str | None = None) -> dict: accounts = config.get("channels", {}).get("slack", {}).get("accounts", {}) if account_id and account_id in accounts: return accounts[account_id] if "default" in accounts: return accounts["default"] return {}