Add HeXi bot codebase: custom plugins, web frontends, tests

- 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>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,964 @@
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)
@@ -0,0 +1,33 @@
from typing import Optional, Union, List
from pydantic import BaseModel, validator
class Config(BaseModel):
steam_api_key: Union[str, List[str]]
proxy: Optional[str] = None
steam_request_interval: int = 300 # seconds
steam_broadcast_type: str = "part" # all, part, none
steam_disable_broadcast_on_startup: bool = False
steam_font_regular_path: Optional[str] = "hexi/resource/fonts/MiSans-Regular.ttf"
steam_font_light_path: Optional[str] = "hexi/resource/fonts/MiSans-Light.ttf"
steam_font_bold_path: Optional[str] = "hexi/resource/fonts/MiSans-Bold.ttf"
# 仅用于迁移旧 JSON 状态文件(运行时 API key 状态已持久化到 SQLite)
steam_api_key_state_path: Optional[str] = "hexi/data/api_keys_state.json"
# 游玩时长表图片主题: light / dark,默认亮色
steam_playtime_theme: str = "light"
@validator("steam_playtime_theme")
def check_theme(cls, v):
v = (v or "light").strip().lower()
if v not in ("light", "dark"):
raise ValueError("steam_playtime_theme 只能是 light 或 dark")
return v
@validator("steam_api_key", pre=True)
def ensure_list(cls, v):
if isinstance(v, str):
return [v]
return v
@@ -0,0 +1,457 @@
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,338 @@
"""使用 Playwright 渲染 HTML 模板生成游玩时长表图片
浏览器为进程级单例,首次调用时懒加载;
任何一步失败都会抛出异常,由调用方(命令处理器)回退到文本格式。
"""
import asyncio
import base64
import datetime
import html
import json
import math
import os
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Any, List, Optional
from PIL import Image
from nonebot.log import logger
from . import draw as draw_util
TEMPLATE_PATH = Path(__file__).parent / "res" / "playtime_template.html"
ECHARTS_PATH = Path(__file__).parent / "res" / "echarts.min.js"
# ---- 主题色板:CSS 变量注入模板 :root,ECharts 颜色同样读 CSS 变量 ----
# 亮色在 #f6f6f7 表面上通过 dataviz 校验;暗色与插件旧卡片风格一致
THEMES: dict[str, dict] = {
"light": {
"--surface": "#f6f6f7",
"--card": "#ffffff",
"--hairline": "#e3e3e6",
"--text-primary": "#1a1a1a",
"--text-secondary": "#52514e",
"--text-muted": "#898781",
"--axis": "#c3c2b7",
"--bar": "#2a78d6",
"--bar-fill-top": "#5598e7",
"--accent": "#2a78d6",
"--game-text": "#242424",
"--fallback-bg": "#f0f0f2",
"pie_colors": [
"#2a78d6", "#eb6834", "#1baf7a", "#eda100",
"#e87ba4", "#008300", "#4a3aa7", "#e34948",
],
"other_color": "#898781",
"badge_colors": ["#c98500", "#898781", "#d95926"], # 金/银/铜
},
"dark": {
"--surface": "#333439",
"--card": "#2e2e33",
"--hairline": "#46464a",
"--text-primary": "#ffffff",
"--text-secondary": "#c3c2b7",
"--text-muted": "#898781",
"--axis": "#383835",
"--bar": "#3987e5",
"--bar-fill-top": "#6aa9e8",
"--accent": "#53a4c4",
"--game-text": "#e8e8e8",
"--fallback-bg": "#2e2e33",
"pie_colors": [
"#3987e5", "#d95926", "#199e70", "#c98500",
"#d55181", "#008300", "#9085e9", "#e66767",
],
"other_color": "#898781",
"badge_colors": ["#c98500", "#898781", "#d95926"], # 金/银/铜
},
}
# 柱状图分桶标签:0 时长(未游玩)单独一桶,便于全库统计
BUCKET_LABELS = ["0", "<1", "1~10", "10~50", "50~100", "100~500", "500+"]
CANVAS_W = 1400
TABLE_ROWS = 20
PIE_TOP = 8
_pw: Optional[Any] = None
_browser: Optional[Any] = None
_browser_lock: Optional[asyncio.Lock] = None
def format_hours(hours: float) -> str:
"""时长人性化:>=100h 取整加千分位, >=1h 保留一位小数, <1h 显示分钟"""
if hours >= 100:
# 四舍五入取整(round 是银行家舍入,1842.5 会得到 1842)
return f"{int(math.floor(hours + 0.5)):,} 小时"
if hours >= 1:
return f"{hours:.1f} 小时"
return f"{max(1, int(round(hours * 60)))} 分钟"
def _truncate_chars(text: str, max_len: int) -> str:
"""按字符数截断,超长以省略号结尾(用于图例等紧凑场景)"""
return text if len(text) <= max_len else text[: max_len - 1] + "…"
def _font_faces() -> str:
"""注入 MiSans 字体(file:// 绝对路径,由 draw.set_font_paths 解析)"""
faces = []
for family, path in (
("MiSansR", draw_util.font_regular_path),
("MiSansB", draw_util.font_bold_path),
):
if path and Path(path).exists():
faces.append(
f"@font-face{{font-family:'{family}';src:url('{Path(path).as_uri()}');}}"
)
return "\n".join(faces)
def _avatar_html(avatar: Optional[Any], nickname: str) -> str:
"""头像转 data URI 内联;缺失时用昵称首字符的占位圆块"""
if avatar is not None:
try:
if isinstance(avatar, Image.Image):
buf = BytesIO()
avatar.convert("RGB").save(buf, format="PNG")
mime, data = "image/png", buf.getvalue()
else:
mime, data = "image/jpeg", bytes(avatar)
uri = "data:" + mime + ";base64," + base64.b64encode(data).decode()
return f'<img class="avatar" src="{uri}">'
except Exception:
pass
char = html.escape(nickname[:1], quote=False) if nickname else "?"
return f'<div class="avatar avatar-fallback">{char}</div>'
def _theme_css(theme: str) -> str:
"""主题 CSS 变量块,覆盖模板 :root 默认(亮色)值"""
t = THEMES.get(theme, THEMES["light"])
vars_css = "".join(
f"{k}:{v};" for k, v in t.items() if k.startswith("--")
)
return f":root{{{vars_css}}}"
def _table_rows(
games: List[dict], start_rank: int, badge_colors: List[str]
) -> str:
"""生成单列表格行(排名徽章 + 游戏名 + 时长)"""
rows = []
for i, g in enumerate(games, start_rank):
if i <= 3:
badge = (
f'<span class="rank-badge" style="background:{badge_colors[i - 1]}">'
f"{i}</span>"
)
else:
badge = f'<span class="rank-plain">{i}</span>'
rows.append(
"<tr>"
f'<td class="rank">{badge}</td>'
f'<td class="game">{html.escape(g["name"], quote=False)}</td>'
f'<td class="hours">{format_hours(g["playtime_hours"])}</td>'
"</tr>"
)
return "\n".join(rows)
def _build_html(
games: List[dict],
all_games: Optional[List[dict]] = None,
steam_name: str = "",
plugin_nick: str = "",
avatar: Optional[Any] = None,
theme: str = "light",
) -> str:
"""把游戏数据填入模板,返回完整 HTML 字符串
Args:
games: 已游玩游戏(playtime_hours > 0, 已排序),用于表格与饼图。
all_games: 全库游戏(含 0 时长),用于柱状图分段统计与总数;缺省时用 games。
steam_name: Steam 昵称(常显)。
plugin_nick: 插件内设置的昵称,设置了才显示。
avatar: 头像图片(PIL Image 或 JPEG bytes),缺失时显示占位块。
theme: 主题 light / dark,默认亮色。
"""
t = THEMES.get(theme, THEMES["light"])
all_games = all_games if all_games is not None else games
total_hours = sum(g["playtime_hours"] for g in games)
top_games = games[:TABLE_ROWS]
rows_left = _table_rows(top_games[:10], 1, t["badge_colors"])
rows_right = _table_rows(top_games[10:], 11, t["badge_colors"])
right_table = (
"<table>"
"<colgroup>"
'<col class="rank"><col><col class="hours">'
"</colgroup>"
"<thead><tr>"
'<th class="rank">排名</th><th>游戏</th><th class="hours">时长</th>'
"</tr></thead>"
f"<tbody>{rows_right}</tbody></table>"
if rows_right
else ""
)
pie_games = games[:PIE_TOP]
other_hours = sum(g["playtime_hours"] for g in games[PIE_TOP:])
pie_items = [
{"name": _truncate_chars(g["name"], 16), "value": round(g["playtime_hours"], 2)}
for g in pie_games
]
if other_hours > 0:
pie_items.append({"name": "其他", "value": round(other_hours, 2)})
pie_colors = t["pie_colors"][: len(pie_games)] + (
[t["other_color"]] if other_hours > 0 else []
)
# 全库按时长分段:0 时长单独一桶,数量差异大时对数坐标避免小桶被压扁
counts = [
sum(1 for g in all_games if g["playtime_hours"] == 0),
sum(1 for g in all_games if 0 < g["playtime_hours"] < 1),
sum(1 for g in all_games if 1 <= g["playtime_hours"] < 10),
sum(1 for g in all_games if 10 <= g["playtime_hours"] < 50),
sum(1 for g in all_games if 50 <= g["playtime_hours"] < 100),
sum(1 for g in all_games if 100 <= g["playtime_hours"] < 500),
sum(1 for g in all_games if g["playtime_hours"] >= 500),
]
data = {
"pie": {"items": pie_items, "total": round(total_hours, 2), "colors": pie_colors},
"bar": {"names": BUCKET_LABELS, "counts": counts},
}
template = TEMPLATE_PATH.read_text(encoding="utf-8")
generated = datetime.datetime.now(
datetime.timezone(datetime.timedelta(hours=8))
).strftime("%Y-%m-%d %H:%M:%S")
footer_parts = []
if len(games) > TABLE_ROWS:
footer_parts.append(f"仅展示 Top {TABLE_ROWS},共 {len(games)} 款游戏")
footer_parts.append(f"生成时间:{generated}")
# 群内昵称:小字挂在 Steam 名后,超长截断,避免画出图片外
plugin_tag = (
f'<span class="nick-tag">{html.escape(_truncate_chars(plugin_nick, 18), quote=False)}</span>'
if plugin_nick
else ""
)
return (
template.replace("__THEME_CSS__", _theme_css(theme))
.replace("__ECHARTS__", ECHARTS_PATH.as_uri())
.replace("__FONT_REGULAR__", _font_url(draw_util.font_regular_path))
.replace("__FONT_BOLD__", _font_url(draw_util.font_bold_path))
.replace("__AVATAR__", _avatar_html(avatar, steam_name))
.replace("__STEAM_NAME__", html.escape(steam_name, quote=False))
.replace("__PLUGIN_TAG__", plugin_tag)
.replace("__GAME_COUNT__", str(len(all_games)))
.replace("__TOTAL_HOURS__", format_hours(total_hours))
.replace("__ROWS_LEFT__", rows_left)
.replace("__RIGHT_TABLE__", right_table)
.replace("__FOOTER__", " · ".join(footer_parts))
# 防注入:JSON 中的 </script> 及 < 均被转义
.replace(
"__DATA__",
json.dumps(data, ensure_ascii=False).replace("<", "\\u003c"),
)
)
def _font_url(path: Optional[str]) -> str:
"""字体 file:// URL;不可用时留空使 @font-face 失效,回退系统字体"""
if path and Path(path).exists():
return Path(path).as_uri()
return ""
async def _ensure_browser():
"""获取进程级单例浏览器(线程安全,幂等)"""
global _pw, _browser, _browser_lock
if _browser_lock is None:
_browser_lock = asyncio.Lock()
async with _browser_lock:
if _browser is None or not _browser.is_connected():
from playwright.async_api import async_playwright
_pw = await async_playwright().start()
_browser = await _pw.chromium.launch(
args=["--force-color-profile=srgb"],
)
return _browser
async def render_play_time_table(
games: List[dict],
all_games: Optional[List[dict]] = None,
steam_name: str = "",
plugin_nick: str = "",
avatar: Optional[Any] = None,
theme: str = "light",
) -> bytes:
"""渲染游玩时长表为 PNG 字节;任何失败抛异常,由调用方提示失败
Args:
games: 已游玩游戏(playtime_hours > 0, 降序),用于表格与饼图。
all_games: 全库游戏(含 0 时长),用于柱状图分段统计与总数;缺省时用 games。
steam_name: Steam 昵称(常显)。
plugin_nick: 插件内设置的昵称,设置了才显示。
avatar: 头像图片(PIL Image 或 JPEG bytes),缺失时显示占位块。
theme: 主题 light / dark,默认亮色。
"""
logger.info(f"游玩时长表:构建 HTML 模板, theme={theme}")
html = _build_html(games, all_games, steam_name, plugin_nick, avatar, theme)
logger.info("游玩时长表:获取浏览器实例")
browser = await _ensure_browser()
page = await browser.new_page(
viewport={"width": CANVAS_W, "height": 1700},
device_scale_factor=2,
)
# 捕获页面 JS 错误,便于定位图表渲染问题
page.on("pageerror", lambda exc: logger.error(f"游玩时长表页面 JS 异常: {exc}"))
page.on(
"console",
lambda msg: logger.error(f"游玩时长表页面 console: {msg.text}")
if msg.type == "error"
else None,
)
tmp_path: Optional[str] = None
try:
fd, tmp_path = tempfile.mkstemp(suffix=".html", prefix="playtime_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(html)
logger.info("游玩时长表:页面加载中")
await page.goto(Path(tmp_path).as_uri(), wait_until="load")
await page.wait_for_function("window.__rendered === true", timeout=15000)
await page.evaluate("document.fonts.ready")
img_bytes = await page.screenshot(full_page=True)
logger.info(f"游玩时长表:截图完成, {len(img_bytes)} bytes")
return img_bytes
finally:
await page.close()
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
@@ -0,0 +1,82 @@
from typing import TypedDict, List
class Player(TypedDict):
steamid: str
communityvisibilitystate: int
profilestate: int
personaname: str
profileurl: str
avatar: str
avatarmedium: str
avatarfull: str
avatarhash: str
lastlogoff: int
personastate: int
realname: str
primaryclanid: str
timecreated: int
personastateflags: int
gameextrainfo: str
gameid: str
class PlayerSummariesResponse(TypedDict):
players: List[Player]
class PlayerSummaries(TypedDict):
response: PlayerSummariesResponse
class ProcessedPlayer(Player):
game_start_time: int # Unix timestamp
class PlayerSummariesProcessedResponse(TypedDict):
players: List[ProcessedPlayer]
class Achievements(TypedDict):
name: str
image: bytes
class GameData(TypedDict):
game_name: str
play_time: str # e.g. 10.2
last_played: str # e.g. 10 月 2 日
game_image: bytes
achievements: List[Achievements]
completed_achievement_number: int
total_achievement_number: int
class PlayerData(TypedDict):
steamid: str
player_name: str
background: bytes
avatar: bytes
description: str
recent_2_week_play_time: str
game_data: List[GameData]
class DrawPlayerStatusData(TypedDict):
game_name: str
game_time: str # e.g. 10.2 小时(过去 2 周)
last_play_time: str # e.g. 10 月 2 日
game_header: bytes
achievements: List[Achievements]
completed_achievement_number: int
total_achievement_number: int
__all__ = [
"Player",
"PlayerSummaries",
"PlayerSummariesResponse",
"ProcessedPlayer",
"PlayerSummariesProcessedResponse",
"DrawPlayerStatusData",
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<style>
@font-face { font-family: 'MiSansR'; src: url('__FONT_REGULAR__'); }
@font-face { font-family: 'MiSansB'; src: url('__FONT_BOLD__'); }
:root {
--surface: #f6f6f7;
--card: #ffffff;
--hairline: #e3e3e6;
--text-primary: #1a1a1a;
--text-secondary: #52514e;
--text-muted: #898781;
--axis: #c3c2b7;
--bar: #2a78d6;
--bar-fill-top: #5598e7;
--accent: #2a78d6;
--game-text: #242424;
--fallback-bg: #f0f0f2;
}
/* 主题覆盖(light/dark 由 Python 注入) */
__THEME_CSS__
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1400px;
background: var(--surface);
font-family: 'MiSansR', 'Microsoft YaHei', sans-serif;
padding: 28px 36px 36px;
color: var(--text-primary);
}
/* ---- 头部:头像 + 昵称 ---- */
.header {
display: flex;
align-items: center;
gap: 18px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--hairline);
flex-shrink: 0;
}
.avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: var(--fallback-bg);
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
font-size: 34px;
color: var(--text-muted);
}
.nickname {
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
font-size: 32px;
display: flex;
align-items: baseline;
min-width: 0;
}
.nick-tag {
font-family: 'MiSansR', 'Microsoft YaHei', sans-serif;
font-size: 16px;
color: var(--text-muted);
margin-left: 8px;
vertical-align: baseline;
min-width: 0;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stats {
font-size: 20px;
color: var(--text-secondary);
margin-top: 6px;
}
/* ---- 双列排行表格 ---- */
.tables {
display: flex;
gap: 20px;
margin-top: 22px;
}
.col {
flex: 1;
min-width: 0;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
col.rank { width: 72px; }
col.hours { width: 150px; }
th {
font-size: 18px;
font-weight: normal;
color: var(--text-muted);
text-align: left;
padding: 6px 8px;
}
td {
font-size: 25px;
padding: 12px 8px;
border-bottom: 1px solid var(--hairline);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
.rank { width: 72px; }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
font-size: 20px;
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
color: #ffffff;
}
.rank-plain {
display: inline-block;
width: 34px;
text-align: center;
font-size: 25px;
color: var(--text-muted);
}
.game {
color: var(--game-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hours {
width: 150px;
text-align: right;
color: var(--text-secondary);
white-space: nowrap;
}
/* ---- 图表卡片 ---- */
.card {
background: var(--card);
border-radius: 16px;
padding: 16px 20px 12px;
margin-top: 22px;
}
.card-title {
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
font-size: 24px;
}
.card-sub {
font-size: 18px;
color: var(--text-muted);
margin-top: 2px;
}
.chart {
width: 100%;
height: 330px;
}
/* ---- 底部 ---- */
.footer {
font-size: 18px;
color: var(--text-muted);
margin-top: 20px;
text-align: right;
}
</style>
</head>
<body>
<div class="header">
__AVATAR__
<div>
<div class="nickname">__STEAM_NAME____PLUGIN_TAG__</div>
<div class="stats">共 __GAME_COUNT__ 款游戏 · 总时长 __TOTAL_HOURS__</div>
</div>
</div>
<div class="tables">
<div class="col">
<table>
<colgroup>
<col class="rank">
<col>
<col class="hours">
</colgroup>
<thead>
<tr>
<th class="rank">排名</th>
<th>游戏</th>
<th class="hours">时长</th>
</tr>
</thead>
<tbody>
__ROWS_LEFT__
</tbody>
</table>
</div>
<div class="col">
__RIGHT_TABLE__
</div>
</div>
<div class="card">
<div class="card-title">各时长段游戏数量</div>
<div class="card-sub">全库按时长分段,0 = 未游玩 · 对数轴</div>
<div class="chart" id="bar"></div>
</div>
<div class="card">
<div class="card-title">时长占比</div>
<div class="card-sub">Top 8 游戏 + 其余聚合</div>
<div class="chart" id="pie"></div>
</div>
<div class="footer">__FOOTER__</div>
<script src="__ECHARTS__"></script>
<script>
const DATA = __DATA__;
// 主题色从 CSS 变量读取(亮/暗由 Python 注入 :root 覆盖)
function cssVar(name) {
return getComputedStyle(document.documentElement)
.getPropertyValue(name).trim() || '#000';
}
// 各时长段游戏数量柱状图(对数轴:0 时长大桶不会压扁其他桶)
const bar = echarts.init(document.getElementById('bar'));
bar.setOption({
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: cssVar('--card'),
borderColor: cssVar('--hairline'),
textStyle: { color: cssVar('--text-primary'), fontSize: 14 },
},
grid: { left: 8, right: 16, top: 30, bottom: 30, containLabel: true },
xAxis: {
type: 'category',
data: DATA.bar.names,
axisLine: { lineStyle: { color: cssVar('--axis') } },
axisTick: { show: false },
axisLabel: { color: cssVar('--text-muted'), fontSize: 15 },
},
yAxis: {
type: 'log',
show: false,
splitLine: { lineStyle: { color: cssVar('--hairline') } },
},
series: [{
type: 'bar',
barWidth: '45%',
barMaxWidth: 110,
data: DATA.bar.counts,
itemStyle: { color: cssVar('--bar'), borderRadius: [5, 5, 0, 0] },
label: {
show: true,
position: 'top',
color: cssVar('--text-primary'),
fontSize: 16,
},
}],
});
// 时长占比环形饼图(禁用动画:截图可能截到入场动画早期帧导致整图空白)
const pie = echarts.init(document.getElementById('pie'));
pie.setOption({
animation: false,
backgroundColor: 'transparent',
color: DATA.pie.colors,
tooltip: {
trigger: 'item',
backgroundColor: cssVar('--card'),
borderColor: cssVar('--hairline'),
textStyle: { color: cssVar('--text-primary'), fontSize: 14 },
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
itemWidth: 12,
itemHeight: 12,
textStyle: { color: cssVar('--text-secondary'), fontSize: 14 },
formatter: function (name) {
try {
const item = (DATA.pie.items || []).find(i => i.name === name);
if (!item || !DATA.pie.total) return name;
return name + ' ' + (item.value / DATA.pie.total * 100).toFixed(1) + '%';
} catch (e) {
return name;
}
},
},
series: [{
type: 'pie',
radius: ['48%', '68%'],
center: ['37%', '50%'],
itemStyle: { borderColor: cssVar('--card'), borderWidth: 2 },
label: {
color: cssVar('--text-primary'),
fontSize: 14,
formatter: function (p) {
return p.percent >= 5 ? p.percent.toFixed(1) + '%' : '';
},
},
labelLine: { show: false },
data: DATA.pie.items,
}],
});
window.__rendered = true;
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
import asyncio
import time
import pytz
import httpx
import datetime
import calendar
from PIL import Image
from io import BytesIO
from pathlib import Path
from typing import Dict, Optional
from .models import Player
from .steam import (
_query_game_icon_hashes,
_upsert_game_icon_hashes,
fetch_clienticon_hash_from_store,
)
async def _fetch_avatar(avatar_url: str, proxy: str = None) -> Image.Image:
default_avatar = Image.open(Path(__file__).parent / "res/unknown_avatar.jpg")
try:
async with httpx.AsyncClient(proxy=proxy, timeout=10) as client:
response = await client.get(avatar_url)
if response.status_code != 200:
return default_avatar
return Image.open(BytesIO(response.content))
except Exception:
# 下载失败(连接超时/网络异常),降级默认头像,不中断命令
return default_avatar
# 游戏图标缓存目录(插件目录下 data/game_icons/)
GAME_ICON_DIR = Path(__file__).parent / "data" / "game_icons"
async def _download_steam_icon(
url: str, appid: int, proxy: str, cache_suffix: str
) -> Optional[Image.Image]:
"""从 steam CDN 下载图标并缓存到 data/game_icons/{appid}_{cache_suffix}.jpg"""
try:
async with httpx.AsyncClient(proxy=proxy, timeout=10) as client:
resp = await client.get(url)
if resp.status_code != 200:
return None
content = resp.content
GAME_ICON_DIR.mkdir(parents=True, exist_ok=True)
cache_path = GAME_ICON_DIR / f"{appid}_{cache_suffix}.jpg"
await asyncio.to_thread(cache_path.write_bytes, content)
return Image.open(BytesIO(content))
except Exception:
return None
async def get_game_icon(
appid: int, proxy: str = None, db_path: Optional[Path] = None
) -> Optional[Image.Image]:
"""获取游戏图标(Steam clienticon .jpg),按 appid 缓存到插件 data/game_icons/ 目录
- 优先用 game_names 表里的 clienticon hash 下载 32x32 小图标
- DB 缺失时从商店页抓 hash
- 取不到就返回 None,绘制时留空占位(不再回退 header.jpg)
"""
if not appid:
return None
icon_path = GAME_ICON_DIR / f"{appid}_icon.jpg"
if icon_path.is_file():
try:
return Image.open(icon_path)
except Exception:
pass
icon_hash = None
if db_path is not None:
icon_hash = _query_game_icon_hashes(db_path, [appid]).get(appid)
if not icon_hash:
# DB 缺失时,从商店页 apphub_AppIcon 稳定抓取 clienticon hash
icon_hash = await fetch_clienticon_hash_from_store(appid)
if icon_hash and db_path is not None:
_upsert_game_icon_hashes(db_path, {appid: icon_hash})
if icon_hash:
icon = await _download_steam_icon(
f"https://shared.akamai.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg",
appid,
proxy,
"icon",
)
if icon is None:
icon = await _download_steam_icon(
f"https://shared.fastly.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg",
appid,
proxy,
"icon",
)
if icon is None:
# 备用:旧社区 CDN 路径(部分早期游戏仍在此处)
icon = await _download_steam_icon(
f"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{appid}/{icon_hash}.jpg",
appid,
proxy,
"icon",
)
if icon is not None:
return icon
# 没有 clienticon 就留空占位,不回退 header
return None
async def get_game_icons(
appids: list[int], proxy: str = None, db_path: Optional[Path] = None
) -> dict[int, Image.Image]:
"""批量获取游戏图标;失败自动跳过,返回 {appid: Image.Image}"""
if not appids:
return {}
results = await asyncio.gather(*(get_game_icon(a, proxy, db_path) for a in appids))
return {a: icon for a, icon in zip(appids, results) if icon is not None}
async def fetch_avatar(
player: Player, avatar_dir: Optional[Path], proxy: str = None
) -> Image.Image:
if avatar_dir is not None:
avatar_path = (
avatar_dir / f"avatar_{player['steamid']}_{player['avatarhash']}.png"
)
if avatar_path.exists():
avatar = Image.open(avatar_path)
else:
avatar = await _fetch_avatar(player["avatarfull"], proxy)
avatar.save(avatar_path)
else:
avatar = await _fetch_avatar(player["avatarfull"], proxy)
return avatar
async def simplize_steam_player_data(
player: Player,
proxy: str = None,
avatar_dir: Path = None,
game_names: Optional[Dict[int, str]] = None,
) -> Dict[str, str]:
avatar = await fetch_avatar(player, avatar_dir, proxy)
gameid = int(player["gameid"]) if player.get("gameid") else None
game_name = game_names.get(gameid) if (gameid is not None and game_names) else None
if not game_name:
game_name = player.get("gameextrainfo")
if player["personastate"] == 0:
if not player.get("lastlogoff"):
status = "离线"
else:
time_logged_off = player["lastlogoff"] # Unix timestamp
time_to_now = calendar.timegm(time.gmtime()) - time_logged_off
# 将时间转换为自然语言
if time_to_now < 60:
status = "上次在线 刚刚"
elif time_to_now < 3600:
status = f"上次在线 {time_to_now // 60} 分钟前"
elif time_to_now < 86400:
status = f"上次在线 {time_to_now // 3600} 小时前"
elif time_to_now < 2592000:
status = f"上次在线 {time_to_now // 86400} 天前"
elif time_to_now < 31536000:
status = f"上次在线 {time_to_now // 2592000} 个月前"
else:
status = f"上次在线 {time_to_now // 31536000} 年前"
elif player["personastate"] in [1, 2, 4]:
status = (
"在线" if player.get("gameextrainfo") is None else player["gameextrainfo"]
)
elif player["personastate"] == 3:
status = (
"离开" if player.get("gameextrainfo") is None else player["gameextrainfo"]
)
elif player["personastate"] in [5, 6]:
status = "在线"
else:
status = "未知"
return {
"steamid": player["steamid"],
"avatar": avatar,
"name": player["personaname"],
"status": status,
"personastate": player["personastate"],
"gameid": gameid,
"game_name": game_name,
}
def image_to_bytes(image: Image.Image) -> bytes:
with BytesIO() as bio:
image.save(bio, format="PNG")
return bio.getvalue()
def hex_to_rgb(hex_color: str):
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
def convert_timestamp_to_beijing_time(timestamp: int) -> str:
beijing_timezone = pytz.timezone("Asia/Shanghai")
date_utc = datetime.datetime.fromtimestamp(timestamp, pytz.utc)
date_beijing = date_utc.astimezone(beijing_timezone)
return date_beijing.strftime("%Y-%m-%d %H:%M:%S")
# example: 2021-09-06 21:00:00