539 lines
18 KiB
Python
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,
|
|
)
|