219 lines
8.2 KiB
Python
219 lines
8.2 KiB
Python
|
|
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, "")
|