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.params import Depends from nonebot.params import CommandArg from nonebot import on_command, require from nonebot.permission import SUPERUSER from typing import Optional, List, Dict from nonebot.adapters import Message, 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, At from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown 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"), ) help = on_command("steamhelp", aliases={"steam帮助"}, priority=10) bind = on_command("steambind", aliases={"绑定steam"}, priority=10) unbind = on_command("steamunbind", aliases={"解绑steam"}, priority=10) info = on_command("steaminfo", aliases={"steam信息"}, priority=10) # 谁在玩游戏:全群共享 CD,防一人狂点挤占 API 配额(steam.py 内另有总配额限频) check = cooldown(30, scope="group", hint="群里刚有人查过,{secs} 秒后再试")( on_command("steamcheck", aliases={"查看steam", "查steam", "谁在玩游戏"}, priority=10) ) enable = on_command("steamenable", aliases={"启用steam"}, priority=10) disable = on_command("steamdisable", aliases={"禁用steam"}, priority=10) # 游戏时长表:单用户 15s CD get_play_time = cooldown(15, hint="查询太频繁了,{secs} 秒后再试")( on_command("steamplaytime", aliases={"游戏时长表"}, priority=10) ) # 全量拉取 SteamDB 较重,仅限超级管理员触发 update_steamdb = on_command( "steamdbupdate", aliases={"更新steamdb"}, priority=10, permission=SUPERUSER ) 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.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": "text", "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 播报") @help.handle() async def help_handle(): await help.finish(__plugin_meta__.usage) @bind.handle() async def bind_handle( event: Event, target: Target = Depends(get_target), cmd_arg: Message = CommandArg() ): parent_id = target.parent_id or target.id arg = cmd_arg.extract_plain_text() if not arg.isdigit(): await bind.finish( "请输入正确的 Steam ID 或 Steam好友代码,格式: steambind [Steam ID 或 Steam好友代码]" ) steam_id = get_steam_id(arg) if user_data := bind_data.get(parent_id, event.get_user_id()): user_data["steam_id"] = steam_id bind_data.save() await bind.finish(f"已更新你的 Steam ID 为 {steam_id}") else: bind_data.add( parent_id, {"user_id": event.get_user_id(), "steam_id": steam_id, "nickname": None}, ) bind_data.save() await bind.finish(f"已绑定你的 Steam ID 为 {steam_id}") @unbind.handle() async def unbind_handle(event: Event, target: Target = Depends(get_target)): parent_id = target.parent_id or target.id user_id = event.get_user_id() if bind_data.get(parent_id, user_id) is not None: bind_data.remove(parent_id, user_id) bind_data.save() await unbind.finish("已解绑 Steam ID") else: await unbind.finish("未绑定 Steam ID") @info.handle() async def info_handle( bot: Bot, event: Event, target: Target = Depends(get_target), arg: Message = CommandArg(), ): parent_id = target.parent_id or target.id uni_arg = await UniMessage.generate(message=arg, event=event, bot=bot) at = uni_arg[At] if len(at) != 0: user_id: str = at[0].target user_data = bind_data.get(parent_id, user_id) if user_data is None: await info.finish("该用户未绑定 Steam ID") steam_id = user_data["steam_id"] steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET) elif arg.extract_plain_text().strip() != "": steam_id = int(arg.extract_plain_text().strip()) if steam_id < STEAM_ID_OFFSET: steam_friend_code = steam_id steam_id += STEAM_ID_OFFSET else: steam_friend_code = steam_id - STEAM_ID_OFFSET else: user_data = bind_data.get(parent_id, event.get_user_id()) if user_data is None: await info.finish( "未绑定 Steam ID, 请使用 “steambind [Steam ID 或 Steam好友代码]” 绑定 Steam ID" ) steam_id = user_data["steam_id"] steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET) player_data = await get_user_data(steam_id, cache_path, config.proxy) # 游戏名本地化(中文名优先) names = await get_game_cn_names( [g.get("appid") for g in player_data["game_data"] if g.get("appid")], db_path, ) for g in player_data["game_data"]: g["game_name"] = localize_game_name(g["game_name"], g.get("appid"), names) draw_data = [ { "game_header": game["game_image"], "game_name": game["game_name"], "game_time": f"{game['play_time']} 小时", "last_play_time": game["last_played"], "achievements": game["achievements"], "completed_achievement_number": game.get("completed_achievement_number"), "total_achievement_number": game.get("total_achievement_number"), } for game in player_data["game_data"] ] image = draw_player_status( player_data["background"], player_data["avatar"], player_data["player_name"], str(steam_friend_code), player_data["description"], player_data["recent_2_week_play_time"], draw_data, ) await info.finish( await UniMessage( Image(raw=image_to_bytes(image)), ).export(bot) ) # 初始化计数器 current_key_index_1 = 0 def key_select(): global current_key_index_1 # 获取key列表 keys = config.steam_api_key # 获取当前使用的 key current_key: str = keys[current_key_index_1] # 更新计数器 current_key_index_1 = (current_key_index_1 + 1) % len(keys) return [current_key] @check.handle() async def check_handle( bot: Bot, target: Target = Depends(get_target), arg: Message = CommandArg(), ): if arg.extract_plain_text().strip() != "": return None parent_id = target.parent_id or target.id steam_ids = bind_data.get_all(parent_id) current_key = key_select() logger.info(f"当前调用的key:{current_key}") # 用户命令快速失败,不挤占播报配额 steam_info = await get_steam_users_info( steam_ids, current_key, config.proxy, wait=False ) if steam_info is None: await check.finish("Steam API 请求过于频繁,请稍后再试") logger.debug(f"{parent_id} Players info: {steam_info}") # 游戏名本地化(中文名优先) players = steam_info["response"]["players"] names = await get_game_cn_names( [int(p["gameid"]) for p in players if p.get("gameid")], db_path, ) for p in players: if p.get("gameid"): p["gameextrainfo"] = localize_game_name( p.get("gameextrainfo", ""), int(p["gameid"]), names ) # 统计所有游玩中的游戏(单人也要显示游戏图标),商店页优先抓取 clienticon game_ids: list[int] = [] seen: set[int] = set() for p in 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 {} ) # 后台用正在玩的 steamid 补剩余缺的 hash(不阻塞当前出图;商店页为主要手段) asyncio.create_task(_fill_missing_icon_hashes(players)) parent_avatar, parent_name = await get_parent_info(bot, parent_id) steam_status_data = [] for player in players: data = await simplize_steam_player_data( player, config.proxy, avatar_path, names ) entry = bind_data.get_by_steam_id(parent_id, player["steamid"]) 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) image = draw_friends_status(parent_avatar, parent_name, steam_status_data) await target.send(UniMessage(Image(raw=image_to_bytes(image)))) @enable.handle() async def enable_handle(target: Target = Depends(get_target)): parent_id = target.parent_id or target.id disable_parent_data.remove(parent_id) disable_parent_data.save() await enable.finish("已启用 Steam 播报") @disable.handle() async def disable_handle(target: Target = Depends(get_target)): parent_id = target.parent_id or target.id disable_parent_data.add(parent_id) disable_parent_data.save() await disable.finish("已禁用 Steam 播报") async def _auto_sync_game_names(): """启动时自动同步游戏名映射表:未全量同步过则全量拉取,已标记完成则跳过""" try: if _is_full_sync_done(db_path): logger.info("游戏名映射表已全量同步过,跳过启动同步") return api_key = config.steam_api_key[0] count = await update_steamdb_apps(db_path, api_key) logger.info(f"游戏名映射表首次同步完成: {count} 条") except Exception as e: logger.error(f"游戏名映射表启动同步失败: {e}") @scheduler.scheduled_job("cron", hour=5, minute=0, id="sync_missing_games") async def _sync_missing_games(): """扫描绑定用户游戏库,自动补全映射表缺失的游戏 英文名从 GetOwnedGames 响应直接取(零额外请求),中文名留给查询时按需补全。 """ try: all_games: dict[str, str] = {} all_icons: dict[int, str] = {} for parent_id in bind_data.content.keys(): for steam_id in bind_data.get_all(parent_id): games = await fetch_play_time(steam_id) if games is None: # API 配额不足,跳过该用户 logger.warning( f"Steam API 配额已满,跳过 {steam_id} 的游戏同步" ) continue for g in games: if g.get("appid"): all_games.setdefault(str(g["appid"]), g["name"]) if g.get("clienticon"): all_icons.setdefault(int(g["appid"]), g["clienticon"]) if not all_games: return known = _query_game_names(db_path, [int(a) for a in all_games]) missing = { aid: name for aid, name in all_games.items() if aid not in known } if missing: _upsert_game_names( db_path, { aid: {"name_en": name, "name_ch": ""} for aid, name in missing.items() }, ) logger.info(f"补全映射表缺失游戏: {len(missing)} 个") if all_icons: _upsert_game_icon_hashes(db_path, all_icons) logger.info(f"补全映射表游戏图标: {len(all_icons)} 个") except Exception as e: logger.error(f"补全映射表缺失游戏失败: {e}") @scheduler.scheduled_job( "cron", hour=4, minute=30, id="sync_game_names" ) async def _daily_sync_game_names(): """每日全量同步游戏名映射表(保证表内始终有全部游戏)""" try: api_key = config.steam_api_key[0] count = await update_steamdb_apps(db_path, api_key) logger.info(f"游戏名映射表每日全量同步完成: {count} 条") except Exception as e: logger.error(f"游戏名映射表每日同步失败: {e}") async def _on_bot_connect(bot): """bot 连接后后台执行映射表首次同步(不阻塞连接流程)""" asyncio.create_task(_auto_sync_game_names()) nonebot.get_driver().on_bot_connect(_on_bot_connect) @update_steamdb.handle() async def update_steamdb_handle(): await update_steamdb.send("正在拉取 Steam 全量 app 列表...") try: api_key = config.steam_api_key[0] count = await update_steamdb_apps(db_path, api_key) except Exception as e: logger.error(f"SteamDB 数据更新失败: {e}") await update_steamdb.finish(f"SteamDB 数据更新失败: {e}") if count: await update_steamdb.finish(f"Steam 数据更新完成,共 {count} 个游戏") else: await update_steamdb.finish("Steam 数据拉取失败,稍后重试") @get_play_time.handle() async def get_play_time_handle( bot: Bot, event: MessageEvent, target: Target = Depends(get_target), cmd_arg: Message = CommandArg(), ): parent_id = target.parent_id or target.id user_data = None logger.info(f"游玩时长表:进入处理, parent={parent_id}") arg_text = cmd_arg.extract_plain_text().strip() if arg_text: if not arg_text.isdigit(): await get_play_time.finish( "请输入正确的 Steam ID 或 Steam好友代码,格式: steamplaytime [Steam ID 或 Steam好友代码]" ) steam_id = get_steam_id(arg_text) else: user_data = bind_data.get(parent_id, event.get_user_id()) if user_data is None: await get_play_time.finish( "未绑定 Steam ID,请使用 steamplaytime [Steam ID 或 Steam好友代码] 查询,或先使用 steambind 绑定" ) steam_id = user_data["steam_id"] await get_play_time.send("正在获取数据,请耐心等待") logger.info(f"游玩时长表:开始获取数据, steam_id={steam_id}") games = await fetch_play_time(steam_id) if games is None: await get_play_time.finish("Steam API 请求过于频繁,请稍后再试") logger.info(f"游玩时长表:数据获取完成, 共 {len(games)} 款游戏") if not games: await get_play_time.finish("没有获取到游玩数据(游戏可能未公开)") # 顺手把 GetOwnedGames 返回的 clienticon hash 写入映射表,供 steamcheck 图标使用 icon_entries = { int(g["appid"]): g.get("clienticon") for g in games if g.get("appid") and g.get("clienticon") } if icon_entries: _upsert_game_icon_hashes(db_path, icon_entries) # 游戏名本地化:仅查本地映射表,有中文用中文,没有保持 API 原始名(不联网补全) appids = [g["appid"] for g in games if g.get("appid")] game_map = _query_game_names(db_path, appids) if appids else {} names = { int(aid): entry["name_ch"] for aid, entry in game_map.items() if entry.get("name_ch") } for g in games: g["name"] = localize_game_name(g["name"], g.get("appid"), names) # 分离已游玩(表格/饼图)与全量(柱状图按时长分段,含 0 时长) played = [g for g in games if g["playtime_hours"] > 0] if not played: await get_play_time.finish("没有游玩记录(游戏可能未公开或全部未游玩)") for i, g in enumerate(played, 1): g["rank"] = i # 渲染 HTML 图片(Playwright);失败则提示并结束 try: from .html_playtime import render_play_time_table # 头像与昵称:Steam 昵称常显,插件昵称(群昵称)设置了才显示 steam_name, plugin_nick, avatar = "", "", None try: player_data = await get_user_data(steam_id, cache_path, config.proxy) steam_name = player_data.get("player_name", "") avatar = player_data.get("avatar") except Exception as e: logger.warning(f"获取玩家资料失败,省略头像昵称: {e}") if user_data: plugin_nick = await get_group_nickname( bot, parent_id, user_data["user_id"] ) logger.info(f"游玩时长表:开始渲染 HTML 图片, steam={steam_name}, nick={plugin_nick}") img_bytes = await render_play_time_table( played, games, steam_name, plugin_nick, avatar, theme=config.steam_playtime_theme, ) logger.info(f"游玩时长表:渲染完成, {len(img_bytes)} bytes") except Exception as e: logger.error(f"渲染游玩时长表图片失败: {e}", exc_info=True) await get_play_time.finish("游玩时长表渲染失败,请稍后重试") # 发送移出 try:finish 成功后抛的 StopPropagation 不能被 except 捕获 # (否则图片发出后还会再补一句"渲染失败"),与 info 指令一致先 export # 回复原指令消息,群消息多时不易混淆 uni_msg = MessageSegment.reply(event.message_id) + await UniMessage( Image(raw=img_bytes) ).export(bot) await get_play_time.finish(uni_msg)