"""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