ForcePilot/backend/package/yuxi/channels/adapters/qqbot/credential_backup.py
Kris ef5483dc1a refactor(qqbot): 重构QQ机器人适配器代码,优化多项功能与结构
主要变更:
1. 修复速率限流器使用setdefault替代重复创建令牌桶
2. 重构交互注册表匹配逻辑,优化精确匹配查找
3. 重构去重缓存逻辑,移到适配器实例方法
4. 重构发送URL解析,增加合法性校验并拆分公共方法
5. 优化流式消息处理逻辑,简化flush_controller调用
6. 重构群聊类型判断代码,简化语法
7. 修复重连管理器对None类型关闭分类的处理
8. 新增消息缓存、线程模拟器、发送初始化模块
9. 重构凭证备份与会话存储逻辑,支持环境变量指定状态目录
10. 新增配置提示与向导二维码绑定功能
11. 优化媒体上传逻辑,增加重试机制与缓存
12. 新增审批键盘模板构建函数
13. 重构消息格式处理,修正媒体发送字段与长度限制
14. 修复令牌过期时间计算,使用time.time替代monotonic
15. 新增群组激活缓冲区与用户追踪器增强功能
16. 修复换行符问题,统一文件结尾格式
2026-05-13 16:13:48 +08:00

207 lines
6.9 KiB
Python

from __future__ import annotations
import base64
import hashlib
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")
def _resolve_backup_dir(backup_dir: str | None = None) -> str:
if backup_dir:
return backup_dir
for env_var in ("OPENCLAW_STATE_DIR", "CLAWDBOT_STATE_DIR"):
env_val = os.environ.get(env_var)
if env_val:
qqbot_dir = os.path.join(env_val, "qqbot", "credentials")
os.makedirs(qqbot_dir, exist_ok=True)
return qqbot_dir
return DEFAULT_BACKUP_DIR
_fernet_warned = False
def _get_fernet():
global _fernet_warned
try:
from cryptography.fernet import Fernet
except ImportError:
if not _fernet_warned:
logger.warning(
"QQBot credential encryption unavailable: cryptography not installed. "
"Credentials will be stored in plaintext. "
"Install with: pip install cryptography"
)
_fernet_warned = True
return None
key = os.environ.get("QQBOT_CREDENTIAL_ENCRYPTION_KEY")
if not key:
if not _fernet_warned:
logger.warning(
"QQBot credential encryption disabled: QQBOT_CREDENTIAL_ENCRYPTION_KEY not set. "
"Credentials will be stored in plaintext at %s",
_resolve_backup_dir(),
)
_fernet_warned = True
return None
digest = hashlib.sha256(key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def _encrypt_field(fernet, value: str) -> str:
if not value:
return value
if not fernet:
logger.warning("QQBot credential stored in plaintext: encryption key not configured")
return value
return fernet.encrypt(value.encode()).decode()
def _decrypt_field(fernet, value: str) -> str:
if not fernet or not value:
return value
try:
return fernet.decrypt(value.encode()).decode()
except Exception:
return value
@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 = _resolve_backup_dir(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)
fernet = _get_fernet()
data = {
"app_id": snapshot.app_id,
"app_secret": _encrypt_field(fernet, snapshot.app_secret),
"access_token": _encrypt_field(fernet, 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)
os.chmod(self._backup_path, 0o600)
logger.info(
"CredentialBackup: saved snapshot for app_id=%s%s",
self._app_id[:6] + "..." if len(self._app_id) > 6 else self._app_id,
" (encrypted)" if fernet else "",
)
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)
fernet = _get_fernet()
snapshot = CredentialSnapshot(
app_id=data.get("app_id", ""),
app_secret=_decrypt_field(fernet, data.get("app_secret", "")),
access_token=_decrypt_field(fernet, 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