这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
173 lines
5.8 KiB
Python
173 lines
5.8 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 _get_fernet():
|
|
try:
|
|
from cryptography.fernet import Fernet
|
|
except ImportError:
|
|
return None
|
|
|
|
key = os.environ.get("QQBOT_CREDENTIAL_ENCRYPTION_KEY")
|
|
if not key:
|
|
return None
|
|
digest = hashlib.sha256(key.encode()).digest()
|
|
return Fernet(base64.urlsafe_b64encode(digest))
|
|
|
|
|
|
def _encrypt_field(fernet, value: str) -> str:
|
|
if not fernet or not value:
|
|
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 = 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)
|
|
|
|
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
|