- 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>
965 lines
34 KiB
Python
965 lines
34 KiB
Python
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)
|