Compare commits
3
Commits
558bcd8abb
...
7d64b8a0a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d64b8a0a2 | ||
|
|
c012a639be | ||
|
|
25e4618573 |
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -0,0 +1,74 @@
|
||||
# 更新日志 (CHANGELOG)
|
||||
|
||||
## [v2.3.1](当前版本)
|
||||
|
||||
- 移除项目内测试文件与测试相关开发依赖/说明
|
||||
|
||||
## [v2.3.0]
|
||||
|
||||
- 新段位:对齐 OverFast API 4.13 `CompetitiveDivision`,新增翡翠 `emerald`(白金与钻石之间)、王者 `ultimate`(最高段位);`champion` 改显示为王者并保留兼容旧数据,`top500` 保留
|
||||
- 自建服务:新增 `api_base_url` 配置项(默认官方 `https://overfast-api.tekrop.fr`),插件所有请求经该地址构造,支持自建 OverFast 实例;非法地址自动回退官方
|
||||
|
||||
## [v2.2.0]
|
||||
|
||||
- 重构:`main.py` 拆分为 `constants.py`/`utils.py`/`bindings.py`,旧导入路径保留兼容
|
||||
- 性能:API 客户端默认超时 30s→15s,进程级 GET 缓存(玩家 120s/榜单 600s/英雄 3600s),429/503 按 `retry_after` 自动重试
|
||||
- 并发:图片/头像下载复用单 session + 6 并发上限,不再每图新建 session
|
||||
- 泄漏:`image_renderer` 新增 `cleanup_old_images()`(每小时,`_save_image` 自动触发),`owherostats` 图片模式避免 54 行超长文本
|
||||
- 健壮:玩家 ID 归一化+长度/字符校验+URL quote;图片开关默认与 schema 一致为开启;移除 `COMPETITIVE_ICONS` 重复;日志统一 AstrBot logger
|
||||
- 杂项:修正 README 仓库地址,删除重复 `logo.png.png`
|
||||
|
||||
## [v2.1.0]
|
||||
|
||||
- 英雄映射新增第 53 位英雄 D.Mon(重装,第 4 赛季)与第 54 位英雄血律 Doctrine(支援,第 5 赛季),现可通过 /owhero D.Mon、/owhero 血律 等查询
|
||||
- 英雄中文映射补全至 54 名英雄(与 OverFast API HeroKey 枚举对齐)
|
||||
|
||||
## [v2.0.0]
|
||||
|
||||
- 删除 /owsearch 与 /owheroes 指令(功能被 /owsummary 与 /owhero 覆盖)
|
||||
- /owherostats 展示全部英雄胜率(不再截断前 15);/owhero 同步查询并展示该英雄的全服胜率与选取率
|
||||
- 多账号绑定:每个 QQ 号可绑定多个 OW 账号(配置 max_binds_per_user,默认 3),KV 结构升级并自动迁移旧数据
|
||||
- 新增 /owbinds 查看绑定列表、/owdefault 设置默认查询账号;/owunbind 支持指定账号解绑
|
||||
- 新增查询结果图片渲染功能(owsummary/owstats/owcareer/owme),渲染为 OW 风格卡片
|
||||
- 绑定账号时支持选择平台(PC端/主机端):/owbind <玩家ID> [pc|主机]
|
||||
- 英雄中文映射补全至 52 名英雄(与 API HeroKey 枚举对齐),新增骇灾、弗蕾娅、无漾、斩仇、安燃、金驭、埃姆雷、瑞稀、飞天猫、西拉
|
||||
- API 报错结构化解析(含状态码、错误详情、retry_after),完整报错记录到控制台 debug 日志
|
||||
- 插件会自动将 # 替换为 - 再查询
|
||||
|
||||
## [v1.3.0]
|
||||
|
||||
- 重构插件
|
||||
- 重构命令
|
||||
- 缓存更改为KV缓存
|
||||
- 添加简单配置界面
|
||||
- 计划下一版本进行战绩图片渲染
|
||||
|
||||
## [v1.2.1]
|
||||
|
||||
- 缓存降级增强
|
||||
- 更改描述页
|
||||
- 增加英雄查询(独立查询)
|
||||
- 可以查询单个英雄战绩,可以查询休闲和竞技两个模式
|
||||
- 绑定提示优化
|
||||
- 添加主机平台战绩查询
|
||||
- resp 异常修复
|
||||
- 错误提示细化
|
||||
|
||||
## [v1.1.1]
|
||||
|
||||
- 找不到方法解决常玩英雄的两百报错问题,直接删去常玩英雄项目查询
|
||||
- 职责显示更改为中文
|
||||
- 竞技段位未定级也显示
|
||||
|
||||
## [v1.1.0]
|
||||
|
||||
- 修复API域名失效问题
|
||||
- 增强错误处理
|
||||
- 添加详细统计信息
|
||||
- 将API请求分为五段整合后再发出减少超时可能
|
||||
- 增加申请管控防止过量请求
|
||||
|
||||
## [v1.0.0]
|
||||
|
||||
- 修复API域名失效问题
|
||||
- 添加自动重试机制
|
||||
@@ -16,3 +16,4 @@ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE A
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
@@ -1,3 +1,69 @@
|
||||
# overwatch_bot
|
||||
# AstrBOT 守望先锋国际服查询插件
|
||||
作者其实是个小白来的,部分由ai创建
|
||||
# 注意,部分使用steam启动的玩家无法被查询
|
||||
|
||||
🎮 **守望先锋·归来国际服查询插件**
|
||||
|
||||
[](https://github.com/TZYCeng/astrbot_plugin_owcx)
|
||||
[](https://www.python.org/)
|
||||
[](https://github.com/AstrBotDevs/AstrBot)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
|
||||
## 📦 安装方法
|
||||
### 方法一:通过AstrBot插件市场
|
||||
1. 打开AstrBot WebUI
|
||||
2. 进入插件管理
|
||||
3. 搜索 "astrbot_plugin_owcx"
|
||||
4. 点击安装
|
||||
### 方法二:手动安装
|
||||
1. 下载插件文件到AstrBot的插件目录
|
||||
```bash
|
||||
cd AstrBot/data/plugins
|
||||
git clone https://github.com/TZYCeng/astrbot_plugin_owcx.git
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
3. 重启AstrBot
|
||||
|
||||
## 使用方法
|
||||
### owsummary
|
||||
查询玩家摘要信息(头像、竞技段位等)。 用法: /owsummary [玩家ID] 示例: /owsummary TeKrop#2217 或 /owsummary 同志有程#3156 说明: 玩家ID支持直接输入 #,插件会自动处理;省略 ID 则查询绑定的账号(使用绑定平台)
|
||||
### owstats
|
||||
查询玩家统计概览(胜率、KDA等)。 用法: /owstats [玩家ID] [游戏模式] 游戏模式: 快速、竞技(默认) 示例: /owstats TeKrop#2217 竞技 说明: 省略 ID 则查询绑定的账号
|
||||
### owcareer
|
||||
查询玩家生涯统计(按英雄详细数据)。 用法: /owcareer [玩家ID] <游戏模式> [英雄名] 游戏模式: 快速、竞技 英雄名: 可选,支持中文(如 源氏、安娜)或英文(如 genji, ana) 示例: /owcareer TeKrop#2217 竞技 /owcareer TeKrop#2217 竞技 源氏 /owcareer 竞技 源氏 (已绑定ID后)
|
||||
### owhero
|
||||
查询英雄详细信息(含该英雄在当前配置地区/平台/模式下的全服胜率与选取率)。 用法: /owhero <英雄名> 英雄名支持中文(如 源氏、安娜)或英文(如 genji, ana) 示例: /owhero 源氏, /owhero ana
|
||||
### owherostats
|
||||
查询全服英雄胜率/选取率排行榜(按地区服务器统计,展示全部英雄)。 用法: /owherostats [角色] [地区] 角色: 坦克、输出、支援(可选) 地区: 亚服(asia)、美服(americas)、欧服(europe),不填则使用配置中的默认地区 平台与游戏模式使用配置文件中的默认值 示例: /owherostats /owherostats 输出 /owherostats 支援 欧服
|
||||
### owbind
|
||||
绑定 Overwatch ID 到当前 QQ 号,可选择平台(PC端/主机端),每个 QQ 号可绑定多个账号(数量上限见配置 max_binds_per_user,默认 3 个),新绑定的账号自动成为默认查询账号。 用法: /owbind <玩家ID> [平台] 平台: pc(电脑端)、console(主机端),不填则使用配置中的默认平台 示例: /owbind TeKrop#2217 或 /owbind TeKrop#2217 主机 说明: 旧版本绑定数据自动迁移,无需手动处理
|
||||
### owunbind
|
||||
解绑当前 QQ 号绑定的 Overwatch ID。 用法: /owunbind [玩家ID] 说明: 不填玩家ID时解绑当前默认查询账号;解绑默认账号后自动切换到剩余账号
|
||||
### owbinds
|
||||
查看当前 QQ 号绑定的所有 Overwatch 账号(含平台与默认标记)。 用法: /owbinds
|
||||
### owdefault
|
||||
设置默认查询的 Overwatch 账号,快捷指令省略 ID 时将查询该账号。 用法: /owdefault <玩家ID> 示例: /owdefault TeKrop#2217
|
||||
### owme
|
||||
快捷查询默认绑定账号的摘要信息。 用法: /owme 说明: 需要先使用 /owbind 绑定,多账号时查询 /owdefault 设置的账号
|
||||
|
||||
|
||||
## ⚙️ 配置说明
|
||||
在 AstrBot WebUI 的插件配置中可设置以下选项:
|
||||
- **默认查询的游戏模式**: 竞技比赛 / 快速游戏
|
||||
- **默认查询的平台**: pc / console(未绑定平台或绑定时未选择平台时使用)
|
||||
- **默认查询的地区服务器** (`default_region`): asia(亚服) / americas(美服) / europe(欧服),用于 /owherostats 等按地区统计的查询
|
||||
- **每QQ号绑定上限** (`max_binds_per_user`): 每个 QQ 号最多可绑定的 Overwatch 账号数量,默认 3
|
||||
- **图片渲染开关** (`enable_image_render`): 开启后(默认开启),/owsummary、/owstats、/owcareer、/owme、/owherostats 的查询结果会渲染为卡片图片;渲染失败自动回退文字;临时图片每小时自动清理,Docker 内如无中文字体请关闭或挂载 `fonts/font.ttf`
|
||||
- **报错展示开关** (`show_api_error`): 开启后,查询出错时机器人会把 API 返回的具体报错(含建议重试等待时间)回复给查询者;无论开关与否,完整报错与堆栈都会记录在控制台 debug 日志中
|
||||
- **API 服务地址** (`api_base_url`): OverFast API 地址,默认官方 `https://overfast-api.tekrop.fr`;自建服务(如 Docker 部署开源 OverFast)填入自建地址即可,非法值自动回退官方
|
||||
- 图片渲染依赖 Pillow:`pip install Pillow>=9.0.0`(已在 requirements.txt 中)
|
||||
- 如系统缺少中文字体导致渲染文字异常,可在插件目录下新建 `fonts` 文件夹并放入任意中文字体文件(命名为 `font.ttf` 或 `font.ttc`)
|
||||
|
||||
## 📕 更新日志
|
||||
|
||||
版本更新记录已迁移至 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
an agent to fetch and analysis overwatch datastics
|
||||
@@ -0,0 +1,5 @@
|
||||
"""OW战绩查询插件 - AstrBot Plugin for Overwatch Stats Query."""
|
||||
|
||||
from .main import OverwatchStatsPlugin
|
||||
|
||||
__all__ = ["OverwatchStatsPlugin"]
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"default_gamemode": {
|
||||
"description": "默认查询的游戏模式",
|
||||
"type": "string",
|
||||
"options": ["竞技比赛", "快速游戏"],
|
||||
"default": "竞技比赛"
|
||||
},
|
||||
"default_platform": {
|
||||
"description": "默认查询的平台(未绑定或绑定时未选择平台时使用)",
|
||||
"type": "string",
|
||||
"options": ["pc", "console"],
|
||||
"default": "pc"
|
||||
},
|
||||
"default_region": {
|
||||
"description": "默认查询的地区(用于英雄胜率/选取率排行榜等查询)",
|
||||
"type": "string",
|
||||
"options": ["asia", "americas", "europe"],
|
||||
"default": "asia"
|
||||
},
|
||||
"max_binds_per_user": {
|
||||
"description": "每个 QQ 号最多可绑定的 Overwatch 账号数量",
|
||||
"type": "int",
|
||||
"default": 3
|
||||
},
|
||||
"enable_image_render": {
|
||||
"description": "是否启用查询结果图片渲染(渲染失败时自动回退为文字)",
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"show_api_error": {
|
||||
"description": "是否在查询出错时把 API 返回的具体报错回复给查询者",
|
||||
"type": "bool",
|
||||
"default": false
|
||||
},
|
||||
"api_base_url": {
|
||||
"description": "OverFast API 服务地址(官方或自建,如 https://overfast-api.tekrop.fr)",
|
||||
"type": "string",
|
||||
"default": "https://overfast-api.tekrop.fr"
|
||||
}
|
||||
}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
提供对 OverFast API (https://overfast-api.tekrop.fr) 的所有异步 HTTP 调用封装,
|
||||
用于查询 Overwatch 玩家战绩、英雄信息等数据。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
try: # 统一使用 AstrBot 日志;独立单测时回退标准 logging
|
||||
from astrbot.api import logger
|
||||
except Exception: # pragma: no cover
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OverFastAPIError(ValueError):
|
||||
"""OverFast API 请求错误。
|
||||
|
||||
携带 HTTP 状态码、服务端返回的错误详情以及重试等待时间,
|
||||
继承 ValueError 以兼容既有的错误捕获逻辑。
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP 状态码。
|
||||
detail: 服务端返回的原始错误文本。
|
||||
retry_after: 服务端建议的重试等待秒数(可能为 None)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
detail: str = "",
|
||||
retry_after: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
# 进程级 GET 缓存:key -> (expire_ts, data),降低限流风险
|
||||
_CACHE: dict[str, tuple[float, Any]] = {}
|
||||
|
||||
|
||||
def _cache_get(key: str) -> Any | None:
|
||||
item = _CACHE.get(key)
|
||||
if not item:
|
||||
return None
|
||||
expire, data = item
|
||||
if expire < time.monotonic():
|
||||
_CACHE.pop(key, None)
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _cache_set(key: str, data: Any, ttl: float) -> None:
|
||||
if ttl <= 0:
|
||||
return
|
||||
# 简单容量保护
|
||||
if len(_CACHE) > 512:
|
||||
_CACHE.clear()
|
||||
_CACHE[key] = (time.monotonic() + ttl, data)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
class OverFastAPIClient:
|
||||
"""OverFast API 异步客户端。
|
||||
|
||||
使用示例:
|
||||
async with OverFastAPIClient() as client:
|
||||
result = await client.search_players("TeKrop")
|
||||
summary = await client.get_player_summary("TeKrop-2217")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://overfast-api.tekrop.fr",
|
||||
timeout: float = 15.0,
|
||||
) -> None:
|
||||
"""初始化 OverFast API 客户端。
|
||||
|
||||
Args:
|
||||
base_url: API 基础 URL,官方实例或自建实例(如 Docker 自部署)。
|
||||
插件侧通过 `api_base_url` 配置项传入。
|
||||
timeout: 请求超时时间(秒),默认 15 秒(聊天场景不宜过长)。
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def session(self) -> aiohttp.ClientSession:
|
||||
"""获取当前 HTTP session。
|
||||
|
||||
Returns:
|
||||
aiohttp.ClientSession 实例。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 如果 session 未初始化(未使用 async with)。
|
||||
"""
|
||||
if self._session is None or self._session.closed:
|
||||
raise RuntimeError(
|
||||
"HTTP session 未初始化。请使用 `async with OverFastAPIClient() as client:`"
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def __aenter__(self) -> "OverFastAPIClient":
|
||||
"""异步上下文管理器入口,创建 HTTP session。"""
|
||||
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
"""异步上下文管理器出口,确保 session 被关闭。"""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _quote_segment(seg: str) -> str:
|
||||
return quote(seg, safe="-")
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
*,
|
||||
use_cache: bool = False,
|
||||
cache_ttl: float = 300.0,
|
||||
max_retries: int = 2,
|
||||
) -> dict | list:
|
||||
"""发送 GET 请求到 OverFast API(含缓存/限流重试)。
|
||||
|
||||
Args:
|
||||
endpoint: API 端点路径(不含 base_url),如 `/players`。
|
||||
params: 查询参数字典,值为 None 的键会被自动过滤。
|
||||
use_cache: 是否启用进程级 GET 缓存。
|
||||
cache_ttl: 缓存秒数。
|
||||
max_retries: 429/503 时的最大重试次数。
|
||||
|
||||
Raises:
|
||||
OverFastAPIError: 当返回 HTTP 4xx/5xx 错误时。
|
||||
"""
|
||||
filtered_params = {k: v for k, v in (params or {}).items() if v is not None}
|
||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
logger.debug(f"OverFast API 请求: GET {url} params={filtered_params}")
|
||||
|
||||
cache_key = ""
|
||||
if use_cache:
|
||||
sorted_items = sorted((k, str(v)) for k, v in filtered_params.items())
|
||||
cache_key = f"GET {url} {sorted_items}"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
logger.debug(f"OverFast API 缓存命中: {url}")
|
||||
return cached
|
||||
|
||||
last_err: OverFastAPIError | None = None
|
||||
for attempt in range(max_retries + 1):
|
||||
async with self.session.get(url, params=filtered_params) as response:
|
||||
if response.status >= 400:
|
||||
error_text = ""
|
||||
retry_after: int | None = None
|
||||
try:
|
||||
error_json = await response.json()
|
||||
if isinstance(error_json, dict):
|
||||
error_text = str(error_json.get("error", ""))
|
||||
ra = error_json.get("retry_after")
|
||||
if isinstance(ra, (int, float)):
|
||||
retry_after = int(ra)
|
||||
if not error_text and "detail" in error_json:
|
||||
error_text = str(error_json["detail"])
|
||||
if not error_text:
|
||||
error_text = str(error_json)
|
||||
else:
|
||||
error_text = str(error_json)
|
||||
except Exception:
|
||||
error_text = await response.text() or f"HTTP {response.status}"
|
||||
|
||||
logger.debug(
|
||||
f"OverFast API 错误: GET {url} params={filtered_params} "
|
||||
f"-> HTTP {response.status}, error={error_text}, retry_after={retry_after}"
|
||||
)
|
||||
|
||||
if response.status == 404:
|
||||
raise OverFastAPIError(
|
||||
f"未找到请求的资源: {error_text}",
|
||||
status_code=404, detail=error_text, retry_after=retry_after,
|
||||
)
|
||||
if response.status in (429, 503) and attempt < max_retries:
|
||||
wait = retry_after if retry_after else (2 ** attempt)
|
||||
wait = min(max(wait, 1), 10)
|
||||
logger.debug(f"OverFast API 限流,{wait}s 后重试 ({attempt + 1}/{max_retries})")
|
||||
await asyncio.sleep(wait)
|
||||
last_err = OverFastAPIError(
|
||||
f"请求过于频繁: {error_text}",
|
||||
status_code=response.status, detail=error_text,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
continue
|
||||
if response.status == 429:
|
||||
raise OverFastAPIError(
|
||||
f"请求过于频繁,请稍后重试: {error_text}",
|
||||
status_code=429, detail=error_text, retry_after=retry_after,
|
||||
)
|
||||
if response.status == 503:
|
||||
raise OverFastAPIError(
|
||||
f"服务暂时不可用(可能被限流): {error_text}",
|
||||
status_code=503, detail=error_text, retry_after=retry_after,
|
||||
)
|
||||
raise OverFastAPIError(
|
||||
f"API 错误 (HTTP {response.status}): {error_text}",
|
||||
status_code=response.status, detail=error_text, retry_after=retry_after,
|
||||
)
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as e:
|
||||
raw_text = await response.text()
|
||||
raise ValueError(
|
||||
f"无法解析 API 响应为 JSON: {e}. 原始响应: {raw_text[:500]}"
|
||||
) from e
|
||||
|
||||
logger.debug(f"OverFast API 响应: {type(data).__name__}")
|
||||
if use_cache:
|
||||
_cache_set(cache_key, data, cache_ttl)
|
||||
return data
|
||||
|
||||
assert last_err is not None
|
||||
raise last_err
|
||||
|
||||
async def search_players(
|
||||
self,
|
||||
name: str,
|
||||
order_by: str = "name:asc",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""搜索玩家。"""
|
||||
return await self._request( # type: ignore[return-value]
|
||||
"/players",
|
||||
params={"name": name, "order_by": order_by, "offset": offset, "limit": limit},
|
||||
use_cache=True,
|
||||
cache_ttl=120,
|
||||
)
|
||||
|
||||
async def get_player_summary(self, player_id: str) -> dict:
|
||||
"""获取玩家摘要信息。"""
|
||||
pid = self._quote_segment(player_id)
|
||||
return await self._request(f"/players/{pid}/summary", use_cache=True, cache_ttl=120) # type: ignore[return-value]
|
||||
|
||||
async def get_player_full(self, player_id: str) -> dict:
|
||||
"""获取玩家完整数据(摘要 + 统计数据)。"""
|
||||
pid = self._quote_segment(player_id)
|
||||
return await self._request(f"/players/{pid}", use_cache=True, cache_ttl=120) # type: ignore[return-value]
|
||||
|
||||
async def get_player_stats_summary(
|
||||
self,
|
||||
player_id: str,
|
||||
gamemode: str | None = None,
|
||||
platform: str | None = None,
|
||||
) -> dict:
|
||||
"""获取玩家统计摘要。"""
|
||||
pid = self._quote_segment(player_id)
|
||||
return await self._request( # type: ignore[return-value]
|
||||
f"/players/{pid}/stats/summary",
|
||||
params={"gamemode": gamemode, "platform": platform},
|
||||
use_cache=True,
|
||||
cache_ttl=120,
|
||||
)
|
||||
|
||||
async def get_player_career_stats(
|
||||
self,
|
||||
player_id: str,
|
||||
gamemode: str,
|
||||
platform: str | None = None,
|
||||
hero: str | None = None,
|
||||
) -> dict:
|
||||
"""获取玩家生涯统计(按英雄分类的详细数据)。"""
|
||||
pid = self._quote_segment(player_id)
|
||||
return await self._request( # type: ignore[return-value]
|
||||
f"/players/{pid}/stats/career",
|
||||
params={"gamemode": gamemode, "platform": platform, "hero": hero},
|
||||
use_cache=True,
|
||||
cache_ttl=120,
|
||||
)
|
||||
|
||||
async def get_heroes_stats(
|
||||
self,
|
||||
platform: str,
|
||||
gamemode: str,
|
||||
region: str,
|
||||
role: str | None = None,
|
||||
map_key: str | None = None,
|
||||
competitive_division: str | None = None,
|
||||
order_by: str = "hero:asc",
|
||||
) -> list:
|
||||
"""获取英雄统计数据(全服英雄选取率/胜率排行榜)。
|
||||
|
||||
competitive_division 可选 bronze/silver/gold/platinum/emerald/diamond/master/grandmaster
|
||||
(API 4.13 段位;ultimate 为玩家段位新值,榜单筛选暂不支持)。
|
||||
"""
|
||||
return await self._request( # type: ignore[return-value]
|
||||
"/heroes/stats",
|
||||
params={
|
||||
"platform": platform,
|
||||
"gamemode": gamemode,
|
||||
"region": region,
|
||||
"role": role,
|
||||
"map": map_key,
|
||||
"competitive_division": competitive_division,
|
||||
"order_by": order_by,
|
||||
},
|
||||
use_cache=True,
|
||||
cache_ttl=600,
|
||||
)
|
||||
|
||||
async def get_hero_info(self, hero_key: str) -> dict:
|
||||
"""获取英雄详细信息。"""
|
||||
hk = self._quote_segment(hero_key.strip().lower())
|
||||
return await self._request(f"/heroes/{hk}", use_cache=True, cache_ttl=3600) # type: ignore[return-value]
|
||||
|
||||
async def list_heroes(
|
||||
self,
|
||||
role: str | None = None,
|
||||
gamemode: str | None = None,
|
||||
) -> list:
|
||||
"""获取英雄列表。"""
|
||||
return await self._request( # type: ignore[return-value]
|
||||
"/heroes",
|
||||
params={"role": role, "gamemode": gamemode},
|
||||
use_cache=True,
|
||||
cache_ttl=3600,
|
||||
)
|
||||
|
||||
async def list_roles(self) -> list:
|
||||
"""获取所有角色类型。"""
|
||||
return await self._request("/roles", use_cache=True, cache_ttl=3600) # type: ignore[return-value]
|
||||
|
||||
async def list_gamemodes(self) -> list:
|
||||
"""获取所有游戏模式。"""
|
||||
return await self._request("/gamemodes", use_cache=True, cache_ttl=3600) # type: ignore[return-value]
|
||||
|
||||
async def list_maps(self, gamemode: str | None = None) -> list:
|
||||
"""获取所有地图。"""
|
||||
return await self._request( # type: ignore[return-value]
|
||||
"/maps",
|
||||
params={"gamemode": gamemode},
|
||||
use_cache=True,
|
||||
cache_ttl=3600,
|
||||
)
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
"""KV 多账号绑定存储,含旧版自动迁移。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
|
||||
_BIND_KEY_PREFIX = "bind_"
|
||||
|
||||
|
||||
def bind_key(qq_id: str) -> str:
|
||||
return f"{_BIND_KEY_PREFIX}{qq_id}"
|
||||
|
||||
|
||||
def normalize_loaded(raw, default_platform: str) -> dict | None:
|
||||
"""将 KV 原始值归一化为新版结构;无法识别返回 None;需迁移的旧版返回新结构。"""
|
||||
empty: dict = {"accounts": [], "default": None}
|
||||
if raw is None or raw == "":
|
||||
return empty
|
||||
if isinstance(raw, dict) and "accounts" in raw:
|
||||
accounts = []
|
||||
for item in raw.get("accounts") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
pid = item.get("player_id")
|
||||
platform = item.get("platform")
|
||||
if platform not in ("pc", "console"):
|
||||
platform = default_platform
|
||||
if pid:
|
||||
accounts.append({"player_id": pid, "platform": platform})
|
||||
default = raw.get("default")
|
||||
if not any(a["player_id"] == default for a in accounts):
|
||||
default = accounts[0]["player_id"] if accounts else None
|
||||
return {"accounts": accounts, "default": default}
|
||||
if isinstance(raw, dict) and raw.get("player_id"):
|
||||
platform = raw.get("platform")
|
||||
if platform not in ("pc", "console"):
|
||||
platform = default_platform
|
||||
return {
|
||||
"accounts": [{"player_id": raw["player_id"], "platform": platform}],
|
||||
"default": raw["player_id"],
|
||||
}
|
||||
if isinstance(raw, str) and raw:
|
||||
return {
|
||||
"accounts": [{"player_id": raw, "platform": default_platform}],
|
||||
"default": raw,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class BindingStore:
|
||||
"""依赖 Star 的 get/put/delete_kv_data,需在插件类中混入使用。"""
|
||||
|
||||
_default_platform_fallback = "pc"
|
||||
|
||||
async def _load_bindings(self, event: "AstrMessageEvent") -> dict:
|
||||
from astrbot.api import logger as _logger
|
||||
|
||||
empty: dict = {"accounts": [], "default": None}
|
||||
qq_id = event.get_sender_id()
|
||||
if not qq_id:
|
||||
return empty
|
||||
try:
|
||||
raw = await self.get_kv_data(bind_key(qq_id), None) # type: ignore[attr-defined]
|
||||
except Exception as e:
|
||||
_logger.debug(f"读取绑定信息失败: {e}")
|
||||
return empty
|
||||
|
||||
default_platform = getattr(self, "default_platform", self._default_platform_fallback)
|
||||
result = normalize_loaded(raw, default_platform)
|
||||
if result is None:
|
||||
return empty
|
||||
# 旧版结构需回写迁移
|
||||
if not (isinstance(raw, dict) and "accounts" in raw):
|
||||
try:
|
||||
await self.put_kv_data(bind_key(qq_id), result) # type: ignore[attr-defined]
|
||||
except Exception as e:
|
||||
_logger.debug(f"迁移绑定数据失败: {e}")
|
||||
return result
|
||||
|
||||
async def _save_bindings(self, event: "AstrMessageEvent", data: dict) -> bool:
|
||||
from astrbot.api import logger as _logger
|
||||
|
||||
qq_id = event.get_sender_id()
|
||||
if not qq_id:
|
||||
return False
|
||||
try:
|
||||
await self.put_kv_data(bind_key(qq_id), data) # type: ignore[attr-defined]
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.error(f"保存绑定信息失败: {e}")
|
||||
return False
|
||||
|
||||
async def _get_binding(self, event: "AstrMessageEvent") -> tuple[str | None, str | None]:
|
||||
data = await self._load_bindings(event)
|
||||
accounts = data.get("accounts", [])
|
||||
if not accounts:
|
||||
return None, None
|
||||
default = data.get("default")
|
||||
for acc in accounts:
|
||||
if acc.get("player_id") == default:
|
||||
return acc["player_id"], acc.get("platform")
|
||||
first = accounts[0]
|
||||
return first.get("player_id"), first.get("platform")
|
||||
|
||||
async def _add_binding(
|
||||
self, event: "AstrMessageEvent", player_id: str, platform: str = "pc"
|
||||
) -> tuple[str, dict | None]:
|
||||
if platform not in ("pc", "console"):
|
||||
platform = getattr(self, "default_platform", "pc")
|
||||
data = await self._load_bindings(event)
|
||||
accounts = data["accounts"]
|
||||
for acc in accounts:
|
||||
if acc["player_id"].lower() == player_id.lower():
|
||||
acc["player_id"] = player_id
|
||||
acc["platform"] = platform
|
||||
data["default"] = player_id
|
||||
if await self._save_bindings(event, data):
|
||||
return "updated", data
|
||||
return "error", None
|
||||
max_binds = max(int(getattr(self, "max_binds_per_user", 3) or 3), 1)
|
||||
if len(accounts) >= max_binds:
|
||||
return "limit", None
|
||||
accounts.append({"player_id": player_id, "platform": platform})
|
||||
data["default"] = player_id
|
||||
if await self._save_bindings(event, data):
|
||||
return "added", data
|
||||
return "error", None
|
||||
|
||||
async def _remove_binding(
|
||||
self, event: "AstrMessageEvent", player_id: str | None = None
|
||||
) -> tuple[str, str | None]:
|
||||
from astrbot.api import logger as _logger
|
||||
|
||||
data = await self._load_bindings(event)
|
||||
accounts = data["accounts"]
|
||||
if not accounts:
|
||||
return "empty", None
|
||||
target = player_id or data.get("default") or accounts[0]["player_id"]
|
||||
idx = next(
|
||||
(i for i, a in enumerate(accounts) if a["player_id"].lower() == target.lower()),
|
||||
None,
|
||||
)
|
||||
if idx is None:
|
||||
return "not_found", None
|
||||
removed = accounts.pop(idx)
|
||||
if not accounts:
|
||||
try:
|
||||
await self.delete_kv_data(bind_key(event.get_sender_id())) # type: ignore[attr-defined]
|
||||
except Exception as e:
|
||||
_logger.error(f"删除绑定信息失败: {e}")
|
||||
return "error", None
|
||||
return "removed", removed["player_id"]
|
||||
if data.get("default") == removed["player_id"]:
|
||||
data["default"] = accounts[0]["player_id"]
|
||||
if await self._save_bindings(event, data):
|
||||
return "removed", removed["player_id"]
|
||||
return "error", None
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""共享常量:段位/角色/模式/平台/地区/英雄映射。"""
|
||||
|
||||
RANK_MAPPING = {
|
||||
"bronze": ("青铜", ""),
|
||||
"silver": ("白银", ""),
|
||||
"gold": ("黄金", ""),
|
||||
"platinum": ("铂金", ""),
|
||||
"emerald": ("翡翠", ""),
|
||||
"diamond": ("钻石", ""),
|
||||
"master": ("大师", ""),
|
||||
"grandmaster": ("宗师", ""),
|
||||
# API 4.13 起最高段位改名为 ultimate;保留 champion 兼容旧数据/旧自建实例
|
||||
"champion": ("王者", ""),
|
||||
"ultimate": ("王者", ""),
|
||||
"top500": ("五百强", ""),
|
||||
}
|
||||
|
||||
ROLE_MAPPING = {
|
||||
"tank": ("坦克", ""),
|
||||
"damage": ("输出", ""),
|
||||
"support": ("支援", ""),
|
||||
}
|
||||
|
||||
GAMEMODE_MAPPING = {
|
||||
"quickplay": "快速游戏",
|
||||
"competitive": "竞技比赛",
|
||||
}
|
||||
|
||||
GAMEMODE_REVERSE_MAPPING = {
|
||||
"快速": "quickplay",
|
||||
"快速游戏": "quickplay",
|
||||
"qp": "quickplay",
|
||||
"quick": "quickplay",
|
||||
"quickplay": "quickplay",
|
||||
"竞技": "competitive",
|
||||
"竞技模式": "competitive",
|
||||
"竞技比赛": "competitive",
|
||||
"排位": "competitive",
|
||||
"排位赛": "competitive",
|
||||
"comp": "competitive",
|
||||
"competitive": "competitive",
|
||||
}
|
||||
|
||||
PLATFORM_MAPPING = {
|
||||
"pc": "PC端",
|
||||
"console": "主机端",
|
||||
}
|
||||
|
||||
PLATFORM_REVERSE_MAPPING = {
|
||||
"pc": "pc",
|
||||
"电脑": "pc",
|
||||
"电脑端": "pc",
|
||||
"端游": "pc",
|
||||
"console": "console",
|
||||
"主机": "console",
|
||||
"主机端": "console",
|
||||
"ps": "console",
|
||||
"ps4": "console",
|
||||
"ps5": "console",
|
||||
"xbox": "console",
|
||||
"switch": "console",
|
||||
"ns": "console",
|
||||
}
|
||||
|
||||
REGION_MAPPING = {
|
||||
"asia": "亚服",
|
||||
"americas": "美服",
|
||||
"europe": "欧服",
|
||||
}
|
||||
|
||||
REGION_REVERSE_MAPPING = {
|
||||
"asia": "asia",
|
||||
"亚服": "asia",
|
||||
"亚洲": "asia",
|
||||
"亚太": "asia",
|
||||
"americas": "americas",
|
||||
"america": "americas",
|
||||
"美服": "americas",
|
||||
"美洲": "americas",
|
||||
"europe": "europe",
|
||||
"eu": "europe",
|
||||
"欧服": "europe",
|
||||
"欧洲": "europe",
|
||||
}
|
||||
|
||||
HERO_NAME_MAPPING = {
|
||||
"doomfist": "末日铁拳",
|
||||
"dva": "D.Va",
|
||||
"dmon": "D.Mon",
|
||||
"domina": "金驭",
|
||||
"hazard": "骇灾",
|
||||
"junker-queen": "渣客女王",
|
||||
"mauga": "毛加",
|
||||
"orisa": "奥丽莎",
|
||||
"ramattra": "拉玛刹",
|
||||
"reinhardt": "莱因哈特",
|
||||
"roadhog": "路霸",
|
||||
"sigma": "西格玛",
|
||||
"winston": "温斯顿",
|
||||
"wrecking-ball": "破坏球",
|
||||
"zarya": "查莉娅",
|
||||
"anran": "安燃",
|
||||
"ashe": "艾什",
|
||||
"bastion": "堡垒",
|
||||
"cassidy": "卡西迪",
|
||||
"echo": "回声",
|
||||
"emre": "埃姆雷",
|
||||
"freja": "弗蕾娅",
|
||||
"genji": "源氏",
|
||||
"hanzo": "半藏",
|
||||
"junkrat": "狂鼠",
|
||||
"mei": "美",
|
||||
"pharah": "法老之鹰",
|
||||
"reaper": "死神",
|
||||
"shion": "紫苑",
|
||||
"sierra": "西拉",
|
||||
"sojourn": "索杰恩",
|
||||
"soldier-76": "士兵:76",
|
||||
"sombra": "黑影",
|
||||
"symmetra": "秩序之光",
|
||||
"torbjorn": "托比昂",
|
||||
"tracer": "猎空",
|
||||
"vendetta": "斩仇",
|
||||
"venture": "探奇",
|
||||
"widowmaker": "黑百合",
|
||||
"ana": "安娜",
|
||||
"baptiste": "巴蒂斯特",
|
||||
"brigitte": "布丽吉塔",
|
||||
"doctrine": "血律",
|
||||
"illari": "伊拉锐",
|
||||
"jetpack-cat": "飞天猫",
|
||||
"juno": "朱诺",
|
||||
"kiriko": "雾子",
|
||||
"lifeweaver": "生命之梭",
|
||||
"lucio": "卢西奥",
|
||||
"mercy": "天使",
|
||||
"mizuki": "瑞稀",
|
||||
"moira": "莫伊拉",
|
||||
"wuyang": "无漾",
|
||||
"zenyatta": "禅雅塔",
|
||||
}
|
||||
|
||||
HERO_NAME_REVERSE_MAPPING = {cn: en for en, cn in HERO_NAME_MAPPING.items()}
|
||||
|
||||
ROLE_CN_TO_EN = {
|
||||
"坦克": "tank", "tank": "tank", "重装": "tank",
|
||||
"输出": "damage", "damage": "damage",
|
||||
"支援": "support", "support": "support", "辅助": "support",
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# 对话归档:OWCX 插件评估、重构与 v2.3.0 更新
|
||||
|
||||
- 归档日期:2026-09-29
|
||||
- 仓库:`https://github.com/TZYCeng/astrbot_plugin_owcx.git`
|
||||
- 分支:`main`
|
||||
- 版本跨度:v2.1.0 → v2.2.0 → v2.3.0
|
||||
|
||||
## 初评发现
|
||||
|
||||
- `main.py` 约 1904 行,常量、工具与绑定逻辑混杂。
|
||||
- 图片与头像下载为每次请求单独创建 aiohttp session;临时图片没有清理。
|
||||
- API 请求缺少缓存与 429/503 重试,默认超时 30 秒。
|
||||
- 玩家 ID 缺少校验及 URL 编码;重复定义竞技图标;日志实现不统一。
|
||||
- 图片开关默认值与配置 schema 不一致;README 仓库地址有误,存在重复 logo 文件。
|
||||
|
||||
## v2.2.0:重构与健壮性(`9f3deca`)
|
||||
|
||||
- 将映射拆至 `constants.py`,纯逻辑拆至 `utils.py`,绑定存储与迁移拆至 `bindings.py`;`main.py` 缩减至约 700 行,并保留旧导入路径兼容。
|
||||
- API 超时改为 15 秒;增加进程级 GET 缓存(玩家 120 秒、榜单 600 秒、英雄 3600 秒)及 429/503 的 `retry_after` 重试(最多 2 次);对 URL 参数进行 quote。
|
||||
- 图片/头像下载复用 session,并限制同时下载数为 6。
|
||||
- 新增旧临时图片清理逻辑及英雄统计卡片渲染;补充玩家 ID 校验;统一使用 AstrBot logger。
|
||||
- 图片渲染默认值与 schema 统一为开启;修正 README 仓库地址并删除重复 logo 文件。
|
||||
|
||||
## v2.3.0:新段位与 API 地址配置化(`c1c813e`)
|
||||
|
||||
依据用户提供的 OverFast API 4.13.0 `openapi.json`:
|
||||
|
||||
- 段位枚举新增 `emerald`(翡翠)及 `ultimate`(王者)。
|
||||
- `champion` 保留兼容旧数据并显示为王者;`top500` 保留。
|
||||
- 54 个 `HeroKey` 与现有英雄映射一致,无需改动。
|
||||
- `_conf_schema.json` 增加 `api_base_url`,默认 `https://overfast-api.tekrop.fr`,可填自建 OverFast 服务地址。
|
||||
- `normalize_api_base_url()` 去除首尾空白和尾斜杠;空值或非 HTTP(S) 地址回退官方地址。
|
||||
- 全部 API 客户端由插件的 `_api_client()` 按配置地址创建,启动日志显示当前 API 地址。
|
||||
- `metadata.yaml` 版本更新至 v2.3.0。
|
||||
|
||||
## 历史提交
|
||||
|
||||
| 提交 | 日期 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| `9f3deca` | 2026-09-29 | v2.2.0 结构重构、缓存重试、下载复用与健壮性修正 |
|
||||
| `c1c813e` | 2026-09-29 | v2.3.0 新段位与 API 地址配置化 |
|
||||
| `fa5d65e` | 2026-09-29 | 开发辅助文件与渲染检查(本次已移除相关文件) |
|
||||
|
||||
## 后续关注
|
||||
|
||||
- 在真实 AstrBot 环境验证 `/owsummary`、`/owstats`、`/owcareer`、`/owherostats` 及绑定指令。
|
||||
- 使用真实翡翠/王者账号确认文字与卡片显示。
|
||||
- 配置自建 OverFast 服务地址后确认请求走向;上游尚未支持 `ultimate` 榜单筛选时不传该筛选值。
|
||||
- 如上游公布不同正式中文译名,可再调整 `champion` 对应显示。
|
||||
- 核对新增英雄中文名是否与官方最新译名完全一致。
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `main.py` | `OverwatchStatsPlugin` 主类与指令入口 |
|
||||
| `api_client.py` | `OverFastAPIClient` / `OverFastAPIError`、缓存、重试与基础 URL |
|
||||
| `constants.py` | 段位、角色、模式、平台、地区与英雄映射 |
|
||||
| `utils.py` | 纯逻辑及 API 地址归一化 |
|
||||
| `bindings.py` | KV 绑定存储与旧数据迁移 |
|
||||
| `image_renderer.py` | 卡片渲染与临时图片清理 |
|
||||
| `_conf_schema.json` | 插件配置 schema |
|
||||
| `CHANGELOG.md` | 版本变更记录 |
|
||||
@@ -0,0 +1,706 @@
|
||||
"""
|
||||
查询结果图片渲染模块。
|
||||
|
||||
所有渲染函数返回生成的 PNG 文件路径,调用方负责发送
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# 常见中文字体不含彩色 emoji 字形,直接绘制会变成方框,
|
||||
# 因此渲染前统一移除 emoji,改用配色区分内容。
|
||||
_EMOJI_PATTERN = re.compile(
|
||||
"["
|
||||
"\U0001F000-\U0001FAFF" # 各类 emoji 区块
|
||||
"\U00002600-\U000027BF" # 杂项符号与装饰符号
|
||||
"\U00002300-\U000023FF" # 杂项技术符号(⏱ 等)
|
||||
"\U00002B00-\U00002BFF" # 箭头与星形(⭐ 等)
|
||||
"\U0001F1E6-\U0001F1FF" # 区域指示符
|
||||
"]\ufe0f?",
|
||||
flags=re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def _clean_text(text: object) -> str:
|
||||
"""移除文本中的 emoji 并规整空白,避免字体缺字形显示为方框。"""
|
||||
if text is None:
|
||||
return ""
|
||||
cleaned = _EMOJI_PATTERN.sub("", str(text))
|
||||
return re.sub(r"\s{2,}", " ", cleaned).strip()
|
||||
|
||||
# ===== 配色方案(OW 风格深色主题)=====
|
||||
BG_COLOR = (24, 26, 33) # 页面背景
|
||||
CARD_COLOR = (34, 37, 46) # 卡片背景
|
||||
CARD_COLOR_ALT = (40, 44, 55) # 次级卡片背景
|
||||
ACCENT_COLOR = (255, 153, 51) # OW 橙
|
||||
TEXT_MAIN = (240, 240, 245) # 主文本
|
||||
TEXT_SUB = (205, 210, 224) # 次级文本(提高对比度)
|
||||
TEXT_DIM = (150, 156, 172) # 弱文本
|
||||
DIVIDER_COLOR = (60, 64, 78) # 分隔线
|
||||
ROLE_COLORS = {
|
||||
"坦克": (96, 165, 250), # 蓝
|
||||
"输出": (248, 113, 113), # 红
|
||||
"支援": (74, 222, 128), # 绿
|
||||
}
|
||||
# 赞赏等级配色(仿游戏内勋章色调)
|
||||
ENDORSEMENT_COLORS = {
|
||||
1: (150, 152, 158),
|
||||
2: (92, 184, 92),
|
||||
3: (70, 160, 220),
|
||||
4: (170, 105, 220),
|
||||
5: (240, 165, 45),
|
||||
}
|
||||
|
||||
# ===== 字体查找 =====
|
||||
# 按优先级列出常见的中文字体路径,覆盖 Linux / Windows / macOS
|
||||
_FONT_CANDIDATES = [
|
||||
# 插件自带字体目录(用户可自行放置字体文件)
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "font.ttf"),
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "font.ttc"),
|
||||
# Linux 常见
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||
# Windows 常见
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/msyh.ttf",
|
||||
"C:/Windows/Fonts/simhei.ttf",
|
||||
"C:/Windows/Fonts/simsun.ttc",
|
||||
# macOS 常见
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
]
|
||||
|
||||
_font_cache: dict[int, ImageFont.FreeTypeFont] = {}
|
||||
|
||||
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont:
|
||||
"""加载支持中文的字体。
|
||||
|
||||
Args:
|
||||
size: 字号。
|
||||
|
||||
Returns:
|
||||
字体对象。找不到中文字体时返回 Pillow 默认字体。
|
||||
"""
|
||||
if size in _font_cache:
|
||||
return _font_cache[size]
|
||||
font = None
|
||||
for path in _FONT_CANDIDATES:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
font = ImageFont.truetype(path, size)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
_font_cache[size] = font
|
||||
return font
|
||||
|
||||
|
||||
def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont) -> int:
|
||||
"""测量文本宽度。"""
|
||||
if not text:
|
||||
return 0
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
|
||||
|
||||
def _text_height(font: ImageFont.FreeTypeFont) -> int:
|
||||
"""获取字体行高。"""
|
||||
bbox = font.getbbox("国Ag")
|
||||
return bbox[3] - bbox[1]
|
||||
|
||||
|
||||
def _fit_text(draw: ImageDraw.ImageDraw, text: object, font: ImageFont.FreeTypeFont, max_width: int) -> str:
|
||||
"""清理文本并截断到指定宽度内(超出部分以省略号结尾)。"""
|
||||
text = _clean_text(text)
|
||||
if _text_width(draw, text, font) <= max_width:
|
||||
return text
|
||||
ellipsis = "..."
|
||||
while text and _text_width(draw, text + ellipsis, font) > max_width:
|
||||
text = text[:-1]
|
||||
return (text + ellipsis) if text else ellipsis
|
||||
|
||||
|
||||
def _paste_avatar(card: Image.Image, avatar_bytes: bytes, x: int, y: int, size: int) -> None:
|
||||
"""将头像以圆形裁剪后粘贴到卡片上。
|
||||
|
||||
Args:
|
||||
card: 目标卡片图像。
|
||||
avatar_bytes: 头像图片二进制数据。
|
||||
x, y: 粘贴位置左上角坐标。
|
||||
size: 头像边长(正方形)。
|
||||
"""
|
||||
try:
|
||||
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA")
|
||||
avatar = avatar.resize((size, size), Image.LANCZOS)
|
||||
# 圆形遮罩
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.ellipse((0, 0, size, size), fill=255)
|
||||
card.paste(avatar, (x, y), mask)
|
||||
# 描边
|
||||
border = ImageDraw.Draw(card)
|
||||
border.ellipse((x, y, x + size, y + size), outline=ACCENT_COLOR, width=3)
|
||||
except Exception:
|
||||
# 头像处理失败时静默跳过,不影响整体渲染
|
||||
pass
|
||||
|
||||
|
||||
def _rounded_rect(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
xy: tuple[int, int, int, int],
|
||||
radius: int,
|
||||
fill: tuple[int, int, int],
|
||||
) -> None:
|
||||
"""绘制圆角矩形。"""
|
||||
draw.rounded_rectangle(xy, radius=radius, fill=fill)
|
||||
|
||||
|
||||
def _save_image(img: Image.Image) -> str:
|
||||
"""将图像保存到临时文件并返回路径(顺带清理过期图片防堆积)。"""
|
||||
try:
|
||||
cleanup_old_images(max_age_seconds=3600)
|
||||
except Exception:
|
||||
pass
|
||||
fd, path = tempfile.mkstemp(suffix=".png", prefix="ow_render_")
|
||||
os.close(fd)
|
||||
img.save(path, format="PNG")
|
||||
return path
|
||||
|
||||
|
||||
def cleanup_old_images(max_age_seconds: int = 3600) -> int:
|
||||
"""删除临时目录中超时的本插件渲染图,返回删除数量。"""
|
||||
import time as _time
|
||||
|
||||
tmpdir = tempfile.gettempdir()
|
||||
now = _time.time()
|
||||
removed = 0
|
||||
try:
|
||||
for name in os.listdir(tmpdir):
|
||||
if not (name.startswith("ow_render_") and name.endswith(".png")):
|
||||
continue
|
||||
full = os.path.join(tmpdir, name)
|
||||
try:
|
||||
if now - os.path.getmtime(full) > max_age_seconds:
|
||||
os.remove(full)
|
||||
removed += 1
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
return removed
|
||||
return removed
|
||||
|
||||
|
||||
# ===== 卡片渲染 =====
|
||||
|
||||
def _draw_endorsement_badge(
|
||||
img: Image.Image, x: int, y: int, level: int, size: int = 34
|
||||
) -> int:
|
||||
"""绘制游戏内风格的赞赏等级六边形勋章,返回勋章宽度。
|
||||
|
||||
Args:
|
||||
img: 目标图像。
|
||||
x, y: 勋章左上角坐标。
|
||||
level: 赞赏等级(0-5)。
|
||||
size: 勋章外接尺寸。
|
||||
|
||||
Returns:
|
||||
勋章占用宽度。
|
||||
"""
|
||||
import math
|
||||
|
||||
level = max(0, min(int(level or 0), 5))
|
||||
color = ENDORSEMENT_COLORS.get(level, ENDORSEMENT_COLORS[5])
|
||||
cx, cy = x + size // 2, y + size // 2
|
||||
r = size // 2
|
||||
|
||||
badge = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(badge)
|
||||
points = [
|
||||
(cx + r * math.cos(math.radians(60 * i - 30)),
|
||||
cy + r * math.sin(math.radians(60 * i - 30)))
|
||||
for i in range(6)
|
||||
]
|
||||
d.polygon(points, fill=color)
|
||||
# 内圈
|
||||
r2 = r - 4
|
||||
points2 = [
|
||||
(cx + r2 * math.cos(math.radians(60 * i - 30)),
|
||||
cy + r2 * math.sin(math.radians(60 * i - 30)))
|
||||
for i in range(6)
|
||||
]
|
||||
d.polygon(points2, fill=(28, 30, 38))
|
||||
img.paste(badge, (x, y), badge)
|
||||
|
||||
# 等级数字
|
||||
font = _load_font(int(size * 0.62))
|
||||
text = str(level)
|
||||
tw = _text_width(ImageDraw.Draw(img), text, font)
|
||||
bbox = font.getbbox(text)
|
||||
th = bbox[3] - bbox[1]
|
||||
ImageDraw.Draw(img).text(
|
||||
(cx - tw // 2, cy - th // 2 - bbox[1]), text, font=font, fill=TEXT_MAIN
|
||||
)
|
||||
return size
|
||||
|
||||
|
||||
def _draw_rank_section(
|
||||
img: Image.Image,
|
||||
y: int,
|
||||
rank_rows: Sequence[dict],
|
||||
width: int,
|
||||
pad: int,
|
||||
) -> int:
|
||||
"""绘制竞技段位区(坦克/输出/支援三职责,含段位图标),返回结束 y 坐标。
|
||||
|
||||
Args:
|
||||
img: 目标图像。
|
||||
y: 起始 y 坐标。
|
||||
rank_rows: [{"role_text", "rank_text", "rank_icon_bytes"}, ...]。
|
||||
width: 卡片宽度。
|
||||
pad: 卡片内边距。
|
||||
|
||||
Returns:
|
||||
绘制结束后的 y 坐标。
|
||||
"""
|
||||
font_body = _load_font(26)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((pad + 6, y), "竞技段位", font=font_body, fill=ACCENT_COLOR)
|
||||
y += _text_height(font_body) + 12
|
||||
for row in rank_rows:
|
||||
role_text = _fit_text(draw, row.get("role_text", ""), font_body, 200)
|
||||
rank_text = _fit_text(draw, row.get("rank_text", ""), font_body, width - pad - 320)
|
||||
color = TEXT_MAIN
|
||||
for role_cn, c in ROLE_COLORS.items():
|
||||
if role_cn in role_text:
|
||||
color = c
|
||||
break
|
||||
draw.text((pad + 20, y + 4), role_text, font=font_body, fill=color)
|
||||
icon_x = pad + 230
|
||||
icon_bytes = row.get("rank_icon_bytes")
|
||||
if icon_bytes:
|
||||
_paste_square_icon(img, icon_bytes, icon_x, y, 36, radius=18)
|
||||
draw.text((icon_x + 46, y + 4), rank_text, font=font_body, fill=TEXT_MAIN)
|
||||
y += 44
|
||||
return y
|
||||
|
||||
|
||||
def _draw_heroes_section(
|
||||
img: Image.Image,
|
||||
y: int,
|
||||
top_heroes: Sequence[tuple[str, str, Optional[bytes]]],
|
||||
width: int,
|
||||
pad: int,
|
||||
) -> int:
|
||||
"""绘制常玩英雄区(最多 3 个,含英雄头像、名称与场次),返回结束 y 坐标。
|
||||
|
||||
Args:
|
||||
img: 目标图像。
|
||||
y: 起始 y 坐标。
|
||||
top_heroes: [(英雄名, 场次/时长文本, 英雄头像数据), ...]。
|
||||
width: 卡片宽度。
|
||||
pad: 卡片内边距。
|
||||
|
||||
Returns:
|
||||
绘制结束后的 y 坐标。
|
||||
"""
|
||||
font_body = _load_font(26)
|
||||
font_hero = _load_font(22)
|
||||
font_small = _load_font(20)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
draw.line((pad, y, width - pad, y), fill=DIVIDER_COLOR, width=1)
|
||||
y += 12
|
||||
draw.text((pad + 6, y), "常玩英雄", font=font_body, fill=ACCENT_COLOR)
|
||||
y += _text_height(font_body) + 12
|
||||
slot_w = (width - pad * 2) // 3
|
||||
portrait_size = 76
|
||||
for idx, (hero_name, info_text, portrait_bytes) in enumerate(top_heroes[:3]):
|
||||
cx = pad + idx * slot_w
|
||||
if portrait_bytes:
|
||||
_paste_square_icon(img, portrait_bytes, cx + 4, y, portrait_size, radius=14)
|
||||
tx = cx + 4 + portrait_size + 12
|
||||
name_text = _fit_text(draw, hero_name, font_hero, slot_w - portrait_size - 20)
|
||||
info_text_c = _fit_text(draw, info_text, font_small, slot_w - portrait_size - 20)
|
||||
draw.text((tx, y + 12), name_text, font=font_hero, fill=TEXT_MAIN)
|
||||
draw.text((tx, y + 12 + _text_height(font_hero) + 8), info_text_c, font=font_small, fill=TEXT_SUB)
|
||||
return y + portrait_size + 16
|
||||
|
||||
|
||||
def _banner_mask(width: int, height: int, radius: int) -> Image.Image:
|
||||
"""生成仅上两角圆润的矩形遮罩(用于名片横幅)。"""
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
d = ImageDraw.Draw(mask)
|
||||
# 先画一个超出底部的圆角矩形,再补齐下半部分,使底部保持直角
|
||||
d.rounded_rectangle((0, 0, width, height + radius), radius=radius, fill=255)
|
||||
d.rectangle((0, height - radius, width, height), fill=255)
|
||||
return mask
|
||||
|
||||
|
||||
def _paste_banner(card: Image.Image, banner_bytes: bytes, xy: tuple[int, int, int, int]) -> None:
|
||||
"""将名片横幅按游戏内效果缩放裁剪后粘贴到卡片指定区域。
|
||||
|
||||
名片为宽幅图片,按区域宽度等比缩放并居中裁剪,上两角保持与卡片一致的圆角。
|
||||
|
||||
Args:
|
||||
card: 目标卡片图像。
|
||||
banner_bytes: 名片图片二进制数据。
|
||||
xy: 目标区域 (x1, y1, x2, y2)。
|
||||
"""
|
||||
try:
|
||||
x1, y1, x2, y2 = xy
|
||||
w, h = x2 - x1, y2 - y1
|
||||
banner = Image.open(io.BytesIO(banner_bytes)).convert("RGBA")
|
||||
# 等比缩放至覆盖目标区域后居中裁剪
|
||||
scale = max(w / banner.width, h / banner.height)
|
||||
new_size = (int(banner.width * scale + 0.5), int(banner.height * scale + 0.5))
|
||||
banner = banner.resize(new_size, Image.LANCZOS)
|
||||
left = (banner.width - w) // 2
|
||||
top = (banner.height - h) // 2
|
||||
banner = banner.crop((left, top, left + w, top + h))
|
||||
# 底部压暗,保证横幅上的文字可读(alpha 混合而非替换)
|
||||
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||
od = ImageDraw.Draw(overlay)
|
||||
for i in range(h):
|
||||
alpha = int(110 * (i / h) ** 1.5)
|
||||
od.line((0, i, w, i), fill=(10, 12, 18, alpha))
|
||||
banner = Image.alpha_composite(banner, overlay)
|
||||
mask = _banner_mask(w, h, 18)
|
||||
card.paste(banner.convert("RGB"), (x1, y1), mask)
|
||||
except Exception:
|
||||
# 名片处理失败时静默跳过,不影响整体渲染
|
||||
pass
|
||||
|
||||
|
||||
def _paste_square_icon(card: Image.Image, icon_bytes: bytes, x: int, y: int, size: int, radius: int = 8) -> None:
|
||||
"""将方形图标(英雄头像/段位图标)圆角粘贴到卡片上。"""
|
||||
try:
|
||||
icon = Image.open(io.BytesIO(icon_bytes)).convert("RGBA")
|
||||
icon = icon.resize((size, size), Image.LANCZOS)
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255)
|
||||
card.paste(icon, (x, y), mask)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def render_summary_card(
|
||||
username: str,
|
||||
title: str,
|
||||
endorsement_level: int,
|
||||
rank_rows: Sequence[dict],
|
||||
platform_label: str,
|
||||
avatar_bytes: Optional[bytes] = None,
|
||||
namecard_bytes: Optional[bytes] = None,
|
||||
top_heroes: Sequence[tuple[str, str, Optional[bytes]]] = (),
|
||||
footer_note: Optional[str] = None,
|
||||
) -> str:
|
||||
"""渲染玩家摘要卡片(游戏内名片 + 头像组合效果)。
|
||||
|
||||
名片作为顶部横幅,头像以圆形叠加在横幅左侧(还原游戏内个人资料页效果);
|
||||
下方依次为竞技段位(含段位图标)与常玩英雄 TOP3(含英雄头像)。
|
||||
|
||||
Args:
|
||||
username: 玩家名。
|
||||
title: 头衔(可为空)。
|
||||
endorsement_level: 赞赏等级。
|
||||
rank_rows: [{"role_text", "rank_text", "rank_icon_bytes"}, ...]。
|
||||
platform_label: 平台显示文本,如 "PC端"。
|
||||
avatar_bytes: 头像图片数据(可选)。
|
||||
namecard_bytes: 名片横幅图片数据(可选,纯文字模式不展示)。
|
||||
top_heroes: [(英雄名, 游戏时长文本, 英雄头像数据), ...]。
|
||||
footer_note: 底部备注(可选)。
|
||||
|
||||
Returns:
|
||||
生成的 PNG 文件路径。
|
||||
"""
|
||||
W = 680
|
||||
PAD = 28
|
||||
font_title = _load_font(36)
|
||||
font_sub = _load_font(23)
|
||||
font_body = _load_font(26)
|
||||
font_hero = _load_font(22)
|
||||
font_small = _load_font(20)
|
||||
|
||||
username = _fit_text(ImageDraw.Draw(Image.new("RGB", (1, 1))), username, font_title, W - PAD * 2 - 150)
|
||||
title = _clean_text(title)
|
||||
platform_label = _clean_text(platform_label)
|
||||
footer_note = _clean_text(footer_note)
|
||||
|
||||
banner_h = 150 if namecard_bytes else 0
|
||||
avatar_size = 108 if avatar_bytes else 0
|
||||
# 无名片时保留原有的文本头部高度
|
||||
header_h = banner_h if namecard_bytes else (max(avatar_size, 96) + 10)
|
||||
rank_block_h = len(rank_rows) * 44 + 58
|
||||
heroes_block_h = (150 if top_heroes else 0)
|
||||
footer_h = 34 if footer_note else 10
|
||||
H = PAD + header_h + rank_block_h + heroes_block_h + footer_h + 24
|
||||
|
||||
img = Image.new("RGB", (W, H), BG_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
|
||||
|
||||
# ===== 头部:名片横幅 + 头像(游戏内效果)=====
|
||||
if namecard_bytes:
|
||||
_paste_banner(img, namecard_bytes, (10, 10, W - 10, 10 + banner_h))
|
||||
else:
|
||||
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
|
||||
|
||||
text_x = PAD + 10
|
||||
text_y_center = 10 + (banner_h // 2 if namecard_bytes else header_h // 2)
|
||||
if avatar_bytes:
|
||||
av_x = PAD + 8
|
||||
av_y = text_y_center - avatar_size // 2
|
||||
_paste_avatar(img, avatar_bytes, av_x, av_y, avatar_size)
|
||||
text_x = av_x + avatar_size + 18
|
||||
|
||||
# 用户名 / 头衔(垂直居中于头部区域)
|
||||
lines_h = _text_height(font_title) + 10
|
||||
if title:
|
||||
lines_h += _text_height(font_sub) + 6
|
||||
lines_h += 40 # 赞赏勋章行
|
||||
ty = text_y_center - lines_h // 2
|
||||
draw.text((text_x, ty), username, font=font_title, fill=TEXT_MAIN)
|
||||
ty += _text_height(font_title) + 10
|
||||
if title:
|
||||
draw.text((text_x, ty), title, font=font_sub, fill=ACCENT_COLOR)
|
||||
ty += _text_height(font_sub) + 6
|
||||
|
||||
# 赞赏等级勋章 + 平台
|
||||
badge_size = 34
|
||||
_draw_endorsement_badge(img, text_x, ty, endorsement_level, badge_size)
|
||||
label = _fit_text(draw, f"赞赏 {endorsement_level} 级 {platform_label}", font_sub, W - text_x - badge_size - 24)
|
||||
draw.text((text_x + badge_size + 10, ty + (badge_size - _text_height(font_sub)) // 2), label, font=font_sub, fill=TEXT_SUB)
|
||||
|
||||
y = 10 + header_h + 12
|
||||
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
|
||||
y += 14
|
||||
|
||||
# ===== 竞技段位(坦克/输出/支援三职责,含段位图标)=====
|
||||
y = _draw_rank_section(img, y, rank_rows, W, PAD)
|
||||
|
||||
# ===== 常玩英雄(含英雄头像与场次/时长)=====
|
||||
if top_heroes:
|
||||
y += 4
|
||||
y = _draw_heroes_section(img, y, top_heroes, W, PAD)
|
||||
|
||||
if footer_note:
|
||||
y += 2
|
||||
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=1)
|
||||
y += 10
|
||||
draw.text((PAD + 6, y), _fit_text(draw, footer_note, font_small, W - PAD * 2 - 12), font=font_small, fill=TEXT_DIM)
|
||||
|
||||
return _save_image(img)
|
||||
|
||||
|
||||
def render_stats_card(
|
||||
player_id: str,
|
||||
gamemode_label: str,
|
||||
platform_label: str,
|
||||
stat_rows: Sequence[tuple[str, str]],
|
||||
rank_rows: Sequence[dict] = (),
|
||||
top_heroes: Sequence[tuple[str, str, Optional[bytes]]] = (),
|
||||
) -> str:
|
||||
"""渲染玩家统计概览卡片。
|
||||
|
||||
Args:
|
||||
player_id: 玩家 ID。
|
||||
gamemode_label: 游戏模式显示文本。
|
||||
platform_label: 平台显示文本。
|
||||
stat_rows: [(标签, 数值), ...]。
|
||||
rank_rows: 竞技模式时传入的三职责段位行(含段位图标),可为空。
|
||||
top_heroes: 常玩英雄 [(名称, 场次文本, 头像数据), ...],可为空。
|
||||
|
||||
Returns:
|
||||
生成的 PNG 文件路径。
|
||||
"""
|
||||
W = 720
|
||||
PAD = 28
|
||||
font_title = _load_font(30)
|
||||
font_sub = _load_font(22)
|
||||
font_body = _load_font(24)
|
||||
|
||||
rows = [(_clean_text(label), _clean_text(value)) for label, value in stat_rows]
|
||||
per_col = (len(rows) + 1) // 2
|
||||
grid_h = per_col * 54 + 10
|
||||
rank_h = (len(rank_rows) * 44 + 70) if rank_rows else 0
|
||||
heroes_h = 158 if top_heroes else 0
|
||||
H = PAD * 2 + 96 + rank_h + grid_h + heroes_h + 20
|
||||
|
||||
img = Image.new("RGB", (W, H), BG_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
|
||||
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
|
||||
|
||||
y = PAD + 6
|
||||
draw.text((PAD + 6, y), "统计概览", font=font_title, fill=ACCENT_COLOR)
|
||||
y += _text_height(font_title) + 10
|
||||
sub = _fit_text(
|
||||
draw,
|
||||
f"{player_id} | {_clean_text(gamemode_label)} | {_clean_text(platform_label)}",
|
||||
font_sub,
|
||||
W - PAD * 2 - 12,
|
||||
)
|
||||
draw.text((PAD + 6, y), sub, font=font_sub, fill=TEXT_SUB)
|
||||
y += _text_height(font_sub) + 14
|
||||
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
|
||||
y += 16
|
||||
|
||||
# ===== 竞技段位区(仅竞技模式)=====
|
||||
if rank_rows:
|
||||
y = _draw_rank_section(img, y, rank_rows, W, PAD)
|
||||
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=1)
|
||||
y += 14
|
||||
|
||||
# ===== 两列网格布局 =====
|
||||
col_w = (W - PAD * 2 - 20) // 2
|
||||
for idx, (label, value) in enumerate(rows):
|
||||
col = idx // per_col
|
||||
row = idx % per_col
|
||||
cx = PAD + 6 + col * (col_w + 20)
|
||||
cy = y + row * 54
|
||||
_rounded_rect(draw, (cx - 6, cy - 6, cx + col_w - 6, cy + 42), 10, CARD_COLOR_ALT)
|
||||
label_text = _fit_text(draw, label, font_body, col_w - 20)
|
||||
draw.text((cx + 8, cy), label_text, font=font_body, fill=TEXT_SUB)
|
||||
lw = _text_width(draw, label_text, font_body)
|
||||
value_text = _fit_text(draw, value, font_body, col_w - lw - 34)
|
||||
draw.text((cx + 8 + lw + 12, cy), value_text, font=font_body, fill=TEXT_MAIN)
|
||||
y += per_col * 54 + 6
|
||||
|
||||
# ===== 常玩英雄(含头像与场次)=====
|
||||
if top_heroes:
|
||||
y = _draw_heroes_section(img, y, top_heroes, W, PAD)
|
||||
|
||||
return _save_image(img)
|
||||
|
||||
|
||||
def render_career_card(
|
||||
player_id: str,
|
||||
gamemode_label: str,
|
||||
platform_label: str,
|
||||
hero_filter: Optional[str],
|
||||
heroes: Sequence[tuple[str, Sequence[tuple[str, str]], Optional[bytes]]],
|
||||
remaining: int = 0,
|
||||
) -> str:
|
||||
"""渲染生涯统计卡片(每个英雄区块带英雄头像)。
|
||||
|
||||
Args:
|
||||
player_id: 玩家 ID。
|
||||
gamemode_label: 游戏模式显示文本。
|
||||
platform_label: 平台显示文本。
|
||||
hero_filter: 英雄筛选显示文本(可选)。
|
||||
heroes: [(英雄名, [(标签, 数值), ...], 英雄头像数据), ...]。
|
||||
remaining: 未显示的剩余英雄数量。
|
||||
|
||||
Returns:
|
||||
生成的 PNG 文件路径。
|
||||
"""
|
||||
W = 720
|
||||
PAD = 28
|
||||
font_title = _load_font(30)
|
||||
font_sub = _load_font(22)
|
||||
font_hero = _load_font(26)
|
||||
font_body = _load_font(23)
|
||||
|
||||
# 计算高度
|
||||
hero_blocks_h = 0
|
||||
for item in heroes:
|
||||
rows = item[1]
|
||||
hero_blocks_h += 44 + max(len(rows), 1) * 36 + 14
|
||||
extra_h = 40 if remaining > 0 else 0
|
||||
H = PAD * 2 + 96 + hero_blocks_h + extra_h + 16
|
||||
|
||||
img = Image.new("RGB", (W, H), BG_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
|
||||
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
|
||||
|
||||
y = PAD + 6
|
||||
draw.text((PAD + 6, y), "生涯统计", font=font_title, fill=ACCENT_COLOR)
|
||||
y += _text_height(font_title) + 10
|
||||
sub = f"{player_id} | {_clean_text(gamemode_label)} | {_clean_text(platform_label)}"
|
||||
if hero_filter:
|
||||
sub += f" | 英雄: {_clean_text(hero_filter)}"
|
||||
draw.text((PAD + 6, y), _fit_text(draw, sub, font_sub, W - PAD * 2 - 12), font=font_sub, fill=TEXT_SUB)
|
||||
y += _text_height(font_sub) + 14
|
||||
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
|
||||
y += 16
|
||||
|
||||
for item in heroes:
|
||||
hero_name, rows = item[0], item[1]
|
||||
portrait_bytes = item[2] if len(item) > 2 else None
|
||||
_rounded_rect(draw, (PAD, y, W - PAD, y + 36 + max(len(rows), 1) * 36 + 8), 12, CARD_COLOR_ALT)
|
||||
# 英雄头像(若有)+ 英雄名
|
||||
text_x = PAD + 14
|
||||
if portrait_bytes:
|
||||
_paste_square_icon(img, portrait_bytes, PAD + 12, y + 2, 34, radius=10)
|
||||
text_x = PAD + 12 + 34 + 10
|
||||
draw.text((text_x, y + 6), _fit_text(draw, hero_name, font_hero, W - text_x - PAD - 12), font=font_hero, fill=ACCENT_COLOR)
|
||||
y += 44
|
||||
for label, value in rows:
|
||||
label_text = _fit_text(draw, label, font_body, 180)
|
||||
draw.text((PAD + 26, y), label_text, font=font_body, fill=TEXT_SUB)
|
||||
lw = _text_width(draw, label_text, font_body)
|
||||
value_text = _fit_text(draw, value, font_body, W - PAD * 2 - 26 - lw - 24)
|
||||
draw.text((PAD + 26 + lw + 12, y), value_text, font=font_body, fill=TEXT_MAIN)
|
||||
y += 36
|
||||
y += 14
|
||||
|
||||
if remaining > 0:
|
||||
draw.text((PAD + 14, y), f"... 还有 {remaining} 个英雄的数据未显示", font=font_sub, fill=TEXT_DIM)
|
||||
|
||||
return _save_image(img)
|
||||
|
||||
|
||||
def render_herostats_card(
|
||||
title: str,
|
||||
subtitle: str,
|
||||
rows: Sequence[tuple[str, str]],
|
||||
footer_note: str | None = None,
|
||||
) -> str:
|
||||
"""渲染全服英雄胜率/选取率榜(文本行列表,避免超长单消息)。"""
|
||||
W = 720
|
||||
PAD = 28
|
||||
font_title = _load_font(30)
|
||||
font_sub = _load_font(22)
|
||||
font_body = _load_font(23)
|
||||
|
||||
H = PAD * 2 + 96 + max(len(rows), 1) * 34 + (40 if footer_note else 0) + 16
|
||||
img = Image.new("RGB", (W, H), BG_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
|
||||
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
|
||||
|
||||
y = PAD + 6
|
||||
draw.text((PAD + 6, y), _fit_text(draw, title, font_title, W - PAD * 2 - 12), font=font_title, fill=ACCENT_COLOR)
|
||||
y += _text_height(font_title) + 10
|
||||
draw.text((PAD + 6, y), _fit_text(draw, subtitle, font_sub, W - PAD * 2 - 12), font=font_sub, fill=TEXT_SUB)
|
||||
y += _text_height(font_sub) + 14
|
||||
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
|
||||
y += 14
|
||||
for name, value in rows:
|
||||
_rounded_rect(draw, (PAD, y, W - PAD, y + 30), 10, CARD_COLOR_ALT)
|
||||
draw.text((PAD + 14, y + 3), _fit_text(draw, name, font_body, 260), font=font_body, fill=TEXT_SUB)
|
||||
draw.text((PAD + 300, y + 3), _fit_text(draw, value, font_body, W - PAD * 2 - 300 - 14), font=font_body, fill=TEXT_MAIN)
|
||||
y += 34
|
||||
if footer_note:
|
||||
y += 8
|
||||
draw.text((PAD + 14, y), _fit_text(draw, footer_note, font_sub, W - PAD * 2 - 28), font=font_sub, fill=TEXT_DIM)
|
||||
return _save_image(img)
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
name: astrbot_plugin_owcx
|
||||
display_name: 守望先锋查询
|
||||
desc: Overwatch 查询插件 - 基于 OverFast API 查询玩家战绩、英雄信息等
|
||||
version: v2.3.1 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
author: TZYCeng # 作者
|
||||
repo: https://github.com/TZYCeng/astrbot_plugin_owcx # 插件的仓库地址
|
||||
@@ -0,0 +1,2 @@
|
||||
aiohttp>=3.8.0
|
||||
Pillow>=9.0.0
|
||||
@@ -0,0 +1,224 @@
|
||||
"""纯函数工具:归一化/解析/格式化/数据提取,可单测。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
from .constants import (
|
||||
GAMEMODE_REVERSE_MAPPING,
|
||||
HERO_NAME_MAPPING,
|
||||
HERO_NAME_REVERSE_MAPPING,
|
||||
PLATFORM_MAPPING,
|
||||
PLATFORM_REVERSE_MAPPING,
|
||||
RANK_MAPPING,
|
||||
REGION_MAPPING,
|
||||
REGION_REVERSE_MAPPING,
|
||||
ROLE_MAPPING,
|
||||
)
|
||||
except ImportError: # 作为独立模块导入时回退
|
||||
from constants import (
|
||||
GAMEMODE_REVERSE_MAPPING,
|
||||
HERO_NAME_MAPPING,
|
||||
HERO_NAME_REVERSE_MAPPING,
|
||||
PLATFORM_MAPPING,
|
||||
PLATFORM_REVERSE_MAPPING,
|
||||
RANK_MAPPING,
|
||||
REGION_MAPPING,
|
||||
REGION_REVERSE_MAPPING,
|
||||
ROLE_MAPPING,
|
||||
)
|
||||
|
||||
_PLAYER_ID_RE = re.compile(r"^[A-Za-z0-9\u4e00-\u9fa5_\- ]{3,32}$")
|
||||
_TIER_ROMAN = ["", "I", "II", "III", "IV", "V"]
|
||||
|
||||
DEFAULT_API_BASE_URL = "https://overfast-api.tekrop.fr"
|
||||
|
||||
|
||||
def normalize_api_base_url(raw: str | None) -> str:
|
||||
"""归一化 API 地址:去空格/去尾斜杠,无效时回退官方地址。"""
|
||||
url = (raw or "").strip().rstrip("/")
|
||||
if not url or not url.lower().startswith(("http://", "https://")):
|
||||
return DEFAULT_API_BASE_URL
|
||||
return url
|
||||
|
||||
|
||||
def normalize_player_id(player_id: str) -> str:
|
||||
"""# -> - 并 strip,不做合法性校验。"""
|
||||
return player_id.replace("#", "-").strip()
|
||||
|
||||
|
||||
def validate_player_id(player_id: str) -> str:
|
||||
"""归一化 + 合法性校验,非法时抛 ValueError。
|
||||
|
||||
允许字母/数字/中文/_/-/空格,长度归一化后 3~32,且必须含 - 分隔符。
|
||||
返回可直接拼 URL path 的 quote 结果。
|
||||
"""
|
||||
norm = normalize_player_id(player_id)
|
||||
if len(norm) < 3 or len(norm) > 32:
|
||||
raise ValueError(f"玩家 ID 长度非法: {player_id!r}")
|
||||
if "/" in player_id or "\\" in player_id or ".." in norm:
|
||||
raise ValueError(f"玩家 ID 非法: {player_id!r}")
|
||||
# BattleTag 归一化后应为 Name-XXXX 形式;宽松校验:至少含 - 或纯字母数字
|
||||
if not _PLAYER_ID_RE.match(norm):
|
||||
raise ValueError(f"玩家 ID 含非法字符: {player_id!r}")
|
||||
return norm
|
||||
|
||||
|
||||
def quote_player_id(player_id: str) -> str:
|
||||
"""返回 URL-safe 的玩家 ID(已归一化+quote)。"""
|
||||
return quote(validate_player_id(player_id), safe="-")
|
||||
|
||||
|
||||
def get_rank_display(division: str | None, tier: int | None) -> str:
|
||||
if not division:
|
||||
return "未定位"
|
||||
rank_info = RANK_MAPPING.get(division.lower(), (division, ""))
|
||||
tier_str = _TIER_ROMAN[tier] if tier and 1 <= tier <= 5 else ""
|
||||
name = rank_info[0]
|
||||
return f"{name}{' ' + tier_str if tier_str else ''}".strip()
|
||||
|
||||
|
||||
def build_rank_data(summary: dict, platform: str) -> tuple[str, list[dict]]:
|
||||
comp = summary.get("competitive")
|
||||
if not isinstance(comp, dict):
|
||||
comp = summary.get("competitive_ranks")
|
||||
comp = comp if isinstance(comp, dict) else {}
|
||||
|
||||
shown_platform = platform
|
||||
container = comp.get(platform)
|
||||
if not isinstance(container, dict) or not container:
|
||||
other_platform = "console" if platform == "pc" else "pc"
|
||||
other = comp.get(other_platform)
|
||||
if isinstance(other, dict) and other:
|
||||
shown_platform = other_platform
|
||||
container = other
|
||||
else:
|
||||
container = None
|
||||
|
||||
rows: list[dict] = []
|
||||
if container:
|
||||
for role_key in ["tank", "damage", "support"]:
|
||||
role_cn = ROLE_MAPPING.get(role_key, (role_key, ""))[0]
|
||||
role_text = f"{role_cn}"
|
||||
role_data = container.get(role_key)
|
||||
if isinstance(role_data, dict) and role_data.get("division"):
|
||||
rows.append({
|
||||
"role_text": role_text,
|
||||
"rank_text": get_rank_display(role_data.get("division"), role_data.get("tier")),
|
||||
"rank_icon": role_data.get("rank_icon") or "",
|
||||
})
|
||||
else:
|
||||
rows.append({"role_text": role_text, "rank_text": "未定位", "rank_icon": ""})
|
||||
return shown_platform, rows
|
||||
|
||||
|
||||
def extract_top_heroes(stats: dict, platform: str, count: int = 3) -> list[tuple[str, int]]:
|
||||
if not isinstance(stats, dict):
|
||||
return []
|
||||
for plat in (platform, "console" if platform == "pc" else "pc"):
|
||||
plat_stats = stats.get(plat)
|
||||
if not isinstance(plat_stats, dict):
|
||||
continue
|
||||
totals: dict[str, int] = {}
|
||||
for gamemode in ("quickplay", "competitive"):
|
||||
gm_stats = plat_stats.get(gamemode)
|
||||
if not isinstance(gm_stats, dict):
|
||||
continue
|
||||
comparisons = gm_stats.get("heroes_comparisons")
|
||||
if not isinstance(comparisons, dict):
|
||||
continue
|
||||
time_played = comparisons.get("time_played")
|
||||
if not isinstance(time_played, dict):
|
||||
continue
|
||||
for item in time_played.get("values") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
hero = item.get("hero")
|
||||
value = item.get("value") or 0
|
||||
if hero:
|
||||
try:
|
||||
totals[hero] = totals.get(hero, 0) + int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if totals:
|
||||
return sorted(totals.items(), key=lambda kv: kv[1], reverse=True)[:count]
|
||||
return []
|
||||
|
||||
|
||||
def get_role_display(role_key: str) -> str:
|
||||
return ROLE_MAPPING.get(role_key.lower(), (role_key, ""))[0]
|
||||
|
||||
|
||||
def get_hero_name_cn(hero_key: str) -> str:
|
||||
return HERO_NAME_MAPPING.get(hero_key.lower(), hero_key)
|
||||
|
||||
|
||||
def resolve_hero_name(hero_input: str) -> str:
|
||||
hero_input = hero_input.strip()
|
||||
lowered = hero_input.lower()
|
||||
if lowered in HERO_NAME_MAPPING:
|
||||
return lowered
|
||||
if hero_input in HERO_NAME_REVERSE_MAPPING:
|
||||
return HERO_NAME_REVERSE_MAPPING[hero_input]
|
||||
for cn_name, en_key in HERO_NAME_REVERSE_MAPPING.items():
|
||||
if lowered in cn_name.lower() or cn_name.lower() in lowered:
|
||||
return en_key
|
||||
return lowered
|
||||
|
||||
|
||||
def resolve_platform(platform_input: str) -> str | None:
|
||||
if not platform_input:
|
||||
return None
|
||||
return PLATFORM_REVERSE_MAPPING.get(platform_input.strip().lower())
|
||||
|
||||
|
||||
def get_platform_display(platform: str) -> str:
|
||||
return PLATFORM_MAPPING.get(platform, platform)
|
||||
|
||||
|
||||
def resolve_region(region_input: str) -> str | None:
|
||||
if not region_input:
|
||||
return None
|
||||
return REGION_REVERSE_MAPPING.get(region_input.strip().lower())
|
||||
|
||||
|
||||
def get_region_display(region: str) -> str:
|
||||
return REGION_MAPPING.get(region, region)
|
||||
|
||||
|
||||
def resolve_gamemode(mode_input: str) -> str | None:
|
||||
if not mode_input:
|
||||
return None
|
||||
return GAMEMODE_REVERSE_MAPPING.get(mode_input.strip().lower())
|
||||
|
||||
|
||||
def format_time_played(seconds: int | float | None) -> str:
|
||||
if not seconds:
|
||||
return "0小时"
|
||||
total_minutes = int(seconds) // 60
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
if hours > 0 and minutes > 0:
|
||||
return f"{hours}小时{minutes}分钟"
|
||||
elif hours > 0:
|
||||
return f"{hours}小时"
|
||||
return f"{minutes}分钟"
|
||||
|
||||
|
||||
def format_time_short(seconds: int | float | None) -> str:
|
||||
if not seconds:
|
||||
return "0分钟"
|
||||
total_minutes = int(seconds) // 60
|
||||
hours = total_minutes / 60
|
||||
if hours >= 1:
|
||||
return f"{hours:.1f}小时"
|
||||
return f"{total_minutes}分钟"
|
||||
|
||||
|
||||
def format_number(num: int | float | None) -> str:
|
||||
if num is None:
|
||||
return "0"
|
||||
if isinstance(num, float):
|
||||
return f"{num:,.1f}" if num != int(num) else f"{int(num):,}"
|
||||
return f"{num:,}"
|
||||
Reference in New Issue
Block a user