Files
HeXi/hexi/plugins/nonebot_plugin_steam_info/__init__.py
T
sansenhoshi 131b92b319 结构调整
视频解析多图/多媒体结构 消息体适配
2026-09-08 14:25:32 +08:00

539 lines
18 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 import require
from typing import Optional, List, Dict
from nonebot.adapters import 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
from nonebot.adapters.onebot.v11 import Bot
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"),
)
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.web_hub.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": "array",
"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 播报")
# 用户命令的 API key 轮询(与播报 key_select_2 独立)
current_key_index_1 = 0
def key_select():
global current_key_index_1
keys = config.steam_api_key
current_key = keys[current_key_index_1]
current_key_index_1 = (current_key_index_1 + 1) % len(keys)
return [current_key]
# 触发器(handlers)在子包中
from .handlers import ( # noqa: E402,F401
bind,
check,
disable,
enable,
get_play_time,
help,
info,
unbind,
update_steamdb,
)