Files
HeXi/hexi/plugins/nonebot_plugin_steam_info/handlers/steam.py
T
sansenhoshiandClaude Code 6af60a7d55 feat(steam-info): 资料页状态/最近游玩/ban 描述增强,时长图渲染与定时任务迁移
- steam.py / draw.py / handlers 扩充:个人资料页状态构建、最近游玩、
  游戏头像与中文游戏名查询、ban 描述
- 定时播报迁入 hexi.core.scheduler
- 新增 tests/test_steam_profile_parse.py(资料 HTML/XML 解析)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:22 +08:00

387 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Steam 信息 —— 触发器(Trigger)层。"""
from __future__ import annotations
import asyncio
from nonebot import logger, on_command
from nonebot.adapters import Message, Event
from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment
from nonebot.params import CommandArg, Depends
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import At, Image, Target, UniMessage
from hexi.core.cooldown import cooldown
# 业务/状态在插件根 __init__ 中(本插件为社区插件,服务层保留在根模块)
from .. import (
__plugin_meta__,
STEAM_ID_OFFSET,
avatar_path,
bind_data,
cache_path,
config,
db_path,
disable_parent_data,
draw_friends_status,
draw_player_status,
fetch_play_time,
get_game_cn_names,
get_game_icons,
get_group_nickname,
get_parent_info,
get_steam_id,
get_steam_users_info,
get_target,
get_user_data,
get_recently_played,
fetch_game_header_image,
image_to_bytes,
key_select,
localize_game_name,
simplize_steam_player_data,
build_profile_status,
describe_ban,
_fill_missing_icon_hashes,
_query_game_names,
_query_game_name_ch_by_en,
_upsert_game_icon_hashes,
update_steamdb_apps,
)
# 信息卡「游戏动态」最多展示的条数(按近两周时长排序,列表本身最多 8 条)
MAX_ACTIVITY_GAMES = 4
help = on_command("steamhelp", aliases={"steam帮助"}, priority=10)
bind = on_command("steambind", aliases={"绑定steam"}, priority=10)
unbind = on_command("steamunbind", aliases={"解绑steam"}, priority=10)
# steaminfo 会额外调一次 Steam API(近两周时长),按群冷却防止被刷配额
info = cooldown(30, scope="group", hint="群里刚有人查过,{secs} 秒后再试")(
on_command("steaminfo", aliases={"steam信息"}, priority=10)
)
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)
get_play_time = cooldown(15, hint="查询太频繁了,{secs} 秒后再试")(
on_command("steamplaytime", aliases={"游戏时长表"}, priority=10)
)
update_steamdb = on_command(
"steamdbupdate", aliases={"更新steamdb"}, priority=10, permission=SUPERUSER
)
@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 = 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)
# 资料页抓取与近两周列表 API 并行;后者是可选增强,无配额时自动跳过
player_data, recent_games = await asyncio.gather(
get_user_data(steam_id, cache_path, config.proxy),
get_recently_played(steam_id, config.proxy),
)
recent_games = recent_games or []
# 游戏动态以最近两周列表为准(资料页只有最近几款),资料页解析的
# 成就/最后运行日期按 appid 合并进来;列表没有的游戏用资料页顺序补齐
page_games = {
g["appid"]: g for g in player_data["game_data"] if g.get("appid")
}
rows = list(recent_games)
known_appids = {r["appid"] for r in rows}
rows += [
{
"appid": appid,
"name": game["game_name"],
"two_week_hours": None,
"total_hours": None,
}
for appid, game in page_games.items()
if appid not in known_appids
]
rows = rows[:MAX_ACTIVITY_GAMES]
# 缺封面的行(不在资料页里)从公开 CDN 并发补,带缓存
missing_appids = [r["appid"] for r in rows if r["appid"] not in page_games]
cdn_headers = {}
if missing_appids:
fetched = await asyncio.gather(
*(
fetch_game_header_image(appid, cache_path, config.proxy)
for appid in missing_appids
)
)
cdn_headers = dict(zip(missing_appids, fetched))
names = await get_game_cn_names([r["appid"] for r in rows], db_path)
draw_data = []
for row in rows:
page = page_games.get(row["appid"], {})
header = page.get("game_image") or cdn_headers.get(row["appid"])
if header is None:
logger.warning(f"游戏 {row['appid']} 封面获取失败,本条跳过")
continue
if row["total_hours"] is not None:
game_time = f"{row['total_hours']:.1f}"
else:
game_time = str(page.get("play_time") or "")
draw_data.append(
{
"game_header": header,
"game_name": localize_game_name(
page.get("game_name") or row["name"], row["appid"], names
),
"game_time": f"{game_time} 小时",
"last_play_time": page.get("last_played") or "",
"achievements": page.get("achievements") or [],
"completed_achievement_number": page.get(
"completed_achievement_number"
),
"total_achievement_number": page.get("total_achievement_number"),
"two_week_time": (
f"近两周 {row['two_week_hours']:.1f} 小时"
if row["two_week_hours"]
else None
),
}
)
# 近两周合计:API 列表求和最准;没有列表(资料不公开/无配额)时退回页面文案
if recent_games:
total_hours = sum(r["two_week_hours"] for r in recent_games)
two_week_total = (
f"{total_hours:.1f} 小时(过去 2 周)" if total_hours > 0 else "近两周未游玩"
)
else:
two_week_total = player_data["recent_2_week_play_time"]
# 状态行/风控标记:资料页「当前正在游戏」优先,其次资料 XML;
# XML 兜底路径的英文游戏名用映射表反查中文
status = build_profile_status(
player_data["online_state"],
player_data["state_message"],
player_data["privacy_state"],
player_data["current_game"],
)
if status.get("game_name_en"):
cn_name = _query_game_name_ch_by_en(db_path, status["game_name_en"])
if cn_name:
status["text"] = cn_name
ban = describe_ban(
player_data["vac_banned"],
player_data["trade_ban_state"],
player_data["limited_account"],
)
extra = {
"level": player_data["level"],
"counts": player_data["counts"],
"member_since": player_data["member_since"],
"status_text": status["text"],
"status_kind": status["kind"],
"ban_text": ban["text"] if ban else None,
"ban_kind": ban["kind"] if ban else None,
}
image = draw_player_status(
player_data["background"], player_data["avatar"], player_data["player_name"],
str(steam_friend_code), player_data["description"],
two_week_total, draw_data,
extra=extra,
avatar_frame=player_data.get("avatar_frame"),
)
await info.finish(await UniMessage(Image(raw=image_to_bytes(image))).export(bot))
@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 请求过于频繁,请稍后再试")
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)
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 {}
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 播报")
@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 请求过于频繁,请稍后再试")
if not games:
await get_play_time.finish("没有获取到游玩数据(游戏可能未公开)")
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)
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)
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
try:
from ..html_playtime import render_play_time_table
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"])
img_bytes = await render_play_time_table(
played, games, steam_name, plugin_nick, avatar, theme=config.steam_playtime_theme,
)
except Exception as e:
logger.error(f"渲染游玩时长表图片失败: {e}", exc_info=True)
await get_play_time.finish("游玩时长表渲染失败,请稍后重试")
uni_msg = MessageSegment.reply(event.message_id) + await UniMessage(Image(raw=img_bytes)).export(bot)
await get_play_time.finish(uni_msg)