ForcePilot/backend/package/yuxi/channel/extensions/twitter/config.py

219 lines
8.2 KiB
Python
Raw Normal View History

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, "")