Files
HeXi/hexi/plugins/nonebot_plugin_bf_bot/services/query.py
T
sansenhoshiandClaude Code a31aab00e8 refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/
- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/
  gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、
  services(fetchers/processing/query)、utils(渲染/参数)分层;
  新增 repository.py / config.py / paths.py
- 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等)
- browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库
- 新增 hexi/web/src/plugins/bf_bot 前端管理页
- 新增 tests:test_bf_bot_helpers / repository / tracker_api

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

296 lines
10 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.
"""查询编排:目标解析 → 取数 → 加工 → 渲染,返回给 Trigger 层发送的结果。
只产出结果对象(文本/图片字节),不直接发消息;"正在查询…"等进度提示通过
`notify` 回调交给 Trigger 层发送,本层保持与 nonebot 消息 API 解耦。
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from nonebot import logger
from ..repository import Bf2042SettingsManager, UserManager
from ..utils.args import (
BF2042_ERROR_HINT,
BF_GAME_NAMES,
parse_bf2042_args,
)
from ..utils.render_bf2042 import build_bf2042_career_card, build_bf2042_list_card
from ..utils.render_ea import build_bf6_stats_card, build_stats_card
from ..utils.text import convert_to_hours
from .fetchers.bf2042 import (
bf2042_list_title,
get_bf2042_gamemodes,
get_bf2042_info,
get_bf2042_list,
)
from .fetchers.gametools import (
fetch_all_stats,
get_bf6_rank,
get_player_info_by_name,
player_display_name,
)
from .processing import (
get_best_classes,
get_best_weapon_and_best_vehicle,
get_vehicle_destroyed,
stats_calculator,
)
Notify = Callable[[str], Awaitable[None]]
_PLATFORM = "pc"
@dataclass
class QueryReply:
"""一次查询的最终回复:文本 +(可选)PNG 图片字节。"""
text: str
image: bytes | None = None
async def run_query(
*, cmd: str, input_text: str, qq_id: str, notify: Notify
) -> QueryReply:
"""`/bft` 及全部别名共用的查询入口。
cmd: 实际输入的游戏别名(bf3/bf4/bf1/bfv/bf2042/bf6)。
input_text: 命令参数(EAID 或 BF2042 子命令),可为空。
qq_id: 触发者 QQ,用于读绑定与取头像。
notify: 进度提示回调("正在查询…")。
"""
started = time.time()
if cmd not in BF_GAME_NAMES:
return QueryReply(
"指令异常:请使用 bf3 / bf4 / bf1 / bfv / bf2042 / bf6(可带 EAID)"
)
if cmd == "bf2042":
reply = await _run_bf2042(input_text, qq_id, notify)
else:
reply = await _run_ea(cmd, input_text, qq_id, notify)
if reply.image is not None:
reply.text = f"本次查询耗时:{round(time.time() - started, 3)}s"
return reply
def _resolve_bound_record(qq_id: str) -> QueryReply | dict:
"""无参数查询时取绑定记录;未绑定返回给用户的提示。"""
content = UserManager().get_user_by_qq(qq_id)
if not content:
return QueryReply(
"未检测到id,也未检测到绑定记录,请使用 绑定 EAID 进行绑定操作"
)
return content
async def _run_ea(cmd: str, input_text: str, qq_id: str, notify: Notify) -> QueryReply:
"""EA 系(BF3/4/1/5/6):gametools 取数 + 卡片渲染。"""
if not input_text:
content = _resolve_bound_record(qq_id)
if isinstance(content, QueryReply):
return content
player = content["ea_player_name"]
player_id = content["ea_player_id"]
user_id = content["ea_user_id"]
else:
content = await get_player_info_by_name(input_text, _PLATFORM)
if "errors" in content:
return QueryReply("未找到玩家,请检查是否拼写正确")
player_id = content["personaId"]
user_id = content["userId"]
player = player_display_name(content)
await notify(f"正在查询 {player} 的 {BF_GAME_NAMES[cmd]} 数据,请耐心等待")
try:
rank = 0
if cmd == "bf6":
play_stat, rank = await asyncio.gather(
fetch_all_stats(cmd, player, player_id, user_id, _PLATFORM),
get_bf6_rank(player, player_id, user_id, _PLATFORM),
)
logger.info(f"等级:{rank}")
else:
play_stat = await fetch_all_stats(
cmd, player, player_id, user_id, _PLATFORM
)
if "errors" in play_stat:
logger.warning(play_stat["errors"][0])
return QueryReply(str(play_stat["errors"][0]))
img = await _render_ea_card(cmd, play_stat, rank, player, qq_id)
if isinstance(img, QueryReply):
return img
return QueryReply("", image=img)
except Exception as e: # noqa: BLE001 - 兜底给用户可见提示,明细写日志
logger.exception(f"异常:{e}")
return QueryReply("查询异常,请稍后再试")
async def _render_ea_card(cmd: str, play_stat, rank, player, qq_id):
"""渲染卡片;返回 PNG 字节,或对用户的错误提示(QueryReply)。"""
weapon, vehicle = await get_best_weapon_and_best_vehicle(play_stat)
pid = play_stat["userId"]
kd = play_stat["killDeath"]
kpm = play_stat["killsPerMinute"]
acc = play_stat["accuracy"]
# 战地3 特化
if cmd == "bf3":
head_shots = play_stat["headShots"]
else:
head_shots = play_stat["headshots"]
time_play = convert_to_hours(play_stat["timePlayed"])
logger.info(f"游玩时长监测:{time_play}")
if time_play == 0:
return QueryReply("查询接口异常,请等待一段时间后查询")
kills = int(play_stat["kills"])
kill_assists = int(play_stat["killAssists"])
revives = int(play_stat["revives"])
wins = int(play_stat["wins"])
loses = int(play_stat["loses"])
best_weapon = weapon
best_vehicle = vehicle
best_class = play_stat["bestClass"]
destroyed = await get_vehicle_destroyed(play_stat["vehicles"])
if cmd == "bf6":
best_class = await get_best_classes(play_stat)
captured = play_stat["objective"]["captured"]
score = sum(item.get("score", 0) for item in play_stat["classes"])
# 计算4兵种游玩时长
seconds = sum(item.get("secondsPlayed", 0) for item in play_stat["classes"])
kpm = round(kills / (int(seconds) / 60), 2)
logger.info(f"击杀数:{kills},游玩分钟数:{seconds / 60},计算kpm值:{kpm}")
spm = int(score / (int(seconds) / 60))
logger.info(f"计算spm值:{spm}")
img = await build_bf6_stats_card(
game="bf6",
qq_id=qq_id,
player=player,
pid=pid,
rank=rank,
kd=kd,
kpm=kpm,
spm=spm,
acc=acc,
head_shots=head_shots,
time_play=time_play,
kills=kills,
kill_assists=kill_assists,
revives=revives,
repairs=play_stat["repairs"],
captured=captured,
score=score,
wins=wins,
loses=loses,
destroyed=destroyed,
best_weapon=best_weapon,
best_vehicle=best_vehicle,
best_class=best_class,
)
else:
spm = play_stat["scorePerMinute"]
longest_head_shot = play_stat["longestHeadShot"]
highest_ill_streak = play_stat["highestKillStreak"]
stat_data, level_designation = await stats_calculator(play_stat, cmd)
img = await build_stats_card(
cmd,
player,
pid,
kd,
kpm,
spm,
acc,
head_shots,
play_stat["rank"],
time_play,
kills,
kill_assists,
revives,
wins,
loses,
destroyed,
best_weapon,
best_vehicle,
best_class,
longest_head_shot,
highest_ill_streak,
stat_data,
level_designation,
qq_id=qq_id,
)
if img is None:
return QueryReply("图片渲染失败,请稍后再试")
return img.getvalue()
async def _run_bf2042(input_text: str, qq_id: str, notify: Notify) -> QueryReply:
"""BF2042 独立流程:tracker.gg 浏览器通道。
与其它战地不同,BF2042 不经过 EA 接口,所以玩家名要么来自指令参数,
要么来自绑定记录,不需要 personaId/userId。
指令形式:
/bf2042 [EAID] 生涯卡
/bf2042 武器|载具|装备 [EAID] 榜单卡
/bf2042 模式 [EAID] 各模式明细
"""
target = parse_bf2042_args(input_text)
player = target.player
if not player:
content = _resolve_bound_record(qq_id)
if isinstance(content, QueryReply):
return content
player = content["ea_player_name"]
if target.view == "list":
what = f"{bf2042_list_title(target.kind)}榜"
elif target.view == "gamemode":
what = "各模式数据"
else:
what = "数据"
await notify(f"正在查询 {player} 的 {BF_GAME_NAMES['bf2042']}{what},请耐心等待")
try:
if target.view == "list":
result = await get_bf2042_list(player, _PLATFORM, target.kind)
elif target.view == "gamemode":
result = await get_bf2042_gamemodes(player, _PLATFORM)
else:
result = await get_bf2042_info(player, _PLATFORM)
except Exception as e: # noqa: BLE001 - 兜底,别让异常静默
logger.exception(f"BF2042 查询异常:{e}")
return QueryReply("BF2042 查询异常,请稍后再试")
if not result.ok:
logger.warning(f"BF2042 查询失败({result.reason}):{result.detail}")
hint = BF2042_ERROR_HINT.get(result.reason, "查询失败,请稍后再试")
return QueryReply(f"BF2042 {hint}")
info = result.data if isinstance(result.data, dict) else {}
if target.view == "career":
# 新版生涯卡:旧版布局 + tracker.gg 数据映射;背景按支持者特权选
from .processing import build_bf2042_career_card as build_card_slots
try:
support = Bf2042SettingsManager().is_support(qq_id)
except Exception: # noqa: BLE001 - 装饰性功能,失败退回公共背景
support = False
card = build_card_slots(
info.get("_profile") or {},
info.get("_weapons") or [],
info.get("_vehicles") or [],
info.get("_soldiers") or [],
)
card["_qq_id"] = qq_id
card["_support"] = support
img = build_bf2042_career_card(card)
else:
img = build_bf2042_list_card(info)
return QueryReply("", image=img.getvalue())