mirror of
https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
synced 2026-03-05 05:36:54 +08:00
Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 435850819c | |||
| 6f439ad357 | |||
| b74cc1f4a0 | |||
| 1a1c2675d1 | |||
| 1f02c107f5 | |||
| 89c319a500 | |||
| 56f9a69c4d | |||
| 50431fe7cb | |||
| 71ad53a1f9 | |||
| 820393f216 | |||
| 27994cea6b | |||
|
|
eb753cb059 | ||
|
|
256d13d1df | ||
|
|
d8d56b44db | ||
|
|
57a57f0259 | ||
|
|
4f7f4a3e33 | ||
|
|
367a9a8297 | ||
|
|
009dd90609 | ||
| 8e6e0dc274 | |||
|
|
df7efc6707 | ||
|
|
d783ecd3eb | ||
|
|
13f3e34f79 | ||
| 566509dd46 | |||
| 737671d7a8 | |||
| e4f19d1d81 | |||
|
|
29c12e9249 | ||
|
|
1e22dae6f9 | ||
|
|
7b308d30bc | ||
|
|
ec13ebc43d | ||
|
|
640ecaea85 | ||
|
|
ef772a97ba | ||
|
|
e6cc6a8451 | ||
| 0989090456 | |||
| f8fc9ebdf8 | |||
|
|
ac2b115bd6 | ||
|
|
46eb9b4517 | ||
|
|
eaa81f8157 | ||
| e2eb288b90 | |||
| ed95c8c9fa | |||
|
|
7138e91b2e | ||
|
|
50642866b2 | ||
|
|
92c91f2388 | ||
|
|
42d1bc9a5f | ||
| c8013a080c | |||
| 8bdde936f8 | |||
| aacf518004 | |||
| 34c857387e | |||
| abc2ac07ef | |||
|
|
43d7972cc1 | ||
| 84a7a70183 | |||
| f61bbd00b7 | |||
| 84b74278a6 | |||
|
|
1438ad5efb | ||
| 01e85960fa | |||
|
|
c705610c1d | ||
| 5f0799d505 | |||
|
|
3454e0afbe | ||
| 95d9b74cd7 | |||
| 4b5f0263e4 | |||
| 7500640330 | |||
| 967a028235 | |||
|
|
abe5e30ede | ||
|
|
d9d3f63118 | ||
|
|
4f864c54bc | ||
|
|
2474f77291 | ||
|
|
6291a2ba70 | ||
| 77b10a858e | |||
| e908b3b67f | |||
|
|
bc98c0a3e6 | ||
|
|
f29caf4dc6 | ||
|
|
a1e88dd1c9 | ||
|
|
0dcfa53bcc | ||
| bf4ccdfd61 | |||
| ae65b5140f | |||
| 95aa5b0419 | |||
| b7b92cd785 | |||
| f97ae15969 | |||
| aae43df953 | |||
| c58f124f0c | |||
| 2f900d0538 | |||
| 3e75a4b4e2 | |||
| e285ccfa15 | |||
|
|
d2acbaa0ad | ||
|
|
c81be48585 | ||
|
|
93ec0d8808 | ||
| d5e07880fd | |||
| 8b370f152d | |||
| e8527c7ba4 | |||
| 1dd3d310c9 | |||
| b08685086a | |||
|
|
c2b6fe920f | ||
| a1ad86d0c7 | |||
| e6260ce170 | |||
| b0e53bc8c8 | |||
| 2267bc8f14 | |||
| 607a0927bc | |||
| 7b3ca9eb2a | |||
| 37c12e439c | |||
| 504579710e | |||
| ce94aee0f4 | |||
| b9c58ae125 | |||
| 92159e93b8 | |||
| f9b11895e2 | |||
| f7c3d493ea | |||
| 4954ab3d60 | |||
| bcca869e72 | |||
| a4247abdad | |||
| 2c1d43601a | |||
| c929c463ec | |||
| 314e1dede3 | |||
| d5b0ef34c5 | |||
| 3d9ef841b1 | |||
| b98871f170 | |||
| 38ab872dd8 | |||
| f44c0baa2e | |||
| 9b8d17577e | |||
| f301bee2b0 | |||
| fbe018e56a | |||
| ab046fe786 | |||
| ce95d8f977 | |||
| fa05b80e61 | |||
| 0ab0d11a98 | |||
| 7f469540b2 | |||
| 21bee29146 | |||
|
|
c2dd9c5d86 | ||
|
|
5927cb2bb5 | ||
|
|
4a4a215b61 | ||
| bfe931d3bf | |||
| b7b152d84d | |||
| b6f6eb1170 | |||
| 934800aae0 | |||
|
|
d19c37e99a | ||
|
|
43167fe9bd | ||
|
|
db8de88667 | ||
| 318b42dbd2 | |||
| af4a9f33b0 | |||
|
|
5e5bc4da2c | ||
|
|
594ea9a76f | ||
|
|
69e9ca7933 | ||
|
|
b1bc111b7a | ||
| 43970f4853 | |||
| 48b200697c | |||
| 1a791f5ef8 | |||
| 9b13a9e87c | |||
| ecad6b8070 | |||
| 1e6932b3de | |||
| 3ef7605e11 | |||
|
|
e8539c15cc | ||
|
|
9ace65f9df | ||
|
|
d727a0bc53 | ||
| 52947556a4 | |||
| 7fe9a6fd3d | |||
| 6dbfd31eab | |||
| 1788d40ed2 | |||
| 18d8e0cdcc | |||
| b37f927be6 | |||
| 314bf4c2f0 | |||
| c9f6817c6a | |||
| 4c7cd00a76 | |||
| b8cf10b45d | |||
| 4ec5c3bde1 | |||
| 270b953bc9 | |||
| 13bd0da592 | |||
| 9545f0b5d0 | |||
| 12f320cbb4 | |||
| 7ff59cfc01 | |||
| 498781f376 | |||
| a3c00dbd93 | |||
| 069d5953f9 | |||
| 3721d92f52 | |||
| 98b58866e1 | |||
|
|
189c3999f7 | ||
|
|
a2622d5102 | ||
|
|
c8832bd1c9 | ||
| e6c3a32532 | |||
| b3015aaa91 | |||
|
|
abc1038082 | ||
|
|
dd91455890 | ||
|
|
4b17b0b907 | ||
| ac4631d1f3 | |||
| b0ee7fe6c7 | |||
| 5bcecc0623 | |||
| 9cf048fce4 | |||
| aff2fa120a | |||
| 1c057661c2 | |||
| 83bcd14012 | |||
| 70f53a2c76 | |||
| 6df70f621e | |||
| 8ba3f3c3f4 | |||
| a5c4e7df5c | |||
| 66db7a8a28 | |||
|
|
716e392a3a | ||
|
|
e47f1bb6f9 | ||
| 03d34c5572 | |||
| 04b480ef52 | |||
| 5563b01937 |
27
.github/workflows/TypeCheck.yml
vendored
Normal file
27
.github/workflows/TypeCheck.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: TypeCheck
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
Mypy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
shell: bash
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'poetry'
|
||||
|
||||
- run: poetry install
|
||||
shell: bash
|
||||
|
||||
- name: Run Mypy
|
||||
shell: bash
|
||||
run: |
|
||||
poetry run mypy ./nonebot_plugin_tetris_stats
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,3 +19,6 @@ package-lock.json
|
||||
bot.py
|
||||
TODO
|
||||
*.fish
|
||||
extracted_skin_mino_*
|
||||
sample_*
|
||||
TODO*
|
||||
|
||||
22
.pre-commit-config.yaml
Normal file
22
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
default_install_hook_types: [pre-commit, prepare-commit-msg]
|
||||
ci:
|
||||
autofix_commit_msg: ':rotating_light: auto fix by pre-commit hooks'
|
||||
autofix_prs: true
|
||||
autoupdate_branch: master
|
||||
autoupdate_schedule: monthly
|
||||
autoupdate_commit_msg: ':arrow_up: auto update by pre-commit hooks'
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.4.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
stages: [commit]
|
||||
- id: ruff-format
|
||||
stages: [commit]
|
||||
|
||||
- repo: https://github.com/nonebot/nonemoji
|
||||
rev: v0.1.4
|
||||
hooks:
|
||||
- id: nonemoji
|
||||
stages: [prepare-commit-msg]
|
||||
10
README.md
10
README.md
@@ -87,3 +87,13 @@ pip install nonebot-plugin-tetris-stats
|
||||
## 📝 开源
|
||||
|
||||
本项目使用 [AGPL-3.0](https://github.com/shoucandanghehe/nonebot-plugin-tetris-stats/blob/main/LICENSE) 许可证开源
|
||||
|
||||
## 🤓☝ 给个 star 吧
|
||||
|
||||
<a href="https://star-history.com/#A-Minos/nonebot-plugin-tetris-stats&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=A-Minos/nonebot-plugin-tetris-stats&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=A-Minos/nonebot-plugin-tetris-stats&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=A-Minos/nonebot-plugin-tetris-stats&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
from nonebot import require
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
|
||||
|
||||
require('nonebot_plugin_localstore')
|
||||
require('nonebot_plugin_orm')
|
||||
require('nonebot_plugin_alconna')
|
||||
require('nonebot_plugin_apscheduler')
|
||||
require_plugins = {
|
||||
'nonebot_plugin_alconna',
|
||||
'nonebot_plugin_apscheduler',
|
||||
'nonebot_plugin_localstore',
|
||||
'nonebot_plugin_orm',
|
||||
'nonebot_plugin_session_orm',
|
||||
'nonebot_plugin_session',
|
||||
'nonebot_plugin_user',
|
||||
'nonebot_plugin_userinfo',
|
||||
}
|
||||
|
||||
from .config.config import migrations # noqa: E402
|
||||
for i in require_plugins:
|
||||
require(i)
|
||||
|
||||
from nonebot_plugin_alconna import namespace # noqa: E402
|
||||
|
||||
with namespace('tetris_stats') as ns:
|
||||
ns.enable_message_cache = False
|
||||
|
||||
from .config import migrations # noqa: E402
|
||||
from .config.config import Config # noqa: E402
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name='Tetris Stats',
|
||||
description='一个用于查询 Tetris 相关游戏玩家数据的插件',
|
||||
usage='发送 {游戏名} --help 查询使用方法',
|
||||
usage='发送 tstats --help 查询使用方法',
|
||||
type='application',
|
||||
homepage='https://github.com/A-minos/nonebot-plugin-tetris-stats',
|
||||
config=Config,
|
||||
supported_adapters=inherit_supported_adapters(*require_plugins),
|
||||
extra={
|
||||
'orm_version_location': migrations,
|
||||
},
|
||||
)
|
||||
|
||||
from . import game_data_processor # noqa: F401, E402
|
||||
from .utils import host # noqa: F401, E402
|
||||
from . import games # noqa: F401, E402
|
||||
|
||||
@@ -3,8 +3,6 @@ from pathlib import Path
|
||||
from nonebot_plugin_localstore import get_cache_dir # type: ignore[import-untyped]
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import migrations # noqa: F401
|
||||
|
||||
CACHE_PATH: Path = get_cache_dir('nonebot_plugin_tetris_stats')
|
||||
|
||||
|
||||
@@ -12,3 +10,4 @@ class Config(BaseModel):
|
||||
"""配置类"""
|
||||
|
||||
tetris_req_timeout: float = 30.0
|
||||
tetris_screenshot_quality: float = 2
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '09d4bb60160d'
|
||||
down_revision: str | Sequence[str] | None = 'b9d65badc713'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '0d50142b780f'
|
||||
down_revision: str | Sequence[str] | None = '09d4bb60160d'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Refactor Historical
|
||||
|
||||
迁移 ID: 3c25a5a8c050
|
||||
父迁移: b7fbdafc339a
|
||||
创建时间: 2024-05-14 09:16:35.193001
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from nonebot.log import logger
|
||||
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn, TimeRemainingColumn
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.dialects import sqlite
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
from ujson import dumps, loads
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '3c25a5a8c050'
|
||||
down_revision: str | Sequence[str] | None = 'b7fbdafc339a'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def migrate_old_data() -> None:
|
||||
Base = automap_base() # noqa: N806
|
||||
Base.prepare(autoload_with=op.get_bind())
|
||||
OldHistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
|
||||
TETRIOHistoricalData = Base.classes.nonebot_plugin_tetris_stats_tetriohistoricaldata # noqa: N806
|
||||
TOSHistoricalData = Base.classes.nonebot_plugin_tetris_stats_toshistoricaldata # noqa: N806
|
||||
with (
|
||||
Session(op.get_bind()) as session,
|
||||
Progress(
|
||||
TextColumn('[progress.description]{task.description}'),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeRemainingColumn(),
|
||||
) as progress,
|
||||
):
|
||||
task_id = progress.add_task('[cyan]Migrating:', total=session.query(OldHistoricalData).count())
|
||||
pointer = 0
|
||||
while pointer < session.query(OldHistoricalData).order_by(desc(OldHistoricalData.id)).limit(1).one().id:
|
||||
result = session.scalars(
|
||||
select(OldHistoricalData)
|
||||
.where(OldHistoricalData.id > pointer)
|
||||
.order_by(OldHistoricalData.id)
|
||||
.limit(100)
|
||||
).all()
|
||||
for j in result:
|
||||
processed_data: dict[str, Any] = loads(j.processed_data)
|
||||
if j.game_platform == 'IO':
|
||||
if (data := processed_data.get('user_info')) is not None:
|
||||
session.add(
|
||||
TETRIOHistoricalData(
|
||||
user_unique_identifier=j.user_unique_identifier,
|
||||
api_type='User Info',
|
||||
data=dumps(data),
|
||||
update_time=datetime.fromisoformat(data['cache']['cached_at']),
|
||||
)
|
||||
)
|
||||
if (data := processed_data.get('user_records')) is not None:
|
||||
session.add(
|
||||
TETRIOHistoricalData(
|
||||
user_unique_identifier=j.user_unique_identifier,
|
||||
api_type='User Records',
|
||||
data=dumps(data),
|
||||
update_time=datetime.fromisoformat(data['cache']['cached_at']),
|
||||
)
|
||||
)
|
||||
if j.game_platform == 'TOS' and not j.user_unique_identifier.isdigit():
|
||||
if (data := processed_data.get('user_info')) is not None:
|
||||
session.add(
|
||||
TOSHistoricalData(
|
||||
user_unique_identifier=j.user_unique_identifier,
|
||||
api_type='User Info',
|
||||
data=dumps(data),
|
||||
update_time=j.finish_time,
|
||||
)
|
||||
)
|
||||
if (data := processed_data.get('user_profile')) is not None:
|
||||
for v in data.values():
|
||||
session.add(
|
||||
TOSHistoricalData(
|
||||
user_unique_identifier=j.user_unique_identifier,
|
||||
api_type='User Profile',
|
||||
data=dumps(v),
|
||||
update_time=j.finish_time,
|
||||
)
|
||||
)
|
||||
progress.update(task_id, advance=1)
|
||||
session.commit()
|
||||
pointer = result[-1].id
|
||||
logger.success('Migrate successfully')
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_tetriohistoricaldata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
|
||||
sa.Column('api_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('update_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetriohistoricaldata')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetriohistoricaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_api_type'), ['api_type'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_update_time'), ['update_time'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_user_unique_identifier'),
|
||||
['user_unique_identifier'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_tophistoricaldata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
|
||||
sa.Column('api_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('update_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tophistoricaldata')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tophistoricaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_api_type'), ['api_type'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_update_time'), ['update_time'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_user_unique_identifier'),
|
||||
['user_unique_identifier'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_toshistoricaldata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
|
||||
sa.Column('api_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('update_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_toshistoricaldata')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_toshistoricaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_api_type'), ['api_type'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_update_time'), ['update_time'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_user_unique_identifier'),
|
||||
['user_unique_identifier'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_triggerhistoricaldata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('trigger_time', sa.DateTime(), nullable=False),
|
||||
sa.Column('session_persist_id', sa.Integer(), nullable=False),
|
||||
sa.Column('game_platform', sa.String(length=32), nullable=False),
|
||||
sa.Column('command_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('command_args', sa.JSON(), nullable=False),
|
||||
sa.Column('finish_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_triggerhistoricaldata')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_triggerhistoricaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_command_type'),
|
||||
['command_type'],
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_game_platform'),
|
||||
['game_platform'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
migrate_old_data()
|
||||
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_command_type')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_account')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_type')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier')
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_historicaldata')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_historicaldata',
|
||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||
sa.Column('trigger_time', sa.DATETIME(), nullable=False),
|
||||
sa.Column('bot_platform', sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column('bot_account', sa.VARCHAR(), nullable=True),
|
||||
sa.Column('source_type', sa.VARCHAR(length=32), nullable=True),
|
||||
sa.Column('source_account', sa.VARCHAR(), nullable=True),
|
||||
sa.Column('message', sa.BLOB(), nullable=True),
|
||||
sa.Column('game_platform', sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column('command_type', sa.VARCHAR(length=16), nullable=False),
|
||||
sa.Column('command_args', sqlite.JSON(), nullable=False),
|
||||
sa.Column('game_user', sqlite.JSON(), nullable=False),
|
||||
sa.Column('processed_data', sqlite.JSON(), nullable=False),
|
||||
sa.Column('finish_time', sa.DATETIME(), nullable=False),
|
||||
sa.Column('user_unique_identifier', sa.VARCHAR(length=32), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='pk_nonebot_plugin_tetris_stats_historicaldata'),
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier',
|
||||
['user_unique_identifier'],
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_source_type', ['source_type'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_source_account', ['source_account'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_game_platform', ['game_platform'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
'ix_nonebot_plugin_tetris_stats_historicaldata_command_type', ['command_type'], unique=False
|
||||
)
|
||||
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_triggerhistoricaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_game_platform'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_command_type'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_triggerhistoricaldata')
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_toshistoricaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_user_unique_identifier'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_update_time'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_api_type'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_toshistoricaldata')
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tophistoricaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_user_unique_identifier'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_update_time'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_api_type'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_tophistoricaldata')
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetriohistoricaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_user_unique_identifier'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_update_time'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_api_type'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_tetriohistoricaldata')
|
||||
# ### end Alembic commands ###
|
||||
@@ -5,15 +5,19 @@
|
||||
创建时间: 2023-11-26 20:15:56.033892
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
from ujson import dumps, loads
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '6c3206f90cc3'
|
||||
down_revision: str | Sequence[str] | None = '9f6582279ce2'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from alembic import op
|
||||
from nonebot.log import logger
|
||||
@@ -16,20 +16,24 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '8a91210ce14d'
|
||||
down_revision: str | Sequence[str] | None = '0d50142b780f'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
def upgrade(name: str = '') -> None: # noqa: C901
|
||||
if name:
|
||||
return
|
||||
from nonebot_plugin_tetris_stats.version import __version__
|
||||
|
||||
if __version__ != '1.0.3':
|
||||
logger.critical('本迁移需要1.0.3版本, 请先锁定版本至1.0.3版本再执行本迁移')
|
||||
raise RuntimeError('本迁移需要1.0.3版本, 请先锁定版本至1.0.3版本再执行本迁移')
|
||||
msg = '本迁移需要1.0.3版本, 请先锁定版本至1.0.3版本再执行本迁移'
|
||||
logger.critical(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2, type_validate_json
|
||||
from pydantic import BaseModel, ValidationError
|
||||
@@ -42,7 +46,9 @@ def upgrade(name: str = '') -> None:
|
||||
TimeRemainingColumn,
|
||||
)
|
||||
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import BaseProcessedData
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import ( # type: ignore[import-untyped]
|
||||
BaseProcessedData,
|
||||
)
|
||||
|
||||
Base = automap_base() # noqa: N806
|
||||
Base.prepare(autoload_with=op.get_bind())
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
创建时间: 2023-11-11 16:24:11.826667
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '9866f53ce44f'
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = ('nonebot_plugin_tetris_stats',)
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
创建时间: 2023-11-11 16:51:30.718277
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from alembic import op
|
||||
from nonebot import get_driver
|
||||
@@ -18,6 +19,9 @@ from sqlalchemy import Connection, create_engine, inspect, text
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '9cd1647db502'
|
||||
down_revision: str | Sequence[str] | None = '9866f53ce44f'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
@@ -33,14 +37,10 @@ def migrate_old_data(connection: Connection) -> None:
|
||||
Bind = Base.classes.nonebot_plugin_tetris_stats_bind # noqa: N806
|
||||
|
||||
def non_empty(obj: str) -> bool:
|
||||
if obj != '' and not obj.isspace():
|
||||
return True
|
||||
return False
|
||||
return bool(obj != '' and not obj.isspace())
|
||||
|
||||
def is_int(obj: int | str) -> bool:
|
||||
if isinstance(obj, int) or obj.isdigit():
|
||||
return True
|
||||
return False
|
||||
return bool(isinstance(obj, int) or obj.isdigit())
|
||||
|
||||
bind_list = [
|
||||
Bind(chat_platform='OneBot V11', chat_account=int(row.QQ), game_platform='IO', game_account=row.USER)
|
||||
@@ -80,8 +80,9 @@ def upgrade(name: str = '') -> None:
|
||||
logger.success('nonebot_plugin_tetris_stats: 跳过迁移')
|
||||
return
|
||||
if 'IORANK' not in tables:
|
||||
logger.warning('nonebot_plugin_tetris_stats: 发现过早版本的数据, 请先更新到 0.4.4 版本')
|
||||
raise RuntimeError('nonebot_plugin_tetris_stats: 请先安装 0.4.4 版本完成迁移之后再升级')
|
||||
msg = 'nonebot_plugin_tetris_stats: 请先安装 0.4.4 版本完成迁移之后再升级'
|
||||
logger.warning(msg)
|
||||
raise RuntimeError(msg)
|
||||
logger.info('nonebot_plugin_tetris_stats: 发现来自老版本的数据, 正在迁移...')
|
||||
migrate_old_data(connection)
|
||||
db_path.unlink()
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
创建时间: 2023-11-21 08:35:50.393246
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import sqlite
|
||||
|
||||
from nonebot_plugin_tetris_stats.db.models import PydanticType
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = '9f6582279ce2'
|
||||
down_revision: str | Sequence[str] | None = '9cd1647db502'
|
||||
@@ -45,8 +47,8 @@ def upgrade(name: str = '') -> None:
|
||||
sa.Column('game_platform', sa.String(length=32), nullable=False),
|
||||
sa.Column('command_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('command_args', sa.JSON(), nullable=False),
|
||||
sa.Column('game_user', PydanticType(list), nullable=False),
|
||||
sa.Column('processed_data', PydanticType(list), nullable=False),
|
||||
sa.Column('game_user', sa.JSON(), nullable=False),
|
||||
sa.Column('processed_data', sa.JSON(), nullable=False),
|
||||
sa.Column('finish_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_historicaldata')),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add TETRIO user configuration
|
||||
|
||||
迁移 ID: a1195e989cc6
|
||||
父迁移: b15844837693
|
||||
创建时间: 2024-06-09 04:20:07.819194
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = 'a1195e989cc6'
|
||||
down_revision: str | Sequence[str] | None = 'b15844837693'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_tetriouserconfig',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('query_template', sa.String(length=2), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetriouserconfig')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('nonebot_plugin_tetris_stats_tetriouserconfig')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Migrate to nonobot-plugin-user
|
||||
|
||||
迁移 ID: b15844837693
|
||||
父迁移: 3c25a5a8c050
|
||||
创建时间: 2024-06-08 02:27:35.227596
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = 'b15844837693'
|
||||
down_revision: str | Sequence[str] | None = '3c25a5a8c050'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_bind_chat_account')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_bind_chat_platform')
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_bind')
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_bind',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('game_platform', sa.String(length=32), nullable=False),
|
||||
sa.Column('game_account', sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_bind')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_nonebot_plugin_tetris_stats_bind_user_id'), ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_bind_user_id'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_bind')
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_bind',
|
||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||
sa.Column('chat_platform', sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column('chat_account', sa.VARCHAR(), nullable=False),
|
||||
sa.Column('game_platform', sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column('game_account', sa.VARCHAR(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='pk_nonebot_plugin_tetris_stats_bind'),
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_nonebot_plugin_tetris_stats_bind_chat_platform', ['chat_platform'], unique=False)
|
||||
batch_op.create_index('ix_nonebot_plugin_tetris_stats_bind_chat_account', ['chat_account'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Add user_unique_identifier field to HistoricalData
|
||||
|
||||
迁移 ID: b7fbdafc339a
|
||||
父迁移: 8a91210ce14d
|
||||
创建时间: 2024-05-07 16:55:29.527215
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from nonebot.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = 'b7fbdafc339a'
|
||||
down_revision: str | Sequence[str] | None = '8a91210ce14d'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
from nonebot_plugin_tetris_stats.version import __version__
|
||||
|
||||
if __version__ != '1.0.4':
|
||||
msg = '本迁移需要1.0.4版本, 请先锁定版本至1.0.4版本再执行本迁移'
|
||||
logger.critical(msg)
|
||||
raise RuntimeError(msg)
|
||||
from nonebot.compat import type_validate_json
|
||||
from pydantic import ValidationError
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
MofNCompleteColumn,
|
||||
Progress,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeRemainingColumn,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import BaseUser # type: ignore[import-untyped]
|
||||
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('user_unique_identifier', sa.String(length=32), nullable=True))
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier'),
|
||||
['user_unique_identifier'],
|
||||
unique=False,
|
||||
)
|
||||
Base = automap_base() # noqa: N806
|
||||
connection = op.get_bind()
|
||||
Base.prepare(autoload_with=connection)
|
||||
HistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
|
||||
|
||||
models: list[type[BaseUser]] = BaseUser.__subclasses__()
|
||||
|
||||
def json_to_model(value: str) -> BaseUser:
|
||||
for i in models:
|
||||
try:
|
||||
return type_validate_json(i, value)
|
||||
except ValidationError: # noqa: PERF203
|
||||
...
|
||||
raise ValueError
|
||||
|
||||
with Session(op.get_bind()) as session:
|
||||
count = session.query(HistoricalData).count()
|
||||
with Progress(
|
||||
TextColumn('[progress.description]{task.description}'),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeRemainingColumn(),
|
||||
) as progress:
|
||||
task_id = progress.add_task('[cyan]Updateing:', total=count)
|
||||
for i in range(0, count, 100):
|
||||
for j in session.scalars(
|
||||
select(HistoricalData).where(HistoricalData.id > i).order_by(HistoricalData.id).limit(100)
|
||||
):
|
||||
model = json_to_model(j.game_user)
|
||||
try:
|
||||
j.user_unique_identifier = model.unique_identifier
|
||||
except ValueError:
|
||||
session.delete(j)
|
||||
progress.update(task_id, advance=1)
|
||||
session.commit()
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.alter_column('user_unique_identifier', existing_type=sa.VARCHAR(length=32), nullable=False)
|
||||
logger.success('database upgrade success')
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier'))
|
||||
batch_op.drop_column('user_unique_identifier')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -5,14 +5,18 @@
|
||||
创建时间: 2023-12-30 00:27:40.991704
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = 'b9d65badc713'
|
||||
down_revision: str | Sequence[str] | None = '6c3206f90cc3'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""TETR.IO new season
|
||||
|
||||
迁移 ID: f5b4a6d1325b
|
||||
父迁移: a1195e989cc6
|
||||
创建时间: 2024-08-01 20:44:48.644912
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = 'f5b4a6d1325b'
|
||||
down_revision: str | Sequence[str] | None = 'a1195e989cc6'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_iorank_file_hash')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_iorank_rank')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_iorank_update_time')
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_iorank')
|
||||
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetriohistoricaldata', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_api_type')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_update_time')
|
||||
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_user_unique_identifier')
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_tetriohistoricaldata')
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_tetriohistoricaldata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
|
||||
sa.Column('api_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('update_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetriohistoricaldata')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetriohistoricaldata', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_api_type'), ['api_type'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_update_time'), ['update_time'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_user_unique_identifier'),
|
||||
['user_unique_identifier'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_iorank',
|
||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||
sa.Column('rank', sa.VARCHAR(length=2), nullable=False),
|
||||
sa.Column('tr_line', sa.FLOAT(), nullable=False),
|
||||
sa.Column('player_count', sa.INTEGER(), nullable=False),
|
||||
sa.Column('low_pps', sa.JSON(), nullable=False),
|
||||
sa.Column('low_apm', sa.JSON(), nullable=False),
|
||||
sa.Column('low_vs', sa.JSON(), nullable=False),
|
||||
sa.Column('avg_pps', sa.FLOAT(), nullable=False),
|
||||
sa.Column('avg_apm', sa.FLOAT(), nullable=False),
|
||||
sa.Column('avg_vs', sa.FLOAT(), nullable=False),
|
||||
sa.Column('high_pps', sa.JSON(), nullable=False),
|
||||
sa.Column('high_apm', sa.JSON(), nullable=False),
|
||||
sa.Column('high_vs', sa.JSON(), nullable=False),
|
||||
sa.Column('update_time', sa.DATETIME(), nullable=False),
|
||||
sa.Column('file_hash', sa.VARCHAR(length=128), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name='pk_nonebot_plugin_tetris_stats_iorank'),
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_nonebot_plugin_tetris_stats_iorank_update_time', ['update_time'], unique=False)
|
||||
batch_op.create_index('ix_nonebot_plugin_tetris_stats_iorank_rank', ['rank'], unique=False)
|
||||
batch_op.create_index('ix_nonebot_plugin_tetris_stats_iorank_file_hash', ['file_hash'], unique=False)
|
||||
@@ -1,10 +1,25 @@
|
||||
from asyncio import Lock
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum, auto
|
||||
from typing import TYPE_CHECKING, Literal, TypeVar, overload
|
||||
|
||||
from nonebot_plugin_orm import AsyncSession
|
||||
from nonebot.exception import FinishedException
|
||||
from nonebot.log import logger
|
||||
from nonebot_plugin_orm import AsyncSession, get_session
|
||||
from nonebot_plugin_user import User # type: ignore[import-untyped]
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..utils.typing import GameType
|
||||
from .models import Bind
|
||||
from ..utils.typing import AllCommandType, BaseCommandType, GameType, TETRIOCommandType
|
||||
from .models import Bind, TriggerHistoricalData
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..games.tetrio.api.models import TETRIOHistoricalData
|
||||
from ..games.top.api.models import TOPHistoricalData
|
||||
from ..games.tos.api.models import TOSHistoricalData
|
||||
|
||||
|
||||
class BindStatus(Enum):
|
||||
@@ -14,37 +29,28 @@ class BindStatus(Enum):
|
||||
|
||||
async def query_bind_info(
|
||||
session: AsyncSession,
|
||||
chat_platform: str,
|
||||
chat_account: str,
|
||||
user: User,
|
||||
game_platform: GameType,
|
||||
) -> Bind | None:
|
||||
return (
|
||||
await session.scalars(
|
||||
select(Bind)
|
||||
.where(Bind.chat_platform == chat_platform)
|
||||
.where(Bind.chat_account == chat_account)
|
||||
.where(Bind.game_platform == game_platform)
|
||||
)
|
||||
await session.scalars(select(Bind).where(Bind.user_id == user.id).where(Bind.game_platform == game_platform))
|
||||
).one_or_none()
|
||||
|
||||
|
||||
async def create_or_update_bind(
|
||||
session: AsyncSession,
|
||||
chat_platform: str,
|
||||
chat_account: str,
|
||||
user: User,
|
||||
game_platform: GameType,
|
||||
game_account: str,
|
||||
) -> BindStatus:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
chat_platform=chat_platform,
|
||||
chat_account=chat_account,
|
||||
user=user,
|
||||
game_platform=game_platform,
|
||||
)
|
||||
if bind is None:
|
||||
bind = Bind(
|
||||
chat_platform=chat_platform,
|
||||
chat_account=chat_account,
|
||||
user_id=user.id,
|
||||
game_platform=game_platform,
|
||||
game_account=game_account,
|
||||
)
|
||||
@@ -55,3 +61,75 @@ async def create_or_update_bind(
|
||||
message = BindStatus.UPDATE
|
||||
await session.commit()
|
||||
return message
|
||||
|
||||
|
||||
T = TypeVar('T', 'TETRIOHistoricalData', 'TOPHistoricalData', 'TOSHistoricalData')
|
||||
|
||||
lock = Lock()
|
||||
|
||||
|
||||
async def anti_duplicate_add(cls: type[T], model: T) -> None:
|
||||
async with lock, get_session() as session:
|
||||
result = (
|
||||
await session.scalars(
|
||||
select(cls)
|
||||
.where(cls.update_time == model.update_time)
|
||||
.where(cls.user_unique_identifier == model.user_unique_identifier)
|
||||
.where(cls.api_type == model.api_type)
|
||||
)
|
||||
).all()
|
||||
if result:
|
||||
for i in result:
|
||||
if i.data == model.data:
|
||||
logger.debug('Anti duplicate successfully')
|
||||
return
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@overload
|
||||
async def trigger(
|
||||
session_persist_id: int,
|
||||
game_platform: Literal['IO'],
|
||||
command_type: TETRIOCommandType,
|
||||
command_args: list[str],
|
||||
) -> AsyncGenerator:
|
||||
yield
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@overload
|
||||
async def trigger(
|
||||
session_persist_id: int,
|
||||
game_platform: GameType,
|
||||
command_type: BaseCommandType,
|
||||
command_args: list[str],
|
||||
) -> AsyncGenerator:
|
||||
yield
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def trigger(
|
||||
session_persist_id: int,
|
||||
game_platform: GameType,
|
||||
command_type: AllCommandType,
|
||||
command_args: list[str],
|
||||
) -> AsyncGenerator:
|
||||
trigger_time = datetime.now(UTC)
|
||||
try:
|
||||
yield
|
||||
except FinishedException:
|
||||
async with get_session() as session:
|
||||
session.add(
|
||||
TriggerHistoricalData(
|
||||
trigger_time=trigger_time,
|
||||
session_persist_id=session_persist_id,
|
||||
game_platform=game_platform,
|
||||
command_type=command_type,
|
||||
command_args=command_args,
|
||||
finish_time=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
raise
|
||||
|
||||
@@ -2,24 +2,29 @@ from collections.abc import Callable, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from nonebot.adapters import Message
|
||||
from nonebot.compat import PYDANTIC_V2, type_validate_json
|
||||
from nonebot_plugin_orm import Model
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy import JSON, DateTime, Dialect, PickleType, String, TypeDecorator
|
||||
from sqlalchemy import JSON, DateTime, Dialect, String, TypeDecorator
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
from typing_extensions import override
|
||||
|
||||
from ..game_data_processor.schemas import BaseProcessedData, BaseUser
|
||||
from ..utils.typing import CommandType, GameType
|
||||
from ..utils.typing import AllCommandType, GameType
|
||||
|
||||
|
||||
class PydanticType(TypeDecorator):
|
||||
impl = JSON
|
||||
|
||||
@override
|
||||
def __init__(self, get_model: Callable[[], Sequence[type[BaseModel]]], *args: Any, **kwargs: Any):
|
||||
def __init__(
|
||||
self,
|
||||
get_model: Sequence[Callable[[], Sequence[type[BaseModel]]]],
|
||||
models: set[type[BaseModel]],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self.get_model = get_model
|
||||
self._models = models
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if PYDANTIC_V2:
|
||||
@@ -27,7 +32,7 @@ class PydanticType(TypeDecorator):
|
||||
@override
|
||||
def process_bind_param(self, value: Any | None, dialect: Dialect) -> str:
|
||||
# 将 Pydantic 模型实例转换为 JSON
|
||||
if isinstance(value, tuple(self.get_model())):
|
||||
if isinstance(value, tuple(self.models)):
|
||||
return value.model_dump_json(by_alias=True) # type: ignore[union-attr]
|
||||
raise TypeError
|
||||
else:
|
||||
@@ -35,7 +40,7 @@ class PydanticType(TypeDecorator):
|
||||
@override
|
||||
def process_bind_param(self, value: Any | None, dialect: Dialect) -> str:
|
||||
# 将 Pydantic 模型实例转换为 JSON
|
||||
if isinstance(value, tuple(self.get_model())):
|
||||
if isinstance(value, tuple(self.models)):
|
||||
return value.json(by_alias=True) # type: ignore[union-attr]
|
||||
raise TypeError
|
||||
|
||||
@@ -43,35 +48,34 @@ class PydanticType(TypeDecorator):
|
||||
def process_result_value(self, value: Any | None, dialect: Dialect) -> BaseModel:
|
||||
# 将 JSON 转换回 Pydantic 模型实例
|
||||
if isinstance(value, str | bytes):
|
||||
for i in self.get_model():
|
||||
for i in self.models:
|
||||
try:
|
||||
return type_validate_json(i, value)
|
||||
except ValidationError: # noqa: PERF203
|
||||
...
|
||||
raise ValueError
|
||||
|
||||
@property
|
||||
def models(self) -> tuple[type[BaseModel], ...]:
|
||||
models: set[type[BaseModel]] = set()
|
||||
for i in self.get_model:
|
||||
models.update(i())
|
||||
models.update(self._models)
|
||||
return tuple(models)
|
||||
|
||||
|
||||
class Bind(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
chat_platform: Mapped[str] = mapped_column(String(32), index=True)
|
||||
chat_account: Mapped[str] = mapped_column(index=True)
|
||||
user_id: Mapped[int] = mapped_column(index=True)
|
||||
game_platform: Mapped[GameType] = mapped_column(String(32))
|
||||
game_account: Mapped[str]
|
||||
|
||||
|
||||
class HistoricalData(MappedAsDataclass, Model):
|
||||
class TriggerHistoricalData(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
trigger_time: Mapped[datetime] = mapped_column(DateTime)
|
||||
bot_platform: Mapped[str | None] = mapped_column(String(32))
|
||||
bot_account: Mapped[str | None]
|
||||
source_type: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
source_account: Mapped[str | None] = mapped_column(index=True)
|
||||
message: Mapped[Message | None] = mapped_column(PickleType)
|
||||
game_platform: Mapped[GameType] = mapped_column(String(32), index=True, init=False)
|
||||
command_type: Mapped[CommandType] = mapped_column(String(16), index=True, init=False)
|
||||
command_args: Mapped[list[str]] = mapped_column(JSON, init=False)
|
||||
game_user: Mapped[BaseUser] = mapped_column(PydanticType(get_model=BaseUser.__subclasses__), init=False)
|
||||
processed_data: Mapped[BaseProcessedData] = mapped_column(
|
||||
PydanticType(get_model=BaseProcessedData.__subclasses__), init=False
|
||||
)
|
||||
finish_time: Mapped[datetime] = mapped_column(DateTime, init=False)
|
||||
session_persist_id: Mapped[int]
|
||||
game_platform: Mapped[GameType] = mapped_column(String(32), index=True)
|
||||
command_type: Mapped[AllCommandType] = mapped_column(String(16), index=True)
|
||||
command_args: Mapped[list[str]] = mapped_column(JSON)
|
||||
finish_time: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import AlcMatches, AlconnaMatcher
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_userinfo import UserInfo # type: ignore[import-untyped]
|
||||
|
||||
from ..utils.exception import MessageFormatError
|
||||
from ..utils.recorder import Recorder
|
||||
from ..utils.typing import CommandType, GameType
|
||||
from .schemas import BaseProcessedData as ProcessedData
|
||||
from .schemas import BaseRawResponse as RawResponse
|
||||
from .schemas import BaseUser as User
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class Processor(ABC):
|
||||
event_id: int
|
||||
command_type: CommandType
|
||||
command_args: list[str]
|
||||
user: User
|
||||
raw_response: RawResponse
|
||||
processed_data: ProcessedData
|
||||
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self,
|
||||
event_id: int,
|
||||
user: User,
|
||||
command_args: list[str],
|
||||
) -> None:
|
||||
self.event_id = event_id
|
||||
self.user = user
|
||||
self.command_args = command_args
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def game_platform(self) -> GameType:
|
||||
"""游戏平台"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def handle_bind(
|
||||
self,
|
||||
platform: str,
|
||||
account: str,
|
||||
bot_info: UserInfo,
|
||||
*args: Any, # noqa: ANN401
|
||||
**kwargs: Any, # noqa: ANN401
|
||||
) -> UniMessage:
|
||||
"""处理绑定消息"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def handle_query(self) -> UniMessage:
|
||||
"""处理查询消息"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __del__(self) -> None:
|
||||
finish_time = datetime.now(tz=UTC)
|
||||
if Recorder.is_error_event(self.event_id):
|
||||
Recorder.del_error_event(self.event_id)
|
||||
return
|
||||
historical_data = Recorder.get_historical_data(self.event_id)
|
||||
historical_data.game_platform = self.game_platform
|
||||
historical_data.command_type = self.command_type
|
||||
historical_data.command_args = self.command_args
|
||||
historical_data.game_user = self.user
|
||||
historical_data.processed_data = self.processed_data
|
||||
historical_data.finish_time = finish_time
|
||||
Recorder.update_historical_data(self.event_id, historical_data)
|
||||
|
||||
|
||||
def add_default_handlers(matcher: type[AlconnaMatcher]) -> None:
|
||||
@matcher.handle()
|
||||
async def _(matcher: Matcher, account: MessageFormatError):
|
||||
await matcher.finish(str(account))
|
||||
|
||||
@matcher.handle()
|
||||
async def _(matcher: Matcher, matches: AlcMatches):
|
||||
if matches.head_matched and matches.options != {} or matches.main_args == {}:
|
||||
await matcher.finish(
|
||||
(f'{matches.error_info!r}\n' if matches.error_info is not None else '')
|
||||
+ f'输入"{matches.header_result} --help"查看帮助'
|
||||
)
|
||||
|
||||
@matcher.handle()
|
||||
async def _(matcher: Matcher, other: Any): # noqa: ANN401
|
||||
await matcher.finish()
|
||||
|
||||
|
||||
from . import ( # noqa: F401, E402
|
||||
io_data_processor,
|
||||
top_data_processor,
|
||||
tos_data_processor,
|
||||
)
|
||||
@@ -1,196 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At, on_alconna
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_userinfo import BotUserInfo, UserInfo # type: ignore[import-untyped]
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ...db import query_bind_info
|
||||
from ...utils.exception import HandleNotFinishedError, NeedCatchError
|
||||
from ...utils.metrics import get_metrics
|
||||
from ...utils.platform import get_platform
|
||||
from ...utils.typing import Me
|
||||
from .. import add_default_handlers
|
||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||
from .constant import GAME_TYPE
|
||||
from .model import IORank
|
||||
from .processor import Processor, User, identify_user_info
|
||||
from .typing import Rank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
alc = on_alconna(
|
||||
Alconna(
|
||||
'io',
|
||||
Option(
|
||||
BIND_COMMAND[0],
|
||||
Args(
|
||||
Arg(
|
||||
'account',
|
||||
identify_user_info,
|
||||
notice='IO 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN],
|
||||
)
|
||||
),
|
||||
alias=BIND_COMMAND[1:],
|
||||
compact=True,
|
||||
dest='bind',
|
||||
help_text='绑定 IO 账号',
|
||||
),
|
||||
Option(
|
||||
QUERY_COMMAND[0],
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 | 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
identify_user_info,
|
||||
notice='IO 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
),
|
||||
alias=QUERY_COMMAND[1:],
|
||||
compact=True,
|
||||
dest='query',
|
||||
help_text='查询 IO 游戏信息',
|
||||
),
|
||||
Option(
|
||||
'rank',
|
||||
Args(Arg('rank', Rank, notice='IO 段位')),
|
||||
alias={'Rank', 'RANK', '段位'},
|
||||
compact=True,
|
||||
dest='rank',
|
||||
help_text='查询 IO 段位信息',
|
||||
),
|
||||
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||
meta=CommandMeta(
|
||||
description='查询 TETR.IO 的信息',
|
||||
example='io绑定scdhh\nio查我\niorankx',
|
||||
compact=True,
|
||||
fuzzy_match=True,
|
||||
),
|
||||
),
|
||||
skip_for_unmatch=False,
|
||||
auto_send_output=True,
|
||||
aliases={'IO'},
|
||||
)
|
||||
|
||||
alc.shortcut('fkosk', {'command': 'io查', 'args': ['我']})
|
||||
|
||||
|
||||
@alc.assign('bind')
|
||||
async def _(bot: Bot, event: Event, matcher: Matcher, account: User, bot_info: UserInfo = BotUserInfo()): # noqa: B008
|
||||
proc = Processor(event_id=id(event), user=account, command_args=[])
|
||||
try:
|
||||
await (
|
||||
await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id(), bot_info=bot_info)
|
||||
).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
chat_platform=get_platform(bot),
|
||||
chat_account=(target.target if isinstance(target, At) else event.get_user_id()),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(ID=bind.game_account),
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (UniMessage(message) + await proc.handle_query()).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(event: Event, matcher: Matcher, account: User):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=account,
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (await proc.handle_query()).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('rank')
|
||||
async def _(matcher: Matcher, rank: Rank):
|
||||
if rank == 'z':
|
||||
await matcher.finish('暂不支持查询未知段位')
|
||||
async with get_session() as session:
|
||||
latest_data = (
|
||||
await session.scalars(select(IORank).where(IORank.rank == rank).order_by(IORank.id.desc()).limit(1))
|
||||
).one()
|
||||
compare_data = (
|
||||
await session.scalars(
|
||||
select(IORank)
|
||||
.where(IORank.rank == rank)
|
||||
.order_by(
|
||||
func.abs(
|
||||
func.julianday(IORank.update_time)
|
||||
- func.julianday(latest_data.update_time - timedelta(hours=24))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).one()
|
||||
message = ''
|
||||
if (datetime.now(UTC) - latest_data.update_time.replace(tzinfo=UTC)) > timedelta(hours=7):
|
||||
message += 'Warning: 数据超过7小时未更新, 请联系Bot主人查看后台\n'
|
||||
message += f'{rank.upper()} 段 分数线 {latest_data.tr_line:.2f} TR, {latest_data.player_count} 名玩家\n'
|
||||
if compare_data.id != latest_data.id:
|
||||
message += f'对比 {(latest_data.update_time-compare_data.update_time).total_seconds()/3600:.2f} 小时前趋势: {f"↑{difference:.2f}" if (difference:=latest_data.tr_line-compare_data.tr_line) > 0 else f"↓{-difference:.2f}" if difference < 0 else "→"}'
|
||||
else:
|
||||
message += '暂无对比数据'
|
||||
avg = get_metrics(pps=latest_data.avg_pps, apm=latest_data.avg_apm, vs=latest_data.avg_vs)
|
||||
low_pps = get_metrics(pps=latest_data.low_pps[1])
|
||||
low_vs = get_metrics(vs=latest_data.low_vs[1])
|
||||
max_pps = get_metrics(pps=latest_data.high_pps[1])
|
||||
max_vs = get_metrics(vs=latest_data.high_vs[1])
|
||||
message += (
|
||||
'\n'
|
||||
'平均数据:\n'
|
||||
f"L'PM: {avg.lpm} ( {avg.pps} pps )\n"
|
||||
f'APM: {avg.apm} ( x{avg.apl} )\n'
|
||||
f'ADPM: {avg.adpm} ( x{avg.adpl} ) ( {avg.vs}vs )\n'
|
||||
'\n'
|
||||
'最低数据:\n'
|
||||
f"L'PM: {low_pps.lpm} ( {low_pps.pps} pps ) By: {latest_data.low_pps[0]['name'].upper()}\n"
|
||||
f'APM: {latest_data.low_apm[1]} By: {latest_data.low_apm[0]["name"].upper()}\n'
|
||||
f'ADPM: {low_vs.adpm} ( {low_vs.vs}vs ) By: {latest_data.low_vs[0]["name"].upper()}\n'
|
||||
'\n'
|
||||
'最高数据:\n'
|
||||
f"L'PM: {max_pps.lpm} ( {max_pps.pps} pps ) By: {latest_data.high_pps[0]['name'].upper()}\n"
|
||||
f'APM: {latest_data.high_apm[1]} By: {latest_data.high_apm[0]["name"].upper()}\n'
|
||||
f'ADPM: {max_vs.adpm} ( {max_vs.vs}vs ) By: {latest_data.high_vs[0]["name"].upper()}\n'
|
||||
'\n'
|
||||
f'数据更新时间: {latest_data.update_time.replace(tzinfo=UTC).astimezone(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")}'
|
||||
)
|
||||
await matcher.finish(message)
|
||||
|
||||
|
||||
add_default_handlers(alc)
|
||||
@@ -1,30 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import JSON, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from .typing import Rank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class IORank(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
rank: Mapped[Rank] = mapped_column(String(2), index=True)
|
||||
tr_line: Mapped[float]
|
||||
player_count: Mapped[int]
|
||||
low_pps: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
low_apm: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
low_vs: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
avg_pps: Mapped[float]
|
||||
avg_apm: Mapped[float]
|
||||
avg_vs: Mapped[float]
|
||||
high_pps: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
high_apm: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
high_vs: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
|
||||
update_time: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
index=True,
|
||||
)
|
||||
file_hash: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
@@ -1,324 +0,0 @@
|
||||
from asyncio import gather
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import md5, sha512
|
||||
from math import floor
|
||||
from re import match
|
||||
from statistics import mean
|
||||
from typing import Literal
|
||||
from urllib.parse import urlunparse
|
||||
|
||||
from aiofiles import open
|
||||
from nonebot import get_driver
|
||||
from nonebot.compat import type_validate_json
|
||||
from nonebot.utils import run_sync
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_apscheduler import scheduler # type: ignore[import-untyped]
|
||||
from nonebot_plugin_localstore import get_data_file # type: ignore[import-untyped]
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_userinfo import UserInfo as NBUserInfo # type: ignore[import-untyped]
|
||||
from sqlalchemy import select
|
||||
from typing_extensions import override
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from ...db import BindStatus, create_or_update_bind
|
||||
from ...utils.avatar import get_avatar
|
||||
from ...utils.exception import MessageFormatError, RequestError, WhatTheFuckError
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.render import render
|
||||
from ...utils.request import splice_url
|
||||
from ...utils.retry import retry
|
||||
from ...utils.screenshot import screenshot
|
||||
from .. import Processor as ProcessorMeta
|
||||
from .cache import Cache
|
||||
from .constant import BASE_URL, GAME_TYPE, RANK_PERCENTILE
|
||||
from .model import IORank
|
||||
from .schemas.base import FailedModel
|
||||
from .schemas.league_all import LeagueAll
|
||||
from .schemas.league_all import ValidUser as LeagueAllUser
|
||||
from .schemas.response import ProcessedData, RawResponse
|
||||
from .schemas.user import User
|
||||
from .schemas.user_info import NeverPlayedLeague, NeverRatedLeague, RatedLeague, UserInfo
|
||||
from .schemas.user_info import SuccessModel as InfoSuccess
|
||||
from .schemas.user_records import SoloRecord, UserRecords
|
||||
from .schemas.user_records import SuccessModel as RecordsSuccess
|
||||
from .typing import Rank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
def identify_user_info(info: str) -> User | MessageFormatError:
|
||||
if match(r'^[a-f0-9]{24}$', info):
|
||||
return User(ID=info)
|
||||
if match(r'^[a-zA-Z0-9_-]{3,16}$', info):
|
||||
return User(name=info.lower())
|
||||
return MessageFormatError('用户名/ID不合法')
|
||||
|
||||
|
||||
class Processor(ProcessorMeta):
|
||||
user: User
|
||||
raw_response: RawResponse
|
||||
processed_data: ProcessedData
|
||||
|
||||
@override
|
||||
def __init__(self, event_id: int, user: User, command_args: list[str]) -> None:
|
||||
super().__init__(event_id, user, command_args)
|
||||
self.raw_response = RawResponse()
|
||||
self.processed_data = ProcessedData()
|
||||
|
||||
@property
|
||||
@override
|
||||
def game_platform(self) -> Literal['IO']:
|
||||
return GAME_TYPE
|
||||
|
||||
@override
|
||||
async def handle_bind(self, platform: str, account: str, bot_info: NBUserInfo) -> UniMessage:
|
||||
"""处理绑定消息"""
|
||||
self.command_type = 'bind'
|
||||
await self.get_user()
|
||||
if self.user.ID is None:
|
||||
raise # FIXME: 不知道怎么才能把这类型给变过来了
|
||||
async with get_session() as session:
|
||||
bind_status = await create_or_update_bind(
|
||||
session=session,
|
||||
chat_platform=platform,
|
||||
chat_account=account,
|
||||
game_platform=GAME_TYPE,
|
||||
game_account=self.user.ID,
|
||||
)
|
||||
bot_avatar = await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg')
|
||||
user_info = await self.get_user_info()
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
async with HostPage(
|
||||
await render(
|
||||
'bind.j2.html',
|
||||
user_avatar=f'https://tetr.io/user-content/avatars/{user_info.data.user.id}.jpg?rv={user_info.data.user.avatar_revision}'
|
||||
if user_info.data.user.avatar_revision is not None
|
||||
else f'../../identicon?md5={md5(user_info.data.user.id.encode()).hexdigest()}', # noqa: S324
|
||||
state='unknown',
|
||||
bot_avatar=bot_avatar,
|
||||
game_type=self.game_platform,
|
||||
user_name=user_info.data.user.username.upper(),
|
||||
bot_name=bot_info.user_name,
|
||||
command='io查我',
|
||||
)
|
||||
) as page_hash:
|
||||
message = UniMessage.image(
|
||||
raw=await screenshot(
|
||||
urlunparse(('http', get_self_netloc(), f'/host/page/{page_hash}.html', '', '', ''))
|
||||
)
|
||||
)
|
||||
return message
|
||||
|
||||
@override
|
||||
async def handle_query(self) -> UniMessage:
|
||||
"""处理查询消息"""
|
||||
self.command_type = 'query'
|
||||
await self.get_user()
|
||||
user_info, user_records = await gather(self.get_user_info(), self.get_user_records())
|
||||
user_name = user_info.data.user.username.upper()
|
||||
league = user_info.data.user.league
|
||||
sprint = user_records.data.records.sprint
|
||||
blitz = user_records.data.records.blitz
|
||||
if isinstance(league, RatedLeague) and league.vs is not None:
|
||||
if sprint.record is None:
|
||||
sprint_value = 'N/A'
|
||||
else:
|
||||
if not isinstance(sprint.record, SoloRecord):
|
||||
raise WhatTheFuckError('40L记录不是单人记录')
|
||||
duration = timedelta(milliseconds=sprint.record.endcontext.final_time).total_seconds()
|
||||
sprint_value = f'{duration:.1f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.1f}s' # noqa: PLR2004
|
||||
if blitz.record is None:
|
||||
blitz_value = 'N/A'
|
||||
else:
|
||||
if not isinstance(blitz.record, SoloRecord):
|
||||
raise WhatTheFuckError('Blitz记录不是单人记录')
|
||||
blitz_value = f'{blitz.record.endcontext.score:,}'
|
||||
async with HostPage(
|
||||
await render(
|
||||
'data.j2.html',
|
||||
user_avatar=f'https://tetr.io/user-content/avatars/{user_info.data.user.id}.jpg?rv={user_info.data.user.avatar_revision}'
|
||||
if user_info.data.user.avatar_revision is not None
|
||||
else f'../../identicon?md5={md5(user_info.data.user.id.encode()).hexdigest()}', # noqa: S324
|
||||
user_name=user_name,
|
||||
user_sign=user_info.data.user.bio,
|
||||
game_type='TETR.IO',
|
||||
ranking=round(league.glicko, 2),
|
||||
rd=round(league.rd, 2),
|
||||
rank=league.rank,
|
||||
TR=round(league.rating, 2),
|
||||
global_rank=league.standing,
|
||||
lpm=round(lpm := (league.pps * 24), 2),
|
||||
pps=league.pps,
|
||||
apm=league.apm,
|
||||
apl=round(league.apm / lpm, 2),
|
||||
adpm=round(adpm := (league.vs * 0.6), 2),
|
||||
adpl=round(adpm / lpm, 2),
|
||||
vs=league.vs,
|
||||
sprint=sprint_value,
|
||||
blitz=blitz_value,
|
||||
data=[[0, 0]],
|
||||
split_value=0,
|
||||
value_max=0,
|
||||
value_min=0,
|
||||
offset=0,
|
||||
app=(app := (league.apm / (60 * league.pps))),
|
||||
dsps=(dsps := ((league.vs / 100) - (league.apm / 60))),
|
||||
dspp=(dspp := (dsps / league.pps)),
|
||||
ci=150 * dspp - 125 * app + 50 * (league.vs / league.apm) - 25,
|
||||
ge=2 * ((app * dsps) / league.pps),
|
||||
)
|
||||
) as page_hash:
|
||||
return UniMessage.image(
|
||||
raw=await screenshot(
|
||||
urlunparse(('http', get_self_netloc(), f'/host/page/{page_hash}.html', '', '', ''))
|
||||
)
|
||||
)
|
||||
# call back
|
||||
ret_message = ''
|
||||
if isinstance(league, NeverPlayedLeague):
|
||||
ret_message += f'用户 {user_name} 没有排位统计数据'
|
||||
else:
|
||||
if isinstance(league, NeverRatedLeague):
|
||||
ret_message += f'用户 {user_name} 暂未完成定级赛, 最近十场的数据:'
|
||||
else:
|
||||
if league.rank == 'z':
|
||||
ret_message += f'用户 {user_name} 暂无段位, {round(league.rating,2)} TR'
|
||||
else:
|
||||
ret_message += (
|
||||
f'{league.rank.upper()} 段用户 {user_name} {round(league.rating,2)} TR (#{league.standing})'
|
||||
)
|
||||
ret_message += f', 段位分 {round(league.glicko,2)}±{round(league.rd,2)}, 最近十场的数据:'
|
||||
lpm = league.pps * 24
|
||||
ret_message += f"\nL'PM: {round(lpm, 2)} ( {league.pps} pps )"
|
||||
ret_message += f'\nAPM: {league.apm} ( x{round(league.apm/lpm,2)} )'
|
||||
if league.vs is not None:
|
||||
adpm = league.vs * 0.6
|
||||
ret_message += f'\nADPM: {round(adpm,2)} ( x{round(adpm/lpm,2)} ) ( {league.vs}vs )'
|
||||
user_records = await self.get_user_records()
|
||||
sprint = user_records.data.records.sprint
|
||||
if sprint.record is not None:
|
||||
if not isinstance(sprint.record, SoloRecord):
|
||||
raise WhatTheFuckError('40L记录不是单人记录')
|
||||
ret_message += f'\n40L: {round(sprint.record.endcontext.final_time/1000,2)}s'
|
||||
ret_message += f' ( #{sprint.rank} )' if sprint.rank is not None else ''
|
||||
blitz = user_records.data.records.blitz
|
||||
if blitz.record is not None:
|
||||
if not isinstance(blitz.record, SoloRecord):
|
||||
raise WhatTheFuckError('Blitz记录不是单人记录')
|
||||
ret_message += f'\nBlitz: {blitz.record.endcontext.score}'
|
||||
ret_message += f' ( #{blitz.rank} )' if blitz.rank is not None else ''
|
||||
return UniMessage(ret_message)
|
||||
|
||||
async def get_user(self) -> None:
|
||||
"""
|
||||
用于获取 UserName 和 UserID 的函数
|
||||
"""
|
||||
if self.user.name is None:
|
||||
self.user.name = (await self.get_user_info()).data.user.username
|
||||
if self.user.ID is None:
|
||||
self.user.ID = (await self.get_user_info()).data.user.id
|
||||
|
||||
async def get_user_info(self) -> InfoSuccess:
|
||||
"""获取用户数据"""
|
||||
if self.processed_data.user_info is None:
|
||||
self.raw_response.user_info = await Cache.get(
|
||||
splice_url([BASE_URL, 'users/', f'{self.user.ID or self.user.name}'])
|
||||
)
|
||||
user_info: UserInfo = type_validate_json(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
|
||||
if isinstance(user_info, FailedModel):
|
||||
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
||||
self.processed_data.user_info = user_info
|
||||
return self.processed_data.user_info
|
||||
|
||||
async def get_user_records(self) -> RecordsSuccess:
|
||||
"""获取Solo数据"""
|
||||
if self.processed_data.user_records is None:
|
||||
self.raw_response.user_records = await Cache.get(
|
||||
splice_url([BASE_URL, 'users/', f'{self.user.ID or self.user.name}/', 'records'])
|
||||
)
|
||||
user_records: UserRecords = type_validate_json(UserRecords, self.raw_response.user_records) # type: ignore[arg-type]
|
||||
if isinstance(user_records, FailedModel):
|
||||
raise RequestError(f'用户Solo数据请求错误:\n{user_records.error}')
|
||||
self.processed_data.user_records = user_records
|
||||
return self.processed_data.user_records
|
||||
|
||||
|
||||
@scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
|
||||
@retry(exception_type=RequestError, delay=timedelta(minutes=15))
|
||||
async def get_io_rank_data() -> None:
|
||||
league_all: LeagueAll = type_validate_json(
|
||||
LeagueAll, # type: ignore[arg-type]
|
||||
(data := await Cache.get(splice_url([BASE_URL, 'users/lists/league/all']))),
|
||||
)
|
||||
if isinstance(league_all, FailedModel):
|
||||
raise RequestError(f'排行榜数据请求错误:\n{league_all.error}')
|
||||
|
||||
def pps(user: LeagueAllUser) -> float:
|
||||
return user.league.pps
|
||||
|
||||
def apm(user: LeagueAllUser) -> float:
|
||||
return user.league.apm
|
||||
|
||||
def vs(user: LeagueAllUser) -> float:
|
||||
return user.league.vs
|
||||
|
||||
def _min(users: list[LeagueAllUser], field: Callable[[LeagueAllUser], float]) -> LeagueAllUser:
|
||||
return min(users, key=field)
|
||||
|
||||
def _max(users: list[LeagueAllUser], field: Callable[[LeagueAllUser], float]) -> LeagueAllUser:
|
||||
return max(users, key=field)
|
||||
|
||||
def build_extremes_data(
|
||||
users: list[LeagueAllUser],
|
||||
field: Callable[[LeagueAllUser], float],
|
||||
sort: Callable[[list[LeagueAllUser], Callable[[LeagueAllUser], float]], LeagueAllUser],
|
||||
) -> tuple[dict[str, str], float]:
|
||||
user = sort(users, field)
|
||||
return User(ID=user.id, name=user.username).dict(), field(user)
|
||||
|
||||
data_hash: str | None = await run_sync((await run_sync(sha512)(data)).hexdigest)()
|
||||
async with open(get_data_file('nonebot_plugin_tetris_stats', f'{data_hash}.json.zst'), mode='wb') as file:
|
||||
await file.write(await run_sync(ZstdCompressor(level=12, threads=-1).compress)(data))
|
||||
|
||||
users = [i for i in league_all.data.users if isinstance(i, LeagueAllUser)]
|
||||
rank_to_users: defaultdict[Rank, list[LeagueAllUser]] = defaultdict(list)
|
||||
for i in users:
|
||||
rank_to_users[i.league.rank].append(i)
|
||||
rank_info: list[IORank] = []
|
||||
for rank, percentile in RANK_PERCENTILE.items():
|
||||
offset = floor((percentile / 100) * len(users)) - 1
|
||||
tr_line = users[offset].league.rating
|
||||
rank_users = rank_to_users[rank]
|
||||
rank_info.append(
|
||||
IORank(
|
||||
rank=rank,
|
||||
tr_line=tr_line,
|
||||
player_count=len(rank_users),
|
||||
low_pps=(build_extremes_data(rank_users, pps, _min)),
|
||||
low_apm=(build_extremes_data(rank_users, apm, _min)),
|
||||
low_vs=(build_extremes_data(rank_users, vs, _min)),
|
||||
avg_pps=mean({i.league.pps for i in rank_users}),
|
||||
avg_apm=mean({i.league.apm for i in rank_users}),
|
||||
avg_vs=mean({i.league.vs for i in rank_users}),
|
||||
high_pps=(build_extremes_data(rank_users, pps, _max)),
|
||||
high_apm=(build_extremes_data(rank_users, apm, _max)),
|
||||
high_vs=(build_extremes_data(rank_users, vs, _max)),
|
||||
update_time=league_all.cache.cached_at,
|
||||
file_hash=data_hash,
|
||||
)
|
||||
)
|
||||
async with get_session() as session:
|
||||
session.add_all(rank_info)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
async def _() -> None:
|
||||
async with get_session() as session:
|
||||
latest_time = await session.scalar(select(IORank.update_time).order_by(IORank.id.desc()).limit(1))
|
||||
if latest_time is None or datetime.now(tz=UTC) - latest_time.replace(tzinfo=UTC) > timedelta(hours=6):
|
||||
await get_io_rank_data()
|
||||
@@ -1,66 +0,0 @@
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..typing import Rank
|
||||
from .base import FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class _User(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: str
|
||||
xp: float
|
||||
supporter: bool
|
||||
verified: bool
|
||||
country: str | None = None
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class SuccessModel(BaseSuccessModel):
|
||||
class Data(BaseModel):
|
||||
class ValidUser(_User):
|
||||
class League(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: float
|
||||
glicko: float
|
||||
rd: float
|
||||
rank: Rank
|
||||
bestrank: Rank
|
||||
apm: float
|
||||
pps: float
|
||||
vs: float
|
||||
decaying: bool
|
||||
|
||||
league: League
|
||||
|
||||
class InvalidUser(_User):
|
||||
class League(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: float
|
||||
glicko: float | None = None
|
||||
rd: float | None = None
|
||||
rank: Rank
|
||||
bestrank: Rank
|
||||
apm: float | None = None
|
||||
pps: float | None = None
|
||||
vs: float | None = None
|
||||
decaying: bool
|
||||
|
||||
league: League
|
||||
|
||||
users: list[ValidUser | InvalidUser]
|
||||
|
||||
data: Data
|
||||
|
||||
|
||||
LeagueAll = SuccessModel | FailedModel
|
||||
ValidUser = SuccessModel.Data.ValidUser
|
||||
InvalidUser = SuccessModel.Data.InvalidUser
|
||||
@@ -1,21 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from ... import ProcessedData as ProcessedDataMeta
|
||||
from ... import RawResponse as RawResponseMeta
|
||||
from ..constant import GAME_TYPE
|
||||
from .user_info import SuccessModel as InfoSuccess
|
||||
from .user_records import SuccessModel as RecordsSuccess
|
||||
|
||||
|
||||
class RawResponse(RawResponseMeta):
|
||||
platform: Literal['IO'] = GAME_TYPE
|
||||
|
||||
user_info: bytes | None = None
|
||||
user_records: bytes | None = None
|
||||
|
||||
|
||||
class ProcessedData(ProcessedDataMeta):
|
||||
platform: Literal['IO'] = GAME_TYPE
|
||||
|
||||
user_info: InfoSuccess | None = None
|
||||
user_records: RecordsSuccess | None = None
|
||||
@@ -1,17 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...schemas import BaseUser
|
||||
from ..constant import GAME_TYPE
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
platform: Literal['IO'] = GAME_TYPE
|
||||
|
||||
ID: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
@property
|
||||
def unique_identifier(self) -> str:
|
||||
if self.ID is None:
|
||||
raise ValueError('不完整的User!')
|
||||
return self.ID
|
||||
@@ -1,132 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..typing import Rank
|
||||
from .base import FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class SuccessModel(BaseSuccessModel):
|
||||
class Data(BaseModel):
|
||||
class User(BaseModel):
|
||||
class Badge(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
ts: datetime | None = None
|
||||
|
||||
class NeverPlayedLeague(BaseModel):
|
||||
gamesplayed: Literal[0]
|
||||
gameswon: Literal[0]
|
||||
rating: Literal[-1]
|
||||
rank: Literal['z']
|
||||
standing: Literal[-1]
|
||||
standing_local: Literal[-1]
|
||||
next_rank: None
|
||||
prev_rank: None
|
||||
next_at: Literal[-1]
|
||||
prev_at: Literal[-1]
|
||||
percentile: Literal[-1]
|
||||
percentile_rank: Literal['z']
|
||||
apm: None = Field(None)
|
||||
pps: None = Field(None)
|
||||
vs: None = Field(None)
|
||||
decaying: bool
|
||||
|
||||
class NeverRatedLeague(BaseModel):
|
||||
gamesplayed: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
gameswon: int
|
||||
rating: Literal[-1]
|
||||
rank: Literal['z']
|
||||
standing: Literal[-1]
|
||||
standing_local: Literal[-1]
|
||||
next_rank: None
|
||||
prev_rank: None
|
||||
next_at: Literal[-1]
|
||||
prev_at: Literal[-1]
|
||||
percentile: Literal[-1]
|
||||
percentile_rank: Literal['z']
|
||||
apm: float
|
||||
pps: float
|
||||
vs: float
|
||||
decaying: bool
|
||||
|
||||
class RatedLeague(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: float
|
||||
rank: Rank
|
||||
bestrank: Rank
|
||||
standing: int
|
||||
standing_local: int
|
||||
next_rank: Rank | None = None
|
||||
prev_rank: Rank | None = None
|
||||
next_at: int
|
||||
prev_at: int
|
||||
percentile: float
|
||||
percentile_rank: str
|
||||
glicko: float
|
||||
rd: float
|
||||
apm: float
|
||||
pps: float
|
||||
vs: float | None = None
|
||||
decaying: bool
|
||||
|
||||
class Connections(BaseModel):
|
||||
class Discord(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
|
||||
discord: Discord | None = None
|
||||
|
||||
class Distinguishment(BaseModel):
|
||||
type: str
|
||||
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
|
||||
ts: datetime | None = None
|
||||
botmaster: str | None = None
|
||||
badges: list[Badge]
|
||||
xp: float
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
gametime: float
|
||||
country: str | None = None
|
||||
badstanding: bool | None = None
|
||||
supporter: bool | None = None # osk说是必有, 但实际上不是 fk osk
|
||||
supporter_tier: int
|
||||
verified: bool
|
||||
league: NeverPlayedLeague | NeverRatedLeague | RatedLeague
|
||||
avatar_revision: int | None = None
|
||||
"""This user's avatar ID. Get their avatar at
|
||||
|
||||
https://tetr.io/user-content/avatars/{ USERID }.jpg?rv={ AVATAR_REVISION }"""
|
||||
banner_revision: int | None = None
|
||||
"""This user's banner ID. Get their banner at
|
||||
|
||||
https://tetr.io/user-content/banners/{ USERID }.jpg?rv={ BANNER_REVISION }
|
||||
|
||||
Ignore this field if the user is not a supporter."""
|
||||
bio: str | None = None
|
||||
connections: Connections
|
||||
friend_count: int | None = None
|
||||
distinguishment: Distinguishment | None = None
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
user: User
|
||||
|
||||
data: Data
|
||||
|
||||
|
||||
NeverPlayedLeague = SuccessModel.Data.User.NeverPlayedLeague
|
||||
NeverRatedLeague = SuccessModel.Data.User.NeverRatedLeague
|
||||
RatedLeague = SuccessModel.Data.User.RatedLeague
|
||||
UserInfo = SuccessModel | FailedModel
|
||||
@@ -1,143 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .base import FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class EndContext(BaseModel):
|
||||
class Time(BaseModel):
|
||||
start: int
|
||||
zero: bool
|
||||
locked: bool
|
||||
prev: int
|
||||
frameoffset: int | None = None
|
||||
|
||||
class Clears(BaseModel):
|
||||
singles: int
|
||||
doubles: int
|
||||
triples: int
|
||||
quads: int
|
||||
pentas: int | None = None
|
||||
realtspins: int
|
||||
minitspins: int
|
||||
minitspinsingles: int
|
||||
tspinsingles: int
|
||||
minitspindoubles: int
|
||||
tspindoubles: int
|
||||
tspintriples: int
|
||||
tspinquads: int
|
||||
allclear: int
|
||||
|
||||
class Garbage(BaseModel):
|
||||
sent: int
|
||||
received: int
|
||||
attack: int | None = None
|
||||
cleared: int
|
||||
|
||||
class Finesse(BaseModel):
|
||||
combo: int
|
||||
faults: int
|
||||
perfectpieces: int
|
||||
|
||||
seed: int
|
||||
lines: int
|
||||
level_lines: int
|
||||
level_lines_needed: int
|
||||
inputs: int
|
||||
holds: int | None = None
|
||||
time: Time
|
||||
score: int
|
||||
zenlevel: int | None = None
|
||||
zenprogress: int | None = None
|
||||
level: int
|
||||
combo: int
|
||||
currentcombopower: int | None = None # WTF
|
||||
topcombo: int
|
||||
btb: int
|
||||
topbtb: int
|
||||
currentbtbchainpower: int | None = None # WTF * 2
|
||||
tspins: int
|
||||
piecesplaced: int
|
||||
clears: Clears
|
||||
garbage: Garbage
|
||||
kills: int
|
||||
finesse: Finesse
|
||||
final_time: float = Field(..., alias='finalTime')
|
||||
gametype: str
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class _User(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class _Record(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
stream: str
|
||||
replayid: str
|
||||
user: _User
|
||||
ts: datetime
|
||||
ismulti: bool | None = None
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class BaseModeRecord(BaseModel):
|
||||
class SoloRecord(_Record):
|
||||
endcontext: EndContext
|
||||
|
||||
class MultiRecord(_Record):
|
||||
endcontext: list[EndContext]
|
||||
|
||||
record: SoloRecord | MultiRecord | None = None
|
||||
rank: int | None = None
|
||||
|
||||
|
||||
class SuccessModel(BaseSuccessModel):
|
||||
class Data(BaseModel):
|
||||
class Records(BaseModel):
|
||||
class Sprint(BaseModeRecord): ...
|
||||
|
||||
class Blitz(BaseModeRecord): ...
|
||||
|
||||
sprint: Sprint = Field(..., alias='40l')
|
||||
blitz: Blitz
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
class Zen(BaseModel):
|
||||
level: int
|
||||
score: int
|
||||
|
||||
records: Records
|
||||
zen: Zen
|
||||
|
||||
data: Data
|
||||
|
||||
|
||||
SoloRecord = BaseModeRecord.SoloRecord
|
||||
MultiRecord = BaseModeRecord.MultiRecord
|
||||
UserRecords = SuccessModel | FailedModel
|
||||
@@ -1,22 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
Rank = Literal[
|
||||
'x',
|
||||
'u',
|
||||
'ss',
|
||||
's+',
|
||||
's',
|
||||
's-',
|
||||
'a+',
|
||||
'a',
|
||||
'a-',
|
||||
'b+',
|
||||
'b',
|
||||
'b-',
|
||||
'c+',
|
||||
'c',
|
||||
'c-',
|
||||
'd+',
|
||||
'd',
|
||||
'z', # 未定级
|
||||
]
|
||||
@@ -1,134 +0,0 @@
|
||||
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At, on_alconna
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo # type: ignore[import-untyped]
|
||||
|
||||
from ...db import query_bind_info
|
||||
from ...utils.exception import HandleNotFinishedError, NeedCatchError
|
||||
from ...utils.platform import get_platform
|
||||
from ...utils.typing import Me
|
||||
from .. import add_default_handlers
|
||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||
from .constant import GAME_TYPE
|
||||
from .processor import Processor, User, identify_user_info
|
||||
|
||||
alc = on_alconna(
|
||||
Alconna(
|
||||
'top',
|
||||
Option(
|
||||
BIND_COMMAND[0],
|
||||
Args(
|
||||
Arg(
|
||||
'account',
|
||||
identify_user_info,
|
||||
notice='TOP 用户名',
|
||||
flags=[ArgFlag.HIDDEN],
|
||||
)
|
||||
),
|
||||
alias=BIND_COMMAND[1:],
|
||||
compact=True,
|
||||
dest='bind',
|
||||
help_text='绑定 TOP 账号',
|
||||
),
|
||||
Option(
|
||||
QUERY_COMMAND[0],
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 | 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
identify_user_info | Me | At,
|
||||
notice='TOP 用户名',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
),
|
||||
alias=QUERY_COMMAND[1:],
|
||||
compact=True,
|
||||
dest='query',
|
||||
help_text='查询 TOP 游戏信息',
|
||||
),
|
||||
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||
meta=CommandMeta(
|
||||
description='查询 TetrisOnline波兰服 的信息',
|
||||
example='top绑定scdhh\ntop查我',
|
||||
compact=True,
|
||||
fuzzy_match=True,
|
||||
),
|
||||
),
|
||||
skip_for_unmatch=False,
|
||||
auto_send_output=True,
|
||||
aliases={'TOP'},
|
||||
)
|
||||
|
||||
|
||||
@alc.assign('bind')
|
||||
async def _( # noqa: PLR0913
|
||||
bot: Bot,
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
account: User,
|
||||
bot_info: UserInfo = BotUserInfo(), # noqa: B008
|
||||
user_info: UserInfo = EventUserInfo(), # noqa: B008
|
||||
):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=account,
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (
|
||||
await proc.handle_bind(
|
||||
platform=get_platform(bot), account=event.get_user_id(), bot_info=bot_info, user_info=user_info
|
||||
)
|
||||
).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
chat_platform=get_platform(bot),
|
||||
chat_account=(target.target if isinstance(target, At) else event.get_user_id()),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(name=bind.game_account),
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (UniMessage(message) + await proc.handle_query()).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(event: Event, matcher: Matcher, account: User):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=account,
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (await proc.handle_query()).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
add_default_handlers(alc)
|
||||
@@ -1,162 +0,0 @@
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from io import StringIO
|
||||
from re import match
|
||||
from typing import Literal
|
||||
from urllib.parse import urlencode, urlunparse
|
||||
|
||||
from lxml import etree
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_userinfo import UserInfo # type: ignore[import-untyped]
|
||||
from pandas import read_html
|
||||
from typing_extensions import override
|
||||
|
||||
from ...db import BindStatus, create_or_update_bind
|
||||
from ...utils.avatar import get_avatar
|
||||
from ...utils.exception import MessageFormatError, RequestError
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.render import render
|
||||
from ...utils.request import Request, splice_url
|
||||
from ...utils.screenshot import screenshot
|
||||
from .. import Processor as ProcessorMeta
|
||||
from ..schemas import BaseUser
|
||||
from .constant import BASE_URL, GAME_TYPE
|
||||
from .schemas.response import ProcessedData, RawResponse
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
platform: Literal['TOP'] = GAME_TYPE
|
||||
|
||||
name: str
|
||||
|
||||
@property
|
||||
@override
|
||||
def unique_identifier(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
class Data:
|
||||
lpm: float
|
||||
apm: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameData:
|
||||
day: Data | None
|
||||
total: Data | None
|
||||
|
||||
|
||||
def identify_user_info(info: str) -> User | MessageFormatError:
|
||||
if match(r'^[a-zA-Z0-9_]{1,16}$', info):
|
||||
return User(name=info)
|
||||
return MessageFormatError('用户名不合法')
|
||||
|
||||
|
||||
class Processor(ProcessorMeta):
|
||||
user: User
|
||||
raw_response: RawResponse
|
||||
processed_data: ProcessedData
|
||||
|
||||
@override
|
||||
def __init__(self, event_id: int, user: User, command_args: list[str]) -> None:
|
||||
super().__init__(event_id, user, command_args)
|
||||
self.raw_response = RawResponse()
|
||||
self.processed_data = ProcessedData()
|
||||
|
||||
@property
|
||||
@override
|
||||
def game_platform(self) -> Literal['TOP']:
|
||||
return GAME_TYPE
|
||||
|
||||
@override
|
||||
async def handle_bind(self, platform: str, account: str, bot_info: UserInfo, user_info: UserInfo) -> UniMessage:
|
||||
"""处理绑定消息"""
|
||||
self.command_type = 'bind'
|
||||
await self.check_user()
|
||||
async with get_session() as session:
|
||||
bind_status = await create_or_update_bind(
|
||||
session=session,
|
||||
chat_platform=platform,
|
||||
chat_account=account,
|
||||
game_platform=GAME_TYPE,
|
||||
game_account=self.user.name,
|
||||
)
|
||||
bot_avatar = await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg')
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
async with HostPage(
|
||||
await render(
|
||||
'bind.j2.html',
|
||||
user_avatar='../../static/static/logo/top.ico',
|
||||
state='unknown',
|
||||
bot_avatar=bot_avatar,
|
||||
game_type=self.game_platform,
|
||||
user_name=(await self.get_user_name()).upper(),
|
||||
bot_name=bot_info.user_name,
|
||||
command='top查我',
|
||||
)
|
||||
) as page_hash:
|
||||
message = UniMessage.image(
|
||||
raw=await screenshot(
|
||||
urlunparse(('http', get_self_netloc(), f'/host/page/{page_hash}.html', '', '', ''))
|
||||
)
|
||||
)
|
||||
return message
|
||||
|
||||
@override
|
||||
async def handle_query(self) -> UniMessage:
|
||||
"""处理查询消息"""
|
||||
self.command_type = 'query'
|
||||
await self.check_user()
|
||||
game_data = await self.get_game_data()
|
||||
message = ''
|
||||
if game_data.day is not None:
|
||||
message += f'用户 {self.user.name} 24小时内统计数据为: '
|
||||
message += f"\nL'PM: {round(game_data.day.lpm,2)} ( {round(game_data.day.lpm/24,2)} pps )"
|
||||
message += f'\nAPM: {round(game_data.day.apm,2)} ( x{round(game_data.day.apm/game_data.day.lpm,2)} )'
|
||||
else:
|
||||
message += f'用户 {self.user.name} 暂无24小时内统计数据'
|
||||
if game_data.total is not None:
|
||||
message += '\n历史统计数据为: '
|
||||
message += f"\nL'PM: {round(game_data.total.lpm,2)} ( {round(game_data.total.lpm/24,2)} pps )"
|
||||
message += f'\nAPM: {round(game_data.total.apm,2)} ( x{round(game_data.total.apm/game_data.total.lpm,2)} )'
|
||||
else:
|
||||
message += '\n暂无历史统计数据'
|
||||
return UniMessage(message)
|
||||
|
||||
async def get_user_profile(self) -> str:
|
||||
"""获取用户信息"""
|
||||
if self.processed_data.user_profile is None:
|
||||
url = splice_url([BASE_URL, 'profile.php', f'?{urlencode({"user":self.user.name})}'])
|
||||
self.raw_response.user_profile = await Request.request(url, is_json=False)
|
||||
self.processed_data.user_profile = self.raw_response.user_profile.decode()
|
||||
return self.processed_data.user_profile
|
||||
|
||||
async def check_user(self) -> None:
|
||||
if 'user not found!' in await self.get_user_profile():
|
||||
raise RequestError('用户不存在!')
|
||||
|
||||
async def get_user_name(self) -> str:
|
||||
"""获取用户名"""
|
||||
data = etree.HTML(await self.get_user_profile()).xpath('//div[@class="mycontent"]/h1/text()')
|
||||
return data[0].replace("'s profile", '')
|
||||
|
||||
async def get_game_data(self) -> GameData:
|
||||
"""获取游戏统计数据"""
|
||||
html = etree.HTML(await self.get_user_profile())
|
||||
day = None
|
||||
with suppress(ValueError):
|
||||
day = Data(
|
||||
lpm=float(str(html.xpath('//div[@class="mycontent"]/text()[3]')[0]).replace('lpm:', '').strip()),
|
||||
apm=float(str(html.xpath('//div[@class="mycontent"]/text()[4]')[0]).replace('apm:', '').strip()),
|
||||
)
|
||||
table = StringIO(
|
||||
etree.tostring(
|
||||
html.xpath('//div[@class="mycontent"]/table[@class="mytable"]')[0],
|
||||
encoding='utf-8',
|
||||
).decode()
|
||||
)
|
||||
dataframe = read_html(table, encoding='utf-8', header=0)[0]
|
||||
total = Data(lpm=dataframe['lpm'].mean(), apm=dataframe['apm'].mean()) if len(dataframe) != 0 else None
|
||||
return GameData(day=day, total=total)
|
||||
@@ -1,16 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...schemas import BaseProcessedData, BaseRawResponse
|
||||
from ..constant import GAME_TYPE
|
||||
|
||||
|
||||
class RawResponse(BaseRawResponse):
|
||||
platform: Literal['TOP'] = GAME_TYPE
|
||||
|
||||
user_profile: bytes | None = None
|
||||
|
||||
|
||||
class ProcessedData(BaseProcessedData):
|
||||
platform: Literal['TOP'] = GAME_TYPE
|
||||
|
||||
user_profile: str | None = None
|
||||
@@ -1,188 +0,0 @@
|
||||
from typing import NoReturn
|
||||
|
||||
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At, on_alconna
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_userinfo import BotUserInfo, UserInfo # type: ignore[import-untyped]
|
||||
|
||||
from ...db import query_bind_info
|
||||
from ...utils.exception import HandleNotFinishedError, NeedCatchError, RequestError
|
||||
from ...utils.platform import get_platform
|
||||
from ...utils.typing import Me
|
||||
from .. import add_default_handlers
|
||||
from ..constant import BIND_COMMAND, QUERY_COMMAND
|
||||
from .constant import GAME_TYPE
|
||||
from .processor import Processor, User, identify_user_info
|
||||
|
||||
alc = on_alconna(
|
||||
Alconna(
|
||||
'茶服',
|
||||
Option(
|
||||
BIND_COMMAND[0],
|
||||
Args(
|
||||
Arg(
|
||||
'account',
|
||||
identify_user_info,
|
||||
notice='茶服 用户名 / TeaID',
|
||||
flags=[ArgFlag.HIDDEN],
|
||||
)
|
||||
),
|
||||
alias=BIND_COMMAND[1:],
|
||||
compact=True,
|
||||
dest='bind',
|
||||
help_text='绑定 茶服 账号',
|
||||
),
|
||||
Option(
|
||||
QUERY_COMMAND[0],
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 | 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
identify_user_info,
|
||||
notice='茶服 用户名 / TeaID',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
# 如果放在一个 Union Args 里, 验证顺序不能保证, 可能出错
|
||||
),
|
||||
alias=QUERY_COMMAND[1:],
|
||||
compact=True,
|
||||
dest='query',
|
||||
help_text='查询 茶服 游戏信息',
|
||||
),
|
||||
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
|
||||
meta=CommandMeta(
|
||||
description='查询 TetrisOnline茶服 的信息',
|
||||
example='茶服查我',
|
||||
compact=True,
|
||||
fuzzy_match=True,
|
||||
),
|
||||
),
|
||||
skip_for_unmatch=False,
|
||||
auto_send_output=True,
|
||||
aliases={'tos', 'TOS'},
|
||||
)
|
||||
|
||||
|
||||
async def finish_special_query(matcher: Matcher, proc: Processor) -> NoReturn:
|
||||
try:
|
||||
await (await proc.handle_query()).finish()
|
||||
except NeedCatchError as e:
|
||||
if isinstance(e, RequestError) and '未找到此用户' in e.message:
|
||||
matcher.skip()
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
try:
|
||||
from nonebot.adapters.onebot.v11 import GROUP as OB11GROUP
|
||||
from nonebot.adapters.onebot.v11 import Bot as OB11Bot
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent as OB11MessageEvent
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(bot: OB11Bot, event: OB11MessageEvent, matcher: Matcher, target: At | Me):
|
||||
if event.is_tome() and await OB11GROUP(bot, event):
|
||||
await matcher.finish('不能查询bot的信息')
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(teaid=f'onebot-{target.target}' if isinstance(target, At) else f'onebot-{event.get_user_id()}'),
|
||||
command_args=[],
|
||||
)
|
||||
await finish_special_query(matcher, proc)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from nonebot.adapters.kaiheila.event import MessageEvent as KookMessageEvent
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(event: KookMessageEvent, matcher: Matcher, target: At | Me):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(teaid=f'kook-{target.target}' if isinstance(target, At) else f'kook-{event.get_user_id()}'),
|
||||
command_args=[],
|
||||
)
|
||||
await finish_special_query(matcher, proc)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from nonebot.adapters.discord import MessageEvent as DiscordMessageEvent
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(event: DiscordMessageEvent, matcher: Matcher, target: At | Me):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(teaid=f'discord-{target.target}' if isinstance(target, At) else f'discord-{event.get_user_id()}'),
|
||||
command_args=[],
|
||||
)
|
||||
await finish_special_query(matcher, proc)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@alc.assign('bind')
|
||||
async def _(bot: Bot, event: Event, matcher: Matcher, account: User, bot_info: UserInfo = BotUserInfo()): # noqa: B008
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=account,
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (
|
||||
await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id(), bot_info=bot_info)
|
||||
).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
chat_platform=get_platform(bot),
|
||||
chat_account=(target.target if isinstance(target, At) else event.get_user_id()),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=User(teaid=bind.game_account),
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (UniMessage(message) + await proc.handle_query()).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
@alc.assign('query')
|
||||
async def _(event: Event, matcher: Matcher, account: User):
|
||||
proc = Processor(
|
||||
event_id=id(event),
|
||||
user=account,
|
||||
command_args=[],
|
||||
)
|
||||
try:
|
||||
await (await proc.handle_query()).finish()
|
||||
except NeedCatchError as e:
|
||||
await matcher.send(str(e))
|
||||
raise HandleNotFinishedError from e
|
||||
|
||||
|
||||
add_default_handlers(alc)
|
||||
@@ -1,253 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from re import match
|
||||
from typing import Literal
|
||||
from urllib.parse import urlencode, urlunparse
|
||||
|
||||
from httpx import TimeoutException
|
||||
from nonebot.compat import type_validate_json
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_userinfo import UserInfo as NBUserInfo # type: ignore[import-untyped]
|
||||
from typing_extensions import override
|
||||
|
||||
from ...db import BindStatus, create_or_update_bind
|
||||
from ...utils.avatar import get_avatar
|
||||
from ...utils.exception import MessageFormatError, RequestError
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.render import render
|
||||
from ...utils.request import Request, splice_url
|
||||
from ...utils.screenshot import screenshot
|
||||
from .. import Processor as ProcessorMeta
|
||||
from ..schemas import BaseUser
|
||||
from .constant import BASE_URL, GAME_TYPE
|
||||
from .schemas.response import ProcessedData, RawResponse
|
||||
from .schemas.user_info import SuccessModel as InfoSuccess
|
||||
from .schemas.user_info import UserInfo
|
||||
from .schemas.user_profile import UserProfile
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
platform: Literal['TOS'] = GAME_TYPE
|
||||
|
||||
teaid: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
@property
|
||||
@override
|
||||
def unique_identifier(self) -> str:
|
||||
if self.teaid is None:
|
||||
raise ValueError('不完整的User!')
|
||||
return self.teaid
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameData:
|
||||
num: int
|
||||
pps: float
|
||||
lpm: float
|
||||
apm: float
|
||||
adpm: float
|
||||
apl: float
|
||||
adpl: float
|
||||
vs: float
|
||||
|
||||
|
||||
def identify_user_info(info: str) -> User | MessageFormatError:
|
||||
if (
|
||||
match(
|
||||
r'^(?!\.)(?!com[0-9]$)(?!con$)(?!lpt[0-9]$)(?!nul$)(?!prn$)[^\-][^\+][^\|\*\?\\\s\!:<>/$"]*[^\.\|\*\?\\\s\!:<>/$"]+$',
|
||||
info,
|
||||
)
|
||||
and info.isdigit() is False
|
||||
and 2 <= len(info) <= 18 # noqa: PLR2004
|
||||
):
|
||||
return User(name=info)
|
||||
if info.startswith(('onebot-', 'qqguild-', 'kook-', 'discord-')) and info.split('-', maxsplit=1)[1].isdigit():
|
||||
return User(teaid=info)
|
||||
return MessageFormatError('用户名/QQ号不合法')
|
||||
|
||||
|
||||
class Processor(ProcessorMeta):
|
||||
user: User
|
||||
raw_response: RawResponse
|
||||
processed_data: ProcessedData
|
||||
|
||||
@override
|
||||
def __init__(self, event_id: int, user: User, command_args: list[str]) -> None:
|
||||
super().__init__(event_id, user, command_args)
|
||||
self.raw_response = RawResponse(user_profile={})
|
||||
self.processed_data = ProcessedData(user_profile={})
|
||||
|
||||
@property
|
||||
@override
|
||||
def game_platform(self) -> Literal['TOS']:
|
||||
return GAME_TYPE
|
||||
|
||||
@override
|
||||
async def handle_bind(self, platform: str, account: str, bot_info: NBUserInfo) -> UniMessage:
|
||||
"""处理绑定消息"""
|
||||
self.command_type = 'bind'
|
||||
await self.get_user()
|
||||
async with get_session() as session:
|
||||
bind_status = await create_or_update_bind(
|
||||
session=session,
|
||||
chat_platform=platform,
|
||||
chat_account=account,
|
||||
game_platform=GAME_TYPE,
|
||||
game_account=self.user.unique_identifier,
|
||||
)
|
||||
bot_avatar = await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg')
|
||||
user_info = await self.get_user_info()
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
async with HostPage(
|
||||
await render(
|
||||
'bind.j2.html',
|
||||
user_avatar='../../static/static/logo/tos.ico',
|
||||
state='unknown',
|
||||
bot_avatar=bot_avatar,
|
||||
game_type=self.game_platform,
|
||||
user_name=user_info.data.name.upper(),
|
||||
bot_name=bot_info.user_name,
|
||||
command='茶服查我',
|
||||
)
|
||||
) as page_hash:
|
||||
message = UniMessage.image(
|
||||
raw=await screenshot(
|
||||
urlunparse(('http', get_self_netloc(), f'/host/page/{page_hash}.html', '', '', ''))
|
||||
)
|
||||
)
|
||||
return message
|
||||
|
||||
@override
|
||||
async def handle_query(self) -> UniMessage:
|
||||
"""处理查询消息"""
|
||||
self.command_type = 'query'
|
||||
await self.get_user()
|
||||
user_info = (await self.get_user_info()).data
|
||||
message = f'用户 {user_info.name} ({user_info.teaid}) '
|
||||
if user_info.ranked_games == '0':
|
||||
message += '暂无段位统计数据'
|
||||
else:
|
||||
message += f', 段位分 {round(float(user_info.rating_now),2)}±{round(float(user_info.rd_now),2)} ({round(float(user_info.vol_now),2)}) '
|
||||
game_data = await self.get_game_data()
|
||||
if game_data is None:
|
||||
message += ', 暂无游戏数据'
|
||||
else:
|
||||
message += f', 最近 {game_data.num} 局数据'
|
||||
message += f"\nL'PM: {game_data.lpm} ( {game_data.pps} pps )"
|
||||
message += f'\nAPM: {game_data.apm} ( x{game_data.apl} )'
|
||||
message += f'\nADPM: {game_data.adpm} ( x{game_data.adpl} ) ( {game_data.vs}vs )'
|
||||
message += f'\n40L: {float(user_info.pb_sprint)/1000:.2f}s' if user_info.pb_sprint != '2147483647' else ''
|
||||
message += f'\nMarathon: {user_info.pb_marathon}' if user_info.pb_marathon != '0' else ''
|
||||
message += f'\nChallenge: {user_info.pb_challenge}' if user_info.pb_challenge != '0' else ''
|
||||
return UniMessage(message)
|
||||
|
||||
async def get_user(self) -> None:
|
||||
"""
|
||||
用于获取 UserName 和 UserID 的函数
|
||||
"""
|
||||
if self.user.name is None:
|
||||
self.user.name = (await self.get_user_info()).data.name
|
||||
if self.user.teaid is None:
|
||||
self.user.teaid = (await self.get_user_info()).data.teaid
|
||||
|
||||
async def get_user_info(self) -> InfoSuccess:
|
||||
"""获取用户信息"""
|
||||
if self.processed_data.user_info is None:
|
||||
if self.user.teaid is not None:
|
||||
url = [
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getTeaIdInfo',
|
||||
f'?{urlencode({"teaId":self.user.teaid})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
]
|
||||
else:
|
||||
url = [
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getUsernameInfo',
|
||||
f'?{urlencode({"username":self.user.name})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
]
|
||||
self.raw_response.user_info = await Request.failover_request(
|
||||
url, failover_code=[502], failover_exc=(TimeoutException,)
|
||||
)
|
||||
user_info: UserInfo = type_validate_json(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
|
||||
if not isinstance(user_info, InfoSuccess):
|
||||
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
|
||||
self.processed_data.user_info = user_info
|
||||
return self.processed_data.user_info
|
||||
|
||||
async def get_user_profile(self, other_parameter: dict[str, str | bytes] | None = None) -> UserProfile:
|
||||
"""获取用户数据"""
|
||||
if other_parameter is None:
|
||||
other_parameter = {}
|
||||
params = urlencode(dict(sorted(other_parameter.items())))
|
||||
if self.processed_data.user_profile.get(params) is None:
|
||||
self.raw_response.user_profile[params] = await Request.failover_request(
|
||||
[
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getProfile',
|
||||
f'?{urlencode({"id":self.user.teaid or self.user.name,**other_parameter})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
],
|
||||
failover_code=[502],
|
||||
failover_exc=(TimeoutException,),
|
||||
)
|
||||
self.processed_data.user_profile[params] = type_validate_json(
|
||||
UserProfile, self.raw_response.user_profile[params]
|
||||
)
|
||||
return self.processed_data.user_profile[params]
|
||||
|
||||
async def get_game_data(self) -> GameData | None:
|
||||
"""获取游戏数据"""
|
||||
user_profile = await self.get_user_profile()
|
||||
if user_profile.data == []:
|
||||
return None
|
||||
weighted_total_lpm = weighted_total_apm = weighted_total_adpm = 0.0
|
||||
total_time = 0.0
|
||||
num = 0
|
||||
for i in user_profile.data:
|
||||
# 排除单人局和时间为0的游戏
|
||||
# 茶: 不计算没挖掘的局, 即使apm和lpm也如此
|
||||
if i.num_players == 1 or i.time == 0 or i.dig is None:
|
||||
continue
|
||||
# 加权计算
|
||||
time = i.time / 1000
|
||||
lpm = 24 * (i.pieces / time)
|
||||
apm = (i.attack / time) * 60
|
||||
adpm = ((i.attack + i.dig) / time) * 60
|
||||
weighted_total_lpm += lpm * time
|
||||
weighted_total_apm += apm * time
|
||||
weighted_total_adpm += adpm * time
|
||||
total_time += time
|
||||
num += 1
|
||||
if num == 50: # noqa: PLR2004 # TODO: 将查询局数作为可选命令参数
|
||||
break
|
||||
if num == 0:
|
||||
return None
|
||||
# TODO: 如果有效局数不满50, 没有无dig信息的局, 且userData['data']内有50个局, 则继续往前获取信息
|
||||
lpm = weighted_total_lpm / total_time
|
||||
apm = weighted_total_apm / total_time
|
||||
adpm = weighted_total_adpm / total_time
|
||||
return GameData(
|
||||
num=num,
|
||||
pps=round(lpm / 24, 2),
|
||||
lpm=round(lpm, 2),
|
||||
apm=round(apm, 2),
|
||||
adpm=round(adpm, 2),
|
||||
apl=round((apm / lpm), 2),
|
||||
adpl=round((adpm / lpm), 2),
|
||||
vs=round((adpm / 60 * 100), 2),
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...schemas import BaseProcessedData, BaseRawResponse
|
||||
from ..constant import GAME_TYPE
|
||||
from .user_info import SuccessModel as InfoSuccess
|
||||
from .user_profile import UserProfile
|
||||
|
||||
|
||||
class RawResponse(BaseRawResponse):
|
||||
platform: Literal['TOS'] = GAME_TYPE
|
||||
|
||||
user_profile: dict[str, bytes]
|
||||
user_info: bytes | None = None
|
||||
|
||||
|
||||
class ProcessedData(BaseProcessedData):
|
||||
platform: Literal['TOS'] = GAME_TYPE
|
||||
|
||||
user_profile: dict[str, UserProfile]
|
||||
user_info: InfoSuccess | None = None
|
||||
@@ -1,106 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SuccessModel(BaseModel):
|
||||
class Data(BaseModel):
|
||||
class PeriodMatch(BaseModel):
|
||||
name: str
|
||||
teaid: str = Field(..., alias='teaId')
|
||||
rating: str
|
||||
rd: str
|
||||
start_time: datetime = Field(..., alias='startTime')
|
||||
end_time: datetime = Field(..., alias='endTime')
|
||||
win: str
|
||||
lose: str
|
||||
score: str
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
class UserDataTotalItem(BaseModel):
|
||||
time_map: str = Field(..., alias='timeMap')
|
||||
pieces_map: str = Field(..., alias='piecesMap')
|
||||
clear_lines_map: str = Field(..., alias='clearLinesMap')
|
||||
attacks_map: str = Field(..., alias='attacksMap')
|
||||
dig_map: str = Field(..., alias='digMap')
|
||||
send_map: str = Field(..., alias='sendMap')
|
||||
rise_map: str = Field(..., alias='riseMap')
|
||||
offset_map: str = Field(..., alias='offsetMap')
|
||||
receive_map: str = Field(..., alias='receiveMap')
|
||||
games_map: str = Field(..., alias='gamesMap')
|
||||
tetris_map: str = Field(..., alias='tetrisMap')
|
||||
combo_map: str = Field(..., alias='comboMap')
|
||||
tspin_map: str = Field(..., alias='tspinMap')
|
||||
b2b_map: str = Field(..., alias='b2bMap')
|
||||
perfect_clear_map: str = Field(..., alias='perfectClearMap')
|
||||
time_no_map: str = Field(..., alias='timeNoMap')
|
||||
pieces_no_map: str = Field(..., alias='piecesNoMap')
|
||||
clear_lines_no_map: str = Field(..., alias='clearLinesNoMap')
|
||||
attacks_no_map: str = Field(..., alias='attacksNoMap')
|
||||
dig_no_map: str = Field(..., alias='digNoMap')
|
||||
send_no_map: str = Field(..., alias='sendNoMap')
|
||||
rise_no_map: str = Field(..., alias='riseNoMap')
|
||||
offset_no_map: str = Field(..., alias='offsetNoMap')
|
||||
receive_no_map: str = Field(..., alias='receiveNoMap')
|
||||
games_no_map: str = Field(..., alias='gamesNoMap')
|
||||
tetris_no_map: str = Field(..., alias='tetrisNoMap')
|
||||
combo_no_map: str = Field(..., alias='comboNoMap')
|
||||
tspin_no_map: str = Field(..., alias='tspinNoMap')
|
||||
b2b_no_map: str = Field(..., alias='b2bNoMap')
|
||||
perfect_clear_no_map: str = Field(..., alias='perfectClearNoMap')
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
teaid: str = Field(..., alias='teaId')
|
||||
name: str
|
||||
total_exp: str = Field(..., alias='totalExp')
|
||||
ranking: str
|
||||
ranked_games: str = Field(..., alias='rankedGames')
|
||||
rating_now: str = Field(..., alias='ratingNow')
|
||||
rd_now: str = Field(..., alias='rdNow')
|
||||
vol_now: str = Field(..., alias='volNow')
|
||||
rating_last: str = Field(..., alias='ratingLast')
|
||||
rd_last: str = Field(..., alias='rdLast')
|
||||
vol_last: str = Field(..., alias='volLast')
|
||||
period_matches: list[PeriodMatch] = Field(..., alias='periodMatches')
|
||||
user_data_total: list[UserDataTotalItem] = Field(..., alias='userDataTotal')
|
||||
ranking_items: str = Field(..., alias='rankingItems')
|
||||
ranking_game_items: str = Field(..., alias='rankingGameItems')
|
||||
training_level: str = Field(..., alias='trainingLevel')
|
||||
training_wins: str = Field(..., alias='trainingWins')
|
||||
pb_sprint: str = Field(..., alias='PBSprint')
|
||||
pb_marathon: str = Field(..., alias='PBMarathon')
|
||||
pb_challenge: str = Field(..., alias='PBChallenge')
|
||||
register_date: datetime = Field(..., alias='registerDate')
|
||||
last_login_date: datetime = Field(..., alias='lastLoginDate')
|
||||
|
||||
if PYDANTIC_V2:
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
else:
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
code: int
|
||||
success: Literal[True]
|
||||
data: Data
|
||||
|
||||
|
||||
class FailedModel(BaseModel):
|
||||
code: int
|
||||
success: Literal[False]
|
||||
error: str
|
||||
|
||||
|
||||
UserInfo = SuccessModel | FailedModel
|
||||
@@ -1,33 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
class Data(BaseModel):
|
||||
idmultiplayergameresult: int
|
||||
iduser: str
|
||||
teaid: str
|
||||
time: int
|
||||
clear_lines: int
|
||||
attack: int
|
||||
send: int
|
||||
offset: int
|
||||
receive: int
|
||||
rise: int
|
||||
dig: int
|
||||
pieces: int
|
||||
max_combo: int
|
||||
pc_count: int
|
||||
place: int
|
||||
num_players: int
|
||||
fumen_code: Literal['0', '1'] # wtf
|
||||
rule_set: str
|
||||
garbage: str
|
||||
idmultiplayergame: int
|
||||
datetime: datetime
|
||||
|
||||
code: int
|
||||
success: bool
|
||||
data: list[Data]
|
||||
55
nonebot_plugin_tetris_stats/games/__init__.py
Normal file
55
nonebot_plugin_tetris_stats/games/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.message import run_postprocessor
|
||||
from nonebot.typing import T_Handler
|
||||
from nonebot_plugin_alconna import AlcMatches, Alconna, At, CommandMeta, on_alconna
|
||||
|
||||
from .. import ns
|
||||
from ..utils.exception import MessageFormatError, NeedCatchError
|
||||
|
||||
command: Alconna = Alconna(
|
||||
['tetris-stats', 'tstats'],
|
||||
namespace=ns,
|
||||
meta=CommandMeta(
|
||||
description='俄罗斯方块相关游戏数据查询',
|
||||
fuzzy_match=True,
|
||||
),
|
||||
)
|
||||
|
||||
alc = on_alconna(
|
||||
command=command,
|
||||
skip_for_unmatch=False,
|
||||
auto_send_output=True,
|
||||
use_origin=True,
|
||||
)
|
||||
|
||||
|
||||
def add_block_handlers(handler: Callable[[T_Handler], T_Handler]) -> None:
|
||||
@handler
|
||||
async def _(bot: Bot, matcher: Matcher, target: At):
|
||||
if isinstance(target, At) and target.target == bot.self_id:
|
||||
await matcher.finish('不能查询bot的信息')
|
||||
|
||||
|
||||
from . import tetrio, top, tos # noqa: F401, E402
|
||||
|
||||
|
||||
@alc.handle()
|
||||
async def _(matcher: Matcher, account: MessageFormatError):
|
||||
await matcher.finish(str(account))
|
||||
|
||||
|
||||
@alc.handle()
|
||||
async def _(matcher: Matcher, matches: AlcMatches):
|
||||
if matches.head_matched and matches.options != {} or matches.main_args == {}:
|
||||
await matcher.finish(
|
||||
(f'{matches.error_info!r}\n' if matches.error_info is not None else '')
|
||||
+ f'输入"{matches.header_result} --help"查看帮助'
|
||||
)
|
||||
|
||||
|
||||
@run_postprocessor
|
||||
async def _(matcher: Matcher, exception: NeedCatchError):
|
||||
await matcher.send(str(exception))
|
||||
@@ -1,2 +1,3 @@
|
||||
BIND_COMMAND: list[str] = ['绑定', 'bind']
|
||||
QUERY_COMMAND: list[str] = ['查', '查询', 'query', 'stats']
|
||||
CANT_VERIFY_MESSAGE = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
||||
@@ -21,11 +21,3 @@ class BaseUser(ABC, Base):
|
||||
@abstractmethod
|
||||
def unique_identifier(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BaseRawResponse(Base):
|
||||
"""原始请求数据"""
|
||||
|
||||
|
||||
class BaseProcessedData(Base):
|
||||
"""处理/验证后的数据"""
|
||||
191
nonebot_plugin_tetris_stats/games/tetrio/__init__.py
Normal file
191
nonebot_plugin_tetris_stats/games/tetrio/__init__.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from arclet.alconna import Arg, ArgFlag, Args, Option, Subcommand
|
||||
from nonebot_plugin_alconna import At
|
||||
|
||||
from ...utils.exception import MessageFormatError
|
||||
from ...utils.typing import Me
|
||||
|
||||
# from .. import add_block_handlers, alc, command
|
||||
from .. import alc, command
|
||||
from .api import Player
|
||||
|
||||
# from .api.typing import ValidRank
|
||||
from .constant import USER_ID, USER_NAME
|
||||
from .typing import Template
|
||||
|
||||
|
||||
def get_player(user_id_or_name: str) -> Player | MessageFormatError:
|
||||
if USER_ID.match(user_id_or_name):
|
||||
return Player(user_id=user_id_or_name, trust=True)
|
||||
if USER_NAME.match(user_id_or_name):
|
||||
return Player(user_name=user_id_or_name, trust=True)
|
||||
return MessageFormatError('用户名/ID不合法')
|
||||
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
'TETR.IO',
|
||||
Subcommand(
|
||||
'bind',
|
||||
Args(
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='TETR.IO 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN],
|
||||
)
|
||||
),
|
||||
help_text='绑定 TETR.IO 账号',
|
||||
),
|
||||
# Subcommand(
|
||||
# 'query',
|
||||
# Args(
|
||||
# Arg(
|
||||
# 'target',
|
||||
# At | Me,
|
||||
# notice='@想要查询的人 / 自己',
|
||||
# flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
# ),
|
||||
# Arg(
|
||||
# 'account',
|
||||
# get_player,
|
||||
# notice='TETR.IO 用户名 / ID',
|
||||
# flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
# ),
|
||||
# ),
|
||||
# Option(
|
||||
# '--template',
|
||||
# Arg('template', Template),
|
||||
# alias=['-T'],
|
||||
# help_text='要使用的查询模板',
|
||||
# ),
|
||||
# help_text='查询 TETR.IO 游戏信息',
|
||||
# ),
|
||||
Subcommand(
|
||||
'record',
|
||||
Option(
|
||||
'--40l',
|
||||
dest='sprint',
|
||||
),
|
||||
Option(
|
||||
'--blitz',
|
||||
dest='blitz',
|
||||
),
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 / 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='TETR.IO 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
),
|
||||
),
|
||||
# Subcommand(
|
||||
# 'list',
|
||||
# Option('--max-tr', Arg('max_tr', float), help_text='TR的上限'),
|
||||
# Option('--min-tr', Arg('min_tr', float), help_text='TR的下限'),
|
||||
# Option('--limit', Arg('limit', int), help_text='查询数量'),
|
||||
# Option('--country', Arg('country', str), help_text='国家代码'),
|
||||
# help_text='查询 TETR.IO 段位排行榜',
|
||||
# ),
|
||||
# Subcommand(
|
||||
# 'rank',
|
||||
# Subcommand(
|
||||
# '--all',
|
||||
# Option(
|
||||
# '--template',
|
||||
# Arg('template', Template),
|
||||
# alias=['-T'],
|
||||
# help_text='要使用的查询模板',
|
||||
# ),
|
||||
# dest='all',
|
||||
# ),
|
||||
# Option(
|
||||
# '--detail',
|
||||
# Arg('rank', ValidRank),
|
||||
# alias=['-D'],
|
||||
# ),
|
||||
# help_text='查询 TETR.IO 段位信息',
|
||||
# ),
|
||||
Subcommand(
|
||||
'config',
|
||||
Option(
|
||||
'--default-template',
|
||||
Arg('template', Template),
|
||||
alias=['-DT', 'DefaultTemplate'],
|
||||
),
|
||||
),
|
||||
alias=['TETRIO', 'tetr.io', 'tetrio', 'io'],
|
||||
dest='TETRIO',
|
||||
help_text='TETR.IO 游戏相关指令',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# def rank_wrapper(slot: int | str, content: str | None):
|
||||
# if slot == 'rank' and not content:
|
||||
# return '--all'
|
||||
# if content is not None:
|
||||
# return f'--detail {content.lower()}'
|
||||
# return content
|
||||
|
||||
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:绑定|绑|bind)',
|
||||
command='tstats TETR.IO bind',
|
||||
humanized='io绑定',
|
||||
)
|
||||
# alc.shortcut(
|
||||
# '(?i:io)(?i:查询|查|query|stats)',
|
||||
# command='tstats TETR.IO query',
|
||||
# humanized='io查',
|
||||
# )
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:记录|record)(?i:40l)',
|
||||
command='tstats TETR.IO record --40l',
|
||||
humanized='io记录40l',
|
||||
)
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:记录|record)(?i:blitz)',
|
||||
command='tstats TETR.IO record --blitz',
|
||||
humanized='io记录blitz',
|
||||
)
|
||||
# alc.shortcut(
|
||||
# r'(?i:io)(?i:段位|段|rank)\s*(?P<rank>[a-zA-Z+-]{0,2})',
|
||||
# command='tstats TETR.IO rank {rank}',
|
||||
# humanized='iorank',
|
||||
# fuzzy=False,
|
||||
# wrapper=rank_wrapper,
|
||||
# )
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:配置|配|config)',
|
||||
command='tstats TETR.IO config',
|
||||
humanized='io配置',
|
||||
)
|
||||
|
||||
# alc.shortcut(
|
||||
# 'fkosk',
|
||||
# command='tstats TETR.IO query',
|
||||
# arguments=['我'],
|
||||
# fuzzy=False,
|
||||
# humanized='An Easter egg!',
|
||||
# )
|
||||
|
||||
# add_block_handlers(alc.assign('TETRIO.query'))
|
||||
|
||||
# from . import bind, config, list, query, rank, record
|
||||
from . import bind, config, record # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
'bind',
|
||||
'config',
|
||||
# 'list',
|
||||
# 'query',
|
||||
# 'rank',
|
||||
'record',
|
||||
]
|
||||
7
nonebot_plugin_tetris_stats/games/tetrio/api/__init__.py
Normal file
7
nonebot_plugin_tetris_stats/games/tetrio/api/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .player import Player
|
||||
from .schemas.user import User
|
||||
from .schemas.user_info import UserInfoSuccess
|
||||
from .schemas.user_records import UserRecordsSuccess
|
||||
from .tetra_league import full_export as tetra_league_full_export
|
||||
|
||||
__all__ = ['Player', 'User', 'UserInfoSuccess', 'UserRecordsSuccess', 'tetra_league_full_export']
|
||||
@@ -1,10 +1,13 @@
|
||||
from asyncio import Lock
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from aiocache import Cache as ACache # type: ignore[import-untyped]
|
||||
from nonebot.compat import type_validate_json
|
||||
from nonebot.log import logger
|
||||
|
||||
from ...utils.request import Request
|
||||
from ....utils.request import Request
|
||||
from .schemas.base import FailedModel, SuccessModel
|
||||
|
||||
UTC = timezone.utc
|
||||
@@ -12,11 +15,15 @@ UTC = timezone.utc
|
||||
|
||||
class Cache:
|
||||
cache = ACache(ACache.MEMORY)
|
||||
task: ClassVar[WeakValueDictionary[str, Lock]] = WeakValueDictionary()
|
||||
|
||||
@classmethod
|
||||
async def get(cls, url: str) -> bytes:
|
||||
cached_data = await cls.cache.get(url)
|
||||
if cached_data is None:
|
||||
lock = cls.task.setdefault(url, Lock())
|
||||
async with lock:
|
||||
if (cached_data := await cls.cache.get(url)) is not None:
|
||||
logger.debug(f'{url}: Cache hit!')
|
||||
return cached_data
|
||||
response_data = await Request.request(url)
|
||||
parsed_data: SuccessModel | FailedModel = type_validate_json(SuccessModel | FailedModel, response_data) # type: ignore[arg-type]
|
||||
if isinstance(parsed_data, SuccessModel):
|
||||
@@ -26,5 +33,3 @@ class Cache:
|
||||
(parsed_data.cache.cached_until - datetime.now(UTC)).total_seconds(),
|
||||
)
|
||||
return response_data
|
||||
logger.debug(f'{url}: Cache hit!')
|
||||
return cached_data
|
||||
18
nonebot_plugin_tetris_stats/games/tetrio/api/models.py
Normal file
18
nonebot_plugin_tetris_stats/games/tetrio/api/models.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from ....db.models import PydanticType
|
||||
from .schemas.base import SuccessModel
|
||||
from .typing import Summaries
|
||||
|
||||
|
||||
class TETRIOHistoricalData(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
user_unique_identifier: Mapped[str] = mapped_column(String(24), index=True)
|
||||
api_type: Mapped[Literal['User Info', Summaries]] = mapped_column(String(16), index=True)
|
||||
data: Mapped[SuccessModel] = mapped_column(PydanticType(get_model=[SuccessModel.__subclasses__], models=set()))
|
||||
update_time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
187
nonebot_plugin_tetris_stats/games/tetrio/api/player.py
Normal file
187
nonebot_plugin_tetris_stats/games/tetrio/api/player.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from types import MappingProxyType
|
||||
from typing import Literal, overload
|
||||
|
||||
from async_lru import alru_cache
|
||||
from nonebot.compat import type_validate_json
|
||||
|
||||
from ....db import anti_duplicate_add
|
||||
from ....utils.exception import RequestError
|
||||
from ....utils.request import splice_url
|
||||
from ..constant import BASE_URL, USER_ID, USER_NAME
|
||||
from .cache import Cache
|
||||
from .models import TETRIOHistoricalData
|
||||
from .schemas.base import FailedModel
|
||||
from .schemas.summaries import (
|
||||
AchievementsSuccessModel,
|
||||
SoloSuccessModel,
|
||||
SummariesModel,
|
||||
ZenithSuccessModel,
|
||||
ZenSuccessModel,
|
||||
)
|
||||
from .schemas.summaries.base import User as SummariesUser
|
||||
from .schemas.user import User
|
||||
from .schemas.user_info import UserInfo, UserInfoSuccess
|
||||
from .typing import Summaries
|
||||
|
||||
|
||||
class Player:
|
||||
__SUMMARIES_MAPPING: MappingProxyType[Summaries, type[SummariesModel]] = MappingProxyType(
|
||||
{
|
||||
'40l': SoloSuccessModel,
|
||||
'blitz': SoloSuccessModel,
|
||||
'zenith': ZenithSuccessModel,
|
||||
'zenithex': ZenithSuccessModel,
|
||||
'zen': ZenSuccessModel,
|
||||
'achievements': AchievementsSuccessModel,
|
||||
}
|
||||
)
|
||||
|
||||
@overload
|
||||
def __init__(self, *, user_id: str, trust: bool = False): ...
|
||||
@overload
|
||||
def __init__(self, *, user_name: str, trust: bool = False): ...
|
||||
def __init__(self, *, user_id: str | None = None, user_name: str | None = None, trust: bool = False):
|
||||
self.user_id = user_id
|
||||
self.user_name = user_name
|
||||
if not trust:
|
||||
if self.user_id is not None:
|
||||
if not USER_ID.match(self.user_id):
|
||||
msg = 'Invalid user id'
|
||||
raise ValueError(msg)
|
||||
elif self.user_name is not None:
|
||||
if not USER_NAME.match(self.user_name):
|
||||
msg = 'Invalid user name'
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
msg = 'Invalid user'
|
||||
raise ValueError(msg)
|
||||
self.__user: User | None = None
|
||||
self._user_info: UserInfoSuccess | None = None
|
||||
self._summaries: dict[Summaries, SummariesModel] = {}
|
||||
|
||||
@property
|
||||
def _request_user_parameter(self) -> str:
|
||||
if self.user_id is not None:
|
||||
return self.user_id
|
||||
if self.user_name is not None:
|
||||
return self.user_name.lower()
|
||||
msg = 'Invalid user'
|
||||
raise ValueError(msg)
|
||||
|
||||
@property
|
||||
async def user(self) -> User:
|
||||
if self.__user is not None:
|
||||
return self.__user
|
||||
if (user := (await self._get_local_summaries_user())) is not None:
|
||||
self.__user = User(
|
||||
ID=user.id,
|
||||
name=user.username,
|
||||
)
|
||||
else:
|
||||
user_info = await self.get_info()
|
||||
self.__user = User(
|
||||
ID=user_info.data.id,
|
||||
name=user_info.data.username,
|
||||
)
|
||||
self.user_id = self.__user.ID
|
||||
self.user_name = self.__user.name
|
||||
return self.__user
|
||||
|
||||
async def get_info(self) -> UserInfoSuccess:
|
||||
"""Get User Info"""
|
||||
if self._user_info is None:
|
||||
raw_user_info = await Cache.get(splice_url([BASE_URL, 'users/', f'{self._request_user_parameter}']))
|
||||
user_info: UserInfo = type_validate_json(UserInfo, raw_user_info) # type: ignore[arg-type]
|
||||
if isinstance(user_info, FailedModel):
|
||||
msg = f'用户信息请求错误:\n{user_info.error}'
|
||||
raise RequestError(msg)
|
||||
self._user_info = user_info
|
||||
await anti_duplicate_add(
|
||||
TETRIOHistoricalData,
|
||||
TETRIOHistoricalData(
|
||||
user_unique_identifier=(await self.user).unique_identifier,
|
||||
api_type='User Info',
|
||||
data=user_info,
|
||||
update_time=user_info.cache.cached_at,
|
||||
),
|
||||
)
|
||||
return self._user_info
|
||||
|
||||
@overload
|
||||
async def get_summaries(self, summaries_type: Literal['40l', 'blitz']) -> SoloSuccessModel: ...
|
||||
@overload
|
||||
async def get_summaries(self, summaries_type: Literal['zenith', 'zenithex']) -> ZenithSuccessModel: ...
|
||||
@overload
|
||||
async def get_summaries(self, summaries_type: Literal['zen']) -> ZenSuccessModel: ...
|
||||
@overload
|
||||
async def get_summaries(self, summaries_type: Literal['achievements']) -> AchievementsSuccessModel: ...
|
||||
|
||||
async def get_summaries(self, summaries_type: Summaries) -> SummariesModel:
|
||||
if summaries_type not in self._summaries:
|
||||
raw_summaries = await Cache.get(
|
||||
splice_url([BASE_URL, 'users/', f'{self._request_user_parameter}/', 'summaries/', summaries_type])
|
||||
)
|
||||
summaries: SummariesModel | FailedModel = type_validate_json(
|
||||
self.__SUMMARIES_MAPPING[summaries_type] | FailedModel, # type: ignore[arg-type]
|
||||
raw_summaries,
|
||||
)
|
||||
if isinstance(summaries, FailedModel):
|
||||
msg = f'用户Summaries数据请求错误:\n{summaries.error}'
|
||||
raise RequestError(msg)
|
||||
self._summaries[summaries_type] = summaries
|
||||
await anti_duplicate_add(
|
||||
TETRIOHistoricalData,
|
||||
TETRIOHistoricalData(
|
||||
user_unique_identifier=(await self.user).unique_identifier,
|
||||
api_type=summaries_type,
|
||||
data=summaries,
|
||||
update_time=summaries.cache.cached_at,
|
||||
),
|
||||
)
|
||||
return self._summaries[summaries_type]
|
||||
|
||||
@property
|
||||
@alru_cache
|
||||
async def sprint(self) -> SoloSuccessModel:
|
||||
return await self.get_summaries('40l')
|
||||
|
||||
@property
|
||||
@alru_cache
|
||||
async def blitz(self) -> SoloSuccessModel:
|
||||
return await self.get_summaries('blitz')
|
||||
|
||||
@property
|
||||
@alru_cache
|
||||
async def zen(self) -> ZenSuccessModel:
|
||||
return await self.get_summaries('zen')
|
||||
|
||||
async def _get_local_summaries_user(self) -> SummariesUser | None:
|
||||
allow_summaries: set[Literal['40l', 'blitz', 'zenith', 'zenithex']] = {
|
||||
'40l',
|
||||
'blitz',
|
||||
'zenith',
|
||||
'zenithex',
|
||||
}
|
||||
if has_summaries := (allow_summaries & self._summaries.keys()):
|
||||
for i in has_summaries:
|
||||
if (record := (await self.get_summaries(i)).data.record) is not None:
|
||||
return record.user
|
||||
return None
|
||||
|
||||
@property
|
||||
@alru_cache
|
||||
async def avatar_revision(self) -> int | None:
|
||||
if self._user_info is not None:
|
||||
return self._user_info.data.avatar_revision
|
||||
if (user := (await self._get_local_summaries_user())) is not None:
|
||||
return user.avatar_revision
|
||||
return (await self.get_info()).data.avatar_revision
|
||||
|
||||
@property
|
||||
@alru_cache
|
||||
async def banner_revision(self) -> int | None:
|
||||
if self._user_info is not None:
|
||||
return self._user_info.data.banner_revision
|
||||
if (user := (await self._get_local_summaries_user())) is not None:
|
||||
return user.banner_revision
|
||||
return (await self.get_info()).data.banner_revision
|
||||
@@ -4,6 +4,12 @@ from typing import Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class P(BaseModel): # what is P
|
||||
pri: float
|
||||
sec: float
|
||||
ter: float
|
||||
|
||||
|
||||
class Cache(BaseModel):
|
||||
status: str
|
||||
cached_at: datetime
|
||||
@@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..base import SuccessModel
|
||||
from .base import Entry as BaseEntry
|
||||
|
||||
|
||||
class ArCounts(BaseModel):
|
||||
bronze: int | None = Field(None, alias='1')
|
||||
silver: int | None = Field(None, alias='2')
|
||||
gold: int | None = Field(None, alias='3')
|
||||
platinum: int | None = Field(None, alias='4')
|
||||
diamond: int | None = Field(None, alias='5')
|
||||
issued: int | None = Field(None, alias='100')
|
||||
top10: int | None = Field(None, alias='t10')
|
||||
|
||||
|
||||
class Entry(BaseEntry):
|
||||
ar: int
|
||||
ar_counts: ArCounts
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
entries: list[Entry]
|
||||
|
||||
|
||||
class ArSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typing import Rank
|
||||
from ..base import P
|
||||
|
||||
|
||||
class League(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: int
|
||||
rank: Rank
|
||||
decaying: bool
|
||||
|
||||
|
||||
class Entry(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: str
|
||||
xp: float
|
||||
league: League
|
||||
supporter: bool | None = None
|
||||
verified: bool
|
||||
country: str | None = None
|
||||
ts: datetime
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
gametime: float
|
||||
p: P
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import SuccessModel
|
||||
from ..summaries.solo import Record
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
entries: list[Record]
|
||||
|
||||
|
||||
class SoloSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import SuccessModel
|
||||
from .base import Entry
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
entries: list[Entry]
|
||||
|
||||
|
||||
class XpSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import SuccessModel
|
||||
from ..summaries.zenith import Record
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
entries: list[Record]
|
||||
|
||||
|
||||
class ZenithSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
@@ -0,0 +1,20 @@
|
||||
from .achievements import Achievements, AchievementsSuccessModel
|
||||
from .solo import Blitz, SoloSuccessModel, Sprint
|
||||
from .zen import Zen, ZenSuccessModel
|
||||
from .zenith import Zenith, ZenithEx, ZenithSuccessModel
|
||||
|
||||
SummariesModel = AchievementsSuccessModel | SoloSuccessModel | ZenSuccessModel | ZenithSuccessModel
|
||||
|
||||
__all__ = [
|
||||
'Achievements',
|
||||
'AchievementsSuccessModel',
|
||||
'Blitz',
|
||||
'Sprint',
|
||||
'SoloSuccessModel',
|
||||
'Zen',
|
||||
'ZenSuccessModel',
|
||||
'Zenith',
|
||||
'ZenithEx',
|
||||
'ZenithSuccessModel',
|
||||
'SummariesModel',
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import FailedModel, SuccessModel
|
||||
|
||||
|
||||
class Achievement(BaseModel):
|
||||
# 这**都是些啥
|
||||
k: int
|
||||
o: int
|
||||
rt: int
|
||||
vt: int
|
||||
min: int
|
||||
deci: int
|
||||
name: str
|
||||
object: str
|
||||
category: str
|
||||
hidden: bool
|
||||
desc: str
|
||||
n: str
|
||||
stub: bool
|
||||
|
||||
|
||||
class AchievementsSuccessModel(SuccessModel):
|
||||
data: list[Achievement]
|
||||
|
||||
|
||||
Achievements: TypeAlias = AchievementsSuccessModel | FailedModel
|
||||
@@ -0,0 +1,23 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
avatar_revision: int | None
|
||||
banner_revision: int | None
|
||||
country: str | None
|
||||
verified: int
|
||||
supporter: int
|
||||
|
||||
|
||||
class AggregateStats(BaseModel):
|
||||
apm: float
|
||||
pps: float
|
||||
vsscore: float
|
||||
|
||||
|
||||
class Finesse(BaseModel):
|
||||
combo: int
|
||||
faults: int
|
||||
perfectpieces: int
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..base import FailedModel, P, SuccessModel
|
||||
from .base import AggregateStats, Finesse, User
|
||||
|
||||
|
||||
class Time(BaseModel):
|
||||
start: int
|
||||
zero: bool
|
||||
locked: bool
|
||||
prev: int
|
||||
frameoffset: int
|
||||
|
||||
|
||||
class Clears(BaseModel):
|
||||
singles: int
|
||||
doubles: int
|
||||
triples: int
|
||||
quads: int
|
||||
realtspins: int
|
||||
minitspins: int
|
||||
minitspinsingles: int
|
||||
tspinsingles: int
|
||||
minitspindoubles: int
|
||||
tspindoubles: int
|
||||
tspintriples: int
|
||||
tspinquads: int
|
||||
allclear: int
|
||||
|
||||
|
||||
class Garbage(BaseModel):
|
||||
sent: int
|
||||
received: int
|
||||
attack: int | None
|
||||
cleared: int
|
||||
|
||||
|
||||
class Stats(BaseModel):
|
||||
seed: int
|
||||
lines: int
|
||||
level_lines: int
|
||||
level_lines_needed: int
|
||||
inputs: int
|
||||
holds: int
|
||||
time: Time
|
||||
score: int
|
||||
zenlevel: int
|
||||
zenprogress: int
|
||||
level: int
|
||||
combo: int
|
||||
currentcombopower: int | None = None
|
||||
topcombo: int
|
||||
btb: int
|
||||
topbtb: int
|
||||
currentbtbchainpower: int | None = None
|
||||
tspins: int
|
||||
piecesplaced: int
|
||||
clears: Clears
|
||||
garbage: Garbage
|
||||
kills: int
|
||||
finesse: Finesse
|
||||
finaltime: float
|
||||
|
||||
|
||||
class Results(BaseModel):
|
||||
aggregatestats: AggregateStats
|
||||
stats: Stats
|
||||
gameoverreason: str
|
||||
|
||||
|
||||
class Record(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
replayid: str
|
||||
stub: bool
|
||||
gamemode: Literal['40l', 'blitz']
|
||||
pb: bool
|
||||
oncepb: bool
|
||||
ts: datetime
|
||||
revolution: None
|
||||
user: User
|
||||
otherusers: list
|
||||
leaderboards: list[str]
|
||||
results: Results
|
||||
extras: dict
|
||||
disputed: bool
|
||||
p: P
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
record: Record | None
|
||||
rank: int
|
||||
rank_local: int
|
||||
|
||||
|
||||
class SoloSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
Sprint: TypeAlias = SoloSuccessModel | FailedModel
|
||||
Blitz: TypeAlias = SoloSuccessModel | FailedModel
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import FailedModel, SuccessModel
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
level: int
|
||||
score: int
|
||||
|
||||
|
||||
class ZenSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
Zen: TypeAlias = ZenSuccessModel | FailedModel
|
||||
@@ -0,0 +1,131 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..base import FailedModel, P, SuccessModel
|
||||
from .base import AggregateStats, Finesse, User
|
||||
|
||||
|
||||
class Clears(BaseModel):
|
||||
singles: int
|
||||
doubles: int
|
||||
triples: int
|
||||
quads: int
|
||||
pentas: int
|
||||
realtspins: int
|
||||
minitspins: int
|
||||
minitspinsingles: int
|
||||
tspinsingles: int
|
||||
minitspindoubles: int
|
||||
tspindoubles: int
|
||||
minitspintriples: int
|
||||
tspintriples: int
|
||||
minitspinquads: int
|
||||
tspinquads: int
|
||||
tspinpentas: int
|
||||
allclear: int
|
||||
|
||||
|
||||
class Garbage(BaseModel):
|
||||
sent: int
|
||||
sent_nomult: int
|
||||
maxspike: int
|
||||
maxspike_nomult: int
|
||||
received: int
|
||||
attack: int
|
||||
cleared: int
|
||||
|
||||
|
||||
class _Zenith(BaseModel):
|
||||
altitude: float
|
||||
rank: float
|
||||
peakrank: float
|
||||
avgrankpts: float
|
||||
floor: int
|
||||
targetingfactor: float
|
||||
targetinggrace: float
|
||||
totalbonus: float
|
||||
revives: int
|
||||
revives_total: int = Field(..., alias='revivesTotal')
|
||||
speedrun: bool
|
||||
speedrun_seen: bool
|
||||
splits: list[int]
|
||||
|
||||
|
||||
class Stats(BaseModel):
|
||||
lines: int
|
||||
level_lines: int
|
||||
level_lines_needed: int
|
||||
inputs: int
|
||||
holds: int
|
||||
score: int
|
||||
zenlevel: int
|
||||
zenprogress: int
|
||||
level: int
|
||||
combo: int
|
||||
topcombo: int
|
||||
combopower: int
|
||||
btb: int
|
||||
topbtb: int
|
||||
btbpower: int
|
||||
tspins: int
|
||||
piecesplaced: int
|
||||
clears: Clears
|
||||
garbage: Garbage
|
||||
kills: int
|
||||
finesse: Finesse
|
||||
zenith: _Zenith
|
||||
finaltime: float
|
||||
|
||||
|
||||
class Results(BaseModel):
|
||||
aggregatestats: AggregateStats
|
||||
stats: Stats
|
||||
gameoverreason: str
|
||||
|
||||
|
||||
class ExtrasZenith(BaseModel):
|
||||
mods: list[str]
|
||||
|
||||
|
||||
class Extras(BaseModel):
|
||||
zenith: ExtrasZenith
|
||||
|
||||
|
||||
class Record(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
replayid: str
|
||||
stub: bool
|
||||
gamemode: Literal['zenith', 'zenithex']
|
||||
pb: bool
|
||||
oncepb: bool
|
||||
ts: datetime
|
||||
revolution: None
|
||||
user: User
|
||||
otherusers: list
|
||||
leaderboards: list[str]
|
||||
results: Results
|
||||
extras: Extras
|
||||
disputed: bool
|
||||
p: P
|
||||
|
||||
|
||||
class Best(BaseModel):
|
||||
record: None # WTF
|
||||
rank: int
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
record: Record | None
|
||||
rank: int
|
||||
rank_local: int
|
||||
best: Best
|
||||
|
||||
|
||||
class ZenithSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
Zenith: TypeAlias = ZenithSuccessModel | FailedModel
|
||||
ZenithEx: TypeAlias = ZenithSuccessModel | FailedModel
|
||||
@@ -0,0 +1,59 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..typing import Rank
|
||||
from .base import FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class _User(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: str
|
||||
xp: float
|
||||
supporter: bool | None = None
|
||||
verified: bool
|
||||
country: str | None = None
|
||||
|
||||
|
||||
class _League(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: float
|
||||
rank: Rank
|
||||
bestrank: Rank
|
||||
decaying: bool
|
||||
|
||||
|
||||
class ValidLeague(_League):
|
||||
glicko: float
|
||||
rd: float
|
||||
apm: float
|
||||
pps: float
|
||||
vs: float
|
||||
|
||||
|
||||
class ValidUser(_User):
|
||||
league: ValidLeague
|
||||
|
||||
|
||||
class InvalidLeague(_League):
|
||||
glicko: float | None = None
|
||||
rd: float | None = None
|
||||
apm: float | None = None
|
||||
pps: float | None = None
|
||||
vs: float | None = None
|
||||
|
||||
|
||||
class InvalidUser(_User):
|
||||
league: InvalidLeague
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
users: list[ValidUser | InvalidUser]
|
||||
|
||||
|
||||
class TetraLeagueSuccess(BaseSuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
TetraLeague = TetraLeagueSuccess | FailedModel
|
||||
18
nonebot_plugin_tetris_stats/games/tetrio/api/schemas/user.py
Normal file
18
nonebot_plugin_tetris_stats/games/tetrio/api/schemas/user.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Literal
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from ....schemas import BaseUser
|
||||
from ...constant import GAME_TYPE
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
platform: Literal['IO'] = GAME_TYPE
|
||||
|
||||
ID: str
|
||||
name: str
|
||||
|
||||
@property
|
||||
@override
|
||||
def unique_identifier(self) -> str:
|
||||
return self.ID
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class Badge(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
group: str | None = None
|
||||
ts: datetime | Literal[False] | None = None
|
||||
|
||||
|
||||
class Discord(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
|
||||
|
||||
class Connections(BaseModel):
|
||||
discord: Discord | None = None
|
||||
|
||||
|
||||
class Distinguishment(BaseModel):
|
||||
type: str
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
|
||||
ts: datetime | None = None
|
||||
botmaster: str | None = None
|
||||
badges: list[Badge]
|
||||
xp: float
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
gametime: float
|
||||
country: str | None = None
|
||||
badstanding: bool | None = None
|
||||
supporter: bool | None = None # osk说是必有, 但实际上不是 fkosk
|
||||
supporter_tier: int
|
||||
verified: bool
|
||||
avatar_revision: int | None = None
|
||||
"""This user's avatar ID. Get their avatar at
|
||||
|
||||
https://tetr.io/user-content/avatars/{ USERID }.jpg?rv={ AVATAR_REVISION }"""
|
||||
banner_revision: int | None = None
|
||||
"""This user's banner ID. Get their banner at
|
||||
|
||||
https://tetr.io/user-content/banners/{ USERID }.jpg?rv={ BANNER_REVISION }
|
||||
|
||||
Ignore this field if the user is not a supporter."""
|
||||
bio: str | None = None
|
||||
connections: Connections
|
||||
friend_count: int | None = None
|
||||
distinguishment: Distinguishment | None = None
|
||||
|
||||
|
||||
class UserInfoSuccess(BaseSuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
UserInfo = UserInfoSuccess | FailedModel
|
||||
@@ -0,0 +1,122 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .....utils.typing import Number
|
||||
from .base import FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class Time(BaseModel):
|
||||
start: int
|
||||
zero: bool
|
||||
locked: bool
|
||||
prev: int
|
||||
frameoffset: int | None = None
|
||||
|
||||
|
||||
class Clears(BaseModel):
|
||||
singles: int
|
||||
doubles: int
|
||||
triples: int
|
||||
quads: int
|
||||
pentas: int | None = None
|
||||
realtspins: int
|
||||
minitspins: int
|
||||
minitspinsingles: int
|
||||
tspinsingles: int
|
||||
minitspindoubles: int
|
||||
tspindoubles: int
|
||||
tspintriples: int
|
||||
tspinquads: int
|
||||
allclear: int
|
||||
|
||||
|
||||
class Garbage(BaseModel):
|
||||
sent: int
|
||||
received: int
|
||||
attack: int | None = None
|
||||
cleared: int | None = None
|
||||
|
||||
|
||||
class Finesse(BaseModel):
|
||||
combo: int
|
||||
faults: int
|
||||
perfectpieces: int
|
||||
|
||||
|
||||
class EndContext(BaseModel):
|
||||
seed: Number
|
||||
lines: int
|
||||
level_lines: int
|
||||
level_lines_needed: int
|
||||
inputs: int
|
||||
holds: int | None = None
|
||||
time: Time
|
||||
score: int
|
||||
zenlevel: int | None = None
|
||||
zenprogress: int | None = None
|
||||
level: int
|
||||
combo: int
|
||||
currentcombopower: int | None = None # WTF
|
||||
topcombo: int
|
||||
btb: int
|
||||
topbtb: int
|
||||
currentbtbchainpower: int | None = None # WTF * 2
|
||||
tspins: int
|
||||
piecesplaced: int
|
||||
clears: Clears
|
||||
garbage: Garbage
|
||||
kills: int
|
||||
finesse: Finesse
|
||||
final_time: float = Field(..., alias='finalTime')
|
||||
gametype: str
|
||||
|
||||
|
||||
class _User(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
|
||||
|
||||
class _Record(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
stream: str
|
||||
replayid: str
|
||||
user: _User
|
||||
ts: datetime
|
||||
ismulti: bool | None = None
|
||||
|
||||
|
||||
class SoloRecord(_Record):
|
||||
endcontext: EndContext
|
||||
|
||||
|
||||
class MultiRecord(_Record):
|
||||
endcontext: list[EndContext]
|
||||
|
||||
|
||||
class SoloModeRecord(BaseModel):
|
||||
record: SoloRecord | None = None
|
||||
rank: int | None = None
|
||||
|
||||
|
||||
class Records(BaseModel):
|
||||
sprint: SoloModeRecord = Field(..., alias='40l')
|
||||
blitz: SoloModeRecord
|
||||
|
||||
|
||||
class Zen(BaseModel):
|
||||
level: int
|
||||
score: int
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
records: Records
|
||||
zen: Zen
|
||||
|
||||
|
||||
class UserRecordsSuccess(BaseSuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
UserRecords = UserRecordsSuccess | FailedModel
|
||||
55
nonebot_plugin_tetris_stats/games/tetrio/api/tetra_league.py
Normal file
55
nonebot_plugin_tetris_stats/games/tetrio/api/tetra_league.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from typing import Literal, NamedTuple, TypedDict, overload
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from nonebot.compat import type_validate_json
|
||||
|
||||
from ....utils.exception import RequestError
|
||||
from ....utils.request import splice_url
|
||||
from ..constant import BASE_URL
|
||||
from .cache import Cache
|
||||
from .schemas.base import FailedModel
|
||||
from .schemas.tetra_league import TetraLeague, TetraLeagueSuccess
|
||||
|
||||
|
||||
class Parameter(TypedDict, total=False):
|
||||
after: float
|
||||
before: float
|
||||
limit: int
|
||||
country: str
|
||||
|
||||
|
||||
async def leaderboard(parameter: Parameter | None = None) -> TetraLeagueSuccess:
|
||||
league: TetraLeague = type_validate_json(
|
||||
TetraLeague, # type: ignore[arg-type]
|
||||
(await Cache.get(splice_url([BASE_URL, 'users/lists/league', f'?{urlencode(parameter or {})}']))),
|
||||
)
|
||||
if isinstance(league, FailedModel):
|
||||
msg = f'排行榜数据请求错误:\n{league.error}'
|
||||
raise RequestError(msg)
|
||||
return league
|
||||
|
||||
|
||||
class FullExport(NamedTuple):
|
||||
model: TetraLeagueSuccess
|
||||
original: bytes
|
||||
|
||||
|
||||
@overload
|
||||
async def full_export(*, with_original: Literal[False]) -> TetraLeagueSuccess: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def full_export(*, with_original: Literal[True]) -> FullExport: ...
|
||||
|
||||
|
||||
async def full_export(*, with_original: bool) -> TetraLeagueSuccess | FullExport:
|
||||
full: TetraLeague = type_validate_json(
|
||||
TetraLeague, # type: ignore[arg-type]
|
||||
(data := await Cache.get(splice_url([BASE_URL, 'users/lists/league/all']))),
|
||||
)
|
||||
if isinstance(full, FailedModel):
|
||||
msg = f'排行榜数据请求错误:\n{full.error}'
|
||||
raise RequestError(msg)
|
||||
if with_original:
|
||||
return FullExport(full, data)
|
||||
return full
|
||||
33
nonebot_plugin_tetris_stats/games/tetrio/api/typing.py
Normal file
33
nonebot_plugin_tetris_stats/games/tetrio/api/typing.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from typing import Literal
|
||||
|
||||
ValidRank = Literal[
|
||||
'x',
|
||||
'u',
|
||||
'ss',
|
||||
's+',
|
||||
's',
|
||||
's-',
|
||||
'a+',
|
||||
'a',
|
||||
'a-',
|
||||
'b+',
|
||||
'b',
|
||||
'b-',
|
||||
'c+',
|
||||
'c',
|
||||
'c-',
|
||||
'd+',
|
||||
'd',
|
||||
]
|
||||
|
||||
Rank = ValidRank | Literal['z'] # 未定级
|
||||
|
||||
Summaries = Literal[
|
||||
'40l',
|
||||
'blitz',
|
||||
'zenith',
|
||||
'zenithex',
|
||||
# 'league', # 等待正式赛季开始
|
||||
'zen',
|
||||
'achievements',
|
||||
]
|
||||
61
nonebot_plugin_tetris_stats/games/tetrio/bind.py
Normal file
61
nonebot_plugin_tetris_stats/games/tetrio/bind.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from asyncio import gather
|
||||
from hashlib import md5
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User # type: ignore[import-untyped]
|
||||
from nonebot_plugin_userinfo import BotUserInfo, UserInfo # type: ignore[import-untyped]
|
||||
|
||||
from ...db import BindStatus, create_or_update_bind, trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
from ...utils.render import Bind, render
|
||||
from ...utils.render.schemas.base import Avatar, People
|
||||
from ...utils.screenshot import screenshot
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TETRIO.bind')
|
||||
async def _(nb_user: User, account: Player, event_session: EventSession, bot_info: UserInfo = BotUserInfo()): # noqa: B008
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='bind',
|
||||
command_args=[],
|
||||
):
|
||||
user, user_info = await gather(account.user, account.get_info())
|
||||
async with get_session() as session:
|
||||
bind_status = await create_or_update_bind(
|
||||
session=session,
|
||||
user=nb_user,
|
||||
game_platform=GAME_TYPE,
|
||||
game_account=user.unique_identifier,
|
||||
)
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/binding',
|
||||
Bind(
|
||||
platform='TETR.IO',
|
||||
status='unknown',
|
||||
user=People(
|
||||
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": avatar_revision})}'
|
||||
if (avatar_revision := (await account.avatar_revision)) is not None and avatar_revision != 0
|
||||
else Avatar(type='identicon', hash=md5(user.ID.encode()).hexdigest()), # noqa: S324
|
||||
name=user.name.upper(),
|
||||
),
|
||||
bot=People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='io查我',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
await UniMessage.image(raw=await screenshot(f'http://{netloc}/host/{page_hash}.html')).finish()
|
||||
30
nonebot_plugin_tetris_stats/games/tetrio/config.py
Normal file
30
nonebot_plugin_tetris_stats/games/tetrio/config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import async_scoped_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User # type: ignore[import-untyped]
|
||||
from sqlalchemy import select
|
||||
|
||||
from ...db import trigger
|
||||
from . import alc
|
||||
from .constant import GAME_TYPE
|
||||
from .models import TETRIOUserConfig
|
||||
from .typing import Template
|
||||
|
||||
|
||||
@alc.assign('TETRIO.config')
|
||||
async def _(user: User, session: async_scoped_session, event_session: EventSession, template: Template):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='config',
|
||||
command_args=[f'--default-template {template}'],
|
||||
):
|
||||
config = (await session.scalars(select(TETRIOUserConfig).where(TETRIOUserConfig.id == user.id))).one_or_none()
|
||||
if config is None:
|
||||
config = TETRIOUserConfig(id=user.id, query_template=template)
|
||||
session.add(config)
|
||||
else:
|
||||
config.query_template = template
|
||||
await session.commit()
|
||||
await UniMessage('配置成功').finish()
|
||||
@@ -1,10 +1,13 @@
|
||||
from re import compile
|
||||
from typing import Literal
|
||||
|
||||
from .typing import Rank
|
||||
from .api.typing import ValidRank
|
||||
|
||||
GAME_TYPE: Literal['IO'] = 'IO'
|
||||
|
||||
BASE_URL = 'https://ch.tetr.io/api/'
|
||||
RANK_PERCENTILE: dict[Rank, float] = {
|
||||
|
||||
RANK_PERCENTILE: dict[ValidRank, float] = {
|
||||
'x': 1,
|
||||
'u': 5,
|
||||
'ss': 11,
|
||||
@@ -23,3 +26,9 @@ RANK_PERCENTILE: dict[Rank, float] = {
|
||||
'd+': 97.5,
|
||||
'd': 100,
|
||||
}
|
||||
|
||||
TR_MIN = 0
|
||||
TR_MAX = 25000
|
||||
|
||||
USER_ID = compile(r'^[a-f0-9]{24}$')
|
||||
USER_NAME = compile(r'^[a-zA-Z0-9_-]{3,16}$')
|
||||
10
nonebot_plugin_tetris_stats/games/tetrio/models.py
Normal file
10
nonebot_plugin_tetris_stats/games/tetrio/models.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from .typing import Template
|
||||
|
||||
|
||||
class TETRIOUserConfig(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
query_template: Mapped[Template] = mapped_column(String(2))
|
||||
@@ -0,0 +1,6 @@
|
||||
from . import blitz, sprint
|
||||
|
||||
__all__ = [
|
||||
'blitz',
|
||||
'sprint',
|
||||
]
|
||||
139
nonebot_plugin_tetris_stats/games/tetrio/record/blitz.py
Normal file
139
nonebot_plugin_tetris_stats/games/tetrio/record/blitz.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from asyncio import gather
|
||||
from datetime import timedelta
|
||||
from hashlib import md5
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
|
||||
|
||||
from ....db import query_bind_info, trigger
|
||||
from ....utils.exception import RecordNotFoundError
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.metrics import get_metrics
|
||||
from ....utils.render import render
|
||||
from ....utils.render.schemas.base import Avatar
|
||||
from ....utils.render.schemas.tetrio.tetrio_record_base import Finesse, Max, Mini, Tspins, User
|
||||
from ....utils.render.schemas.tetrio.tetrio_record_blitz import Record, Statistic
|
||||
from ....utils.screenshot import screenshot
|
||||
from ....utils.typing import Me
|
||||
from ...constant import CANT_VERIFY_MESSAGE
|
||||
from .. import alc
|
||||
from ..api.player import Player
|
||||
from ..constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TETRIO.record.blitz')
|
||||
async def _(
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='record',
|
||||
command_args=['--blitz'],
|
||||
):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
user=await get_user(
|
||||
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
|
||||
),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = UniMessage(CANT_VERIFY_MESSAGE)
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
await (message + UniMessage.image(raw=await make_blitz_image(player))).finish()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.record.blitz')
|
||||
async def _(account: Player, event_session: EventSession):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='record',
|
||||
command_args=['--blitz'],
|
||||
):
|
||||
await UniMessage.image(raw=await make_blitz_image(account)).finish()
|
||||
|
||||
|
||||
async def make_blitz_image(player: Player) -> bytes:
|
||||
user, blitz = await gather(player.user, player.blitz)
|
||||
if blitz.data.record is None:
|
||||
msg = f'未找到用户 {user.name.upper()} 的 Blitz 记录'
|
||||
raise RecordNotFoundError(msg)
|
||||
stats = blitz.data.record.results.stats
|
||||
clears = stats.clears
|
||||
duration = timedelta(milliseconds=stats.finaltime).total_seconds()
|
||||
metrics = get_metrics(pps=stats.piecesplaced / duration)
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
page=await render(
|
||||
'v2/tetrio/record/blitz',
|
||||
Record(
|
||||
type='personal_best',
|
||||
user=User(
|
||||
id=user.ID,
|
||||
name=user.name.upper(),
|
||||
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": avatar_revision})}'
|
||||
if (avatar_revision := (await player.avatar_revision)) is not None and avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
),
|
||||
replay_id=blitz.data.record.replayid,
|
||||
rank=blitz.data.rank,
|
||||
personal_rank=1,
|
||||
statistic=Statistic(
|
||||
keys=stats.inputs,
|
||||
kpp=round(stats.inputs / stats.piecesplaced, 2),
|
||||
kps=round(stats.inputs / duration, 2),
|
||||
max=Max(
|
||||
combo=max((0, stats.topcombo - 1)),
|
||||
btb=max((0, stats.topbtb - 1)),
|
||||
),
|
||||
pieces=stats.piecesplaced,
|
||||
pps=metrics.pps,
|
||||
lines=stats.lines,
|
||||
lpm=metrics.lpm,
|
||||
holds=stats.holds,
|
||||
score=stats.score,
|
||||
spp=round(stats.score / stats.piecesplaced, 2),
|
||||
single=clears.singles,
|
||||
double=clears.doubles,
|
||||
triple=clears.triples,
|
||||
quad=clears.quads,
|
||||
tspins=Tspins(
|
||||
total=clears.realtspins,
|
||||
single=clears.tspinsingles,
|
||||
double=clears.tspindoubles,
|
||||
triple=clears.tspintriples,
|
||||
mini=Mini(
|
||||
total=clears.minitspins,
|
||||
single=clears.minitspinsingles,
|
||||
double=clears.minitspindoubles,
|
||||
),
|
||||
),
|
||||
all_clear=clears.allclear,
|
||||
finesse=Finesse(
|
||||
faults=stats.finesse.faults,
|
||||
accuracy=round(stats.finesse.perfectpieces / stats.piecesplaced * 100, 2),
|
||||
),
|
||||
level=stats.level,
|
||||
),
|
||||
play_at=blitz.data.record.ts,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
139
nonebot_plugin_tetris_stats/games/tetrio/record/sprint.py
Normal file
139
nonebot_plugin_tetris_stats/games/tetrio/record/sprint.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from asyncio import gather
|
||||
from datetime import timedelta
|
||||
from hashlib import md5
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
|
||||
|
||||
from ....db import query_bind_info, trigger
|
||||
from ....utils.exception import RecordNotFoundError
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.metrics import get_metrics
|
||||
from ....utils.render import render
|
||||
from ....utils.render.schemas.base import Avatar
|
||||
from ....utils.render.schemas.tetrio.tetrio_record_base import Finesse, Max, Mini, Statistic, Tspins, User
|
||||
from ....utils.render.schemas.tetrio.tetrio_record_sprint import Record
|
||||
from ....utils.screenshot import screenshot
|
||||
from ....utils.typing import Me
|
||||
from ...constant import CANT_VERIFY_MESSAGE
|
||||
from .. import alc
|
||||
from ..api.player import Player
|
||||
from ..constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TETRIO.record.sprint')
|
||||
async def _(
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='record',
|
||||
command_args=['--40l'],
|
||||
):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
user=await get_user(
|
||||
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
|
||||
),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = UniMessage(CANT_VERIFY_MESSAGE)
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
await (message + UniMessage.image(raw=await make_sprint_image(player))).finish()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.record.sprint')
|
||||
async def _(account: Player, event_session: EventSession):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='record',
|
||||
command_args=['--40l'],
|
||||
):
|
||||
await UniMessage.image(raw=await make_sprint_image(account)).finish()
|
||||
|
||||
|
||||
async def make_sprint_image(player: Player) -> bytes:
|
||||
user, sprint = await gather(player.user, player.sprint)
|
||||
if sprint.data.record is None:
|
||||
msg = f'未找到用户 {user.name.upper()} 的 40L 记录'
|
||||
raise RecordNotFoundError(msg)
|
||||
stats = sprint.data.record.results.stats
|
||||
clears = stats.clears
|
||||
duration = timedelta(milliseconds=stats.finaltime).total_seconds()
|
||||
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
||||
metrics = get_metrics(pps=stats.piecesplaced / duration)
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
page=await render(
|
||||
'v2/tetrio/record/40l',
|
||||
Record(
|
||||
type='personal_best',
|
||||
user=User(
|
||||
id=user.ID,
|
||||
name=user.name.upper(),
|
||||
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": avatar_revision})}'
|
||||
if (avatar_revision := (await player.avatar_revision)) is not None and avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
),
|
||||
time=sprint_value,
|
||||
replay_id=sprint.data.record.replayid,
|
||||
rank=sprint.data.rank,
|
||||
personal_rank=1,
|
||||
statistic=Statistic(
|
||||
keys=stats.inputs,
|
||||
kpp=round(stats.inputs / stats.piecesplaced, 2),
|
||||
kps=round(stats.inputs / duration, 2),
|
||||
max=Max(
|
||||
combo=max((0, stats.topcombo - 1)),
|
||||
btb=max((0, stats.topbtb - 1)),
|
||||
),
|
||||
pieces=stats.piecesplaced,
|
||||
pps=metrics.pps,
|
||||
lines=stats.lines,
|
||||
lpm=metrics.lpm,
|
||||
holds=stats.holds,
|
||||
score=stats.score,
|
||||
single=clears.singles,
|
||||
double=clears.doubles,
|
||||
triple=clears.triples,
|
||||
quad=clears.quads,
|
||||
tspins=Tspins(
|
||||
total=clears.realtspins,
|
||||
single=clears.tspinsingles,
|
||||
double=clears.tspindoubles,
|
||||
triple=clears.tspintriples,
|
||||
mini=Mini(
|
||||
total=clears.minitspins,
|
||||
single=clears.minitspinsingles,
|
||||
double=clears.minitspindoubles,
|
||||
),
|
||||
),
|
||||
all_clear=clears.allclear,
|
||||
finesse=Finesse(
|
||||
faults=stats.finesse.faults,
|
||||
accuracy=round(stats.finesse.perfectpieces / stats.piecesplaced * 100, 2),
|
||||
),
|
||||
),
|
||||
play_at=sprint.data.record.ts,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
3
nonebot_plugin_tetris_stats/games/tetrio/typing.py
Normal file
3
nonebot_plugin_tetris_stats/games/tetrio/typing.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from typing import Literal
|
||||
|
||||
Template = Literal['v1', 'v2']
|
||||
59
nonebot_plugin_tetris_stats/games/top/__init__.py
Normal file
59
nonebot_plugin_tetris_stats/games/top/__init__.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from arclet.alconna import Arg, ArgFlag, Args, Subcommand
|
||||
from nonebot_plugin_alconna import At
|
||||
|
||||
from ...utils.exception import MessageFormatError
|
||||
from ...utils.typing import Me
|
||||
from .. import add_block_handlers, alc, command
|
||||
from .api import Player
|
||||
from .constant import USER_NAME
|
||||
|
||||
|
||||
def get_player(name: str) -> Player | MessageFormatError:
|
||||
if USER_NAME.match(name):
|
||||
return Player(user_name=name, trust=True)
|
||||
return MessageFormatError('用户名/ID不合法')
|
||||
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
'TOP',
|
||||
Subcommand(
|
||||
'bind',
|
||||
Args(
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='TOP 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN],
|
||||
)
|
||||
),
|
||||
help_text='绑定 TOP 账号',
|
||||
),
|
||||
Subcommand(
|
||||
'query',
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 / 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='TOP 用户名',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
),
|
||||
help_text='查询 TOP 游戏信息',
|
||||
),
|
||||
help_text='TOP 游戏相关指令',
|
||||
)
|
||||
)
|
||||
|
||||
alc.shortcut('(?i:top)(?i:绑定|绑|bind)', {'command': 'tstats TOP bind', 'humanized': 'top绑定'})
|
||||
alc.shortcut('(?i:top)(?i:查询|查|query|stats)', {'command': 'tstats TOP query', 'humanized': 'top查'})
|
||||
|
||||
add_block_handlers(alc.assign('TOP.query'))
|
||||
|
||||
from . import bind, query # noqa: E402, F401
|
||||
3
nonebot_plugin_tetris_stats/games/top/api/__init__.py
Normal file
3
nonebot_plugin_tetris_stats/games/top/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .player import Player
|
||||
|
||||
__all__ = ['Player']
|
||||
17
nonebot_plugin_tetris_stats/games/top/api/models.py
Normal file
17
nonebot_plugin_tetris_stats/games/top/api/models.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from ....db.models import PydanticType
|
||||
from .schemas.user_profile import UserProfile
|
||||
|
||||
|
||||
class TOPHistoricalData(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
user_unique_identifier: Mapped[str] = mapped_column(String(24), index=True)
|
||||
api_type: Mapped[Literal['User Profile']] = mapped_column(String(16), index=True)
|
||||
data: Mapped[UserProfile] = mapped_column(PydanticType(get_model=[], models={UserProfile}))
|
||||
update_time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
71
nonebot_plugin_tetris_stats/games/top/api/player.py
Normal file
71
nonebot_plugin_tetris_stats/games/top/api/player.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from lxml import etree
|
||||
from pandas import read_html
|
||||
|
||||
from ....db import anti_duplicate_add
|
||||
from ....utils.request import Request, splice_url
|
||||
from ..constant import BASE_URL, USER_NAME
|
||||
from .models import TOPHistoricalData
|
||||
from .schemas.user import User
|
||||
from .schemas.user_profile import Data, UserProfile
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class Player:
|
||||
def __init__(self, *, user_name: str, trust: bool = False) -> None:
|
||||
self.user_name = user_name
|
||||
if not trust and not USER_NAME.match(self.user_name):
|
||||
msg = 'Invalid user name'
|
||||
raise ValueError(msg)
|
||||
self.__user: User | None = None
|
||||
self._user_profile: UserProfile | None = None
|
||||
|
||||
@property
|
||||
async def user(self) -> User:
|
||||
if self.__user is None:
|
||||
profile = await self.get_profile()
|
||||
self.__user = User(user_name=profile.user_name)
|
||||
return self.__user
|
||||
|
||||
async def get_profile(self) -> UserProfile:
|
||||
"""获取用户信息"""
|
||||
if self._user_profile is None:
|
||||
url = splice_url([BASE_URL, 'profile.php', f'?{urlencode({"user":self.user_name})}'])
|
||||
raw_user_profile = await Request.request(url, is_json=False)
|
||||
self._user_profile = self._parse_profile(raw_user_profile)
|
||||
await anti_duplicate_add(
|
||||
TOPHistoricalData,
|
||||
TOPHistoricalData(
|
||||
user_unique_identifier=(await self.user).unique_identifier,
|
||||
api_type='User Profile',
|
||||
data=self._user_profile,
|
||||
update_time=datetime.now(tz=UTC),
|
||||
),
|
||||
)
|
||||
return self._user_profile
|
||||
|
||||
def _parse_profile(self, original_user_profile: bytes) -> UserProfile:
|
||||
html = etree.HTML(original_user_profile)
|
||||
user_name = html.xpath('//div[@class="mycontent"]/h1/text()')[0].replace("'s profile", '')
|
||||
today = None
|
||||
with suppress(ValueError):
|
||||
today = Data(
|
||||
lpm=float(str(html.xpath('//div[@class="mycontent"]/text()[3]')[0]).replace('lpm:', '').strip()),
|
||||
apm=float(str(html.xpath('//div[@class="mycontent"]/text()[4]')[0]).replace('apm:', '').strip()),
|
||||
)
|
||||
table = StringIO(
|
||||
etree.tostring(
|
||||
html.xpath('//div[@class="mycontent"]/table[@class="mytable"]')[0],
|
||||
encoding='utf-8',
|
||||
).decode()
|
||||
)
|
||||
dataframe = read_html(table, encoding='utf-8', header=0)[0]
|
||||
total: list[Data] = []
|
||||
for _, value in dataframe.iterrows():
|
||||
total.append(Data(lpm=value['lpm'], apm=value['apm']))
|
||||
return UserProfile(user_name=user_name, today=today, total=total)
|
||||
17
nonebot_plugin_tetris_stats/games/top/api/schemas/user.py
Normal file
17
nonebot_plugin_tetris_stats/games/top/api/schemas/user.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Literal
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from ....schemas import BaseUser
|
||||
from ...constant import GAME_TYPE
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
platform: Literal['TOP'] = GAME_TYPE
|
||||
|
||||
user_name: str
|
||||
|
||||
@property
|
||||
@override
|
||||
def unique_identifier(self) -> str:
|
||||
return self.user_name
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
lpm: float
|
||||
apm: float
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
user_name: str
|
||||
today: Data | None
|
||||
total: list[Data] | None
|
||||
62
nonebot_plugin_tetris_stats/games/top/bind.py
Normal file
62
nonebot_plugin_tetris_stats/games/top/bind.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User # type: ignore[import-untyped]
|
||||
from nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo # type: ignore[import-untyped]
|
||||
|
||||
from ...db import BindStatus, create_or_update_bind, trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
from ...utils.render import Bind, render
|
||||
from ...utils.render.schemas.base import People
|
||||
from ...utils.screenshot import screenshot
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TOP.bind')
|
||||
async def _(
|
||||
nb_user: User,
|
||||
account: Player,
|
||||
event_session: EventSession,
|
||||
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
|
||||
bot_info: UserInfo = BotUserInfo(), # noqa: B008
|
||||
):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='bind',
|
||||
command_args=[],
|
||||
):
|
||||
user = await account.user
|
||||
async with get_session() as session:
|
||||
bind_status = await create_or_update_bind(
|
||||
session=session,
|
||||
user=nb_user,
|
||||
game_platform=GAME_TYPE,
|
||||
game_account=user.unique_identifier,
|
||||
)
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/binding',
|
||||
Bind(
|
||||
platform=GAME_TYPE,
|
||||
status='unknown',
|
||||
user=People(
|
||||
avatar=await get_avatar(event_user_info, 'Data URI', None),
|
||||
name=user.user_name,
|
||||
),
|
||||
bot=People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='top查我',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
await UniMessage.image(
|
||||
raw=await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
|
||||
).finish()
|
||||
@@ -1,4 +1,8 @@
|
||||
from re import compile
|
||||
from typing import Literal
|
||||
|
||||
GAME_TYPE: Literal['TOP'] = 'TOP'
|
||||
|
||||
BASE_URL = 'http://tetrisonline.pl/top/'
|
||||
|
||||
USER_NAME = compile(r'^[a-zA-Z0-9_]{1,16}$')
|
||||
74
nonebot_plugin_tetris_stats/games/top/query.py
Normal file
74
nonebot_plugin_tetris_stats/games/top/query.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
|
||||
|
||||
from ...db import query_bind_info, trigger
|
||||
from ...utils.metrics import get_metrics
|
||||
from ...utils.typing import Me
|
||||
from ..constant import CANT_VERIFY_MESSAGE
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .api.schemas.user_profile import UserProfile
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TOP.query')
|
||||
async def _(event: Event, matcher: Matcher, target: At | Me, event_session: EventSession):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[],
|
||||
):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
user=await get_user(
|
||||
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
|
||||
),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = CANT_VERIFY_MESSAGE
|
||||
await (message + make_query_text(await Player(user_name=bind.game_account, trust=True).get_profile())).finish()
|
||||
|
||||
|
||||
@alc.assign('TOP.query')
|
||||
async def _(account: Player, event_session: EventSession):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[],
|
||||
):
|
||||
await (make_query_text(await account.get_profile())).finish()
|
||||
|
||||
|
||||
def make_query_text(profile: UserProfile) -> UniMessage:
|
||||
message = ''
|
||||
if profile.today is not None:
|
||||
today = get_metrics(lpm=profile.today.lpm, apm=profile.today.apm)
|
||||
message += f'用户 {profile.user_name} 24小时内统计数据为: '
|
||||
message += f"\nL'PM: {today.lpm} ( {today.pps} pps )"
|
||||
message += f'\nAPM: {today.apm} ( x{today.apl} )'
|
||||
else:
|
||||
message += f'用户 {profile.user_name} 暂无24小时内统计数据'
|
||||
if profile.total is not None:
|
||||
total_lpm = total_apm = 0.0
|
||||
for value in profile.total:
|
||||
total_lpm += value.lpm
|
||||
total_apm += value.apm
|
||||
num = len(profile.total)
|
||||
total = get_metrics(lpm=total_lpm / num, apm=total_apm / num)
|
||||
message += '\n历史统计数据为: '
|
||||
message += f"\nL'PM: {total.lpm} ( {total.pps} pps )"
|
||||
message += f'\nAPM: {total.apm} ( x{total.apl} )'
|
||||
else:
|
||||
message += '\n暂无历史统计数据'
|
||||
return UniMessage(message)
|
||||
64
nonebot_plugin_tetris_stats/games/tos/__init__.py
Normal file
64
nonebot_plugin_tetris_stats/games/tos/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from arclet.alconna import Arg, ArgFlag, Args, Subcommand
|
||||
from nonebot_plugin_alconna import At
|
||||
|
||||
from ...utils.exception import MessageFormatError
|
||||
from ...utils.typing import Me
|
||||
from .. import add_block_handlers, alc, command
|
||||
from .api import Player
|
||||
from .constant import USER_NAME
|
||||
|
||||
|
||||
def get_player(teaid_or_name: str) -> Player | MessageFormatError:
|
||||
if (
|
||||
teaid_or_name.startswith(('onebot-', 'qqguild-', 'kook-', 'discord-'))
|
||||
and teaid_or_name.split('-', maxsplit=1)[1].isdigit()
|
||||
):
|
||||
return Player(teaid=teaid_or_name, trust=True)
|
||||
if USER_NAME.match(teaid_or_name) and not teaid_or_name.isdigit() and 2 <= len(teaid_or_name) <= 18: # noqa: PLR2004
|
||||
return Player(user_name=teaid_or_name, trust=True)
|
||||
return MessageFormatError('用户名/ID不合法')
|
||||
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
'TOS',
|
||||
Subcommand(
|
||||
'bind',
|
||||
Args(
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='茶服 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN],
|
||||
)
|
||||
),
|
||||
help_text='绑定 茶服 账号',
|
||||
),
|
||||
Subcommand(
|
||||
'query',
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 / 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='茶服 用户名 / TeaID',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
),
|
||||
help_text='查询 茶服 游戏信息',
|
||||
),
|
||||
help_text='茶服 游戏相关指令',
|
||||
)
|
||||
)
|
||||
|
||||
alc.shortcut('(?i:tos|茶服)(?i:绑定|绑|bind)', {'command': 'tstats TOS bind', 'humanized': '茶服绑定'})
|
||||
alc.shortcut('(?i:tos|茶服)(?i:查询|查|query|stats)', {'command': 'tstats TOS query', 'humanized': '茶服查'})
|
||||
|
||||
add_block_handlers(alc.assign('TOS.query'))
|
||||
|
||||
from . import bind, query # noqa: E402, F401
|
||||
3
nonebot_plugin_tetris_stats/games/tos/api/__init__.py
Normal file
3
nonebot_plugin_tetris_stats/games/tos/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .player import Player
|
||||
|
||||
__all__ = ['Player']
|
||||
20
nonebot_plugin_tetris_stats/games/tos/api/models.py
Normal file
20
nonebot_plugin_tetris_stats/games/tos/api/models.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from ....db.models import PydanticType
|
||||
from .schemas.user_info import UserInfoSuccess
|
||||
from .schemas.user_profile import UserProfile
|
||||
|
||||
|
||||
class TOSHistoricalData(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
user_unique_identifier: Mapped[str] = mapped_column(String(24), index=True)
|
||||
api_type: Mapped[Literal['User Info', 'User Profile']] = mapped_column(String(16), index=True)
|
||||
data: Mapped[UserInfoSuccess | UserProfile] = mapped_column(
|
||||
PydanticType(get_model=[], models={UserInfoSuccess, UserProfile})
|
||||
)
|
||||
update_time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
128
nonebot_plugin_tetris_stats/games/tos/api/player.py
Normal file
128
nonebot_plugin_tetris_stats/games/tos/api/player.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import overload
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from httpx import TimeoutException
|
||||
from nonebot.compat import type_validate_json
|
||||
|
||||
from ....db import anti_duplicate_add
|
||||
from ....utils.exception import RequestError
|
||||
from ....utils.request import Request, splice_url
|
||||
from ..constant import BASE_URL, USER_NAME
|
||||
from .models import TOSHistoricalData
|
||||
from .schemas.user import User
|
||||
from .schemas.user_info import UserInfo, UserInfoSuccess
|
||||
from .schemas.user_profile import UserProfile
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class Player:
|
||||
@overload
|
||||
def __init__(self, *, teaid: str, trust: bool = False): ...
|
||||
@overload
|
||||
def __init__(self, *, user_name: str, trust: bool = False): ...
|
||||
def __init__(self, *, teaid: str | None = None, user_name: str | None = None, trust: bool = False):
|
||||
self.teaid = teaid
|
||||
self.user_name = user_name
|
||||
if not trust:
|
||||
if self.teaid is not None:
|
||||
if (
|
||||
not self.teaid.startswith(('onebot-', 'qqguild-', 'kook-', 'discord-'))
|
||||
or not self.teaid.split('-', maxsplit=1)[1].isdigit()
|
||||
):
|
||||
msg = 'Invalid teaid'
|
||||
raise ValueError(msg)
|
||||
elif self.user_name is not None:
|
||||
if not USER_NAME.match(self.user_name) or self.user_name.isdigit() or 2 > len(self.user_name) > 18: # noqa: PLR2004
|
||||
msg = 'Invalid user name'
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
msg = 'Invalid user'
|
||||
raise ValueError(msg)
|
||||
self.__user: User | None = None
|
||||
self._user_info: UserInfoSuccess | None = None
|
||||
self._user_profile: dict[str, UserProfile] = {}
|
||||
|
||||
@property
|
||||
async def user(self) -> User:
|
||||
if self.__user is None:
|
||||
user_info = await self.get_info()
|
||||
self.__user = User(teaid=user_info.data.teaid, name=user_info.data.name)
|
||||
self.teaid = user_info.data.teaid
|
||||
self.user_name = user_info.data.name
|
||||
return self.__user
|
||||
|
||||
async def get_info(self) -> UserInfoSuccess:
|
||||
"""获取用户信息"""
|
||||
if self._user_info is None:
|
||||
if self.teaid is not None:
|
||||
url = [
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getTeaIdInfo',
|
||||
f'?{urlencode({"teaId":self.teaid})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
]
|
||||
else:
|
||||
url = [
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getUsernameInfo',
|
||||
f'?{urlencode({"username":self.user_name})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
]
|
||||
raw_user_info = await Request.failover_request(url, failover_code=[502], failover_exc=(TimeoutException,))
|
||||
user_info: UserInfo = type_validate_json(UserInfo, raw_user_info) # type: ignore[arg-type]
|
||||
if not isinstance(user_info, UserInfoSuccess):
|
||||
msg = f'用户信息请求错误:\n{user_info.error}'
|
||||
raise RequestError(msg)
|
||||
self._user_info = user_info
|
||||
await anti_duplicate_add(
|
||||
TOSHistoricalData,
|
||||
TOSHistoricalData(
|
||||
user_unique_identifier=(await self.user).unique_identifier,
|
||||
api_type='User Info',
|
||||
data=user_info,
|
||||
update_time=datetime.now(UTC),
|
||||
),
|
||||
)
|
||||
return self._user_info
|
||||
|
||||
async def get_profile(self, other_parameter: dict[str, str | bytes] | None = None) -> UserProfile:
|
||||
"""获取用户数据"""
|
||||
if other_parameter is None:
|
||||
other_parameter = {}
|
||||
params = urlencode(dict(sorted(other_parameter.items())))
|
||||
if self._user_profile.get(params) is None:
|
||||
raw_user_profile = await Request.failover_request(
|
||||
[
|
||||
splice_url(
|
||||
[
|
||||
i,
|
||||
'getProfile',
|
||||
f'?{urlencode({"id":self.teaid or self.user_name,**other_parameter})}',
|
||||
]
|
||||
)
|
||||
for i in BASE_URL
|
||||
],
|
||||
failover_code=[502],
|
||||
failover_exc=(TimeoutException,),
|
||||
)
|
||||
self._user_profile[params] = type_validate_json(UserProfile, raw_user_profile)
|
||||
await anti_duplicate_add(
|
||||
TOSHistoricalData,
|
||||
TOSHistoricalData(
|
||||
user_unique_identifier=(await self.user).unique_identifier,
|
||||
api_type='User Profile',
|
||||
data=self._user_profile[params],
|
||||
update_time=datetime.now(UTC),
|
||||
),
|
||||
)
|
||||
return self._user_profile[params]
|
||||
18
nonebot_plugin_tetris_stats/games/tos/api/schemas/user.py
Normal file
18
nonebot_plugin_tetris_stats/games/tos/api/schemas/user.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Literal
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from ....schemas import BaseUser
|
||||
from ...constant import GAME_TYPE
|
||||
|
||||
|
||||
class User(BaseUser):
|
||||
platform: Literal['TOS'] = GAME_TYPE
|
||||
|
||||
teaid: str
|
||||
name: str
|
||||
|
||||
@property
|
||||
@override
|
||||
def unique_identifier(self) -> str:
|
||||
return self.teaid
|
||||
@@ -0,0 +1,89 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PeriodMatch(BaseModel):
|
||||
name: str
|
||||
teaid: str = Field(..., alias='teaId')
|
||||
rating: str
|
||||
rd: str
|
||||
start_time: datetime = Field(..., alias='startTime')
|
||||
end_time: datetime = Field(..., alias='endTime')
|
||||
win: str
|
||||
lose: str
|
||||
score: str
|
||||
|
||||
|
||||
class UserDataTotalItem(BaseModel):
|
||||
time_map: str = Field(..., alias='timeMap')
|
||||
pieces_map: str = Field(..., alias='piecesMap')
|
||||
clear_lines_map: str = Field(..., alias='clearLinesMap')
|
||||
attacks_map: str = Field(..., alias='attacksMap')
|
||||
dig_map: str = Field(..., alias='digMap')
|
||||
send_map: str = Field(..., alias='sendMap')
|
||||
rise_map: str = Field(..., alias='riseMap')
|
||||
offset_map: str = Field(..., alias='offsetMap')
|
||||
receive_map: str = Field(..., alias='receiveMap')
|
||||
games_map: str = Field(..., alias='gamesMap')
|
||||
tetris_map: str = Field(..., alias='tetrisMap')
|
||||
combo_map: str = Field(..., alias='comboMap')
|
||||
tspin_map: str = Field(..., alias='tspinMap')
|
||||
b2b_map: str = Field(..., alias='b2bMap')
|
||||
perfect_clear_map: str = Field(..., alias='perfectClearMap')
|
||||
time_no_map: str = Field(..., alias='timeNoMap')
|
||||
pieces_no_map: str = Field(..., alias='piecesNoMap')
|
||||
clear_lines_no_map: str = Field(..., alias='clearLinesNoMap')
|
||||
attacks_no_map: str = Field(..., alias='attacksNoMap')
|
||||
dig_no_map: str = Field(..., alias='digNoMap')
|
||||
send_no_map: str = Field(..., alias='sendNoMap')
|
||||
rise_no_map: str = Field(..., alias='riseNoMap')
|
||||
offset_no_map: str = Field(..., alias='offsetNoMap')
|
||||
receive_no_map: str = Field(..., alias='receiveNoMap')
|
||||
games_no_map: str = Field(..., alias='gamesNoMap')
|
||||
tetris_no_map: str = Field(..., alias='tetrisNoMap')
|
||||
combo_no_map: str = Field(..., alias='comboNoMap')
|
||||
tspin_no_map: str = Field(..., alias='tspinNoMap')
|
||||
b2b_no_map: str = Field(..., alias='b2bNoMap')
|
||||
perfect_clear_no_map: str = Field(..., alias='perfectClearNoMap')
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
teaid: str = Field(..., alias='teaId')
|
||||
name: str
|
||||
total_exp: str = Field(..., alias='totalExp')
|
||||
ranking: str
|
||||
ranked_games: str = Field(..., alias='rankedGames')
|
||||
rating_now: str = Field(..., alias='ratingNow')
|
||||
rd_now: str = Field(..., alias='rdNow')
|
||||
vol_now: str = Field(..., alias='volNow')
|
||||
rating_last: str = Field(..., alias='ratingLast')
|
||||
rd_last: str = Field(..., alias='rdLast')
|
||||
vol_last: str = Field(..., alias='volLast')
|
||||
period_matches: list[PeriodMatch] = Field(..., alias='periodMatches')
|
||||
user_data_total: list[UserDataTotalItem] = Field(..., alias='userDataTotal')
|
||||
ranking_items: str = Field(..., alias='rankingItems')
|
||||
ranking_game_items: str = Field(..., alias='rankingGameItems')
|
||||
training_level: str = Field(..., alias='trainingLevel')
|
||||
training_wins: str = Field(..., alias='trainingWins')
|
||||
pb_sprint: str = Field(..., alias='PBSprint')
|
||||
pb_marathon: str = Field(..., alias='PBMarathon')
|
||||
pb_challenge: str = Field(..., alias='PBChallenge')
|
||||
register_date: datetime = Field(..., alias='registerDate')
|
||||
last_login_date: datetime = Field(..., alias='lastLoginDate')
|
||||
|
||||
|
||||
class UserInfoSuccess(BaseModel):
|
||||
code: int
|
||||
success: Literal[True]
|
||||
data: Data
|
||||
|
||||
|
||||
class FailedModel(BaseModel):
|
||||
code: int
|
||||
success: Literal[False]
|
||||
error: str
|
||||
|
||||
|
||||
UserInfo = UserInfoSuccess | FailedModel
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
idmultiplayergameresult: int
|
||||
iduser: str
|
||||
teaid: str
|
||||
time: int
|
||||
clear_lines: int
|
||||
attack: int
|
||||
send: int
|
||||
offset: int
|
||||
receive: int
|
||||
rise: int
|
||||
dig: int
|
||||
pieces: int
|
||||
max_combo: int
|
||||
pc_count: int
|
||||
place: int
|
||||
num_players: int
|
||||
fumen_code: Literal['0', '1'] # wtf
|
||||
rule_set: str
|
||||
garbage: str
|
||||
idmultiplayergame: int
|
||||
datetime: datetime
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
code: int
|
||||
success: bool
|
||||
data: list[Data]
|
||||
62
nonebot_plugin_tetris_stats/games/tos/bind.py
Normal file
62
nonebot_plugin_tetris_stats/games/tos/bind.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User # type: ignore[import-untyped]
|
||||
from nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo # type: ignore[import-untyped]
|
||||
|
||||
from ...db import BindStatus, create_or_update_bind, trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
from ...utils.render import Bind, render
|
||||
from ...utils.render.schemas.base import People
|
||||
from ...utils.screenshot import screenshot
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TOS.bind')
|
||||
async def _(
|
||||
nb_user: User,
|
||||
account: Player,
|
||||
event_session: EventSession,
|
||||
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
|
||||
bot_info: UserInfo = BotUserInfo(), # noqa: B008
|
||||
):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='bind',
|
||||
command_args=[],
|
||||
):
|
||||
user = await account.user
|
||||
async with get_session() as session:
|
||||
bind_status = await create_or_update_bind(
|
||||
session=session,
|
||||
user=nb_user,
|
||||
game_platform=GAME_TYPE,
|
||||
game_account=user.unique_identifier,
|
||||
)
|
||||
user_info = await account.get_info()
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/binding',
|
||||
Bind(
|
||||
platform=GAME_TYPE,
|
||||
status='unknown',
|
||||
user=People(
|
||||
avatar=await get_avatar(event_user_info, 'Data URI', None), name=user_info.data.name
|
||||
),
|
||||
bot=People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_remark or bot_info.user_displayname or bot_info.user_name,
|
||||
),
|
||||
command='茶服查我',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
await UniMessage.image(
|
||||
raw=await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
|
||||
).finish()
|
||||
@@ -1,6 +1,8 @@
|
||||
from re import compile
|
||||
from typing import Literal
|
||||
|
||||
GAME_TYPE: Literal['TOS'] = 'TOS'
|
||||
|
||||
BASE_URL = {
|
||||
'https://teatube.cn:8888/',
|
||||
'http://cafuuchino1.studio26f.org:19970',
|
||||
@@ -8,3 +10,7 @@ BASE_URL = {
|
||||
'http://cafuuchino3.studio26f.org:19970',
|
||||
'http://cafuuchino4.studio26f.org:19970',
|
||||
}
|
||||
|
||||
USER_NAME = compile(
|
||||
r'^(?!\.)(?!com[0-9]$)(?!con$)(?!lpt[0-9]$)(?!nul$)(?!prn$)[^\-][^\+][^\|\*\?\\\s\!:<>/$"]*[^\.\|\*\?\\\s\!:<>/$"]+$'
|
||||
)
|
||||
252
nonebot_plugin_tetris_stats/games/tos/query.py
Normal file
252
nonebot_plugin_tetris_stats/games/tos/query.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from asyncio import gather
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
|
||||
from nonebot_plugin_userinfo import EventUserInfo, UserInfo # type: ignore[import-untyped]
|
||||
|
||||
from ...db import query_bind_info, trigger
|
||||
from ...utils.exception import RequestError
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
from ...utils.metrics import TetrisMetricsProWithLPMADPM, get_metrics
|
||||
from ...utils.render import render
|
||||
from ...utils.render.schemas.base import People, Ranking
|
||||
from ...utils.render.schemas.tos_info import Info, Multiplayer, Radar
|
||||
from ...utils.screenshot import screenshot
|
||||
from ...utils.typing import Me, Number
|
||||
from ..constant import CANT_VERIFY_MESSAGE
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .api.schemas.user_info import UserInfoSuccess
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
|
||||
def add_special_handlers(
|
||||
teaid_prefix: Literal['onebot-', 'kook-', 'discord-', 'qqguild-'], match_event: type[Event]
|
||||
) -> None:
|
||||
@alc.assign('TOS.query')
|
||||
async def _(
|
||||
event: Event,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
|
||||
):
|
||||
if isinstance(event, match_event):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[],
|
||||
):
|
||||
player = Player(
|
||||
teaid=f'{teaid_prefix}{target.target}'
|
||||
if isinstance(target, At)
|
||||
else f'{teaid_prefix}{event.get_user_id()}',
|
||||
trust=True,
|
||||
)
|
||||
try:
|
||||
user_info, game_data = await gather(player.get_info(), get_game_data(player))
|
||||
if game_data is not None:
|
||||
await UniMessage.image(
|
||||
raw=await make_query_image(user_info, game_data, event_user_info)
|
||||
).finish()
|
||||
await make_query_text(user_info, game_data).finish()
|
||||
except RequestError as e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST and '未找到此用户' in e.message:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
try:
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent as OB11MessageEvent
|
||||
|
||||
add_special_handlers('onebot-', OB11MessageEvent)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from nonebot.adapters.qq.event import GuildMessageEvent as QQGuildMessageEvent
|
||||
from nonebot.adapters.qq.event import QQMessageEvent
|
||||
|
||||
add_special_handlers('qqguild-', QQGuildMessageEvent)
|
||||
add_special_handlers('onebot-', QQMessageEvent)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from nonebot.adapters.kaiheila.event import MessageEvent as KookMessageEvent
|
||||
|
||||
add_special_handlers('kook-', KookMessageEvent)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from nonebot.adapters.discord import MessageEvent as DiscordMessageEvent
|
||||
|
||||
add_special_handlers('discord-', DiscordMessageEvent)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@alc.assign('TOS.query')
|
||||
async def _(
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
|
||||
):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[],
|
||||
):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
user=await get_user(
|
||||
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
|
||||
),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = CANT_VERIFY_MESSAGE
|
||||
player = Player(teaid=bind.game_account, trust=True)
|
||||
user_info, game_data = await gather(player.get_info(), get_game_data(player))
|
||||
if game_data is not None:
|
||||
await (
|
||||
message + UniMessage.image(raw=await make_query_image(user_info, game_data, event_user_info))
|
||||
).finish()
|
||||
await (message + make_query_text(user_info, game_data)).finish()
|
||||
|
||||
|
||||
@alc.assign('TOS.query')
|
||||
async def _(account: Player, event_session: EventSession, event_user_info: UserInfo = EventUserInfo()): # noqa: B008
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[],
|
||||
):
|
||||
user_info, game_data = await gather(account.get_info(), get_game_data(account))
|
||||
if game_data is not None:
|
||||
await UniMessage.image(raw=await make_query_image(user_info, game_data, event_user_info)).finish()
|
||||
await make_query_text(user_info, game_data).finish()
|
||||
|
||||
|
||||
class GameData(NamedTuple):
|
||||
game_num: int
|
||||
metrics: TetrisMetricsProWithLPMADPM
|
||||
OR: Number
|
||||
dspp: Number
|
||||
ge: Number
|
||||
|
||||
|
||||
async def get_game_data(player: Player, query_num: int = 50) -> GameData | None:
|
||||
"""获取游戏数据"""
|
||||
user_profile = await player.get_profile()
|
||||
if user_profile.data == []:
|
||||
return None
|
||||
weighted_total_lpm = weighted_total_apm = weighted_total_adpm = total_time = 0.0
|
||||
total_attack = total_dig = total_offset = total_pieses = total_receive = num = 0
|
||||
for i in user_profile.data:
|
||||
# 排除单人局和时间为0的游戏
|
||||
# 茶: 不计算没挖掘的局, 即使apm和lpm也如此
|
||||
if i.num_players == 1 or i.time == 0 or i.dig is None:
|
||||
continue
|
||||
# 加权计算
|
||||
time = i.time / 1000
|
||||
lpm = 24 * (i.pieces / time)
|
||||
apm = (i.attack / time) * 60
|
||||
adpm = ((i.attack + i.dig) / time) * 60
|
||||
weighted_total_lpm += lpm * time
|
||||
weighted_total_apm += apm * time
|
||||
weighted_total_adpm += adpm * time
|
||||
total_attack += i.attack
|
||||
total_dig += i.dig
|
||||
total_offset += i.offset
|
||||
total_pieses += i.pieces
|
||||
total_receive += i.receive
|
||||
total_time += time
|
||||
num += 1
|
||||
if num >= query_num:
|
||||
break
|
||||
if num == 0:
|
||||
return None
|
||||
# TODO: 如果有效局数小于 {查询数} , 并且没有无dig信息的局, 且 user_profile.data 内有{请求数}个局, 则继续往前获取信息
|
||||
metrics = get_metrics(
|
||||
lpm=weighted_total_lpm / total_time, apm=weighted_total_apm / total_time, adpm=weighted_total_adpm / total_time
|
||||
)
|
||||
return GameData(
|
||||
game_num=num,
|
||||
metrics=metrics,
|
||||
OR=total_offset / total_receive * 100,
|
||||
dspp=total_dig / total_pieses,
|
||||
ge=2 * ((total_attack * total_dig) / total_pieses**2),
|
||||
)
|
||||
|
||||
|
||||
async def make_query_image(user_info: UserInfoSuccess, game_data: GameData, event_user_info: UserInfo) -> bytes:
|
||||
metrics = game_data.metrics
|
||||
duration = timedelta(milliseconds=float(user_info.data.pb_sprint)).total_seconds()
|
||||
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/tos/info',
|
||||
Info(
|
||||
user=People(avatar=await get_avatar(event_user_info, 'Data URI', None), name=user_info.data.name),
|
||||
ranking=Ranking(rating=float(user_info.data.ranking), rd=round(float(user_info.data.rd_now), 2)),
|
||||
multiplayer=Multiplayer(
|
||||
pps=metrics.pps,
|
||||
lpm=metrics.lpm,
|
||||
apm=metrics.apm,
|
||||
apl=metrics.apl,
|
||||
vs=metrics.vs,
|
||||
adpm=metrics.adpm,
|
||||
adpl=metrics.adpl,
|
||||
),
|
||||
radar=Radar(
|
||||
app=(app := (metrics.apm / (60 * metrics.pps))),
|
||||
OR=game_data.OR,
|
||||
dspp=game_data.dspp,
|
||||
ci=150 * game_data.dspp - 125 * app + 50 * (metrics.vs / metrics.apm) - 25,
|
||||
ge=game_data.ge,
|
||||
),
|
||||
sprint=sprint_value,
|
||||
challenge=f'{int(user_info.data.pb_challenge):,}' if user_info.data.pb_challenge != '0' else 'N/A',
|
||||
marathon=f'{int(user_info.data.pb_marathon):,}' if user_info.data.pb_marathon != '0' else 'N/A',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
|
||||
|
||||
|
||||
def make_query_text(user_info: UserInfoSuccess, game_data: GameData | None) -> UniMessage:
|
||||
user_data = user_info.data
|
||||
message = f'用户 {user_data.name} ({user_data.teaid}) '
|
||||
if user_data.ranked_games == '0':
|
||||
message += '暂无段位统计数据'
|
||||
else:
|
||||
message += f', 段位分 {round(float(user_data.rating_now),2)}±{round(float(user_data.rd_now),2)} ({round(float(user_data.vol_now),2)}) '
|
||||
if game_data is None:
|
||||
message += ', 暂无游戏数据'
|
||||
else:
|
||||
message += f', 最近 {game_data.game_num} 局数据'
|
||||
message += f"\nL'PM: {game_data.metrics.lpm} ( {game_data.metrics.pps} pps )"
|
||||
message += f'\nAPM: {game_data.metrics.apm} ( x{game_data.metrics.apl} )'
|
||||
message += f'\nADPM: {game_data.metrics.adpm} ( x{game_data.metrics.adpl} ) ( {game_data.metrics.vs}vs )'
|
||||
message += f'\n40L: {float(user_data.pb_sprint)/1000:.2f}s' if user_data.pb_sprint != '2147483647' else ''
|
||||
message += f'\nMarathon: {user_data.pb_marathon}' if user_data.pb_marathon != '0' else ''
|
||||
message += f'\nChallenge: {user_data.pb_challenge}' if user_data.pb_challenge != '0' else ''
|
||||
return UniMessage(message)
|
||||
@@ -1,3 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).absolute().parent
|
||||
@@ -1,43 +0,0 @@
|
||||
<!doctype html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title></title>
|
||||
<link href="../../static/css/bind.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="background">
|
||||
<div id="main-content">
|
||||
<div id="bind-subject">
|
||||
<div id="bind-icons">
|
||||
<img id="user-avatar" src="{{ user_avatar }}" />
|
||||
<img id="state" src="../../static/static/bind/{{ state }}.svg" />
|
||||
<img id="bot-avatar" src="{{ bot_avatar }}" />
|
||||
</div>
|
||||
<div id="command-result">
|
||||
已将您在
|
||||
<p id="game-type">{{ game_type }}</p>
|
||||
上的账号
|
||||
<br />
|
||||
<p id="user-name">{{ user_name }}</p>
|
||||
<br />
|
||||
{% if state == 'success' %} 成功验证并绑定至
|
||||
<p id="bot-name">{{ bot_name }}.</p>
|
||||
{% elif state == 'unverified'%} 绑定至
|
||||
<p id="bot-name">{{ bot_name }}</p>
|
||||
, 但尚未通过验证. {% elif state == 'unknown' %} 绑定至
|
||||
<p id="bot-name">{{ bot_name }}</p>
|
||||
,<br />但是
|
||||
<p id="bot-name">{{ bot_name }}</p>
|
||||
暂时无法验证您的身份. {% elif state == 'unlink' %} 成功从
|
||||
<p id="bot-name">{{ bot_name }}</p>
|
||||
解绑. {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="extra-info">您可以输入 “{{ command }}” 命令来查找您在该平台上的统计数据.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,118 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'SourceHanSansSC-VF';
|
||||
src: url('../static/fonts/SourceHanSans/SourceHanSansSC-VF.otf.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'CabinetGrotesk-Variable';
|
||||
src: url('../static/fonts/CabinetGrotesk/CabinetGrotesk-Variable.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#background {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 30px;
|
||||
gap: 10px;
|
||||
|
||||
width: 444px;
|
||||
|
||||
background: #f1f1f1;
|
||||
font-family: 'CabinetGrotesk-Variable', 'SourceHanSansSC-VF';
|
||||
}
|
||||
|
||||
#main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
padding: 0px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
#bind-subject {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0px;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
#bind-icons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
#user-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
|
||||
filter: drop-shadow(0px 11px 23px rgba(0, 0, 0, 0.22));
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#state {
|
||||
width: 128px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
#bot-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
|
||||
filter: drop-shadow(0px 11px 23px rgba(0, 0, 0, 0.22));
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#command-result {
|
||||
font-weight: 350;
|
||||
font-size: 25px;
|
||||
line-height: 36.2px;
|
||||
text-align: center;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#game-type {
|
||||
display: inline;
|
||||
font-weight: 800;
|
||||
line-height: 31px;
|
||||
}
|
||||
|
||||
#user-name {
|
||||
display: inline;
|
||||
font-weight: 800;
|
||||
line-height: 31px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#bot-name {
|
||||
display: inline;
|
||||
font-weight: 400;
|
||||
line-height: 31px;
|
||||
}
|
||||
|
||||
#extra-info {
|
||||
width: 324px;
|
||||
|
||||
margin: 0 auto;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 23px;
|
||||
text-align: center;
|
||||
|
||||
color: #52525c;
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'CabinetGrotesk-Variable';
|
||||
src: url('../static/fonts/CabinetGrotesk/CabinetGrotesk-Variable.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SourceHanSansSC-VF';
|
||||
src: url('../static/fonts/SourceHanSans/SourceHanSansSC-VF.otf.woff2') format('woff2');
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.flex-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.big-title {
|
||||
margin-left: 25px;
|
||||
margin-top: 22px;
|
||||
|
||||
font-weight: 900;
|
||||
font-size: 35px;
|
||||
line-height: 43px;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.box-shadow {
|
||||
box-shadow: 0px 9px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.chart-shadow {
|
||||
box-shadow: 0px 15px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.box-rounded-corners {
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.small-data-box {
|
||||
position: relative;
|
||||
width: 275px;
|
||||
height: 125px;
|
||||
}
|
||||
|
||||
.big-data-value {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
top: 52px;
|
||||
|
||||
font-weight: 500;
|
||||
font-size: 45px;
|
||||
line-height: 56px;
|
||||
}
|
||||
|
||||
.small-data-value {
|
||||
position: absolute;
|
||||
top: 79px;
|
||||
right: 25px;
|
||||
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
line-height: 19px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 625px;
|
||||
|
||||
background: #f1f1f1;
|
||||
font-family: 'CabinetGrotesk-Variable', 'SourceHanSansSC-VF';
|
||||
}
|
||||
|
||||
.account-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.user-info-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 25px;
|
||||
gap: 10px;
|
||||
|
||||
width: 275px;
|
||||
height: 275px;
|
||||
|
||||
background: #fafafa;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 125px;
|
||||
height: 125px;
|
||||
filter: drop-shadow(0px 11px 23px rgba(0, 0, 0, 0.22));
|
||||
border-radius: 65px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 800;
|
||||
font-size: 25px;
|
||||
line-height: 31px;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.user-sign {
|
||||
width: 225px;
|
||||
height: 66px;
|
||||
|
||||
font-weight: 400;
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.game-info-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 25px;
|
||||
gap: 10px;
|
||||
|
||||
width: 275px;
|
||||
height: 275px;
|
||||
|
||||
background: #fafafa;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.game-type-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.game-name {
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
line-height: 37px;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.game-info-dividing-line {
|
||||
width: 225px;
|
||||
border: 1px solid #bababa;
|
||||
transform: rotate(0.25deg);
|
||||
}
|
||||
|
||||
.ranking-info-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ranking-title {
|
||||
font-weight: 800;
|
||||
font-size: 25px;
|
||||
line-height: 31px;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.ranking {
|
||||
font-weight: 400;
|
||||
font-size: 50px;
|
||||
line-height: 120%;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.rd {
|
||||
margin-top: -16px;
|
||||
|
||||
font-weight: 300;
|
||||
font-size: 30px;
|
||||
line-height: 120%;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#TR-curve-chart {
|
||||
align-self: center;
|
||||
margin-top: 25px;
|
||||
|
||||
width: 575px;
|
||||
height: 275px;
|
||||
|
||||
background: linear-gradient(222.34deg, #525252 11.97%, #1d1916 89.73%);
|
||||
}
|
||||
|
||||
.TR-title {
|
||||
position: absolute;
|
||||
margin-left: 24px;
|
||||
margin-top: 19px;
|
||||
|
||||
font-weight: 800;
|
||||
font-size: 25px;
|
||||
line-height: 31px;
|
||||
white-space: nowrap;
|
||||
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.rank-icon {
|
||||
position: absolute;
|
||||
margin-left: 27px;
|
||||
margin-top: 90px;
|
||||
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.TR {
|
||||
position: absolute;
|
||||
margin-left: 24px;
|
||||
margin-top: 143px;
|
||||
|
||||
font-weight: 800;
|
||||
font-size: 45px;
|
||||
line-height: 120%;
|
||||
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.multiplayer-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.multiplayer-data-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.multiplayer-data {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.multiplayer-data:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.lpm-box {
|
||||
background-image: url('../static/data/LPM.svg');
|
||||
}
|
||||
|
||||
.lpm-value {
|
||||
color: #4d7d0f;
|
||||
}
|
||||
|
||||
.pps-value {
|
||||
color: #4d7d0f;
|
||||
}
|
||||
|
||||
.apm-box {
|
||||
background-image: url('../static/data/APM.svg');
|
||||
}
|
||||
|
||||
.apm-value {
|
||||
color: #b5530a;
|
||||
}
|
||||
|
||||
.apl-value {
|
||||
color: #b5530a;
|
||||
}
|
||||
|
||||
.adpm-box {
|
||||
background-image: url('../static/data/ADPM.svg');
|
||||
}
|
||||
|
||||
.adpm-value {
|
||||
color: #235db4;
|
||||
}
|
||||
|
||||
.vs-value {
|
||||
top: 62px;
|
||||
color: #4779c6;
|
||||
}
|
||||
|
||||
.adpl-value {
|
||||
color: #4779c6;
|
||||
}
|
||||
|
||||
.radar-chart-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.radar-background {
|
||||
background: linear-gradient(222.34deg, #525252 11.97%, #1d1916 89.73%),
|
||||
linear-gradient(222.34deg, #4f9dff 11.97%, #2563ea 89.73%);
|
||||
}
|
||||
|
||||
#radar-chart {
|
||||
width: 275px;
|
||||
height: 275px;
|
||||
}
|
||||
|
||||
.radar-description {
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
padding-top: 15px;
|
||||
font-size: 12px;
|
||||
color: #fafafa;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.singleplayer-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-content: space-between;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.sprint-box {
|
||||
background-image: url('../static/data/40L.svg');
|
||||
}
|
||||
|
||||
.blitz-box {
|
||||
background-image: url('../static/data/Blitz.svg');
|
||||
}
|
||||
|
||||
.sprint-value {
|
||||
color: #b42323;
|
||||
}
|
||||
|
||||
.blitz-value {
|
||||
color: #8e23b4;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
font-size: 30px;
|
||||
font-weight: 750;
|
||||
line-height: 120%;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
<!doctype html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title></title>
|
||||
<link href="../../static/css/data.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main-content">
|
||||
<span class="big-title">Account&Rankings</span>
|
||||
<div class="account-box">
|
||||
<div class="info-box">
|
||||
<div class="flex-gap"></div>
|
||||
<div class="box-shadow box-rounded-corners user-info-box">
|
||||
<div class="flex-gap"></div>
|
||||
<img class="user-avatar" src="{{user_avatar}}" />
|
||||
<div class="flex-gap"></div>
|
||||
<div class="user-name">{{user_name}}</div>
|
||||
<div class="flex-gap"></div>
|
||||
{% if user_sign is not none %}
|
||||
<div class="user-sign">“{{user_sign}}”</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-gap"></div>
|
||||
<div class="box-shadow box-rounded-corners game-info-box">
|
||||
<div class="game-type-box">
|
||||
<img class="game-logo" src="../../static/static/logo/{{game_type}}.svg" />
|
||||
<span class="game-name">{{game_type}}</span>
|
||||
</div>
|
||||
<div class="game-info-dividing-line"></div>
|
||||
<div class="ranking-info-box">
|
||||
<span class="ranking-title">Ranking</span>
|
||||
<span class="ranking">{{ranking}}</span>
|
||||
<span class="rd">±{{rd}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-gap"></div>
|
||||
</div>
|
||||
{# <div class="chart-shadow box-rounded-corners" id="TR-curve-chart">
|
||||
<span class="TR-title">Tetra Rating (TR)</span>
|
||||
<img class="rank-icon" src="../../static/static/rank/{{rank}}.svg" />
|
||||
<span class="TR" style="display: flex; align-items: flex-end"
|
||||
>{{TR}}
|
||||
<p style="font-size: 30px; font-weight: 400; line-height: 47px">(#{{global_rank}})</p>
|
||||
</span>
|
||||
</div> #}
|
||||
</div>
|
||||
<span class="big-title">Multiplayer Stats</span>
|
||||
<div class="multiplayer-box">
|
||||
<div class="flex-gap"></div>
|
||||
<div class="multiplayer-data-box">
|
||||
<div class="multiplayer-data small-data-box box-shadow box-rounded-corners lpm-box">
|
||||
<span class="big-data-value lpm-value">{{lpm}}</span>
|
||||
<span class="small-data-value pps-value">{{pps}} pps</span>
|
||||
</div>
|
||||
<div class="multiplayer-data small-data-box box-shadow box-rounded-corners apm-box">
|
||||
<span class="big-data-value apm-value">{{apm}}</span>
|
||||
<span class="small-data-value apl-value">x{{apl}}</span>
|
||||
</div>
|
||||
<div class="multiplayer-data small-data-box box-shadow box-rounded-corners adpm-box">
|
||||
<span class="big-data-value adpm-value">{{adpm}}</span>
|
||||
<span class="small-data-value adpl-value">x{{adpl}}</span>
|
||||
<span class="small-data-value vs-value">{{vs}} vs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-gap"></div>
|
||||
<div class="radar-chart-box">
|
||||
<div class="chart-shadow box-rounded-corners radar-background" id="radar-chart"></div>
|
||||
<div class="flex-gap"></div>
|
||||
<div class="chart-shadow box-rounded-corners small-data-box radar-background radar-description"><p style="font-size: 18px;display: inline;">tips: </p><br />DSPS 每秒挖掘<br />DSPP 每块挖掘<br />CI 奶酪指数<br />GE 垃圾利用率</div>
|
||||
</div>
|
||||
<div class="flex-gap"></div>
|
||||
</div>
|
||||
<span class="big-title">Singleplayer Stats</span>
|
||||
<div class="singleplayer-box">
|
||||
<div class="flex-gap"></div>
|
||||
<div class="small-data-box box-shadow box-rounded-corners sprint-box">
|
||||
<span class="big-data-value sprint-value">{{sprint}}</span>
|
||||
</div>
|
||||
<div class="flex-gap"></div>
|
||||
<div class="small-data-box box-shadow box-rounded-corners blitz-box">
|
||||
<span class="big-data-value blitz-value">{{blitz}}</span>
|
||||
</div>
|
||||
<div class="flex-gap"></div>
|
||||
</div>
|
||||
<div class="footer">Powered by<br />Nonebot2 x nonebot-plugin-tetris-stats</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script src="../../static/js/echarts.js"></script>
|
||||
|
||||
<script>
|
||||
var data = {{data}}
|
||||
|
||||
// 曲线图
|
||||
var lineChartDom = document.getElementById('TR-curve-chart');
|
||||
var lineChart = echarts.init(lineChartDom, null, { renderer: 'svg' });
|
||||
var option;
|
||||
|
||||
/** @type EChartsOption */
|
||||
option = {
|
||||
animation: false,
|
||||
grid: {
|
||||
left: '-5%',
|
||||
bottom: '17%',
|
||||
width: '90%',
|
||||
height: '70%',
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
minInterval: 3600 * 48 * 1000,
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: function (value, index) {
|
||||
var date = new Date(value);
|
||||
var lst;
|
||||
var ret;
|
||||
function format_date() {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(date)
|
||||
.split('/');
|
||||
}
|
||||
switch (index) {
|
||||
case 0:
|
||||
case 6:
|
||||
ret = '';
|
||||
break;
|
||||
default:
|
||||
lst = format_date();
|
||||
if (index === 5) {
|
||||
ret = '{last_month|' + lst[0] + '}\n{last_day|' + lst[1] + '}';
|
||||
break;
|
||||
}
|
||||
ret = '{month|' + lst[0] + '}\n{day|' + lst[1] + '}';
|
||||
}
|
||||
return ret;
|
||||
},
|
||||
rich: {
|
||||
month: {
|
||||
fontFamily: 'CabinetGrotesk-Variable',
|
||||
fontSize: 13,
|
||||
fontWeight: '400',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
day: {
|
||||
fontFamily: 'CabinetGrotesk-Variable',
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
last_month: {
|
||||
fontFamily: 'CabinetGrotesk-Variable',
|
||||
fontSize: 13,
|
||||
fontWeight: '400',
|
||||
color: '#373533',
|
||||
backgroundColor: '#FAFAFA',
|
||||
borderRadius: 6,
|
||||
padding: [-10, 0, 10, 0],
|
||||
width: 36,
|
||||
height: 37,
|
||||
lineHeight: 32,
|
||||
},
|
||||
last_day: {
|
||||
fontFamily: 'CabinetGrotesk-Variable',
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
color: '#373533',
|
||||
padding: [-18, 0, 0, 0],
|
||||
lineHeight: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
zlevel: 1,
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
interval: {{split_value}},
|
||||
position: 'right',
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
align: 'right',
|
||||
formatter: function (value, index) {
|
||||
return '{value|' + value.toLocaleString() + '}';
|
||||
},
|
||||
rich: {
|
||||
value: {
|
||||
fontFamily: 'CabinetGrotesk-Variable',
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
},
|
||||
},
|
||||
offset: 70,
|
||||
max: {{value_max+offset}},
|
||||
min: {{value_min-offset}},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
// 10天的数据,最后一天只要第一条 (时间戳最少要多1ms)
|
||||
data: data,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: function (value, params) {
|
||||
if (params.dataIndex === data.length - 1) {
|
||||
return 'image://../../static/static/data/point.svg';
|
||||
}
|
||||
return 'none';
|
||||
},
|
||||
symbolSize: 75,
|
||||
symbolOffset: [0.79, 0],
|
||||
lineStyle: {
|
||||
color: '#FAFAFA99',
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(250, 250, 250, 0.3)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(250, 250, 250, 0)',
|
||||
},
|
||||
],
|
||||
global: false,
|
||||
},
|
||||
},
|
||||
markLine: {
|
||||
data: [
|
||||
{
|
||||
xAxis: 'max',
|
||||
y: 300,
|
||||
},
|
||||
],
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#FAFAFA',
|
||||
width: 3,
|
||||
type: 'dashed',
|
||||
cap: 'round',
|
||||
},
|
||||
symbol: 'none',
|
||||
animation: false,
|
||||
},
|
||||
z: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
option && lineChart.setOption(option);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// 雷达图
|
||||
var radarChartDom = document.getElementById('radar-chart');
|
||||
var radarChart = echarts.init(radarChartDom, null, { renderer: 'svg' });
|
||||
var option;
|
||||
|
||||
option = {
|
||||
animation: false,
|
||||
radar: [
|
||||
{
|
||||
indicator: [
|
||||
{ name: 'PPS' },
|
||||
{ name: 'APP', nameRotate: 60 },
|
||||
{ name: 'DSPS', nameRotate: -60 },
|
||||
{ name: 'DSPP' },
|
||||
{ name: 'CI', nameRotate: 60 },
|
||||
{ name: 'GE', nameRotate: -60 },
|
||||
],
|
||||
center: ['50%', '50%'],
|
||||
radius: '65%',
|
||||
startAngle: 90,
|
||||
splitNumber: 4,
|
||||
shape: 'circle',
|
||||
silent: true,
|
||||
axisName: {
|
||||
color: '#FAFAFA',
|
||||
fontFamily: 'CabinetGrotesk-Variable',
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
splitArea: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(250, 250, 250, 0.3)',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
rotate: 0,
|
||||
margin: -1,
|
||||
fontFamily: 'CabinetGrotesk-Variable',
|
||||
fontSize: 7,
|
||||
fontWeight: '800',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(250, 250, 250, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
symbol: 'none',
|
||||
label: {
|
||||
show: true,
|
||||
},
|
||||
emphasis: {
|
||||
disabled: true,
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#FAFAFA',
|
||||
width: 2.5,
|
||||
shadowBlur: 20,
|
||||
shadowColor: 'rgba(250, 250, 250, 1)',
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'rgba(250, 250, 250, 0.45)',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: [{{pps}}, {{app}}, {{dsps}}, {{dspp}}, {{ci}}, {{ge}}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
option && radarChart.setOption(option);
|
||||
</script>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Identicon.js 2.3.3
|
||||
* http://github.com/stewartlord/identicon.js
|
||||
*
|
||||
* PNGLib required for PNG output
|
||||
* http://www.xarg.org/download/pnglib.js
|
||||
*
|
||||
* Copyright 2018, Stewart Lord
|
||||
* Released under the BSD license
|
||||
* http://www.opensource.org/licenses/bsd-license.php
|
||||
*/
|
||||
|
||||
(function() {
|
||||
var PNGlib;
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
||||
PNGlib = require('./pnglib');
|
||||
} else {
|
||||
PNGlib = window.PNGlib;
|
||||
}
|
||||
|
||||
var Identicon = function(hash, options){
|
||||
if (typeof(hash) !== 'string' || hash.length < 15) {
|
||||
throw 'A hash of at least 15 characters is required.';
|
||||
}
|
||||
|
||||
this.defaults = {
|
||||
background: [240, 240, 240, 255],
|
||||
margin: 0.08,
|
||||
size: 64,
|
||||
saturation: 0.7,
|
||||
brightness: 0.5,
|
||||
format: 'png'
|
||||
};
|
||||
|
||||
this.options = typeof(options) === 'object' ? options : this.defaults;
|
||||
|
||||
// backward compatibility with old constructor (hash, size, margin)
|
||||
if (typeof(arguments[1]) === 'number') { this.options.size = arguments[1]; }
|
||||
if (arguments[2]) { this.options.margin = arguments[2]; }
|
||||
|
||||
this.hash = hash
|
||||
this.background = this.options.background || this.defaults.background;
|
||||
this.size = this.options.size || this.defaults.size;
|
||||
this.format = this.options.format || this.defaults.format;
|
||||
this.margin = this.options.margin !== undefined ? this.options.margin : this.defaults.margin;
|
||||
|
||||
// foreground defaults to last 7 chars as hue at 70% saturation, 50% brightness
|
||||
var hue = parseInt(this.hash.substr(-7), 16) / 0xfffffff;
|
||||
var saturation = this.options.saturation || this.defaults.saturation;
|
||||
var brightness = this.options.brightness || this.defaults.brightness;
|
||||
this.foreground = this.options.foreground || this.hsl2rgb(hue, saturation, brightness);
|
||||
};
|
||||
|
||||
Identicon.prototype = {
|
||||
background: null,
|
||||
foreground: null,
|
||||
hash: null,
|
||||
margin: null,
|
||||
size: null,
|
||||
format: null,
|
||||
|
||||
image: function(){
|
||||
return this.isSvg()
|
||||
? new Svg(this.size, this.foreground, this.background)
|
||||
: new PNGlib(this.size, this.size, 256);
|
||||
},
|
||||
|
||||
render: function(){
|
||||
var image = this.image(),
|
||||
size = this.size,
|
||||
baseMargin = Math.floor(size * this.margin),
|
||||
cell = Math.floor((size - (baseMargin * 2)) / 5),
|
||||
margin = Math.floor((size - cell * 5) / 2),
|
||||
bg = image.color.apply(image, this.background),
|
||||
fg = image.color.apply(image, this.foreground);
|
||||
|
||||
// the first 15 characters of the hash control the pixels (even/odd)
|
||||
// they are drawn down the middle first, then mirrored outwards
|
||||
var i, color;
|
||||
for (i = 0; i < 15; i++) {
|
||||
color = parseInt(this.hash.charAt(i), 16) % 2 ? bg : fg;
|
||||
if (i < 5) {
|
||||
this.rectangle(2 * cell + margin, i * cell + margin, cell, cell, color, image);
|
||||
} else if (i < 10) {
|
||||
this.rectangle(1 * cell + margin, (i - 5) * cell + margin, cell, cell, color, image);
|
||||
this.rectangle(3 * cell + margin, (i - 5) * cell + margin, cell, cell, color, image);
|
||||
} else if (i < 15) {
|
||||
this.rectangle(0 * cell + margin, (i - 10) * cell + margin, cell, cell, color, image);
|
||||
this.rectangle(4 * cell + margin, (i - 10) * cell + margin, cell, cell, color, image);
|
||||
}
|
||||
}
|
||||
|
||||
return image;
|
||||
},
|
||||
|
||||
rectangle: function(x, y, w, h, color, image){
|
||||
if (this.isSvg()) {
|
||||
image.rectangles.push({x: x, y: y, w: w, h: h, color: color});
|
||||
} else {
|
||||
var i, j;
|
||||
for (i = x; i < x + w; i++) {
|
||||
for (j = y; j < y + h; j++) {
|
||||
image.buffer[image.index(i, j)] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// adapted from: https://gist.github.com/aemkei/1325937
|
||||
hsl2rgb: function(h, s, b){
|
||||
h *= 6;
|
||||
s = [
|
||||
b += s *= b < .5 ? b : 1 - b,
|
||||
b - h % 1 * s * 2,
|
||||
b -= s *= 2,
|
||||
b,
|
||||
b + h % 1 * s,
|
||||
b + s
|
||||
];
|
||||
|
||||
return[
|
||||
s[ ~~h % 6 ] * 255, // red
|
||||
s[ (h|16) % 6 ] * 255, // green
|
||||
s[ (h|8) % 6 ] * 255 // blue
|
||||
];
|
||||
},
|
||||
|
||||
toString: function(raw){
|
||||
// backward compatibility with old toString, default to base64
|
||||
if (raw) {
|
||||
return this.render().getDump();
|
||||
} else {
|
||||
return this.render().getBase64();
|
||||
}
|
||||
},
|
||||
|
||||
isSvg: function(){
|
||||
return this.format.match(/svg/i)
|
||||
}
|
||||
};
|
||||
|
||||
var Svg = function(size, foreground, background){
|
||||
this.size = size;
|
||||
this.foreground = this.color.apply(this, foreground);
|
||||
this.background = this.color.apply(this, background);
|
||||
this.rectangles = [];
|
||||
};
|
||||
|
||||
Svg.prototype = {
|
||||
size: null,
|
||||
foreground: null,
|
||||
background: null,
|
||||
rectangles: null,
|
||||
|
||||
color: function(r, g, b, a){
|
||||
var values = [r, g, b].map(Math.round);
|
||||
values.push((a >= 0) && (a <= 255) ? a/255 : 1);
|
||||
return 'rgba(' + values.join(',') + ')';
|
||||
},
|
||||
|
||||
getDump: function(){
|
||||
var i,
|
||||
xml,
|
||||
rect,
|
||||
fg = this.foreground,
|
||||
bg = this.background,
|
||||
stroke = this.size * 0.005;
|
||||
|
||||
xml = "<svg xmlns='http://www.w3.org/2000/svg'"
|
||||
+ " width='" + this.size + "' height='" + this.size + "'"
|
||||
+ " style='background-color:" + bg + ";'>"
|
||||
+ "<g style='fill:" + fg + "; stroke:" + fg + "; stroke-width:" + stroke + ";'>";
|
||||
|
||||
for (i = 0; i < this.rectangles.length; i++) {
|
||||
rect = this.rectangles[i];
|
||||
if (rect.color == bg) continue;
|
||||
xml += "<rect "
|
||||
+ " x='" + rect.x + "'"
|
||||
+ " y='" + rect.y + "'"
|
||||
+ " width='" + rect.w + "'"
|
||||
+ " height='" + rect.h + "'"
|
||||
+ "/>";
|
||||
}
|
||||
xml += "</g></svg>"
|
||||
|
||||
return xml;
|
||||
},
|
||||
|
||||
getBase64: function(){
|
||||
if ('function' === typeof btoa) {
|
||||
return btoa(this.getDump());
|
||||
} else if (Buffer) {
|
||||
return new Buffer(this.getDump(), 'binary').toString('base64');
|
||||
} else {
|
||||
throw 'Cannot generate base64 output';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
||||
module.exports = Identicon;
|
||||
} else {
|
||||
window.Identicon = Identicon;
|
||||
}
|
||||
})();
|
||||
@@ -1,20 +0,0 @@
|
||||
<svg width="132" height="56" viewBox="0 0 132 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 10.5C1.17157 10.5 0.5 11.1716 0.5 12C0.5 12.8284 1.17157 13.5 2 13.5V10.5ZM131.061 13.0607C131.646 12.4749 131.646 11.5251 131.061 10.9393L121.515 1.3934C120.929 0.807611 119.979 0.807611 119.393 1.3934C118.808 1.97919 118.808 2.92893 119.393 3.51472L127.879 12L119.393 20.4853C118.808 21.0711 118.808 22.0208 119.393 22.6066C119.979 23.1924 120.929 23.1924 121.515 22.6066L131.061 13.0607ZM4.95385 13.5C5.78227 13.5 6.45385 12.8284 6.45385 12C6.45385 11.1716 5.78227 10.5 4.95385 10.5V13.5ZM11.8462 10.5C11.0177 10.5 10.3462 11.1716 10.3462 12C10.3462 12.8284 11.0177 13.5 11.8462 13.5V10.5ZM17.7538 13.5C18.5823 13.5 19.2538 12.8284 19.2538 12C19.2538 11.1716 18.5823 10.5 17.7538 10.5V13.5ZM24.6462 10.5C23.8177 10.5 23.1462 11.1716 23.1462 12C23.1462 12.8284 23.8177 13.5 24.6462 13.5V10.5ZM30.5538 13.5C31.3823 13.5 32.0538 12.8284 32.0538 12C32.0538 11.1716 31.3823 10.5 30.5538 10.5V13.5ZM37.4462 10.5C36.6177 10.5 35.9462 11.1716 35.9462 12C35.9462 12.8284 36.6177 13.5 37.4462 13.5V10.5ZM43.3538 13.5C44.1823 13.5 44.8538 12.8284 44.8538 12C44.8538 11.1716 44.1823 10.5 43.3538 10.5V13.5ZM50.2462 10.5C49.4177 10.5 48.7462 11.1716 48.7462 12C48.7462 12.8284 49.4177 13.5 50.2462 13.5V10.5ZM56.1538 13.5C56.9823 13.5 57.6538 12.8284 57.6538 12C57.6538 11.1716 56.9823 10.5 56.1538 10.5V13.5ZM63.0462 10.5C62.2177 10.5 61.5462 11.1716 61.5462 12C61.5462 12.8284 62.2177 13.5 63.0462 13.5V10.5ZM68.9538 13.5C69.7823 13.5 70.4538 12.8284 70.4538 12C70.4538 11.1716 69.7823 10.5 68.9538 10.5V13.5ZM75.8462 10.5C75.0177 10.5 74.3462 11.1716 74.3462 12C74.3462 12.8284 75.0177 13.5 75.8462 13.5V10.5ZM81.7539 13.5C82.5823 13.5 83.2539 12.8284 83.2539 12C83.2539 11.1716 82.5823 10.5 81.7539 10.5V13.5ZM88.6462 10.5C87.8177 10.5 87.1462 11.1716 87.1462 12C87.1462 12.8284 87.8177 13.5 88.6462 13.5V10.5ZM94.5539 13.5C95.3823 13.5 96.0539 12.8284 96.0539 12C96.0539 11.1716 95.3823 10.5 94.5539 10.5V13.5ZM101.446 10.5C100.618 10.5 99.9462 11.1716 99.9462 12C99.9462 12.8284 100.618 13.5 101.446 13.5V10.5ZM107.354 13.5C108.182 13.5 108.854 12.8284 108.854 12C108.854 11.1716 108.182 10.5 107.354 10.5V13.5ZM114.246 10.5C113.418 10.5 112.746 11.1716 112.746 12C112.746 12.8284 113.418 13.5 114.246 13.5V10.5ZM120.154 13.5C120.982 13.5 121.654 12.8284 121.654 12C121.654 11.1716 120.982 10.5 120.154 10.5V13.5ZM127.046 10.5C126.218 10.5 125.546 11.1716 125.546 12C125.546 12.8284 126.218 13.5 127.046 13.5V10.5ZM2 13.5H4.95385V10.5H2V13.5ZM11.8462 13.5H17.7538V10.5H11.8462V13.5ZM24.6462 13.5H30.5538V10.5H24.6462V13.5ZM37.4462 13.5H43.3538V10.5H37.4462V13.5ZM50.2462 13.5H56.1538V10.5H50.2462V13.5ZM63.0462 13.5H68.9538V10.5H63.0462V13.5ZM75.8462 13.5H81.7539V10.5H75.8462V13.5ZM88.6462 13.5H94.5539V10.5H88.6462V13.5ZM101.446 13.5H107.354V10.5H101.446V13.5ZM114.246 13.5H120.154V10.5H114.246V13.5ZM127.046 13.5H130V10.5H127.046V13.5Z" fill="#A1A1AB"/>
|
||||
<path d="M130 45.5C130.828 45.5 131.5 44.8284 131.5 44C131.5 43.1716 130.828 42.5 130 42.5V45.5ZM0.939331 42.9393C0.353546 43.5251 0.353546 44.4749 0.939331 45.0607L10.4853 54.6066C11.0711 55.1924 12.0208 55.1924 12.6066 54.6066C13.1924 54.0208 13.1924 53.0711 12.6066 52.4853L4.12132 44L12.6066 35.5147C13.1924 34.9289 13.1924 33.9792 12.6066 33.3934C12.0208 32.8076 11.0711 32.8076 10.4853 33.3934L0.939331 42.9393ZM127.046 42.5C126.218 42.5 125.546 43.1716 125.546 44C125.546 44.8284 126.218 45.5 127.046 45.5V42.5ZM120.154 45.5C120.982 45.5 121.654 44.8284 121.654 44C121.654 43.1716 120.982 42.5 120.154 42.5V45.5ZM114.246 42.5C113.418 42.5 112.746 43.1716 112.746 44C112.746 44.8284 113.418 45.5 114.246 45.5V42.5ZM107.354 45.5C108.182 45.5 108.854 44.8284 108.854 44C108.854 43.1716 108.182 42.5 107.354 42.5V45.5ZM101.446 42.5C100.618 42.5 99.9462 43.1716 99.9462 44C99.9462 44.8284 100.618 45.5 101.446 45.5V42.5ZM94.5538 45.5C95.3823 45.5 96.0538 44.8284 96.0538 44C96.0538 43.1716 95.3823 42.5 94.5538 42.5V45.5ZM88.6462 42.5C87.8177 42.5 87.1462 43.1716 87.1462 44C87.1462 44.8284 87.8177 45.5 88.6462 45.5V42.5ZM81.7538 45.5C82.5823 45.5 83.2538 44.8284 83.2538 44C83.2538 43.1716 82.5823 42.5 81.7538 42.5V45.5ZM75.8462 42.5C75.0177 42.5 74.3462 43.1716 74.3462 44C74.3462 44.8284 75.0177 45.5 75.8462 45.5V42.5ZM68.9538 45.5C69.7823 45.5 70.4538 44.8284 70.4538 44C70.4538 43.1716 69.7823 42.5 68.9538 42.5V45.5ZM63.0462 42.5C62.2177 42.5 61.5462 43.1716 61.5462 44C61.5462 44.8284 62.2177 45.5 63.0462 45.5V42.5ZM56.1538 45.5C56.9823 45.5 57.6538 44.8284 57.6538 44C57.6538 43.1716 56.9823 42.5 56.1538 42.5V45.5ZM50.2461 42.5C49.4177 42.5 48.7461 43.1716 48.7461 44C48.7461 44.8284 49.4177 45.5 50.2461 45.5V42.5ZM43.3538 45.5C44.1823 45.5 44.8538 44.8284 44.8538 44C44.8538 43.1716 44.1823 42.5 43.3538 42.5V45.5ZM37.4461 42.5C36.6177 42.5 35.9461 43.1716 35.9461 44C35.9461 44.8284 36.6177 45.5 37.4461 45.5V42.5ZM30.5538 45.5C31.3823 45.5 32.0538 44.8284 32.0538 44C32.0538 43.1716 31.3823 42.5 30.5538 42.5V45.5ZM24.6461 42.5C23.8177 42.5 23.1461 43.1716 23.1461 44C23.1461 44.8284 23.8177 45.5 24.6461 45.5V42.5ZM17.7538 45.5C18.5823 45.5 19.2538 44.8284 19.2538 44C19.2538 43.1716 18.5823 42.5 17.7538 42.5V45.5ZM11.8461 42.5C11.0177 42.5 10.3461 43.1716 10.3461 44C10.3461 44.8284 11.0177 45.5 11.8461 45.5V42.5ZM4.95383 45.5C5.78225 45.5 6.45383 44.8284 6.45383 44C6.45383 43.1716 5.78225 42.5 4.95383 42.5V45.5ZM130 42.5H127.046V45.5H130V42.5ZM120.154 42.5H114.246V45.5H120.154V42.5ZM107.354 42.5H101.446V45.5H107.354V42.5ZM94.5538 42.5H88.6462V45.5H94.5538V42.5ZM81.7538 42.5H75.8462V45.5H81.7538V42.5ZM68.9538 42.5H63.0462V45.5H68.9538V42.5ZM56.1538 42.5H50.2461V45.5H56.1538V42.5ZM43.3538 42.5H37.4461V45.5H43.3538V42.5ZM30.5538 42.5H24.6461V45.5H30.5538V42.5ZM17.7538 42.5H11.8461V45.5H17.7538V42.5ZM4.95383 42.5H2V45.5H4.95383V42.5Z" fill="#A1A1AB"/>
|
||||
<circle cx="66" cy="28" r="28" fill="#F1F1F1"/>
|
||||
<g filter="url(#filter0_d_503_299)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M66 48C77.0457 48 86 39.0457 86 28C86 16.9543 77.0457 8 66 8C54.9543 8 46 16.9543 46 28C46 39.0457 54.9543 48 66 48ZM59.6667 37.0667L66.2667 30.4667L72.8667 37.0667C73.1778 37.3778 73.5444 37.5333 73.9667 37.5333C74.3889 37.5333 74.7556 37.3778 75.0667 37.0667C75.3778 36.7556 75.5333 36.3889 75.5333 35.9667C75.5333 35.5444 75.3778 35.1778 75.0667 34.8667L68.4667 28.2667L75.0667 21.6667C75.3778 21.3556 75.5333 20.9889 75.5333 20.5667C75.5333 20.1444 75.3778 19.7778 75.0667 19.4667C74.7556 19.1556 74.3889 19 73.9667 19C73.5444 19 73.1778 19.1556 72.8667 19.4667L66.2667 26.0667L59.6667 19.4667C59.3556 19.1556 58.9889 19 58.5667 19C58.1444 19 57.7778 19.1556 57.4667 19.4667C57.1556 19.7778 57 20.1444 57 20.5667C57 20.9889 57.1556 21.3556 57.4667 21.6667L64.0667 28.2667L57.4667 34.8667C57.1556 35.1778 57 35.5444 57 35.9667C57 36.3889 57.1556 36.7556 57.4667 37.0667C57.7778 37.3778 58.1444 37.5333 58.5667 37.5333C58.9889 37.5333 59.3556 37.3778 59.6667 37.0667Z" fill="#F04444"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_503_299" x="42" y="7" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="3"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_503_299"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_503_299" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user