- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
458 lines
17 KiB
Python
458 lines
17 KiB
Python
import json
|
|
import sqlite3
|
|
from contextlib import closing
|
|
import time
|
|
from pathlib import Path
|
|
import httpx
|
|
from PIL import Image
|
|
from nonebot.log import logger
|
|
from typing import Any, List, Dict, Optional, Tuple
|
|
|
|
from .models import Player, ProcessedPlayer
|
|
|
|
|
|
def _game_key(player: Player) -> Optional[tuple]:
|
|
"""玩家当前游戏的稳定标识:gameid 优先,非 Steam 游戏(无 gameid)回退到游戏名"""
|
|
gameid = player.get("gameid")
|
|
if gameid:
|
|
return ("appid", str(gameid))
|
|
name = player.get("gameextrainfo")
|
|
if name:
|
|
return ("name", name)
|
|
return None
|
|
|
|
|
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# isolation_level=None:自动提交模式,配合 closing 确保写入持久化
|
|
return sqlite3.connect(str(db_path), isolation_level=None)
|
|
|
|
|
|
class BindData:
|
|
"""绑定数据:parent_id + user_id -> {user_id, steam_id, nickname}"""
|
|
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.content: Dict[str, List[Dict[str, str]]] = {}
|
|
self._db_path = Path(db_path)
|
|
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS bind_data ("
|
|
"parent_id TEXT NOT NULL,"
|
|
"user_id TEXT NOT NULL,"
|
|
"steam_id TEXT NOT NULL,"
|
|
"nickname TEXT,"
|
|
"PRIMARY KEY (parent_id, user_id))"
|
|
)
|
|
self._migrate_json()
|
|
self._load()
|
|
|
|
def _migrate_json(self) -> None:
|
|
json_path = self._db_path.parent / "bind_data.json"
|
|
if json_path.is_file():
|
|
try:
|
|
data = json.loads(json_path.read_text("utf-8"))
|
|
with closing(_connect(self._db_path)) as conn:
|
|
for pid, entries in data.items():
|
|
for entry in entries:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO bind_data "
|
|
"(parent_id, user_id, steam_id, nickname) VALUES (?, ?, ?, ?)",
|
|
(
|
|
pid,
|
|
entry.get("user_id", ""),
|
|
entry.get("steam_id", ""),
|
|
entry.get("nickname"),
|
|
),
|
|
)
|
|
json_path.rename(json_path.with_suffix(".json.bak"))
|
|
except Exception:
|
|
pass
|
|
|
|
def _load(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
rows = conn.execute(
|
|
"SELECT parent_id, user_id, steam_id, nickname FROM bind_data"
|
|
).fetchall()
|
|
self.content = {}
|
|
for pid, uid, sid, nick in rows:
|
|
self.content.setdefault(pid, []).append(
|
|
{"user_id": uid, "steam_id": sid, "nickname": nick}
|
|
)
|
|
|
|
def save(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute("DELETE FROM bind_data")
|
|
for pid, entries in self.content.items():
|
|
for entry in entries:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO bind_data "
|
|
"(parent_id, user_id, steam_id, nickname) VALUES (?, ?, ?, ?)",
|
|
(
|
|
pid,
|
|
entry.get("user_id", ""),
|
|
entry.get("steam_id", ""),
|
|
entry.get("nickname"),
|
|
),
|
|
)
|
|
|
|
def add(self, parent_id: str, content: Dict[str, str]) -> None:
|
|
if parent_id not in self.content:
|
|
self.content[parent_id] = [content]
|
|
else:
|
|
self.content[parent_id].append(content)
|
|
|
|
def remove(self, parent_id: str, user_id: str) -> None:
|
|
if parent_id not in self.content:
|
|
return
|
|
for data in self.content[parent_id]:
|
|
if data["user_id"] == user_id:
|
|
self.content[parent_id].remove(data)
|
|
break
|
|
|
|
def update(self, parent_id: str, content: Dict[str, str]) -> None:
|
|
self.content[parent_id] = content
|
|
|
|
def get(self, parent_id: str, user_id: str) -> Optional[Dict[str, str]]:
|
|
if parent_id not in self.content:
|
|
return None
|
|
for data in self.content[parent_id]:
|
|
if data["user_id"] == user_id:
|
|
return data
|
|
return None
|
|
|
|
def get_by_steam_id(
|
|
self, parent_id: str, steam_id: str
|
|
) -> Optional[Dict[str, str]]:
|
|
if parent_id not in self.content:
|
|
return None
|
|
for data in self.content[parent_id]:
|
|
if data["steam_id"] == steam_id:
|
|
return data
|
|
return None
|
|
|
|
def get_all(self, parent_id: str) -> List[str]:
|
|
if parent_id not in self.content:
|
|
return []
|
|
result = []
|
|
for data in self.content[parent_id]:
|
|
if not data["steam_id"] in result:
|
|
result.append(data["steam_id"])
|
|
return result
|
|
|
|
def get_all_steam_id(self) -> List[str]:
|
|
result = []
|
|
for parent_id in self.content:
|
|
for data in self.content[parent_id]:
|
|
if not data["steam_id"] in result:
|
|
result.append(data["steam_id"])
|
|
return result
|
|
|
|
|
|
class SteamInfoData:
|
|
"""播报状态:steamid -> 玩家数据(JSON 列存储,保留完整结构)"""
|
|
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.content: List[ProcessedPlayer] = []
|
|
self._by_id: Dict[str, ProcessedPlayer] = {}
|
|
self._db_path = Path(db_path)
|
|
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS steam_info ("
|
|
"steamid TEXT PRIMARY KEY,"
|
|
"data TEXT NOT NULL)"
|
|
)
|
|
self._migrate_json()
|
|
self._load()
|
|
|
|
def _migrate_json(self) -> None:
|
|
json_path = self._db_path.parent / "steam_info.json"
|
|
if json_path.is_file():
|
|
try:
|
|
data = json.loads(json_path.read_text("utf-8"))
|
|
if isinstance(data, list):
|
|
with closing(_connect(self._db_path)) as conn:
|
|
for player in data:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO steam_info (steamid, data) VALUES (?, ?)",
|
|
(player["steamid"], json.dumps(player, ensure_ascii=False)),
|
|
)
|
|
json_path.rename(json_path.with_suffix(".json.bak"))
|
|
except Exception:
|
|
pass
|
|
|
|
def _load(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
rows = conn.execute("SELECT data FROM steam_info").fetchall()
|
|
self.content = [json.loads(r[0]) for r in rows]
|
|
self._rebuild_index()
|
|
|
|
def _rebuild_index(self) -> None:
|
|
"""重建 steamid -> 玩家 索引,get_player 由线性扫描变 O(1)"""
|
|
self._by_id = {p["steamid"]: p for p in self.content}
|
|
|
|
def save(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute("DELETE FROM steam_info")
|
|
for player in self.content:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO steam_info (steamid, data) VALUES (?, ?)",
|
|
(player["steamid"], json.dumps(player, ensure_ascii=False)),
|
|
)
|
|
|
|
def update(self, player: ProcessedPlayer) -> None:
|
|
self.content.append(player)
|
|
self._by_id[player["steamid"]] = player
|
|
|
|
def update_by_players(self, players: List[Player]):
|
|
# 将 Player 转换为 ProcessedPlayer
|
|
processed_players = []
|
|
for player in players:
|
|
old_player = self.get_player(player["steamid"])
|
|
|
|
if old_player is None:
|
|
if player.get("gameextrainfo") is not None:
|
|
player["game_start_time"] = int(time.time())
|
|
else:
|
|
player["game_start_time"] = None
|
|
processed_players.append(player)
|
|
else:
|
|
old_key = _game_key(old_player)
|
|
new_key = _game_key(player)
|
|
if new_key is not None and old_key is None:
|
|
# 开始游戏
|
|
player["game_start_time"] = int(time.time())
|
|
elif new_key is None and old_key is not None:
|
|
# 结束游戏
|
|
player["game_start_time"] = None
|
|
elif new_key is not None and old_key is not None:
|
|
# 继续游戏:游戏不变则沿用开始时间,换游戏则从新游戏开始时重新计时
|
|
if new_key == old_key:
|
|
player["game_start_time"] = old_player["game_start_time"]
|
|
else:
|
|
player["game_start_time"] = int(time.time())
|
|
else:
|
|
player["game_start_time"] = None
|
|
processed_players.append(player)
|
|
|
|
self.content = processed_players
|
|
self._rebuild_index()
|
|
|
|
def get_player(self, steam_id: str) -> Optional[Player]:
|
|
return self._by_id.get(steam_id)
|
|
|
|
def get_players(self, steam_ids: List[str]) -> List[Player]:
|
|
id_set = set(steam_ids)
|
|
result = []
|
|
for player in self.content:
|
|
if player["steamid"] in id_set:
|
|
result.append(player)
|
|
return result
|
|
|
|
def compare(
|
|
self, old_players: List[Player], new_players: List[Player]
|
|
) -> List[Dict[str, Any]]:
|
|
result = []
|
|
|
|
old_players_dict = {player["steamid"]: player for player in old_players}
|
|
|
|
for player in new_players:
|
|
old_player = old_players_dict.get(player["steamid"])
|
|
if old_player is None:
|
|
continue
|
|
|
|
new_game = _game_key(player)
|
|
old_game = _game_key(old_player)
|
|
|
|
if new_game == old_game:
|
|
continue
|
|
|
|
if new_game is not None and old_game is None:
|
|
entry_type = "start"
|
|
elif new_game is None and old_game is not None:
|
|
entry_type = "stop"
|
|
elif new_game is not None and old_game is not None:
|
|
entry_type = "change"
|
|
else:
|
|
entry_type = "error"
|
|
|
|
result.append(
|
|
{
|
|
"type": entry_type,
|
|
"player": player,
|
|
"old_player": old_player,
|
|
}
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
class ParentData:
|
|
"""群信息:parent_id -> 群名 + 更新时间,头像图片存文件
|
|
|
|
群名/群头像一律从 QQ 自动获取并缓存(steamupdate 指令已移除),
|
|
updated_at 用于判断缓存是否过期(默认 7 天自动刷新)。
|
|
"""
|
|
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.content: Dict[str, str] = {}
|
|
self._updated_at: Dict[str, int] = {}
|
|
self._db_path = Path(db_path)
|
|
self._avatar_dir = self._db_path.parent / "avatars"
|
|
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS parent_data ("
|
|
"parent_id TEXT PRIMARY KEY,"
|
|
"name TEXT NOT NULL,"
|
|
"updated_at INTEGER)"
|
|
)
|
|
# 旧表迁移:补充 updated_at 列
|
|
cols = [row[1] for row in conn.execute("PRAGMA table_info(parent_data)")]
|
|
if "updated_at" not in cols:
|
|
conn.execute("ALTER TABLE parent_data ADD COLUMN updated_at INTEGER")
|
|
self._migrate_json()
|
|
self._load()
|
|
|
|
def _migrate_json(self) -> None:
|
|
json_path = self._db_path.parent / "parent_data.json"
|
|
if json_path.is_file():
|
|
try:
|
|
data = json.loads(json_path.read_text("utf-8"))
|
|
self.content = {pid: name for pid, name in data.items()}
|
|
self.save()
|
|
json_path.rename(json_path.with_suffix(".json.bak"))
|
|
except Exception:
|
|
pass
|
|
# 迁移旧头像文件(data 根目录 -> avatars/ 子目录)
|
|
self._avatar_dir.mkdir(parents=True, exist_ok=True)
|
|
for old in self._db_path.parent.glob("*.png"):
|
|
try:
|
|
old.rename(self._avatar_dir / old.name)
|
|
except OSError:
|
|
pass
|
|
|
|
def _load(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
rows = conn.execute(
|
|
"SELECT parent_id, name, updated_at FROM parent_data"
|
|
).fetchall()
|
|
self.content = {pid: name for pid, name, _ in rows}
|
|
self._updated_at = {pid: ts or 0 for pid, _, ts in rows}
|
|
|
|
def save(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute("DELETE FROM parent_data")
|
|
for pid, name in self.content.items():
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO parent_data "
|
|
"(parent_id, name, updated_at) VALUES (?, ?, ?)",
|
|
(pid, name, self._updated_at.get(pid, 0)),
|
|
)
|
|
|
|
def _download_avatar(self, parent_id: str, avatar_path: Path) -> None:
|
|
"""头像缓存丢失时从 QQ 群头像接口重新下载"""
|
|
try:
|
|
url = f"http://p.qlogo.cn/gh/{parent_id}/{parent_id}/640"
|
|
resp = httpx.get(url, timeout=10)
|
|
if resp.status_code == 200 and resp.content:
|
|
self._avatar_dir.mkdir(parents=True, exist_ok=True)
|
|
avatar_path.write_bytes(resp.content)
|
|
except Exception as e:
|
|
logger.warning(f"群头像重新下载失败 {parent_id}: {e}")
|
|
|
|
def update(self, parent_id: str, avatar: Image.Image, name: str) -> None:
|
|
self.content[parent_id] = name
|
|
self._updated_at[parent_id] = int(time.time())
|
|
self.save()
|
|
# 保存图片
|
|
self._avatar_dir.mkdir(parents=True, exist_ok=True)
|
|
avatar.save(self._avatar_dir / f"{parent_id}.png")
|
|
|
|
def update_name(self, parent_id: str, name: str) -> None:
|
|
"""只更新群名,不动头像(头像下载失败时的降级路径)"""
|
|
self.content[parent_id] = name
|
|
self._updated_at[parent_id] = int(time.time())
|
|
self.save()
|
|
|
|
def is_fresh(self, parent_id: str, ttl: int = 7 * 24 * 3600) -> bool:
|
|
"""缓存是否新鲜(默认 7 天内自动刷新过)"""
|
|
return (
|
|
parent_id in self.content
|
|
and int(time.time()) - self._updated_at.get(parent_id, 0) < ttl
|
|
)
|
|
|
|
def get(self, parent_id: str) -> Tuple[Image.Image, str]:
|
|
if parent_id not in self.content:
|
|
return (
|
|
Image.open(Path(__file__).parent / "res/unknown_avatar.jpg"),
|
|
parent_id,
|
|
)
|
|
avatar_path = self._avatar_dir / f"{parent_id}.png"
|
|
if not avatar_path.is_file():
|
|
# 头像缓存丢失,自动重新下载
|
|
self._download_avatar(parent_id, avatar_path)
|
|
if avatar_path.is_file():
|
|
return Image.open(avatar_path), self.content[parent_id]
|
|
# 下载失败,降级默认头像
|
|
return (
|
|
Image.open(Path(__file__).parent / "res/unknown_avatar.jpg"),
|
|
self.content[parent_id],
|
|
)
|
|
|
|
|
|
class DisableParentData:
|
|
"""储存禁用 Steam 通知的 parent"""
|
|
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.content: List[str] = []
|
|
self._db_path = Path(db_path)
|
|
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS disable_parent_data ("
|
|
"parent_id TEXT PRIMARY KEY)"
|
|
)
|
|
self._migrate_json()
|
|
self._load()
|
|
|
|
def _migrate_json(self) -> None:
|
|
json_path = self._db_path.parent / "disable_parent_data.json"
|
|
if json_path.is_file():
|
|
try:
|
|
data = json.loads(json_path.read_text("utf-8"))
|
|
if isinstance(data, list):
|
|
self.content = [str(pid) for pid in data]
|
|
self.save()
|
|
json_path.rename(json_path.with_suffix(".json.bak"))
|
|
except Exception:
|
|
pass
|
|
|
|
def _load(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
rows = conn.execute("SELECT parent_id FROM disable_parent_data").fetchall()
|
|
self.content = [r[0] for r in rows]
|
|
|
|
def save(self) -> None:
|
|
with closing(_connect(self._db_path)) as conn:
|
|
conn.execute("DELETE FROM disable_parent_data")
|
|
for pid in self.content:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO disable_parent_data (parent_id) VALUES (?)",
|
|
(pid,),
|
|
)
|
|
|
|
def add(self, parent_id: str) -> None:
|
|
if parent_id not in self.content:
|
|
self.content.append(parent_id)
|
|
self.save()
|
|
|
|
def remove(self, parent_id: str) -> None:
|
|
if parent_id in self.content:
|
|
self.content.remove(parent_id)
|
|
self.save()
|
|
|
|
def is_disabled(self, parent_id: str) -> bool:
|
|
return parent_id in self.content
|