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.
- nonebot_plugin_cs2match/__init__.py +197 -0
- nonebot_plugin_cs2match/config.py +21 -0
- nonebot_plugin_cs2match/dynamic_config.py +39 -0
- nonebot_plugin_cs2match/rule.py +16 -0
- nonebot_plugin_cs2match/tools.py +522 -0
- nonebot_plugin_cs2match/typst_template.py +556 -0
- nonebot_plugin_cs2match-1.0.0.dist-info/METADATA +106 -0
- nonebot_plugin_cs2match-1.0.0.dist-info/RECORD +9 -0
- nonebot_plugin_cs2match-1.0.0.dist-info/WHEEL +4 -0
|
@@ -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
|