Files
HeXi/hexi/plugins/nonebot_plugin_steam_info/__init__.py
T

965 lines
34 KiB
Python
Raw Normal View History

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)