nonebot-plugin-cs2radar 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_cs2radar/__init__.py +485 -0
- nonebot_plugin_cs2radar/binding_store.py +197 -0
- nonebot_plugin_cs2radar/config.py +120 -0
- nonebot_plugin_cs2radar/crawler.py +614 -0
- nonebot_plugin_cs2radar/llm.py +227 -0
- nonebot_plugin_cs2radar/match_service.py +1052 -0
- nonebot_plugin_cs2radar/renderer.py +173 -0
- nonebot_plugin_cs2radar/storage.py +51 -0
- nonebot_plugin_cs2radar/templates/events.html +179 -0
- nonebot_plugin_cs2radar/templates/match_detail.html +326 -0
- nonebot_plugin_cs2radar/templates/match_results.html +291 -0
- nonebot_plugin_cs2radar/templates/matches.html +301 -0
- nonebot_plugin_cs2radar/templates/player_detail.html +424 -0
- nonebot_plugin_cs2radar/templates/pw_stats.html +344 -0
- nonebot_plugin_cs2radar/templates/stats.html +307 -0
- nonebot_plugin_cs2radar-0.1.0.dist-info/METADATA +137 -0
- nonebot_plugin_cs2radar-0.1.0.dist-info/RECORD +19 -0
- nonebot_plugin_cs2radar-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_cs2radar-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from nonebot import get_driver, get_plugin_config, logger, on_command, require
|
|
6
|
+
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
|
|
7
|
+
from nonebot.exception import FinishedException, MatcherException
|
|
8
|
+
from nonebot.params import CommandArg
|
|
9
|
+
from nonebot.plugin import PluginMetadata
|
|
10
|
+
|
|
11
|
+
require("nonebot_plugin_htmlrender")
|
|
12
|
+
require("nonebot_plugin_localstore")
|
|
13
|
+
|
|
14
|
+
from .binding_store import BindingStore
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .crawler import FiveEEventCrawler, FiveECrawler, PWCrawler
|
|
17
|
+
from .llm import LLMEvaluator
|
|
18
|
+
from .match_service import MatchService, parse_bind_args, parse_match_args
|
|
19
|
+
from .renderer import (
|
|
20
|
+
render_events_card,
|
|
21
|
+
render_match_detail_card,
|
|
22
|
+
render_player_detail,
|
|
23
|
+
render_pw_stats_card,
|
|
24
|
+
render_results_card,
|
|
25
|
+
render_matches_card,
|
|
26
|
+
render_stats_card,
|
|
27
|
+
)
|
|
28
|
+
from .storage import get_bind_db_path
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
plugin_config = get_plugin_config(Config)
|
|
33
|
+
driver_config = get_driver().config
|
|
34
|
+
|
|
35
|
+
for legacy_name in (
|
|
36
|
+
"cs_pro_priority",
|
|
37
|
+
"cs_pro_bind_db_path",
|
|
38
|
+
"cs_pro_http_timeout",
|
|
39
|
+
"cs_pro_llm_enabled",
|
|
40
|
+
"cs_pro_llm_api_type",
|
|
41
|
+
"cs_pro_llm_api_url",
|
|
42
|
+
"cs_pro_llm_api_key",
|
|
43
|
+
"cs_pro_llm_model",
|
|
44
|
+
"cs_pro_llm_backup_enabled",
|
|
45
|
+
"cs_pro_llm_backup_api_type",
|
|
46
|
+
"cs_pro_llm_backup_api_url",
|
|
47
|
+
"cs_pro_llm_backup_api_key",
|
|
48
|
+
"cs_pro_llm_backup_model",
|
|
49
|
+
"cs_pro_llm_timeout",
|
|
50
|
+
"cs_pro_llm_system_prompt",
|
|
51
|
+
):
|
|
52
|
+
if getattr(plugin_config, legacy_name, None) is not None:
|
|
53
|
+
logger.warning(f"[nonebot_plugin_cs2radar] `{legacy_name}` is deprecated; migrate to `cs2radar_*` config names.")
|
|
54
|
+
|
|
55
|
+
__plugin_meta__ = PluginMetadata(
|
|
56
|
+
name="CS2 Radar",
|
|
57
|
+
description="CS2 赛事、选手、5E/完美/官匹战绩查询与详细对局分析",
|
|
58
|
+
usage=(
|
|
59
|
+
"cs查询 [选手]\n"
|
|
60
|
+
"cs赛事\n"
|
|
61
|
+
"赛果\n"
|
|
62
|
+
"5e [ID/昵称]\n"
|
|
63
|
+
"pw [ID/昵称]\n"
|
|
64
|
+
"pwlogin [手机号] [验证码]\n"
|
|
65
|
+
"bind [platform] [name]\n"
|
|
66
|
+
"match [platform] [@群友] [round]"
|
|
67
|
+
),
|
|
68
|
+
type="application",
|
|
69
|
+
homepage="https://github.com/luojisama/nonebot-plugin-cs2radar",
|
|
70
|
+
config=Config,
|
|
71
|
+
supported_adapters={"~onebot.v11"},
|
|
72
|
+
extra={
|
|
73
|
+
"author": "luojisama",
|
|
74
|
+
"version": __version__,
|
|
75
|
+
"pypi": "nonebot-plugin-cs2radar",
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Commands
|
|
80
|
+
cs_search = on_command("cs查询", aliases={"cs选手", "csplayer"}, priority=plugin_config.priority, block=True)
|
|
81
|
+
game_search = on_command("cs赛事", aliases={"赛事", "csgo赛事", "cs2赛事"}, priority=plugin_config.priority, block=True)
|
|
82
|
+
result_search = on_command("赛果", aliases={"cs赛果", "赛事赛果"}, priority=plugin_config.priority, block=True)
|
|
83
|
+
five_e_stats = on_command("5e", aliases={"5e战绩", "5e查询", "cs战绩"}, priority=plugin_config.priority, block=True)
|
|
84
|
+
pw_stats = on_command("pw", aliases={"pw战绩", "pw查询", "完美战绩"}, priority=plugin_config.priority, block=True)
|
|
85
|
+
pw_login = on_command("pwlogin", aliases={"完美登录"}, priority=plugin_config.priority, block=True)
|
|
86
|
+
bind_cmd = on_command("bind", aliases={"绑定", "添加", "绑定用户", "添加用户"}, priority=plugin_config.priority, block=True)
|
|
87
|
+
match_cmd = on_command("match", aliases={"战绩", "查询战绩"}, priority=plugin_config.priority, block=True)
|
|
88
|
+
|
|
89
|
+
# Shared services
|
|
90
|
+
store = BindingStore(str(get_bind_db_path(plugin_config.bind_db_path)))
|
|
91
|
+
match_service = MatchService(timeout=plugin_config.http_timeout)
|
|
92
|
+
_llm_api_key = (plugin_config.llm_api_key or "").strip()
|
|
93
|
+
_llm_api_type = plugin_config.llm_api_type
|
|
94
|
+
_llm_api_url = plugin_config.llm_api_url
|
|
95
|
+
_llm_model = plugin_config.llm_model
|
|
96
|
+
_llm_backup_enabled = plugin_config.llm_backup_enabled
|
|
97
|
+
_llm_backup_api_key = (plugin_config.llm_backup_api_key or "").strip()
|
|
98
|
+
_llm_backup_api_type = plugin_config.llm_backup_api_type
|
|
99
|
+
_llm_backup_api_url = plugin_config.llm_backup_api_url
|
|
100
|
+
_llm_backup_model = plugin_config.llm_backup_model
|
|
101
|
+
if not _llm_api_key:
|
|
102
|
+
_llm_api_key = str(getattr(driver_config, "personification_api_key", "") or "").strip()
|
|
103
|
+
_llm_api_type = str(getattr(driver_config, "personification_api_type", "openai") or "openai")
|
|
104
|
+
_llm_api_url = str(getattr(driver_config, "personification_api_url", "https://api.openai.com/v1") or "https://api.openai.com/v1")
|
|
105
|
+
_llm_model = str(getattr(driver_config, "personification_model", "gpt-4o-mini") or "gpt-4o-mini")
|
|
106
|
+
llm = LLMEvaluator(
|
|
107
|
+
enabled=plugin_config.llm_enabled,
|
|
108
|
+
api_type=_llm_api_type,
|
|
109
|
+
api_url=_llm_api_url,
|
|
110
|
+
api_key=_llm_api_key,
|
|
111
|
+
model=_llm_model,
|
|
112
|
+
backup_enabled=_llm_backup_enabled,
|
|
113
|
+
backup_api_type=_llm_backup_api_type,
|
|
114
|
+
backup_api_url=_llm_backup_api_url,
|
|
115
|
+
backup_api_key=_llm_backup_api_key,
|
|
116
|
+
backup_model=_llm_backup_model,
|
|
117
|
+
timeout=plugin_config.llm_timeout,
|
|
118
|
+
system_prompt=plugin_config.llm_system_prompt,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Shared crawler instances
|
|
122
|
+
event_crawler = FiveEEventCrawler()
|
|
123
|
+
five_e_crawler = FiveECrawler()
|
|
124
|
+
pw_crawler = PWCrawler()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_target_qq(bot: Bot, event: MessageEvent) -> str:
|
|
128
|
+
target = str(event.user_id)
|
|
129
|
+
for seg in event.message:
|
|
130
|
+
if seg.type == "at":
|
|
131
|
+
qq = str(seg.data.get("qq") or "")
|
|
132
|
+
if qq and qq != str(bot.self_id):
|
|
133
|
+
target = qq
|
|
134
|
+
break
|
|
135
|
+
return target
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _platform_theme(platform: str) -> tuple[str, str, str]:
|
|
139
|
+
if platform == "5e":
|
|
140
|
+
return "5E平台", "#f74d4d", "#f78c00"
|
|
141
|
+
if platform == "mm":
|
|
142
|
+
return "官匹", "#2db3ff", "#3b82f6"
|
|
143
|
+
return "完美平台", "#2db3ff", "#06b6d4"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fmt_pct(v: float) -> str:
|
|
147
|
+
return f"{v * 100:.1f}%"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_match_view_data(match_data, llm_title: str, llm_detail: str) -> dict:
|
|
151
|
+
platform_label, color_a, color_b = _platform_theme(match_data.platform)
|
|
152
|
+
|
|
153
|
+
def _highlight_view(highlights):
|
|
154
|
+
return {
|
|
155
|
+
"first_kills": highlights.first_kills,
|
|
156
|
+
"multi_kills": highlights.multi_kills,
|
|
157
|
+
"clutch_wins": highlights.clutch_wins,
|
|
158
|
+
"summary_cards": [
|
|
159
|
+
{"label": "首杀", "value": highlights.first_kills},
|
|
160
|
+
{"label": "多杀", "value": highlights.multi_kills},
|
|
161
|
+
{"label": "残局", "value": highlights.clutch_wins},
|
|
162
|
+
{"label": "2K/3K/4K/5K", "value": f"{highlights.kills_2}/{highlights.kills_3}/{highlights.kills_4}/{highlights.kills_5}"},
|
|
163
|
+
],
|
|
164
|
+
"clutch_cards": [
|
|
165
|
+
{"label": "1v1", "value": highlights.clutch_1v1},
|
|
166
|
+
{"label": "1v2", "value": highlights.clutch_1v2},
|
|
167
|
+
{"label": "1v3", "value": highlights.clutch_1v3},
|
|
168
|
+
{"label": "1v4", "value": highlights.clutch_1v4},
|
|
169
|
+
{"label": "1v5", "value": highlights.clutch_1v5},
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
def _p(p):
|
|
174
|
+
return {
|
|
175
|
+
"name": p.name,
|
|
176
|
+
"rating": f"{p.rating:.2f}",
|
|
177
|
+
"adr": f"{p.adr:.1f}",
|
|
178
|
+
"kill": p.kill,
|
|
179
|
+
"death": p.death,
|
|
180
|
+
"hs": _fmt_pct(p.headshot_rate),
|
|
181
|
+
"elo": f"{p.elo_change:+.1f}",
|
|
182
|
+
"rws": f"{p.rws:.2f}",
|
|
183
|
+
"uuid": p.uuid,
|
|
184
|
+
"highlights": _highlight_view(p.highlights),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
def _round_view(item):
|
|
188
|
+
result_map = {"W": ("胜", "win"), "L": ("负", "loss")}
|
|
189
|
+
label, css = result_map.get(item.result, ("?", "unknown"))
|
|
190
|
+
return {
|
|
191
|
+
"no": item.round_no,
|
|
192
|
+
"result": item.result,
|
|
193
|
+
"result_label": label,
|
|
194
|
+
"result_class": css,
|
|
195
|
+
"side": item.side or "",
|
|
196
|
+
"score_after": item.score_after or "",
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
def _segment_view(segment):
|
|
200
|
+
return {
|
|
201
|
+
"key": segment.key,
|
|
202
|
+
"label": segment.label,
|
|
203
|
+
"our_score": segment.our_score,
|
|
204
|
+
"enemy_score": segment.enemy_score,
|
|
205
|
+
"score_text": f"{segment.our_score}:{segment.enemy_score}",
|
|
206
|
+
"rounds": [_round_view(x) for x in segment.rounds],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
start_at = datetime.fromtimestamp(match_data.start_time).strftime("%Y-%m-%d %H:%M:%S")
|
|
210
|
+
all_teammates = [match_data.player] + match_data.teammates
|
|
211
|
+
all_teammates.sort(key=lambda x: x.rating, reverse=True)
|
|
212
|
+
halves = [_segment_view(x) for x in match_data.halves]
|
|
213
|
+
half_summary = " / ".join(f"{item['label']} {item['score_text']}" for item in halves) or "暂无"
|
|
214
|
+
player_view = _p(match_data.player)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"platform_label": platform_label,
|
|
218
|
+
"theme_a": color_a,
|
|
219
|
+
"theme_b": color_b,
|
|
220
|
+
"map_name": match_data.map_name,
|
|
221
|
+
"match_type": match_data.match_type or "未知模式",
|
|
222
|
+
"start_at": start_at,
|
|
223
|
+
"duration_min": match_data.duration_min,
|
|
224
|
+
"result_text": match_data.result_text,
|
|
225
|
+
"result_class": "good" if match_data.result_text == "胜利" else ("draw" if match_data.result_text == "平局" else "bad"),
|
|
226
|
+
"match_id": match_data.match_id,
|
|
227
|
+
"score_text": f"{match_data.score_our}:{match_data.score_enemy}",
|
|
228
|
+
"half_summary": half_summary,
|
|
229
|
+
"halves": halves,
|
|
230
|
+
"has_rounds": any(item["rounds"] for item in halves),
|
|
231
|
+
"has_overtime": match_data.has_overtime,
|
|
232
|
+
"player": player_view,
|
|
233
|
+
"player_highlights": player_view["highlights"],
|
|
234
|
+
"teammates": [_p(x) for x in all_teammates],
|
|
235
|
+
"opponents": [_p(x) for x in match_data.opponents],
|
|
236
|
+
"llm_title": llm_title,
|
|
237
|
+
"llm_detail": llm_detail,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@bind_cmd.handle()
|
|
242
|
+
async def handle_bind(event: MessageEvent, args: Message = CommandArg()):
|
|
243
|
+
raw = args.extract_plain_text().strip()
|
|
244
|
+
if not raw:
|
|
245
|
+
await bind_cmd.finish("用法: /bind [5e|pw] [玩家名]")
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
default_platform = store.get_default_platform(str(event.user_id))
|
|
249
|
+
platform, name = parse_bind_args(raw, default_platform=default_platform)
|
|
250
|
+
|
|
251
|
+
# 5E绑定复用 /5e 查询规则:ID/域名直接绑定,昵称走 search_player 首条匹配。
|
|
252
|
+
if platform == "5e":
|
|
253
|
+
is_id = re.match(r"^\d+s\w+$|^\d+$|^[0-9a-f-]{36}$", name)
|
|
254
|
+
if is_id:
|
|
255
|
+
bound = await match_service.bind_5e_domain(store, str(event.user_id), name, canonical_name=name)
|
|
256
|
+
else:
|
|
257
|
+
candidates = await five_e_crawler.search_player(name)
|
|
258
|
+
if not candidates:
|
|
259
|
+
await bind_cmd.finish(f"绑定失败: 未找到5E玩家 {name}")
|
|
260
|
+
first = candidates[0]
|
|
261
|
+
domain = str(first.get("domain") or "").strip()
|
|
262
|
+
if not domain:
|
|
263
|
+
await bind_cmd.finish("绑定失败: 5E搜索结果缺少domain")
|
|
264
|
+
canonical = str(first.get("name") or name)
|
|
265
|
+
bound = await match_service.bind_5e_domain(store, str(event.user_id), domain, canonical_name=canonical)
|
|
266
|
+
else:
|
|
267
|
+
bound = await match_service.bind_player(store, str(event.user_id), platform, name)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
await bind_cmd.finish(f"绑定失败: {e}")
|
|
270
|
+
|
|
271
|
+
if bound.platform == "pw" and (not bound.domain or not bound.uuid):
|
|
272
|
+
await bind_cmd.finish(
|
|
273
|
+
f"绑定成功\n平台: {bound.platform}\n玩家: {bound.player_name}\n"
|
|
274
|
+
"已按用户名绑定,将在首次查询官匹/完美战绩时自动补全平台ID与SteamID。"
|
|
275
|
+
)
|
|
276
|
+
await bind_cmd.finish(
|
|
277
|
+
f"绑定成功\n平台: {bound.platform}\n玩家: {bound.player_name}\n平台ID: {bound.domain}\nSteamID: {bound.uuid}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@match_cmd.handle()
|
|
282
|
+
async def handle_match(bot: Bot, event: MessageEvent, args: Message = CommandArg()):
|
|
283
|
+
raw = args.extract_plain_text().strip()
|
|
284
|
+
platform, round_index = parse_match_args(raw)
|
|
285
|
+
target_qq = _extract_target_qq(bot, event)
|
|
286
|
+
|
|
287
|
+
await match_cmd.send("正在查询详细战绩并生成评价...")
|
|
288
|
+
try:
|
|
289
|
+
match_data = await match_service.fetch_match(store, target_qq, platform, round_index)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
await match_cmd.finish(f"查询失败: {e}")
|
|
292
|
+
|
|
293
|
+
llm_title = "评价暂不可用"
|
|
294
|
+
llm_detail = "未配置或调用失败,本次仅展示战绩数据。"
|
|
295
|
+
try:
|
|
296
|
+
result = await llm.evaluate(match_data.llm_context())
|
|
297
|
+
if result:
|
|
298
|
+
llm_title = result.title
|
|
299
|
+
llm_detail = result.detail
|
|
300
|
+
except Exception as e:
|
|
301
|
+
logger.warning(f"[cs_pro] llm evaluate failed: {e}")
|
|
302
|
+
|
|
303
|
+
view_data = _build_match_view_data(match_data, llm_title, llm_detail)
|
|
304
|
+
image_bytes = await render_match_detail_card(view_data)
|
|
305
|
+
await match_cmd.finish(MessageSegment.image(image_bytes))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@cs_search.handle()
|
|
309
|
+
async def handle_cs_search(args: Message = CommandArg()):
|
|
310
|
+
query = args.extract_plain_text().strip()
|
|
311
|
+
if not query:
|
|
312
|
+
await cs_search.finish("请输入选手名称,例如: cs查询 sh1ro")
|
|
313
|
+
|
|
314
|
+
search_api = "https://api.viki.moe/pw-cs/search"
|
|
315
|
+
async with httpx.AsyncClient() as client:
|
|
316
|
+
try:
|
|
317
|
+
resp = await client.get(search_api, params={"type": "player", "s": query})
|
|
318
|
+
data = resp.json()
|
|
319
|
+
except Exception as e:
|
|
320
|
+
await cs_search.finish(f"查询出错: {e}")
|
|
321
|
+
|
|
322
|
+
if not isinstance(data, list):
|
|
323
|
+
await cs_search.finish(f"查询出错: {data.get('message') if isinstance(data, dict) else '未知错误'}")
|
|
324
|
+
if not data:
|
|
325
|
+
await cs_search.finish("未找到相关选手,请检查名称")
|
|
326
|
+
|
|
327
|
+
player_brief = data[0]
|
|
328
|
+
hltv_id = player_brief.get("hltv_id")
|
|
329
|
+
if not hltv_id:
|
|
330
|
+
await cs_search.finish("未找到选手HLTV ID")
|
|
331
|
+
|
|
332
|
+
detail_api = f"https://api.viki.moe/pw-cs/player/{hltv_id}"
|
|
333
|
+
async with httpx.AsyncClient() as client:
|
|
334
|
+
try:
|
|
335
|
+
resp = await client.get(detail_api)
|
|
336
|
+
player = resp.json()
|
|
337
|
+
except Exception as e:
|
|
338
|
+
await cs_search.finish(f"获取选手详情出错: {e}")
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
image_bytes = await render_player_detail(player)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
logger.error(f"Error rendering player detail: {e}")
|
|
344
|
+
name = player.get("name", "未知")
|
|
345
|
+
team_name = player.get("team", {}).get("name", "无战队")
|
|
346
|
+
await cs_search.finish(f"选手: {name}\n战队: {team_name}\n(图片渲染失败)")
|
|
347
|
+
|
|
348
|
+
await cs_search.finish(MessageSegment.image(image_bytes))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@game_search.handle()
|
|
352
|
+
async def handle_game_search():
|
|
353
|
+
await game_search.send("正在获取实时赛程与赛事信息...")
|
|
354
|
+
try:
|
|
355
|
+
matches = await event_crawler.get_matches()
|
|
356
|
+
if matches:
|
|
357
|
+
image_bytes = await render_matches_card(matches)
|
|
358
|
+
await game_search.finish(MessageSegment.image(image_bytes))
|
|
359
|
+
|
|
360
|
+
events = await event_crawler.get_events()
|
|
361
|
+
if events:
|
|
362
|
+
image_bytes = await render_events_card(events)
|
|
363
|
+
await game_search.finish(MessageSegment.image(image_bytes))
|
|
364
|
+
|
|
365
|
+
await game_search.finish("暂无实时赛程数据。")
|
|
366
|
+
except (FinishedException, MatcherException):
|
|
367
|
+
raise
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.error(f"Error in game_search: {e}")
|
|
370
|
+
await game_search.finish(f"查询赛事失败: {e}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@result_search.handle()
|
|
374
|
+
async def handle_result_search():
|
|
375
|
+
await result_search.send("正在获取赛果数据...")
|
|
376
|
+
try:
|
|
377
|
+
results = await event_crawler.get_results()
|
|
378
|
+
if not results:
|
|
379
|
+
await result_search.finish("暂无赛果数据。")
|
|
380
|
+
image_bytes = await render_results_card(results)
|
|
381
|
+
await result_search.finish(MessageSegment.image(image_bytes))
|
|
382
|
+
except (FinishedException, MatcherException):
|
|
383
|
+
raise
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.error(f"Error in result_search: {e}")
|
|
386
|
+
await result_search.finish(f"查询赛果失败: {e}")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@five_e_stats.handle()
|
|
390
|
+
async def handle_five_e_stats(arg: Message = CommandArg()):
|
|
391
|
+
input_str = arg.extract_plain_text().strip()
|
|
392
|
+
if not input_str:
|
|
393
|
+
await five_e_stats.finish("请输入5E玩家域名、ID或昵称,例如: /5e 15429443s91f72")
|
|
394
|
+
|
|
395
|
+
await five_e_stats.send(f"正在查询 5E 玩家 {input_str}...")
|
|
396
|
+
domain = input_str
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
is_id = re.match(r"^\d+s\w+$|^\d+$|^[0-9a-f-]{36}$", input_str)
|
|
400
|
+
search_info = {}
|
|
401
|
+
if not is_id:
|
|
402
|
+
search_results = await five_e_crawler.search_player(input_str)
|
|
403
|
+
if not search_results:
|
|
404
|
+
await five_e_stats.finish(f"未找到昵称为 {input_str} 的玩家。")
|
|
405
|
+
search_info = search_results[0]
|
|
406
|
+
domain = search_info["domain"]
|
|
407
|
+
await five_e_stats.send(f"匹配到玩家: {search_info['name']} ({domain}),正在获取详细战绩...")
|
|
408
|
+
|
|
409
|
+
data = await five_e_crawler.get_player_data(domain)
|
|
410
|
+
if (not data.get("nickname") or data["nickname"] == "Unknown") and search_info.get("name"):
|
|
411
|
+
data["nickname"] = search_info["name"]
|
|
412
|
+
if (not data.get("avatar")) and search_info.get("avatar"):
|
|
413
|
+
data["avatar"] = search_info["avatar"]
|
|
414
|
+
|
|
415
|
+
if not data.get("stats") or not data["stats"].get("career"):
|
|
416
|
+
await five_e_stats.finish(f"未找到玩家 {domain} 的有效战绩数据。")
|
|
417
|
+
|
|
418
|
+
image_bytes = await render_stats_card(data)
|
|
419
|
+
await five_e_stats.finish(MessageSegment.image(image_bytes))
|
|
420
|
+
except (FinishedException, MatcherException):
|
|
421
|
+
raise
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.error(f"Error in five_e_stats: {e}")
|
|
424
|
+
await five_e_stats.finish(f"5E 查询失败: {str(e)}")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@pw_login.handle()
|
|
428
|
+
async def handle_pw_login(arg: Message = CommandArg()):
|
|
429
|
+
args = arg.extract_plain_text().strip().split()
|
|
430
|
+
if len(args) != 2:
|
|
431
|
+
await pw_login.finish("请输入手机号和验证码,例如: /pwlogin 13800138000 123456")
|
|
432
|
+
|
|
433
|
+
mobile, code = args
|
|
434
|
+
await pw_login.send("正在尝试登录完美平台...")
|
|
435
|
+
|
|
436
|
+
result = await pw_crawler.login(mobile, code)
|
|
437
|
+
if "error" in result:
|
|
438
|
+
await pw_login.finish(f"登录失败: {result['error']}")
|
|
439
|
+
|
|
440
|
+
nickname = result.get("nickname", "未知")
|
|
441
|
+
await pw_login.finish(f"登录成功,欢迎回来,{nickname}。Session 已更新。")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@pw_stats.handle()
|
|
445
|
+
async def handle_pw_stats(arg: Message = CommandArg()):
|
|
446
|
+
input_str = arg.extract_plain_text().strip()
|
|
447
|
+
if not input_str:
|
|
448
|
+
await pw_stats.finish("请输入完美平台玩家昵称或 SteamId,例如: /pw sh1ro")
|
|
449
|
+
if not pw_crawler.has_session():
|
|
450
|
+
await pw_stats.finish("请先使用 /pwlogin <手机号> <验证码> 登录完美平台后再查询。")
|
|
451
|
+
|
|
452
|
+
await pw_stats.send(f"正在查询完美玩家 {input_str}...")
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
is_steam_id = input_str.isdigit() and len(input_str) > 10
|
|
456
|
+
target_steam_id = input_str
|
|
457
|
+
search_info = {}
|
|
458
|
+
|
|
459
|
+
if not is_steam_id:
|
|
460
|
+
search_results = await pw_crawler.search_player(input_str)
|
|
461
|
+
if not search_results:
|
|
462
|
+
await pw_stats.finish(f"未找到昵称为 {input_str} 的玩家。")
|
|
463
|
+
search_info = search_results[0]
|
|
464
|
+
target_steam_id = str(search_info["steamId"])
|
|
465
|
+
await pw_stats.send(f"匹配到玩家: {search_info.get('pvpNickName', '未知')},正在获取详细战绩...")
|
|
466
|
+
|
|
467
|
+
data = await pw_crawler.get_player_data(target_steam_id)
|
|
468
|
+
if "error" in data:
|
|
469
|
+
await pw_stats.finish(f"查询完美战绩失败: {data['error']}")
|
|
470
|
+
if not data or not data.get("stats"):
|
|
471
|
+
await pw_stats.finish(f"未找到玩家 {target_steam_id} 的有效战绩数据。")
|
|
472
|
+
|
|
473
|
+
if not data.get("summary", {}).get("nickname"):
|
|
474
|
+
data["summary"]["nickname"] = search_info.get("pvpNickName", "Unknown")
|
|
475
|
+
if not data.get("summary", {}).get("avatarUrl"):
|
|
476
|
+
data["summary"]["avatarUrl"] = search_info.get("pvpAvatar")
|
|
477
|
+
|
|
478
|
+
image_bytes = await render_pw_stats_card(data)
|
|
479
|
+
await pw_stats.finish(MessageSegment.image(image_bytes))
|
|
480
|
+
except (FinishedException, MatcherException):
|
|
481
|
+
raise
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.error(f"Error in pw_stats: {e}")
|
|
484
|
+
await pw_stats.finish(f"完美战绩查询失败: {str(e)}")
|
|
485
|
+
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .storage import LEGACY_DATA_DIRS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class UserBinding:
|
|
15
|
+
qq_id: str
|
|
16
|
+
platform: str
|
|
17
|
+
player_name: str
|
|
18
|
+
domain: str
|
|
19
|
+
uuid: str
|
|
20
|
+
updated_at: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BindingStore:
|
|
24
|
+
def __init__(self, db_path: str) -> None:
|
|
25
|
+
self.db_path = Path(db_path)
|
|
26
|
+
self._lock = threading.RLock()
|
|
27
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._init_db()
|
|
29
|
+
self._migrate_legacy_once()
|
|
30
|
+
|
|
31
|
+
def _connect(self) -> sqlite3.Connection:
|
|
32
|
+
conn = sqlite3.connect(self.db_path)
|
|
33
|
+
conn.row_factory = sqlite3.Row
|
|
34
|
+
return conn
|
|
35
|
+
|
|
36
|
+
def _init_db(self) -> None:
|
|
37
|
+
with self._connect() as conn:
|
|
38
|
+
conn.execute(
|
|
39
|
+
"""
|
|
40
|
+
CREATE TABLE IF NOT EXISTS user_bindings (
|
|
41
|
+
qq_id TEXT NOT NULL,
|
|
42
|
+
platform TEXT NOT NULL,
|
|
43
|
+
player_name TEXT NOT NULL,
|
|
44
|
+
domain TEXT,
|
|
45
|
+
uuid TEXT,
|
|
46
|
+
updated_at INTEGER NOT NULL,
|
|
47
|
+
PRIMARY KEY (qq_id, platform)
|
|
48
|
+
)
|
|
49
|
+
"""
|
|
50
|
+
)
|
|
51
|
+
conn.execute(
|
|
52
|
+
"""
|
|
53
|
+
CREATE TABLE IF NOT EXISTS plugin_meta (
|
|
54
|
+
key TEXT PRIMARY KEY,
|
|
55
|
+
value TEXT NOT NULL
|
|
56
|
+
)
|
|
57
|
+
"""
|
|
58
|
+
)
|
|
59
|
+
conn.commit()
|
|
60
|
+
|
|
61
|
+
def upsert_binding(self, qq_id: str, platform: str, player_name: str, domain: str, uuid: str) -> None:
|
|
62
|
+
with self._lock, self._connect() as conn:
|
|
63
|
+
conn.execute(
|
|
64
|
+
"""
|
|
65
|
+
INSERT INTO user_bindings (qq_id, platform, player_name, domain, uuid, updated_at)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
67
|
+
ON CONFLICT(qq_id, platform)
|
|
68
|
+
DO UPDATE SET
|
|
69
|
+
player_name=excluded.player_name,
|
|
70
|
+
domain=excluded.domain,
|
|
71
|
+
uuid=excluded.uuid,
|
|
72
|
+
updated_at=excluded.updated_at
|
|
73
|
+
""",
|
|
74
|
+
(qq_id, platform, player_name, domain, uuid, int(time.time())),
|
|
75
|
+
)
|
|
76
|
+
conn.commit()
|
|
77
|
+
|
|
78
|
+
def get_binding(self, qq_id: str, platform: str) -> UserBinding | None:
|
|
79
|
+
with self._lock, self._connect() as conn:
|
|
80
|
+
row = conn.execute(
|
|
81
|
+
"""
|
|
82
|
+
SELECT qq_id, platform, player_name, domain, uuid, updated_at
|
|
83
|
+
FROM user_bindings
|
|
84
|
+
WHERE qq_id=? AND platform=?
|
|
85
|
+
""",
|
|
86
|
+
(qq_id, platform),
|
|
87
|
+
).fetchone()
|
|
88
|
+
if not row:
|
|
89
|
+
return None
|
|
90
|
+
return UserBinding(**dict(row))
|
|
91
|
+
|
|
92
|
+
def get_default_platform(self, qq_id: str) -> str:
|
|
93
|
+
with self._lock, self._connect() as conn:
|
|
94
|
+
rows = conn.execute(
|
|
95
|
+
"""
|
|
96
|
+
SELECT platform FROM user_bindings
|
|
97
|
+
WHERE qq_id=?
|
|
98
|
+
ORDER BY updated_at DESC
|
|
99
|
+
""",
|
|
100
|
+
(qq_id,),
|
|
101
|
+
).fetchall()
|
|
102
|
+
plats = [str(x["platform"]) for x in rows]
|
|
103
|
+
if "5e" in plats:
|
|
104
|
+
return "5e"
|
|
105
|
+
if "pw" in plats:
|
|
106
|
+
return "pw"
|
|
107
|
+
if plats:
|
|
108
|
+
return plats[0]
|
|
109
|
+
return "5e"
|
|
110
|
+
|
|
111
|
+
def get_all_bindings(self) -> list[UserBinding]:
|
|
112
|
+
with self._lock, self._connect() as conn:
|
|
113
|
+
rows = conn.execute(
|
|
114
|
+
"SELECT qq_id, platform, player_name, domain, uuid, updated_at FROM user_bindings"
|
|
115
|
+
).fetchall()
|
|
116
|
+
return [UserBinding(**dict(row)) for row in rows]
|
|
117
|
+
|
|
118
|
+
def _meta_get(self, key: str) -> str | None:
|
|
119
|
+
with self._connect() as conn:
|
|
120
|
+
row = conn.execute("SELECT value FROM plugin_meta WHERE key=?", (key,)).fetchone()
|
|
121
|
+
return str(row["value"]) if row else None
|
|
122
|
+
|
|
123
|
+
def _meta_set(self, key: str, value: str) -> None:
|
|
124
|
+
with self._connect() as conn:
|
|
125
|
+
conn.execute(
|
|
126
|
+
"INSERT INTO plugin_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
127
|
+
(key, value),
|
|
128
|
+
)
|
|
129
|
+
conn.commit()
|
|
130
|
+
|
|
131
|
+
def _migrate_legacy_once(self) -> None:
|
|
132
|
+
if self._meta_get("legacy_migrated") == "1":
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
imported = 0
|
|
136
|
+
imported += self._migrate_from_sqlite_candidates()
|
|
137
|
+
imported += self._migrate_from_json_candidates()
|
|
138
|
+
self._meta_set("legacy_migrated", "1")
|
|
139
|
+
self._meta_set("legacy_imported", str(imported))
|
|
140
|
+
|
|
141
|
+
def _migrate_from_sqlite_candidates(self) -> int:
|
|
142
|
+
candidates = []
|
|
143
|
+
for legacy_dir in LEGACY_DATA_DIRS:
|
|
144
|
+
candidates.append(legacy_dir / "user_bindings.db")
|
|
145
|
+
candidates.append(legacy_dir / "user_data.db")
|
|
146
|
+
total = 0
|
|
147
|
+
for db in candidates:
|
|
148
|
+
if not db.exists() or db.resolve() == self.db_path.resolve():
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
conn = sqlite3.connect(db)
|
|
152
|
+
conn.row_factory = sqlite3.Row
|
|
153
|
+
rows = conn.execute(
|
|
154
|
+
"SELECT qq_id, platform, player_name, domain, uuid, updated_at FROM user_bindings"
|
|
155
|
+
).fetchall()
|
|
156
|
+
conn.close()
|
|
157
|
+
for row in rows:
|
|
158
|
+
self.upsert_binding(
|
|
159
|
+
qq_id=str(row["qq_id"]),
|
|
160
|
+
platform=str(row["platform"]),
|
|
161
|
+
player_name=str(row["player_name"] or ""),
|
|
162
|
+
domain=str(row["domain"] or ""),
|
|
163
|
+
uuid=str(row["uuid"] or ""),
|
|
164
|
+
)
|
|
165
|
+
total += 1
|
|
166
|
+
except Exception:
|
|
167
|
+
continue
|
|
168
|
+
return total
|
|
169
|
+
|
|
170
|
+
def _migrate_from_json_candidates(self) -> int:
|
|
171
|
+
candidates = [legacy_dir / "user_data.json" for legacy_dir in LEGACY_DATA_DIRS]
|
|
172
|
+
total = 0
|
|
173
|
+
for js in candidates:
|
|
174
|
+
if not js.exists():
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
raw = json.loads(js.read_text(encoding="utf-8"))
|
|
178
|
+
if not isinstance(raw, dict):
|
|
179
|
+
continue
|
|
180
|
+
for qq_id, user_entry in raw.items():
|
|
181
|
+
qq = str(qq_id)
|
|
182
|
+
platform_data = user_entry.get("platform_data", {}) if isinstance(user_entry, dict) else {}
|
|
183
|
+
if isinstance(platform_data, dict):
|
|
184
|
+
for platform, bind in platform_data.items():
|
|
185
|
+
if not isinstance(bind, dict):
|
|
186
|
+
continue
|
|
187
|
+
self.upsert_binding(
|
|
188
|
+
qq_id=qq,
|
|
189
|
+
platform=str(platform),
|
|
190
|
+
player_name=str(bind.get("name") or ""),
|
|
191
|
+
domain=str(bind.get("domain") or ""),
|
|
192
|
+
uuid=str(bind.get("uuid") or ""),
|
|
193
|
+
)
|
|
194
|
+
total += 1
|
|
195
|
+
except Exception:
|
|
196
|
+
continue
|
|
197
|
+
return total
|