新增QQ Bot适配器完整代码栈,包含: 1. 基础适配器入口与工具类封装 2. 会话管理、重试队列与流量控制 3. 命令系统与内置指令(ping/help/status等) 4. 富媒体消息处理与格式转换 5. 引用存储与审批管理 6. 凭证备份与会话持久化 7. 健康检查与交互回调系统
136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_BACKUP_DIR = os.path.join(tempfile.gettempdir(), "yuxi_qqbot_credentials")
|
|
|
|
|
|
@dataclass
|
|
class CredentialSnapshot:
|
|
app_id: str = ""
|
|
app_secret: str = ""
|
|
access_token: str = ""
|
|
expires_at: float = 0
|
|
token_obtained_at: float = 0
|
|
session_id: str = ""
|
|
sandbox: bool = False
|
|
metadata: dict = field(default_factory=dict)
|
|
|
|
def is_valid(self) -> bool:
|
|
return bool(self.app_id and self.app_secret)
|
|
|
|
def token_expired(self) -> bool:
|
|
if not self.access_token or not self.expires_at:
|
|
return True
|
|
return time.monotonic() > self.expires_at - 300
|
|
|
|
|
|
class CredentialBackup:
|
|
def __init__(self, app_id: str, backup_dir: str | None = None):
|
|
self._app_id = app_id
|
|
self._backup_dir = backup_dir or DEFAULT_BACKUP_DIR
|
|
self._backup_path = os.path.join(self._backup_dir, f"{app_id}.json")
|
|
|
|
def save(self, snapshot: CredentialSnapshot) -> bool:
|
|
try:
|
|
os.makedirs(self._backup_dir, exist_ok=True)
|
|
|
|
data = {
|
|
"app_id": snapshot.app_id,
|
|
"app_secret": snapshot.app_secret,
|
|
"access_token": snapshot.access_token,
|
|
"expires_at": snapshot.expires_at,
|
|
"token_obtained_at": snapshot.token_obtained_at,
|
|
"session_id": snapshot.session_id,
|
|
"sandbox": snapshot.sandbox,
|
|
"metadata": snapshot.metadata,
|
|
"saved_at": time.time(),
|
|
}
|
|
|
|
tmp_path = self._backup_path + ".tmp"
|
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False)
|
|
os.replace(tmp_path, self._backup_path)
|
|
|
|
logger.info("CredentialBackup: saved snapshot for app_id=%s", self._app_id[:6] + "...")
|
|
return True
|
|
except OSError:
|
|
logger.exception("CredentialBackup: failed to save snapshot")
|
|
return False
|
|
|
|
def restore(self) -> CredentialSnapshot | None:
|
|
try:
|
|
if not os.path.exists(self._backup_path):
|
|
return None
|
|
|
|
with open(self._backup_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
snapshot = CredentialSnapshot(
|
|
app_id=data.get("app_id", ""),
|
|
app_secret=data.get("app_secret", ""),
|
|
access_token=data.get("access_token", ""),
|
|
expires_at=data.get("expires_at", 0),
|
|
token_obtained_at=data.get("token_obtained_at", 0),
|
|
session_id=data.get("session_id", ""),
|
|
sandbox=data.get("sandbox", False),
|
|
metadata=data.get("metadata", {}),
|
|
)
|
|
|
|
if not snapshot.is_valid():
|
|
logger.warning("CredentialBackup: restored snapshot is invalid for app_id=%s", self._app_id[:6] + "...")
|
|
return None
|
|
|
|
logger.info("CredentialBackup: restored snapshot for app_id=%s", self._app_id[:6] + "...")
|
|
return snapshot
|
|
except (OSError, json.JSONDecodeError, KeyError):
|
|
logger.exception("CredentialBackup: failed to restore snapshot")
|
|
return None
|
|
|
|
def clear(self) -> bool:
|
|
try:
|
|
if os.path.exists(self._backup_path):
|
|
os.remove(self._backup_path)
|
|
tmp_path = self._backup_path + ".tmp"
|
|
if os.path.exists(tmp_path):
|
|
os.remove(tmp_path)
|
|
logger.info("CredentialBackup: cleared backup for app_id=%s", self._app_id[:6] + "...")
|
|
return True
|
|
except OSError:
|
|
logger.exception("CredentialBackup: failed to clear backup")
|
|
return False
|
|
|
|
@staticmethod
|
|
def cleanup_expired(backup_dir: str | None = None, max_age_s: float = 86400 * 7) -> int:
|
|
directory = backup_dir or DEFAULT_BACKUP_DIR
|
|
removed = 0
|
|
|
|
if not os.path.exists(directory):
|
|
return 0
|
|
|
|
now = time.time()
|
|
try:
|
|
for filename in os.listdir(directory):
|
|
if not filename.endswith(".json"):
|
|
continue
|
|
filepath = os.path.join(directory, filename)
|
|
try:
|
|
stat = os.stat(filepath)
|
|
if now - stat.st_mtime > max_age_s:
|
|
os.remove(filepath)
|
|
removed += 1
|
|
logger.debug("CredentialBackup: removed expired backup %s", filename)
|
|
except OSError:
|
|
pass
|
|
except OSError:
|
|
logger.exception("CredentialBackup: cleanup failed")
|
|
|
|
return removed
|