from __future__ import annotations import hashlib import logging import os logger = logging.getLogger(__name__) class TwitterConfigAdapter: def __init__(self): self._config: dict = {} @property def _tw_cfg(self) -> dict: return self._config.get("channels", {}).get("twitter", {}) def list_account_ids(self, config: dict) -> list[str]: self._config = config accounts = self._tw_cfg.get("accounts", {}) if accounts: return list(accounts.keys()) if self._env_credentials_exist(): return ["default"] return [] async def resolve_account(self, account_id: str) -> dict: return self._build_account(account_id) def is_configured(self, account: dict) -> bool: return all( [ account.get("api_key"), account.get("api_secret"), account.get("access_token"), account.get("access_secret"), ] ) def is_enabled(self, account: dict) -> bool: return account.get("enabled", True) def describe_account(self, account: dict) -> dict: return { "account_id": account.get("account_id", ""), "name": account.get("name", ""), "username": account.get("username", ""), "user_id": account.get("user_id", ""), "token_source": account.get("token_source", ""), "dm_policy": account.get("dm_policy", "pairing"), "configured": self.is_configured(account), } def default_account_id(self) -> str: return self._tw_cfg.get("defaultAccount", "default") def config_schema(self) -> dict: return { "$schema": "https://json-schema.org/draft-07/schema#", "type": "object", "title": "X (Twitter) 渠道配置", "properties": { "apiKey": { "type": "string", "title": "API Key (Consumer Key)", "description": "X Developer Portal 中的 API Key,也可通过环境变量 X_API_KEY 注入", "x-ui-password": True, }, "apiSecret": { "type": "string", "title": "API Secret (Consumer Secret)", "description": "也可通过环境变量 X_API_SECRET 注入", "x-ui-password": True, }, "accessToken": { "type": "string", "title": "Access Token", "description": "OAuth 1.0a 授权后获得的 Access Token,也可通过环境变量 X_ACCESS_TOKEN 注入", "x-ui-password": True, }, "accessSecret": { "type": "string", "title": "Access Token Secret", "description": "也可通过环境变量 X_ACCESS_SECRET 注入", "x-ui-password": True, }, "dmPolicy": { "type": "string", "title": "DM 策略", "enum": ["pairing", "allowlist", "open", "disabled"], "default": "pairing", }, "groupPolicy": { "type": "string", "title": "群组 DM 策略", "enum": ["open", "disabled", "allowlist"], "default": "disabled", }, "textChunkLimit": { "type": "integer", "title": "文本分块上限", "default": 10000, "minimum": 100, "maximum": 10000, }, "webhookUrl": { "type": "string", "title": "Webhook URL", "description": "Account Activity API 的 Webhook 回调地址(公网可访问)。留空则使用 Polling 模式", }, "webhookEnv": { "type": "string", "title": "Webhook 环境名称", "default": "dev", "description": "Account Activity API 的环境名称,对应 X Developer Portal 中的环境标识", }, "connectionMode": { "type": "string", "title": "连接模式", "enum": ["auto", "webhook", "polling"], "default": "auto", }, "pollingIntervalSec": { "type": "integer", "title": "Polling 间隔(秒)", "default": 180, "minimum": 60, "maximum": 900, }, "reactionLevel": { "type": "string", "title": "Reaction 级别", "enum": ["off", "ack", "minimal"], "default": "ack", }, }, "required": ["apiKey", "apiSecret", "accessToken", "accessSecret"], } @staticmethod def credential_fingerprint(api_key: str, access_token: str) -> str: return hashlib.sha256(f"{api_key}:{access_token}".encode()).hexdigest()[:8] @staticmethod def _env_credentials_exist() -> bool: return bool( os.environ.get("X_API_KEY", "") and os.environ.get("X_API_SECRET", "") and os.environ.get("X_ACCESS_TOKEN", "") and os.environ.get("X_ACCESS_SECRET", "") ) def _build_account(self, account_id: str) -> dict: tw_cfg = self._tw_cfg accounts = tw_cfg.get("accounts", {}) account_raw = ( accounts.get(account_id, {}) if account_id != "default" else tw_cfg ) def _get(key: str, default=None): return account_raw.get(key, tw_cfg.get(key, default)) api_key = self._resolve_env_credential("X_API_KEY", account_id) api_secret = self._resolve_env_credential("X_API_SECRET", account_id) access_token = self._resolve_env_credential("X_ACCESS_TOKEN", account_id) access_secret = self._resolve_env_credential("X_ACCESS_SECRET", account_id) token_source = ( "env" if (api_key and api_secret and access_token and access_secret) else "none" ) if not api_key: api_key = _get("apiKey", "") if not api_secret: api_secret = _get("apiSecret", "") if not access_token: access_token = _get("accessToken", "") if not access_secret: access_secret = _get("accessSecret", "") if token_source == "none" and all( [api_key, api_secret, access_token, access_secret] ): token_source = "config" return { "account_id": account_id, "api_key": api_key, "api_secret": api_secret, "access_token": access_token, "access_secret": access_secret, "token_source": token_source, "name": account_raw.get("name", account_id), "user_id": account_raw.get("userId", ""), "username": account_raw.get("username", ""), "enabled": account_raw.get("enabled", True), "dm_policy": _get("dmPolicy", "pairing"), "group_policy": _get("groupPolicy", "disabled"), "allow_from": _get("allowFrom", []), "group_allow_from": _get("groupAllowFrom", []), "text_chunk_limit": _get("textChunkLimit", 10000), "webhook_url": _get("webhookUrl", ""), "webhook_env": _get("webhookEnv", "dev"), "connection_mode": _get("connectionMode", "auto"), "polling_interval_sec": _get("pollingIntervalSec", 180), "reaction_level": _get("reactionLevel", "ack"), "streaming_mode": _get("streamingMode", "block"), } @staticmethod def _resolve_env_credential(base_env: str, account_id: str) -> str: if account_id != "default": val = os.environ.get(f"{base_env}_{account_id.upper()}", "") if val: return val return os.environ.get(base_env, "")