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