import asyncio import time import httpx import nonebot from io import BytesIO from pathlib import Path from nonebot.log import logger from PIL import Image as PILImage from nonebot import require from typing import Optional, List, Dict from nonebot.adapters import Event from nonebot.plugin import PluginMetadata, inherit_supported_adapters require("nonebot_plugin_alconna") require("nonebot_plugin_localstore") require("nonebot_plugin_apscheduler") from nonebot_plugin_alconna.uniseg import get_target as uni_get_target import nonebot_plugin_localstore as store from nonebot_plugin_apscheduler import scheduler from nonebot_plugin_alconna import Text, Image, UniMessage, Target from nonebot.adapters.onebot.v11 import Bot from .config import Config from .models import ProcessedPlayer from .data_source import BindData, SteamInfoData, ParentData, DisableParentData from .steam import ( get_steam_id, get_user_data, STEAM_ID_OFFSET, get_steam_users_info, init_api_key_manager, get_play_time as fetch_play_time, get_game_cn_names, localize_game_name, migrate_game_names_json, update_steamdb_apps, _is_full_sync_done, _query_game_names, _query_game_icon_hashes, _upsert_game_names, _upsert_game_icon_hashes, ) from .draw import ( check_font, set_font_paths, draw_start_gaming, draw_player_status, draw_friends_status, vertically_concatenate_images, ) from .utils import ( fetch_avatar, get_game_icons, image_to_bytes, simplize_steam_player_data, ) __plugin_meta__ = PluginMetadata( name="Steam信息", description="播报绑定的 Steam 好友状态", usage=""" steamhelp: 查看帮助 steambind [Steam ID 或 Steam 好友代码]: 绑定 Steam ID steamunbind: 解绑 Steam ID steaminfo (可选)[@某人 或 Steam ID 或 Steam好友代码]: 查看 Steam 主页 steamcheck: 查看 Steam 好友状态 steamenable: 启用 Steam 播报 steamdisable: 禁用 Steam 播报 steamplaytime (可选)[Steam ID 或 Steam好友代码]: 查看游玩时长排行 steamdbupdate: 更新 SteamDB 游戏名映射(仅限超级管理员) (群名/群头像/群内昵称均由机器人自动获取,无需手动设置) """.strip(), type="application", homepage="https://github.com/zhaomaoniu/nonebot-plugin-steam-info", config=Config, supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"), ) if hasattr(nonebot, "get_plugin_config"): config = nonebot.get_plugin_config(Config) else: from nonebot import get_driver config = Config.parse_obj(get_driver().config) set_font_paths( config.steam_font_regular_path, config.steam_font_light_path, config.steam_font_bold_path, ) # 全部数据整合进单一 SQLite(绑定/播报状态/群信息/禁用/游戏名映射), # 存插件目录 data/ 下,迁移只拷一个文件;头像缓存保留在 localstore _plugin_data_dir = Path(__file__).resolve().parent / "data" _plugin_data_dir.mkdir(parents=True, exist_ok=True) db_path = _plugin_data_dir / "steam.db" # 一次性迁移旧 JSON 游戏名映射到 SQLite(其余数据类内部自带迁移) migrate_game_names_json(_plugin_data_dir / "game_names.json", db_path) avatar_path = store.get_cache_dir("nonebot_plugin_steam_info") cache_path = avatar_path bind_data = BindData(db_path) steam_info_data = SteamInfoData(db_path) parent_data = ParentData(db_path) disable_parent_data = DisableParentData(db_path) try: check_font() except FileNotFoundError as e: logger.error( f"{e}, nonebot_plugin_steam_info 无法使用,请参照 `https://github.com/zhaomaoniu/nonebot-plugin-steam-info` 配置字体文件" ) try: config = nonebot.get_plugin_config(Config) init_api_key_manager(config, db_path) logger.success("Steam API key 管理器已初始化") except Exception as e: logger.error(f"初始化失败: {e}") # 统一配置标准:把常见可调配置暴露给 Web 管理台 from hexi.web_hub.config_standard import register_model_config # noqa: E402 register_model_config( __name__, config, fields=[ "steam_api_key", "steam_request_interval", "steam_broadcast_type", "steam_playtime_theme", "steam_disable_broadcast_on_startup", "proxy", ], types={ "steam_api_key": "array", "steam_broadcast_type": "enum", "steam_playtime_theme": "enum", }, options={ "steam_broadcast_type": [ {"value": "all", "label": "全部(带图)"}, {"value": "part", "label": "部分(开始游戏图)"}, {"value": "none", "label": "仅文字"}, ], "steam_playtime_theme": [ {"value": "light", "label": "亮色"}, {"value": "dark", "label": "暗色"}, ], }, labels={ "steam_api_key": "Steam Web API Key", "steam_request_interval": "播报轮询间隔(秒)", "steam_broadcast_type": "播报类型", "steam_playtime_theme": "时长表主题", "steam_disable_broadcast_on_startup": "启动时不播报", "proxy": "代理", }, descriptions={ "steam_api_key": "每行一个,可多 key 轮询;改后需重启生效", "steam_request_interval": "播报轮询周期(分钟=值/60)", "steam_broadcast_type": "控制播报附带图片的粒度", "steam_playtime_theme": "游玩时长表图片主题", "steam_disable_broadcast_on_startup": "True 时 bot 连接不触发首次播报", "proxy": "如 http://127.0.0.1:7890", }, ) async def get_target(event: Event, bot: Bot) -> Optional[Target]: target = uni_get_target(event) if target.private: # 不支持私聊消息 return None return target async def _refresh_parent_info(bot: Bot, parent_id: str) -> None: """从 QQ 自动获取群名+群头像并更新缓存(steamupdate 指令已移除,一律自动)""" name = None try: group_info = await bot.get_group_info(group_id=int(parent_id)) name = group_info.get("group_name") or None except Exception as e: logger.warning(f"获取群 {parent_id} 信息失败: {e}") try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(f"http://p.qlogo.cn/gh/{parent_id}/{parent_id}/640") if resp.status_code == 200 and resp.content: avatar = PILImage.open(BytesIO(resp.content)) parent_data.update(parent_id, avatar, name or str(parent_id)) return except Exception as e: logger.warning(f"下载群 {parent_id} 头像失败: {e}") if name: parent_data.update_name(parent_id, name) async def get_parent_info(bot: Bot, parent_id: str): """获取群名+群头像:缓存新鲜直接返回,否则从 QQ 刷新(7 天过期)""" if not parent_data.is_fresh(parent_id): await _refresh_parent_info(bot, parent_id) return parent_data.get(parent_id) async def get_group_nickname(bot: Bot, parent_id: str, user_id: str) -> str: """获取群内昵称:群名片优先,无名片用群昵称;失败返回空串""" try: info = await bot.get_group_member_info( group_id=int(parent_id), user_id=int(user_id) ) return info.get("card") or info.get("nickname") or "" except Exception as e: logger.warning(f"获取群成员 {parent_id}/{user_id} 信息失败: {e}") return "" async def _fill_missing_icon_hashes(players: list[dict]) -> None: """按需补 clienticon hash 对"多人同玩"且本地没有 icon hash 的游戏,用正在玩该游戏的绑定用户 steamid 调一次 GetOwnedGames 拿 img_icon_url 写回映射表,避免依赖每日同步才有图标。 """ if not players: return game_counts: dict[int, int] = {} for p in players: if p.get("gameid"): gid = int(p["gameid"]) game_counts[gid] = game_counts.get(gid, 0) + 1 missing = [ gid for gid, cnt in game_counts.items() if cnt >= 2 and not _query_game_icon_hashes(db_path, [gid]).get(gid) ] if not missing: return missing_set = set(missing) reps: dict[int, str] = {} for p in players: if p.get("gameid") and int(p["gameid"]) in missing_set: reps.setdefault(int(p["gameid"]), p["steamid"]) for steam_id in set(reps.values()): games = await fetch_play_time(steam_id) if games is None: # 配额不足,等下次再补 continue entries = { int(g["appid"]): g.get("clienticon") for g in games if g.get("appid") and g.get("clienticon") } if entries: _upsert_game_icon_hashes(db_path, entries) def format_play_duration(start_time: Optional[int]) -> str: """根据游戏开始时间计算游玩时长,缺少开始时间时返回占位文案""" if start_time is None: return "未知时长" elapsed = time.time() - start_time hours = int(elapsed / 3600) minutes = int(elapsed % 3600 / 60) return f"{hours} 小时 {minutes} 分钟" if hours > 0 else f"{minutes} 分钟" async def broadcast_steam_info( parent_id: str, old_players: List[ProcessedPlayer], new_players: List[ProcessedPlayer], ): if disable_parent_data.is_disabled(parent_id): return None bot = nonebot.get_bot() play_data = steam_info_data.compare(old_players, new_players) # 游戏名本地化(中文名优先):只替换播报用的副本,不修改已存储的玩家数据, # 否则下一轮对比时同一游戏的中英文名不一致会被误判为换游戏,反复通报 appids = [ int(p["gameid"]) for entry in play_data for p in (entry["player"], entry.get("old_player")) if p and p.get("gameid") ] # 播报每 1~5 分钟一轮:只查本地映射表,不联网补全中文名 # (appdetails 高频会被封 IP;缺中文名的游戏留到用户命令查询时再补) names = await get_game_cn_names(appids, db_path, fetch_missing=False) def _localized_copy(p: Optional[ProcessedPlayer]) -> Optional[ProcessedPlayer]: if p and p.get("gameid"): return { **p, "gameextrainfo": localize_game_name( p.get("gameextrainfo", ""), int(p["gameid"]), names ), } return p for entry in play_data: for key in ("player", "old_player"): if entry.get(key): entry[key] = _localized_copy(entry[key]) msg = [] for entry in play_data: player: ProcessedPlayer = entry["player"] old_player: ProcessedPlayer = entry.get("old_player") if entry["type"] == "start": msg.append(f"{player['personaname']} 开始玩 {player['gameextrainfo']} 了") elif entry["type"] == "stop": time_str = format_play_duration(old_player["game_start_time"]) msg.append( f"{player['personaname']} 玩了 {time_str} {old_player['gameextrainfo']} 后不玩了" ) elif entry["type"] == "change": time_str = format_play_duration(old_player["game_start_time"]) msg.append( f"{player['personaname']} 玩了 {time_str} {old_player['gameextrainfo']} 后开始玩 {player['gameextrainfo']} 了" ) elif entry["type"] == "error": msg.append( f"出现错误!{player['personaname']}\nNew: {player.get('gameextrainfo')}\nOld: {old_player.get('gameextrainfo')}" ) else: logger.error(f"未知的播报类型: {entry['type']}") if msg == []: return 0 if config.steam_broadcast_type == "all": # 与 steamcheck 一样:所有游戏都取图标(单人也要图标),商店页优先 game_ids: list[int] = [] seen: set[int] = set() for p in new_players: if p.get("gameid"): gid = int(p["gameid"]) if gid not in seen: seen.add(gid) game_ids.append(gid) game_icons = ( await get_game_icons(game_ids, config.proxy, db_path) if game_ids else {} ) steam_status_data = [] for player in (_localized_copy(p) for p in new_players): data = await simplize_steam_player_data( player, config.proxy, avatar_path, names ) entry = bind_data.get_by_steam_id(parent_id, player["steamid"]) # 绑定的玩家展示群内昵称,未绑定/获取失败则用 Steam 昵称 data["nickname"] = ( await get_group_nickname(bot, parent_id, entry["user_id"]) if entry is not None else None ) data["game_icon"] = game_icons.get(data["gameid"]) steam_status_data.append(data) parent_avatar, parent_name = await get_parent_info(bot, parent_id) image = draw_friends_status(parent_avatar, parent_name, steam_status_data) uni_msg = UniMessage([Text("\n".join(msg)), Image(raw=image_to_bytes(image))]) elif config.steam_broadcast_type == "part": images = [] for entry in play_data: if entry["type"] != "start": continue user_data = bind_data.get_by_steam_id( parent_id, entry["player"]["steamid"] ) nickname = ( await get_group_nickname(bot, parent_id, user_data["user_id"]) if user_data is not None else None ) images.append( draw_start_gaming( (await fetch_avatar(entry["player"], avatar_path, config.proxy)), entry["player"]["personaname"], entry["player"]["gameextrainfo"], nickname, ) ) if images == []: uni_msg = UniMessage([Text("\n".join(msg))]) else: image = ( vertically_concatenate_images(images) if len(images) > 1 else images[0] ) uni_msg = UniMessage( [Text("\n".join(msg)), Image(raw=image_to_bytes(image))] ) elif config.steam_broadcast_type == "none": uni_msg = UniMessage([Text("\n".join(msg))]) else: logger.error(f"未知的播报类型: {config.steam_broadcast_type}") return None try: logger.info(f"播报发送: 群 {parent_id}, {len(msg)} 条: {msg}") await uni_msg.send( Target(parent_id, parent_id, True, False, "", bot.adapter.get_name()), bot ) return len(msg) except Exception as e: logger.error(f"✗ 播报发送失败 群 {parent_id}: {type(e).__name__}: {e}") return 0 # 初始化计数器 current_key_index = 0 def key_select_2(): global current_key_index # 获取key列表 keys = config.steam_api_key # 获取当前使用的 key current_key: str = keys[current_key_index] # 更新计数器 current_key_index = (current_key_index + 1) % len(keys) return [current_key] async def update_steam_info(): steam_ids = bind_data.get_all_steam_id() api_keys = config.steam_api_key if not api_keys: logger.warning("没有可用的 key。") current_key = key_select_2() logger.info(f"播报轮询: 开始拉取 {len(steam_ids)} 个绑定玩家, key={current_key}") # 播报等得起,等待配额最多 60s;配额不足跳过本轮 steam_info = await get_steam_users_info( steam_ids, current_key, config.proxy, wait=True ) if steam_info is None: logger.warning("✗ 播报轮询: Steam API 配额已满,跳过本轮") return None players = steam_info["response"]["players"] logger.info(f"✓ 播报轮询: 成功拉取 {len(players)} 个玩家状态") old_players_dict: Dict[str, List[ProcessedPlayer]] = {} for parent_id in bind_data.content.keys(): steam_ids = bind_data.get_all(parent_id) old_players_dict[parent_id] = steam_info_data.get_players(steam_ids) steam_info_data.update_by_players(steam_info["response"]["players"]) steam_info_data.save() return bind_data, old_players_dict # 播报互斥锁:定时任务与启动/重连钩子共用,防止并发拉取导致 # steam_info 表双写与重复广播 _broadcast_lock = asyncio.Lock() @scheduler.scheduled_job( "interval", minutes=config.steam_request_interval / 60, id="update_steam_info" ) async def fetch_and_broadcast_steam_info(): try: async with _broadcast_lock: result = await update_steam_info() if result is None: # API 配额不足,跳过本轮播报 return bind_data, old_players_dict = result total_changes = 0 total_sent = 0 for parent_id in bind_data.content.keys(): old_players = old_players_dict[parent_id] new_players = steam_info_data.get_players( bind_data.get_all(parent_id) ) changes = await broadcast_steam_info( parent_id, old_players, new_players ) if changes: total_changes += changes total_sent += 1 if total_changes: logger.info( f"✓ 播报轮询完成: {total_changes} 条变化, " f"{total_sent}/{len(bind_data.content)} 个群有播报" ) else: logger.info("✓ 播报轮询完成: 无状态变化,本轮不播报") except Exception as e: # 兜底:任何异常都要打堆栈,避免定时任务静默失败 logger.error(f"✗ 播报轮询异常: {type(e).__name__}: {e}", exc_info=True) async def _update_steam_info_once(bot): """启动/重连时的首轮拉取(与定时任务互斥,只拉取不广播)""" async with _broadcast_lock: await update_steam_info() if not config.steam_disable_broadcast_on_startup: nonebot.get_driver().on_bot_connect(_update_steam_info_once) else: logger.info("已禁用启动时的 Steam 播报") # 用户命令的 API key 轮询(与播报 key_select_2 独立) current_key_index_1 = 0 def key_select(): global current_key_index_1 keys = config.steam_api_key current_key = keys[current_key_index_1] current_key_index_1 = (current_key_index_1 + 1) % len(keys) return [current_key] # 触发器(handlers)在子包中 from .handlers import ( # noqa: E402,F401 bind, check, disable, enable, get_play_time, help, info, unbind, update_steamdb, )