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>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:06 +08:00
1 parent 614e1d7e00
commit a31aab00e8
752 files changed
+6241 -2506

No files matched your search

@@ -0,0 +1,295 @@
"""查询编排:目标解析 → 取数 → 加工 → 渲染,返回给 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())