Clone original repository

This commit is contained in:
2026-10-09 20:21:24 +08:00
parent 25e4618573
commit c012a639be
16 changed files with 2627 additions and 1 deletions
+746
View File
@@ -0,0 +1,746 @@
"""OW战绩查询插件(重构版:常量/工具/绑定已拆分)。"""
import asyncio
import aiohttp
import astrbot.api.message_components as Comp
from astrbot.api import logger
from astrbot.api.all import AstrBotConfig
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
from .api_client import OverFastAPIClient
from .bindings import BindingStore
from .constants import (
GAMEMODE_MAPPING,
GAMEMODE_REVERSE_MAPPING,
HERO_NAME_MAPPING,
HERO_NAME_REVERSE_MAPPING,
PLATFORM_MAPPING,
PLATFORM_REVERSE_MAPPING,
RANK_MAPPING,
REGION_MAPPING,
REGION_REVERSE_MAPPING,
ROLE_CN_TO_EN,
ROLE_MAPPING,
)
from .utils import (
DEFAULT_API_BASE_URL,
build_rank_data as _build_rank_data,
extract_top_heroes as _extract_top_heroes,
format_number as _format_number,
format_time_played as _format_time_played,
format_time_short as _format_time_short,
get_hero_name_cn as _get_hero_name_cn,
get_platform_display as _get_platform_display,
get_rank_display as _get_rank_display,
get_region_display as _get_region_display,
get_role_display as _get_role_display,
normalize_api_base_url,
normalize_player_id as _normalize_player_id,
resolve_gamemode as _resolve_gamemode,
resolve_hero_name as _resolve_hero_name,
resolve_platform as _resolve_platform,
resolve_region as _resolve_region,
validate_player_id as _validate_player_id,
)
# 向后兼容:保留旧导入路径
__all__ = [
"OverwatchStatsPlugin",
"RANK_MAPPING",
"ROLE_MAPPING",
"GAMEMODE_MAPPING",
"PLATFORM_MAPPING",
"REGION_MAPPING",
"HERO_NAME_MAPPING",
]
try:
from . import image_renderer
_RENDERER_AVAILABLE = True
try:
image_renderer.cleanup_old_images()
except Exception:
pass
except Exception: # pragma: no cover
image_renderer = None # type: ignore[assignment]
_RENDERER_AVAILABLE = False
_DOWNLOAD_SEM = asyncio.Semaphore(6)
class OverwatchStatsPlugin(BindingStore, Star):
"""OW战绩查询插件主类(基于 OverFast API)。"""
def __init__(self, context: Context, config: AstrBotConfig | None = None) -> None:
super().__init__(context)
self.config = config or {}
raw_gamemode = self.config.get("default_gamemode", "competitive")
resolved = _resolve_gamemode(str(raw_gamemode))
self.default_gamemode: str = resolved if resolved in ("quickplay", "competitive") else "competitive"
raw_platform = self.config.get("default_platform", "pc")
resolved_platform = _resolve_platform(str(raw_platform))
self.default_platform: str = resolved_platform if resolved_platform in ("pc", "console") else "pc"
raw_region = self.config.get("default_region", "asia")
resolved_region = _resolve_region(str(raw_region))
self.default_region: str = resolved_region if resolved_region in ("asia", "americas", "europe") else "asia"
# 与 _conf_schema.json 保持一致,默认开启图片渲染
self.enable_image_render: bool = bool(self.config.get("enable_image_render", True))
self.show_api_error: bool = bool(self.config.get("show_api_error", False))
# OverFast 开源可自建,允许通过配置覆盖 API 地址
self.api_base_url: str = normalize_api_base_url(
str(self.config.get("api_base_url", DEFAULT_API_BASE_URL) or DEFAULT_API_BASE_URL)
)
try:
self.max_binds_per_user: int = max(int(self.config.get("max_binds_per_user", 3) or 3), 1)
except (TypeError, ValueError):
self.max_binds_per_user = 3
if self.enable_image_render and not _RENDERER_AVAILABLE:
logger.warning("已开启图片渲染,但渲染模块加载失败(可能未安装 Pillow),将回退文字输出。pip install Pillow")
logger.info(f"OW战绩查询插件已加载(图片渲染: {'开启' if self.enable_image_render else '关闭'},API: {self.api_base_url})")
def _api_client(self) -> OverFastAPIClient:
"""按配置地址构造 API 客户端(支持自建 OverFast 实例)。"""
return OverFastAPIClient(base_url=self.api_base_url)
def _effective_platform(self, bound_platform: str | None = None) -> str:
if bound_platform in ("pc", "console"):
return bound_platform
return self.default_platform
def _api_error_reply(self, e: Exception, friendly: str) -> str:
logger.debug(f"API 错误详情: {type(e).__name__}: {e}", exc_info=True)
if self.show_api_error:
detail = getattr(e, "detail", "") or str(e)
retry_after = getattr(e, "retry_after", None)
reply = f"查询出错: {detail}"
if retry_after:
reply += f"\n建议等待 {retry_after} 秒后重试"
return reply
return friendly
@staticmethod
async def _download_with_session(session: aiohttp.ClientSession, url: str) -> bytes | None:
if not url:
return None
try:
async with _DOWNLOAD_SEM:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.read()
except Exception as e:
logger.debug(f"下载图片失败: {e}")
return None
@classmethod
async def _download_many(cls, urls: list[str]) -> list[bytes | None]:
if not urls:
return []
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
return await asyncio.gather(*(cls._download_with_session(session, u) for u in urls))
async def _fetch_hero_portraits(self, hero_keys: list[str]) -> dict[str, bytes]:
if not hero_keys:
return {}
portrait_map: dict[str, str] = {}
try:
async with self._api_client() as client:
heroes_list = await client.list_heroes()
for h in heroes_list or []:
if isinstance(h, dict) and h.get("key") and h.get("portrait"):
portrait_map[h["key"]] = h["portrait"]
except Exception as e:
logger.debug(f"获取英雄头像列表失败: {e}")
return {}
urls = [portrait_map.get(k, "") for k in hero_keys]
results = await self._download_many(urls)
return {k: b for k, b in zip(hero_keys, results) if b}
def _prepare_player_id(self, raw: str, from_binding: bool) -> str:
"""绑定存储已是规范化形式直接用;外部输入归一化+校验。"""
if from_binding:
return raw
norm = _normalize_player_id(raw)
try:
_validate_player_id(norm)
except ValueError as e:
raise ValueError(str(e)) from e
return norm
async def _send_summary_result(
self, event: AstrMessageEvent, full: dict, player_id: str, platform: str,
footer_note: str | None = None, extra_lines: list[str] | None = None,
):
summary = full.get("summary") if isinstance(full.get("summary"), dict) else full
stats = full.get("stats") if isinstance(full.get("stats"), dict) else {}
username = summary.get("username", player_id)
avatar_url = summary.get("avatar") or ""
namecard_url = summary.get("namecard") or ""
endorsement = summary.get("endorsement", {})
endorsement_level = endorsement.get("level", 0) if isinstance(endorsement, dict) else 0
title = summary.get("title") or ""
shown_platform, rank_rows = _build_rank_data(summary, platform)
top_heroes = _extract_top_heroes(stats, platform)
if self.enable_image_render and _RENDERER_AVAILABLE:
try:
urls = [avatar_url, namecard_url] + [r.get("rank_icon", "") for r in rank_rows]
results = await self._download_many(urls)
avatar_bytes, namecard_bytes = results[0], results[1]
render_rows = [
{"role_text": r["role_text"], "rank_text": r["rank_text"], "rank_icon_bytes": b}
for r, b in zip(rank_rows, results[2:])
] or [{"role_text": "段位", "rank_text": "暂无竞技段位数据", "rank_icon_bytes": None}]
portraits = await self._fetch_hero_portraits([k for k, _ in top_heroes])
render_heroes = [(_get_hero_name_cn(k), _format_time_short(v), portraits.get(k)) for k, v in top_heroes]
img_path = image_renderer.render_summary_card(
username=username, title=title, endorsement_level=endorsement_level,
rank_rows=render_rows, platform_label=_get_platform_display(shown_platform),
avatar_bytes=avatar_bytes, namecard_bytes=namecard_bytes,
top_heroes=render_heroes, footer_note=footer_note,
)
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"摘要图片渲染失败,回退文字: {e}")
logger.debug("堆栈:", exc_info=True)
rank_lines = [f" {r['role_text']}: {r['rank_text']}" for r in rank_rows] or [" 暂无竞技段位数据"]
lines = [f"👤 {username}"]
if title:
lines.append(f"头衔: {title}")
lines.append(f"赞赏等级: {endorsement_level}")
lines.append(f"竞技段位 ({_get_platform_display(shown_platform)}):")
lines.extend(rank_lines)
if top_heroes:
lines.append("常玩英雄: " + "、".join(f"{_get_hero_name_cn(k)}({_format_time_played(v)})" for k, v in top_heroes))
if extra_lines:
lines.extend(extra_lines)
result_text = "\n".join(lines)
if avatar_url:
yield event.chain_result([Comp.Image.fromURL(avatar_url), Comp.Plain(result_text)])
else:
yield event.plain_result(result_text)
@filter.command("owsummary")
async def player_summary(self, event: AstrMessageEvent, player_id: str = ""):
"""查询玩家摘要信息。用法: /owsummary [玩家ID]"""
bound_platform: str | None = None
from_binding = False
if not player_id or not player_id.strip():
bound_id, bound_platform = await self._get_binding(event)
if not bound_id:
yield event.plain_result("你还没有绑定 Overwatch ID。\n用法: /owsummary <玩家ID>\n或 /owbind <玩家ID> [平台] 后直接查询")
return
player_id = bound_id
from_binding = True
platform = self._effective_platform(bound_platform)
try:
player_id = self._prepare_player_id(player_id, from_binding)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
try:
async with self._api_client() as client:
full = await client.get_player_full(player_id)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "未找到" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}`,请检查 ID。")
return
yield event.plain_result(self._api_error_reply(e, "获取玩家信息失败,请稍后重试。"))
return
except Exception:
logger.debug("获取玩家摘要错误堆栈:", exc_info=True)
yield event.plain_result("获取玩家信息时出错,请稍后重试。")
return
async for res in self._send_summary_result(event, full, player_id, platform):
yield res
@filter.command("owstats")
async def player_stats(self, event: AstrMessageEvent, player_id: str = "", gamemode: str = ""):
"""查询玩家统计概览。用法: /owstats [玩家ID] [快速|竞技]"""
bound_platform: str | None = None
from_binding = False
if not player_id or not player_id.strip():
bound_id, bound_platform = await self._get_binding(event)
if not bound_id:
yield event.plain_result("你还没有绑定 Overwatch ID。用法: /owstats <玩家ID> [快速|竞技]")
return
player_id = bound_id
from_binding = True
platform = self._effective_platform(bound_platform)
try:
player_id = self._prepare_player_id(player_id, from_binding)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
gamemode = _resolve_gamemode(gamemode or self.default_gamemode) or ""
if gamemode not in ("quickplay", "competitive"):
yield event.plain_result("游戏模式无效。可选: 快速(quickplay)、竞技(competitive)")
return
try:
async with self._api_client() as client:
data = await client.get_player_stats_summary(player_id, gamemode=gamemode, platform=platform)
full: dict = {}
try:
full = await client.get_player_full(player_id)
except Exception as fe:
logger.debug(f"完整数据获取失败: {fe}")
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}` 或资料私密。")
return
yield event.plain_result(self._api_error_reply(e, "获取统计失败,请稍后重试。"))
return
except Exception:
logger.debug("获取统计错误堆栈:", exc_info=True)
yield event.plain_result("获取统计信息时出错,请稍后重试。")
return
general = data.get("general", {}) if isinstance(data, dict) else {}
if not general:
yield event.plain_result(f"玩家 `{player_id}` | {GAMEMODE_MAPPING.get(gamemode, gamemode)}暂无统计数据。")
return
average = general.get("average", {}) if isinstance(general.get("average"), dict) else {}
games_played = general.get("games_played", 0) or 0
games_won = general.get("games_won", 0) or 0
games_lost = general.get("games_lost", 0) or 0
winrate = general.get("winrate", 0) or 0
kda = general.get("kda", 0) or 0
eliminations_avg = average.get("eliminations", 0) or 0
deaths_avg = average.get("deaths", 0) or 0
damage_avg = average.get("damage", 0) or 0
healing_avg = average.get("healing", 0) or 0
time_played = general.get("time_played", 0) or 0
summary = full.get("summary") if isinstance(full.get("summary"), dict) else {}
stats_full = full.get("stats") if isinstance(full.get("stats"), dict) else {}
shown_platform, rank_data = _build_rank_data(summary, platform) if summary else (platform, [])
comp_rank_rows = rank_data if gamemode == "competitive" else []
heroes_stats = data.get("heroes", {}) if isinstance(data.get("heroes"), dict) else {}
top_hero_rows = sorted(
((k, int(v.get("games_played") or 0)) for k, v in heroes_stats.items()
if isinstance(v, dict) and (v.get("games_played") or 0) > 0),
key=lambda kv: kv[1], reverse=True,
)[:3]
if not top_hero_rows and stats_full:
top_hero_rows = [(k, 0) for k, _ in _extract_top_heroes(stats_full, platform)]
gamemode_label = GAMEMODE_MAPPING.get(gamemode, gamemode)
if self.enable_image_render and _RENDERER_AVAILABLE:
try:
render_rank_rows = []
if comp_rank_rows:
icons = await self._download_many([r.get("rank_icon", "") for r in comp_rank_rows])
for r, b in zip(comp_rank_rows, icons):
render_rank_rows.append({"role_text": r["role_text"], "rank_text": r["rank_text"], "rank_icon_bytes": b})
portraits = await self._fetch_hero_portraits([k for k, _ in top_hero_rows])
render_heroes = [(_get_hero_name_cn(k), f"{_format_number(g)}场" if g else "常用英雄", portraits.get(k)) for k, g in top_hero_rows]
stat_rows = [
("场次", f"{_format_number(games_played)} ({_format_number(games_won)}胜/{_format_number(games_lost)}负)"),
("胜率", f"{winrate}%"),
("KDA", f"{kda:.2f}" if kda else "N/A"),
("消灭", f"{_format_number(eliminations_avg)}/场" if eliminations_avg else "N/A"),
("死亡", f"{_format_number(deaths_avg)}/场" if deaths_avg else "N/A"),
("伤害", f"{_format_number(damage_avg)}/场" if damage_avg else "N/A"),
("治疗", f"{_format_number(healing_avg)}/场" if healing_avg else "N/A"),
("游戏时间", _format_time_played(time_played)),
]
img_path = image_renderer.render_stats_card(
player_id=player_id, gamemode_label=gamemode_label,
platform_label=_get_platform_display(shown_platform),
stat_rows=stat_rows, rank_rows=render_rank_rows, top_heroes=render_heroes,
)
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"统计图片渲染失败,回退文字: {e}")
lines = [f"{player_id} | {gamemode_label} | {_get_platform_display(shown_platform)}", "━━━━━━━━━━━━"]
if comp_rank_rows:
lines.append(f"竞技段位 ({_get_platform_display(shown_platform)}):")
lines.extend(f" {r['role_text']}: {r['rank_text']}" for r in comp_rank_rows)
lines.extend([
f"场次: {_format_number(games_played)} ({_format_number(games_won)}胜/{_format_number(games_lost)}负)",
f"胜率: {winrate}%",
f"KDA: {kda:.2f}" if kda else "KDA: N/A",
f"消灭: {_format_number(eliminations_avg)}/场" if eliminations_avg else "消灭: N/A",
f"死亡: {_format_number(deaths_avg)}/场" if deaths_avg else "死亡: N/A",
f"伤害: {_format_number(damage_avg)}/场" if damage_avg else "伤害: N/A",
f"治疗: {_format_number(healing_avg)}/场" if healing_avg else "治疗: N/A",
f"游戏时间: {_format_time_played(time_played)}",
])
if top_hero_rows:
lines.append("常玩英雄: " + "、".join(f"{_get_hero_name_cn(k)}({_format_number(g)}场)" if g else _get_hero_name_cn(k) for k, g in top_hero_rows))
yield event.plain_result("\n".join(lines))
@filter.command("owcareer")
async def player_career(self, event: AstrMessageEvent, player_id: str = "", gamemode: str = "", hero: str = ""):
"""用法: /owcareer [玩家ID] <快速|竞技> [英雄]"""
bound_platform: str | None = None
from_binding = False
if not player_id or not player_id.strip():
bound_id, bound_platform = await self._get_binding(event)
if not bound_id:
yield event.plain_result("用法: /owcareer <玩家ID> <游戏模式> [英雄名]")
return
player_id = bound_id
from_binding = True
elif not gamemode or not gamemode.strip():
resolved = _resolve_gamemode(player_id)
if resolved in ("quickplay", "competitive"):
bound_id, bound_platform = await self._get_binding(event)
if bound_id:
gamemode = player_id
player_id = bound_id
from_binding = True
else:
yield event.plain_result("你还没有绑定 ID。/owbind <玩家ID> 后可 /owcareer 竞技 源氏")
return
platform = self._effective_platform(bound_platform)
resolved_mode = _resolve_gamemode(gamemode)
if not resolved_mode or resolved_mode not in ("quickplay", "competitive"):
yield event.plain_result("请输入有效模式。用法: /owcareer <玩家ID> <快速|竞技> [英雄]")
return
try:
player_id = self._prepare_player_id(player_id, from_binding)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
gamemode = resolved_mode
hero_key = _resolve_hero_name(hero) if hero else None
try:
async with self._api_client() as client:
data = await client.get_player_career_stats(player_id, gamemode=gamemode, platform=platform, hero=hero_key)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}` 或资料私密。")
return
yield event.plain_result(self._api_error_reply(e, "获取生涯统计失败,请稍后重试。"))
return
except Exception:
logger.debug("生涯统计错误堆栈:", exc_info=True)
yield event.plain_result("获取生涯统计时出错,请稍后重试。")
return
if not data or not isinstance(data, dict):
yield event.plain_result(f"玩家 `{player_id}` 暂无生涯统计数据。")
return
hero_cards: list[tuple[str, str, list[tuple[str, str]]]] = []
remaining = 0
for hero_key_raw, categories in data.items():
if not isinstance(categories, dict):
continue
hero_name = _get_hero_name_cn(hero_key_raw)
rows: list[tuple[str, str]] = []
combat = categories.get("combat", {})
if combat:
eliminations = combat.get("eliminations")
deaths = combat.get("deaths")
kd = ""
if eliminations and deaths and int(deaths) > 0:
kd = f" (K/D: {float(eliminations) / float(deaths):.2f})"
elif eliminations:
kd = " (K/D: ∞)"
if eliminations is not None:
rows.append(("消灭", f"{_format_number(eliminations)}{kd}"))
if deaths is not None:
rows.append(("死亡", _format_number(deaths)))
if combat.get("damage_done") is not None:
rows.append(("伤害", _format_number(combat.get("damage_done"))))
if combat.get("healing_done") is not None:
rows.append(("治疗", _format_number(combat.get("healing_done"))))
game_stats = categories.get("game", {})
if game_stats:
gp, gw = game_stats.get("games_played"), game_stats.get("games_won")
if gp is not None and gw is not None:
gl = (gp or 0) - (gw or 0)
wr = (gw / gp * 100) if gp > 0 else 0
rows.append(("场次", f"{_format_number(gp)} ({_format_number(gw)}胜/{_format_number(gl)}负, {wr:.1f}%)"))
if game_stats.get("time_played"):
rows.append(("时间", _format_time_played(game_stats.get("time_played"))))
hero_cards.append((hero_key_raw, hero_name, rows))
if len(hero_cards) >= 8:
remaining = max(len(data) - 8, 0)
break
gamemode_label = GAMEMODE_MAPPING.get(gamemode, gamemode)
hero_filter_name = _get_hero_name_cn(hero_key) if hero_key else None
if self.enable_image_render and _RENDERER_AVAILABLE and hero_cards:
try:
portraits = await self._fetch_hero_portraits([k for k, _, _ in hero_cards])
render_heroes = [(name, rows, portraits.get(key)) for key, name, rows in hero_cards]
img_path = image_renderer.render_career_card(
player_id=player_id, gamemode_label=gamemode_label,
platform_label=_get_platform_display(platform),
hero_filter=hero_filter_name, heroes=render_heroes, remaining=remaining,
)
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"生涯图片渲染失败,回退文字: {e}")
hero_filter = f" | 英雄: {hero_filter_name}" if hero_filter_name else ""
lines = [f"{player_id} | {gamemode_label} | {_get_platform_display(platform)}{hero_filter}", "━━━━━━━━━━━━"]
for _, hero_name, rows in hero_cards:
lines.append(f"\n{hero_name}")
lines.extend(f" {label}: {value}" for label, value in rows)
if remaining > 0:
lines.append(f"\n... 还有 {remaining} 个英雄未显示")
if not hero_cards:
lines.append("暂无生涯统计数据。")
yield event.plain_result("\n".join(lines))
@filter.command("owhero")
async def hero_info(self, event: AstrMessageEvent, hero_name: str = ""):
"""用法: /owhero <英雄名>"""
if not hero_name or not hero_name.strip():
yield event.plain_result("请输入英雄名称。用法: /owhero <英雄名>")
return
hero_key = _resolve_hero_name(hero_name)
hero_stat: dict | None = None
try:
async with self._api_client() as client:
data = await client.get_hero_info(hero_key)
try:
all_stats = await client.get_heroes_stats(
platform=self.default_platform, gamemode=self.default_gamemode, region=self.default_region)
for item in all_stats or []:
if isinstance(item, dict) and item.get("hero") == hero_key:
hero_stat = item
break
except Exception as se:
logger.debug(f"英雄胜率获取失败: {se}")
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到英雄 `{hero_key}`。")
return
yield event.plain_result(self._api_error_reply(e, "获取英雄信息失败,请稍后重试。"))
return
except Exception:
logger.debug("英雄信息错误堆栈:", exc_info=True)
yield event.plain_result("获取英雄信息时出错,请稍后重试。")
return
name = data.get("name", hero_key)
description = data.get("description", "")
role = data.get("role", "")
hp = data.get("hitpoints", {}) if isinstance(data.get("hitpoints"), dict) else {}
portrait = data.get("portrait", "")
abilities = data.get("abilities", [])
story_summary = data.get("story", {}).get("summary", "") if isinstance(data.get("story"), dict) else ""
lines = [f"{name}", f"角色: {_get_role_display(role) if role else '未知'}",
f"生命: {hp.get('health', 0)}{' | 护甲: ' + str(hp.get('armor')) if hp.get('armor') else ''}{' | 护盾: ' + str(hp.get('shields')) if hp.get('shields') else ''}"]
if hero_stat:
lines.append(f"全服({_get_region_display(self.default_region)}|{GAMEMODE_MAPPING.get(self.default_gamemode, self.default_gamemode)}): 胜率 {(hero_stat.get('winrate') or 0):.1f}% | 选取率 {(hero_stat.get('pickrate') or 0):.1f}%")
lines.append("━━━━━━━━━━━━")
if abilities:
lines.append("技能:")
for ability in abilities[:8]:
if isinstance(ability, dict):
desc = ability.get("description", "")
if len(desc) > 80:
desc = desc[:77] + "..."
lines.append(f" • {ability.get('name', '')}: {desc}")
if story_summary:
s = story_summary[:197] + "..." if len(story_summary) > 200 else story_summary
lines += ["", "背景故事:", f" {s}"]
result_text = "\n".join(lines)
if portrait:
yield event.chain_result([Comp.Image.fromURL(portrait), Comp.Plain(result_text)])
else:
yield event.plain_result(result_text)
@filter.command("owherostats")
async def heroes_stats(self, event: AstrMessageEvent, role: str = "", region: str = ""):
"""用法: /owherostats [坦克|输出|支援] [亚服|美服|欧服]"""
role_filter: str | None = None
resolved_region: str | None = None
for arg in (role, region):
arg = (arg or "").strip()
if not arg:
continue
lowered = arg.lower()
if lowered in ROLE_CN_TO_EN or arg in ROLE_CN_TO_EN:
if role_filter is not None:
yield event.plain_result("角色参数重复。可选: 坦克、输出、支援")
return
role_filter = ROLE_CN_TO_EN.get(lowered) or ROLE_CN_TO_EN.get(arg)
elif _resolve_region(arg):
if resolved_region is not None:
yield event.plain_result("地区参数重复。可选: 亚服、美服、欧服")
return
resolved_region = _resolve_region(arg)
else:
yield event.plain_result(f"无法识别 `{arg}`。用法: /owherostats [角色] [地区]")
return
region_v = resolved_region or self.default_region
try:
async with self._api_client() as client:
stats = await client.get_heroes_stats(
platform=self.default_platform, gamemode=self.default_gamemode,
region=region_v, role=role_filter, order_by="winrate:desc")
except ValueError as e:
yield event.plain_result(self._api_error_reply(e, "获取英雄统计失败,请稍后重试。"))
return
except Exception:
logger.debug("英雄统计错误堆栈:", exc_info=True)
yield event.plain_result("获取英雄统计时出错,请稍后重试。")
return
if not stats or not isinstance(stats, list):
yield event.plain_result("暂无英雄统计数据。")
return
role_cn = ROLE_MAPPING.get(role_filter, (role_filter,))[0] if role_filter else ""
title = "英雄胜率排行"
subtitle = f"{_get_region_display(region_v)}{' | ' + role_cn if role_cn else ''} | {GAMEMODE_MAPPING.get(self.default_gamemode, self.default_gamemode)} | {_get_platform_display(self.default_platform)}"
rows: list[tuple[str, str]] = []
text_lines = [f"{title} | {subtitle}", "━━━━━━━━━━━━"]
for idx, item in enumerate(stats, 1):
if not isinstance(item, dict):
continue
hn = _get_hero_name_cn(item.get("hero", ""))
wr = item.get("winrate", 0) or 0
pr = item.get("pickrate", 0) or 0
rows.append((f"{idx:>2}. {hn}", f"胜率 {wr:.1f}% | 选取率 {pr:.1f}%"))
text_lines.append(f"{idx:>2}. {hn:<6} 胜率 {wr:.1f}% | 选取率 {pr:.1f}%")
text_lines += ["", "可加参数筛选: /owherostats 输出 欧服"]
if self.enable_image_render and _RENDERER_AVAILABLE:
try:
img_path = image_renderer.render_herostats_card(title, subtitle, rows, "可加参数筛选: /owherostats 输出 欧服")
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"榜单图片渲染失败,回退文字: {e}")
yield event.plain_result("\n".join(text_lines))
@filter.command("owbind")
async def bind_id(self, event: AstrMessageEvent, player_id: str = "", platform: str = ""):
"""用法: /owbind <玩家ID> [pc|主机]"""
if not player_id or not player_id.strip():
yield event.plain_result(f"请输入玩家 ID。用法: /owbind <玩家ID> [平台]\n最多绑定 {self.max_binds_per_user} 个,用 /owbinds 查看")
return
if platform and platform.strip():
rp = _resolve_platform(platform)
if not rp:
yield event.plain_result("平台无效。可选: pc(电脑端)、console(主机端)")
return
platform_v = rp
else:
platform_v = self.default_platform
try:
player_id = self._prepare_player_id(player_id, False)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
user_name = event.get_sender_name()
current = await self._load_bindings(event)
if not any(a["player_id"].lower() == player_id.lower() for a in current["accounts"]) and len(current["accounts"]) >= self.max_binds_per_user:
yield event.plain_result(f"{user_name} 最多绑定 {self.max_binds_per_user} 个账号。/owunbind 解绑后再试。")
return
try:
async with self._api_client() as client:
await client.get_player_summary(player_id)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}`,请检查 ID。")
return
except Exception:
pass
result, data = await self._add_binding(event, player_id, platform_v)
if result == "limit":
yield event.plain_result(f"最多绑定 {self.max_binds_per_user} 个账号。")
elif result in ("added", "updated"):
action = "绑定" if result == "added" else "更新绑定"
count = len(data["accounts"]) if data else 1
yield event.plain_result(
f"{user_name} 已{action}: `{player_id}`\n平台: {_get_platform_display(platform_v)}(已设默认)\n"
f"已绑定 {count}/{self.max_binds_per_user}\n/owme 快捷查询,/owbinds 查看列表")
else:
yield event.plain_result("绑定失败,请稍后重试。")
@filter.command("owunbind")
async def unbind_id(self, event: AstrMessageEvent, player_id: str = ""):
user_name = event.get_sender_name()
target = _normalize_player_id(player_id) if player_id and player_id.strip() else None
result, removed_id = await self._remove_binding(event, target)
if result == "empty":
yield event.plain_result(f"{user_name} 还没有绑定任何 ID。/owbind <玩家ID> 绑定。")
elif result == "not_found":
yield event.plain_result(f"你没有绑定 `{target}`。/owbinds 查看列表。")
elif result == "removed":
remaining = await self._load_bindings(event)
tip = f"剩余 {len(remaining['accounts'])} 个,默认 `{remaining['default']}`" if remaining["accounts"] else "需重新 /owbind 绑定"
yield event.plain_result(f"已解绑: `{removed_id}`\n{tip}")
else:
yield event.plain_result("解绑失败,请稍后重试。")
@filter.command("owbinds")
async def list_bindings(self, event: AstrMessageEvent):
user_name = event.get_sender_name()
data = await self._load_bindings(event)
accounts = data["accounts"]
if not accounts:
yield event.plain_result(f"{user_name} 还没有绑定 ID。/owbind <玩家ID> 绑定。")
return
default = data.get("default")
lines = [f"{user_name} 的绑定列表 ({len(accounts)}/{self.max_binds_per_user}):", "━━━━━━━━━━━━"]
for idx, acc in enumerate(accounts, 1):
mark = " 默认" if acc["player_id"] == default else ""
lines.append(f"{idx}. `{acc['player_id']}` | {_get_platform_display(acc.get('platform', self.default_platform))}{mark}")
lines += ["", "/owdefault <玩家ID> 切换默认,/owunbind [玩家ID] 解绑"]
yield event.plain_result("\n".join(lines))
@filter.command("owdefault")
async def set_default_binding(self, event: AstrMessageEvent, player_id: str = ""):
user_name = event.get_sender_name()
if not player_id or not player_id.strip():
yield event.plain_result("用法: /owdefault <玩家ID>")
return
target = _normalize_player_id(player_id)
data = await self._load_bindings(event)
if not data["accounts"]:
yield event.plain_result(f"{user_name} 还没有绑定 ID。")
return
matched = next((a for a in data["accounts"] if a["player_id"].lower() == target.lower()), None)
if not matched:
yield event.plain_result(f"你没有绑定 `{target}`。/owbinds 查看列表。")
return
data["default"] = matched["player_id"]
if await self._save_bindings(event, data):
yield event.plain_result(f"已设默认: `{matched['player_id']}`")
else:
yield event.plain_result("设置失败,请稍后重试。")
@filter.command("owme")
async def quick_summary(self, event: AstrMessageEvent):
bound_id, bound_platform = await self._get_binding(event)
user_name = event.get_sender_name()
if not bound_id:
yield event.plain_result(f"{user_name} 还没有绑定 ID。/owbind <玩家ID> 绑定。")
return
platform = self._effective_platform(bound_platform)
try:
async with self._api_client() as client:
full = await client.get_player_full(bound_id)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "未找到" in err or "404" in err:
yield event.plain_result(f"未找到绑定玩家 `{bound_id}`,可能改名/私密,请重绑。")
return
yield event.plain_result(self._api_error_reply(e, "获取玩家信息失败,请稍后重试。"))
return
except Exception:
logger.debug("owme 错误堆栈:", exc_info=True)
yield event.plain_result("获取玩家信息时出错,请稍后重试。")
return
async for res in self._send_summary_result(
event, full, bound_id, platform,
footer_note=f"快捷查询 | {bound_id} | {_get_platform_display(platform)}",
extra_lines=["", "/owstats [模式] 统计,/owcareer <模式> [英雄] 生涯,/owunbind 解绑"],
):
yield res