nonebot-plugin-cs2match 1.0.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.
@@ -0,0 +1,522 @@
1
+ # Copyright (c) 2026 StarsetNight, XuanRikka
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+ from typing import Coroutine
6
+ from typing import ParamSpec, TypeVar
7
+ from asyncio import create_task, Task, to_thread, sleep, gather
8
+ from functools import wraps
9
+ from typing import Any, cast, Callable
10
+ from datetime import datetime
11
+ from collections import defaultdict, OrderedDict
12
+ from binascii import crc32
13
+ from time import time
14
+
15
+ from aiohttp import ClientSession, ClientError, ClientTimeout
16
+ from ayafileio import open
17
+ import typst
18
+
19
+ from nonebot.adapters.onebot.v11 import Message, MessageSegment, Bot
20
+ from nonebot import require, get_driver, get_plugin_config, logger
21
+
22
+ require("nonebot_plugin_localstore")
23
+ from nonebot_plugin_localstore import get_plugin_cache_dir
24
+
25
+ from . import typst_template
26
+ from .config import Config
27
+
28
+ driver = get_driver()
29
+ global_config = driver.config
30
+ config = get_plugin_config(Config)
31
+
32
+ RENDER_CACHE_DIR = get_plugin_cache_dir() / "render_cache"
33
+ RENDER_CACHE_DIR.mkdir(exist_ok=True)
34
+
35
+ P = ParamSpec("P")
36
+ T = TypeVar("T")
37
+ AsyncFunc = Callable[P, Coroutine[Any, Any, T]]
38
+
39
+ def async_dedupe(func: AsyncFunc[P, T]) -> AsyncFunc[P, T]:
40
+ tasks: dict[int, Task[T]] = {}
41
+ @wraps(func)
42
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
43
+ nonlocal tasks
44
+ key = hash((args, tuple(sorted(kwargs.items()))))
45
+ if key in tasks:
46
+ return await tasks[key]
47
+ task = create_task(func(*args, **kwargs))
48
+ tasks[key] = task
49
+ try:
50
+ result = await task
51
+ return result
52
+ finally:
53
+ tasks.pop(key, None)
54
+ return wrapper
55
+
56
+ CACHE_TTL = config.cache_ttl
57
+ MAXSIZE = config.cache_max_size
58
+
59
+ def func_ttl_cache(maxsize: int) -> Callable[[AsyncFunc[P, T]], AsyncFunc[P, T]]:
60
+ def _func_ttl_cache(func: AsyncFunc[P, T]) -> AsyncFunc[P, T]:
61
+ cache: OrderedDict[int, tuple[float, Any]] = OrderedDict()
62
+ maxsize_ = maxsize
63
+
64
+ @wraps(func)
65
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
66
+ nonlocal cache
67
+ key = hash((args, tuple(sorted(kwargs.items()))))
68
+ now = time()
69
+
70
+ if key in cache:
71
+ ttl, data = cache[key]
72
+ if ttl > now:
73
+ cache.move_to_end(key)
74
+ return data
75
+ del cache[key]
76
+
77
+ data = await func(*args, **kwargs)
78
+ cache[key] = (now + CACHE_TTL, data)
79
+
80
+ while len(cache) > maxsize_:
81
+ cache.popitem(last=False)
82
+
83
+ return data
84
+
85
+ return wrapper
86
+
87
+ return _func_ttl_cache
88
+
89
+
90
+ def format_iso(iso: str) -> str:
91
+ try:
92
+ if not iso:
93
+ return "时间未知"
94
+
95
+ dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
96
+
97
+ # 自动读取系统时区
98
+ local_tz = datetime.now().astimezone().tzinfo
99
+
100
+ dt_local = dt.astimezone(local_tz)
101
+
102
+ return dt_local.strftime("%m-%d %H:%M")
103
+
104
+ except ValueError:
105
+ return "时间未知"
106
+
107
+ @async_dedupe
108
+ async def typst_render(typst_content: str, cache_key: str) -> MessageSegment:
109
+ cache_index = crc32(typst_content.encode("utf-8"))
110
+
111
+ cache_file_path = RENDER_CACHE_DIR / f"{cache_key}_{cache_index:08X}.png"
112
+
113
+ if cache_file_path.exists():
114
+ cache_file = open(cache_file_path, "rb")
115
+ cache_data = await cache_file.readall()
116
+ await cache_file.close()
117
+ return MessageSegment.image(cache_data)
118
+
119
+ # 清理死缓存
120
+ for i in RENDER_CACHE_DIR.rglob(f"{cache_key}_*.png"):
121
+ i.unlink()
122
+
123
+ file_data = await to_thread(_typst_render, typst_content)
124
+ cache_file = open(cache_file_path, "wb")
125
+ await cache_file.write(file_data)
126
+ await cache_file.close()
127
+
128
+ return MessageSegment.image(file_data)
129
+
130
+ def _typst_render(typst_content: str) -> bytes:
131
+ # 一般来说是不会输出多页的,所以干脆写个cast哄一下检查器了
132
+ return cast(bytes, typst.compile(typst_content.encode(), format="png", ppi=144.0))
133
+
134
+
135
+ class MonitorClient:
136
+ def __init__(self, client: PandaScoreClient, bot: Bot):
137
+ self.client: PandaScoreClient = client
138
+ self.bot: Bot = bot
139
+ # slug -> 群号集合
140
+ self.monitors: dict[str, set[int]] = {}
141
+ # slug -> 最近一次比赛数据
142
+ self.matches: dict[str, dict[str, Any]] = {}
143
+ self.task: Task = create_task(self.monitor_loop())
144
+
145
+
146
+ def add_monitor(self, slug: str, group_id: int):
147
+ self.monitors.setdefault(slug, set()).add(group_id)
148
+
149
+
150
+ def remove_monitor(self, group_id: int):
151
+ for (_, groups) in self.monitors.items():
152
+ groups.discard(group_id)
153
+
154
+ self.monitors = {
155
+ k: v for k, v in self.monitors.items() if v
156
+ }
157
+
158
+
159
+ async def monitor_loop(self):
160
+ while True:
161
+ try:
162
+ if not self.monitors:
163
+ await sleep(CACHE_TTL)
164
+ continue
165
+
166
+ matches = await self.client.list_matches()
167
+
168
+ for slug, groups in self.monitors.items():
169
+ current = next(
170
+ (m for m in matches if m.get("slug", "").lower() == slug),
171
+ None
172
+ )
173
+
174
+ if current is None:
175
+ logger.warning(f"监控目标消失:{slug}")
176
+ continue
177
+
178
+ old = self.matches.get(slug)
179
+
180
+ if old is not None and self.has_changed(old, current):
181
+ logger.info(f"比赛发生变化:{slug}")
182
+
183
+ message = await typst_render(
184
+ MatchParser.prerender_match(
185
+ current,
186
+ typst_template.push_comment
187
+ ),
188
+ "monitor"
189
+ )
190
+
191
+ for group_id in groups:
192
+ await self.bot.send_group_msg(group_id=group_id, message=Message(message))
193
+
194
+ self.matches[slug] = current
195
+
196
+ if current.get("status") == "finished":
197
+ logger.info(f"比赛结束,停止监控:{slug}")
198
+ self.monitors.pop(slug, None)
199
+ self.matches.pop(slug, None)
200
+
201
+ except Exception as e:
202
+ for groups in self.monitors.values():
203
+ for group_id in groups:
204
+ logger.exception("比赛监视服务异常")
205
+ await self.bot.send_group_msg(
206
+ group_id=group_id,
207
+ message=f">比赛监视服务异常<\n"
208
+ f"错误:{type(e).__name__}\n"
209
+ f"详情请管理员查看日志,\n"
210
+ f"如再次看到此消息,请取消监视。"
211
+ )
212
+ finally:
213
+ await sleep(CACHE_TTL)
214
+
215
+
216
+
217
+ @staticmethod
218
+ def has_changed(old: dict, new: dict) -> bool:
219
+ return any(
220
+ old.get(key) != new.get(key)
221
+ for key in ("status", "results", "games")
222
+ )
223
+
224
+
225
+ class MatchParser:
226
+ @staticmethod
227
+ def parse(match: dict[str, Any]) -> dict[str, Any]:
228
+ # 基础信息
229
+ serie = match.get("serie", {}).get("full_name", "Unknown Match")
230
+ slug = match.get("slug", "unknown")
231
+ match_time = match.get("scheduled_at") or match.get("begin_at") or "unknown time"
232
+ status = match.get("status", "unknown")
233
+
234
+ # 队伍
235
+ opponents = match.get("opponents", [])
236
+ if len(opponents) >= 2:
237
+ team_a = opponents[0]["opponent"]["name"]
238
+ team_b = opponents[1]["opponent"]["name"]
239
+ else:
240
+ team_a = "TBD"
241
+ team_b = "TBD"
242
+
243
+ # 比分(bo match)
244
+ score_map: dict[int, int] = {}
245
+ for r in match.get("results", []):
246
+ tid = r.get("team_id")
247
+ score_map[tid] = r.get("score", 0)
248
+
249
+ # 按顺序映射
250
+ score_a = 0
251
+ score_b = 0
252
+
253
+ if len(opponents) >= 2:
254
+ a_id = opponents[0]["opponent"]["id"]
255
+ b_id = opponents[1]["opponent"]["id"]
256
+
257
+ score_a = score_map.get(a_id)
258
+ score_b = score_map.get(b_id)
259
+
260
+ return {
261
+ "serie": serie,
262
+ "slug": slug,
263
+ "time": format_iso(match_time),
264
+ "team_a": team_a,
265
+ "team_b": team_b,
266
+ "score_a": score_a,
267
+ "score_b": score_b,
268
+ "status": status,
269
+ }
270
+
271
+ @classmethod
272
+ def prerender_list(cls, matches: list[dict[str, Any]], priority_mode: str) -> str:
273
+ series = defaultdict(list)
274
+
275
+ for match in matches:
276
+ serie = (match.get("serie") or {}).get("full_name", "未知赛事")
277
+ if (
278
+ priority_mode == "whitelist-only"
279
+ and cls.serie_priority(serie) == 0
280
+ ):
281
+ continue
282
+ series[serie].append(match)
283
+
284
+ sorted_series = dict(sorted(
285
+ series.items(),
286
+ key=lambda s: cls.serie_priority(s[0]),
287
+ reverse=True
288
+ ))
289
+
290
+ content = typst_template.list_match
291
+
292
+ for serie_name, serie_matches in sorted_series.items():
293
+ content += f'#series_card("{serie_name}", [\n'
294
+
295
+ serie_matches.sort(key=lambda x: x["scheduled_at"])
296
+
297
+ for match in serie_matches:
298
+ match_json = cls.parse(match)
299
+
300
+ content += (
301
+ f'#match_card('
302
+ f'"{match_json["slug"]}",'
303
+ f'"{match_json["time"]}",'
304
+ f'"{match_json["team_a"]}",'
305
+ f'{match_json["score_a"]},'
306
+ f'{match_json["score_b"]},'
307
+ f'"{match_json["team_b"]}",'
308
+ f'{match_json["status"]}'
309
+ f')\n'
310
+ )
311
+
312
+ content += '])\n\n'
313
+
314
+ return content
315
+
316
+ @classmethod
317
+ def prerender_match(cls, match: dict[str, Any], comment: str = "") -> str:
318
+ opponents = match.get("opponents") or []
319
+
320
+ team_a = (
321
+ opponents[0]
322
+ .get("opponent", {})
323
+ .get("name", "未知")
324
+ if len(opponents) > 0
325
+ else "未知"
326
+ )
327
+
328
+ team_b = (
329
+ opponents[1]
330
+ .get("opponent", {})
331
+ .get("name", "未知")
332
+ if len(opponents) > 1
333
+ else "未知"
334
+ )
335
+
336
+ results = match.get("results") or []
337
+
338
+ score_a = results[0].get("score", 0) if len(results) > 0 else 0
339
+ score_b = results[1].get("score", 0) if len(results) > 1 else 0
340
+
341
+ games = []
342
+
343
+ for game in match.get("games") or []:
344
+ winner_id = (
345
+ game.get("winner") or {}
346
+ ).get("id")
347
+
348
+ if winner_id == (
349
+ opponents[0]
350
+ .get("opponent", {})
351
+ .get("id")
352
+ ):
353
+ winner = team_a
354
+
355
+ elif winner_id == (
356
+ opponents[1]
357
+ .get("opponent", {})
358
+ .get("id")
359
+ ):
360
+ winner = team_b
361
+
362
+ else:
363
+ winner = "未知"
364
+
365
+ games.append(
366
+ f"""
367
+ (
368
+ position: {game.get("position", 0)},
369
+ winner: "{winner}",
370
+ status: "{game.get("status", "unknown")}",
371
+ ),
372
+ """
373
+ )
374
+
375
+ games_text = "\n".join(games)
376
+
377
+ return f"""{typst_template.get_match}
378
+ #let match = (
379
+ name: "{match.get("name", "未知比赛")}",
380
+ league: "{(match.get("league") or {}).get("name", "未知赛事")}",
381
+ serie: "{(match.get("serie") or {}).get("full_name", "未知系列")}",
382
+ team_a: "{team_a}",
383
+ team_b: "{team_b}",
384
+ score_a: {score_a},
385
+ score_b: {score_b},
386
+ status: "{match.get("status", "unknown")}",
387
+ time: "{format_iso(match.get("scheduled_at") or match.get("begin_at") or "未知时间")}",
388
+ bo: {match.get("number_of_games", 0)},
389
+ games: (
390
+ {games_text}
391
+ ),
392
+ )
393
+
394
+ {comment}
395
+
396
+ #match_detail(match)
397
+ """
398
+
399
+ @classmethod
400
+ def classify_serie(cls, name: str) -> str:
401
+ if not name:
402
+ return "other"
403
+
404
+ n = name.lower()
405
+
406
+ for key, _, keywords in config.serie_rules:
407
+ if any(k in n for k in keywords):
408
+ return key
409
+
410
+ return "other"
411
+
412
+ @classmethod
413
+ def serie_priority(cls, name: str) -> int:
414
+ key = cls.classify_serie(name)
415
+
416
+ for k, priority, _ in config.serie_rules:
417
+ if k == key:
418
+ return priority
419
+
420
+ return 0
421
+
422
+
423
+
424
+
425
+ class PandaScoreClient:
426
+ def __init__(self, token: str) -> None:
427
+ self.base = "https://api.pandascore.co"
428
+ self.headers = {
429
+ "Authorization": f"Bearer {token}"
430
+ }
431
+ self.session = ClientSession(timeout=ClientTimeout(total=config.client_timeout))
432
+
433
+ async def _get(self, path, params=None) -> Any:
434
+ url = f"{self.base}{path}"
435
+ try:
436
+ async with self.session.get(url, headers=self.headers, params=params) as resp:
437
+ return await resp.json()
438
+ except (ClientError, TimeoutError) as e:
439
+ logger.warning(f"请求失败:{url}:{e}")
440
+ raise
441
+
442
+ async def list_matches(self) -> list[dict[str, Any]]:
443
+ """
444
+ 注意,这个函数调用消耗3次API调用额度,并且会存储3份不同类型的比赛列表缓存。
445
+ """
446
+ past, running, upcoming = await gather(
447
+ self.list_past_matches(),
448
+ self.list_running_matches(),
449
+ self.list_upcoming_matches(),
450
+ )
451
+ return past + running + upcoming
452
+
453
+ @async_dedupe
454
+ @func_ttl_cache(MAXSIZE)
455
+ async def list_past_matches(self) -> list[dict[str, Any]]:
456
+ return [
457
+ m for m in await self._get("/matches/past")
458
+ if m.get("videogame", {}).get("id") == 3
459
+ ]
460
+
461
+ @async_dedupe
462
+ @func_ttl_cache(MAXSIZE)
463
+ async def list_running_matches(self) -> list[dict[str, Any]]:
464
+ return [
465
+ m for m in await self._get("/matches/running")
466
+ if m.get("videogame", {}).get("id") == 3
467
+ ]
468
+
469
+ @async_dedupe
470
+ @func_ttl_cache(MAXSIZE)
471
+ async def list_upcoming_matches(self) -> list[dict[str, Any]]:
472
+ return [
473
+ m for m in await self._get("/matches/upcoming")
474
+ if m.get("videogame", {}).get("id") == 3
475
+ ]
476
+
477
+ @async_dedupe
478
+ @func_ttl_cache(MAXSIZE)
479
+ async def get_match(self, match_id: str) -> dict[str, Any]:
480
+ return await self._get(f"/matches/{match_id}")
481
+
482
+ @async_dedupe
483
+ @func_ttl_cache(MAXSIZE)
484
+ async def get_match_score(self, match_id: str) -> dict[str, int] | None:
485
+ match = await self.get_match(match_id)
486
+
487
+ results = match.get("results", [])
488
+ opponents = match.get("opponents", [])
489
+
490
+ if len(opponents) < 2:
491
+ return None
492
+
493
+ team_map = {
494
+ opponents[0]["opponent"]["id"]: opponents[0]["opponent"]["name"],
495
+ opponents[1]["opponent"]["id"]: opponents[1]["opponent"]["name"]
496
+ }
497
+
498
+ score = {}
499
+ for r in results:
500
+ score[team_map.get(r["team_id"], str(r["team_id"]))] = r["score"]
501
+
502
+ return score
503
+
504
+ @async_dedupe
505
+ @func_ttl_cache(MAXSIZE)
506
+ async def get_teams(self, match_id: str) -> list[dict[str, Any]]:
507
+ match = await self.get_match(match_id)
508
+
509
+ opponents = match.get("opponents", [])
510
+
511
+ teams = []
512
+ for o in opponents:
513
+ t = o["opponent"]
514
+ teams.append({
515
+ "id": t["id"],
516
+ "name": t["name"],
517
+ "acronym": t.get("acronym"),
518
+ "country": t.get("location"),
519
+ "image": t.get("image_url")
520
+ })
521
+
522
+ return teams