mirror of
https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
synced 2026-03-05 05:36:54 +08:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ package-lock.json
|
||||
bot.py
|
||||
TODO
|
||||
*.fish
|
||||
extracted_skin_mino_*
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
from nonebot import require
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
require('nonebot_plugin_localstore')
|
||||
require('nonebot_plugin_orm')
|
||||
require('nonebot_plugin_alconna')
|
||||
require('nonebot_plugin_apscheduler')
|
||||
require('nonebot_plugin_localstore')
|
||||
require('nonebot_plugin_orm')
|
||||
require('nonebot_plugin_session_orm')
|
||||
require('nonebot_plugin_session')
|
||||
require('nonebot_plugin_user')
|
||||
require('nonebot_plugin_userinfo')
|
||||
|
||||
from .config.config import migrations # noqa: E402
|
||||
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
|
||||
|
||||
__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',
|
||||
extra={
|
||||
@@ -19,5 +28,4 @@ __plugin_meta__ = PluginMetadata(
|
||||
},
|
||||
)
|
||||
|
||||
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')
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -80,8 +84,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 ###
|
||||
@@ -8,12 +8,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
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
|
||||
@@ -26,8 +29,9 @@ def upgrade(name: str = '') -> None:
|
||||
from nonebot_plugin_tetris_stats.version import __version__
|
||||
|
||||
if __version__ != '1.0.4':
|
||||
logger.critical('本迁移需要1.0.4版本, 请先锁定版本至1.0.4版本再执行本迁移')
|
||||
raise RuntimeError('本迁移需要1.0.4版本, 请先锁定版本至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 (
|
||||
@@ -42,7 +46,7 @@ def upgrade(name: str = '') -> None:
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import BaseUser
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 CommandType, GameType
|
||||
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: CommandType | Literal['rank', 'config', 'record'],
|
||||
command_args: list[str],
|
||||
) -> AsyncGenerator:
|
||||
yield
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@overload
|
||||
async def trigger(
|
||||
session_persist_id: int,
|
||||
game_platform: GameType,
|
||||
command_type: CommandType,
|
||||
command_args: list[str],
|
||||
) -> AsyncGenerator:
|
||||
yield
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def trigger(
|
||||
session_persist_id: int,
|
||||
game_platform: GameType,
|
||||
command_type: CommandType | Literal['rank', 'config', 'record'],
|
||||
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
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -18,8 +16,15 @@ 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,36 +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)
|
||||
user_unique_identifier: Mapped[str] = mapped_column(String(32), index=True, 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[CommandType | Literal['rank', 'config', 'record']] = mapped_column(String(16), index=True)
|
||||
command_args: Mapped[list[str]] = mapped_column(JSON)
|
||||
finish_time: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
@@ -1,100 +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.user_unique_identifier = self.user.unique_identifier
|
||||
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,458 +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 ceil, floor
|
||||
from re import match
|
||||
from statistics import mean
|
||||
from typing import Literal
|
||||
from urllib.parse import urlunparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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 ...db.models import HistoricalData
|
||||
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 Bind, TETRIOInfo, 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, TR_MAX, TR_MIN
|
||||
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 MultiRecord, 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不合法')
|
||||
|
||||
|
||||
def get_value_bounds(values: list[int | float]) -> tuple[int, int]:
|
||||
value_max = 10 * ceil(max(values) / 10)
|
||||
value_min = 10 * floor(min(values) / 10)
|
||||
return value_max, value_min
|
||||
|
||||
|
||||
def get_split(value_max: int, value_min: int) -> tuple[int, int]:
|
||||
offset = 0
|
||||
overflow = 0
|
||||
|
||||
while True:
|
||||
if (new_max_value := value_max + offset + overflow) > TR_MAX:
|
||||
overflow -= 1
|
||||
continue
|
||||
if (new_min_value := value_min - offset + overflow) < TR_MIN:
|
||||
overflow += 1
|
||||
continue
|
||||
if ((new_max_value - new_min_value) / 40).is_integer():
|
||||
split_value = int((value_max + offset - (value_min - offset)) / 4)
|
||||
break
|
||||
offset += 1
|
||||
return split_value, offset + overflow
|
||||
|
||||
|
||||
def get_specified_point(
|
||||
previous_point: TETRIOInfo.TetraLeagueHistory.Data,
|
||||
behind_point: TETRIOInfo.TetraLeagueHistory.Data,
|
||||
point_time: datetime,
|
||||
) -> TETRIOInfo.TetraLeagueHistory.Data:
|
||||
"""根据给出的 previous_point 和 behind_point, 推算 point_time 点处的数据
|
||||
|
||||
Args:
|
||||
previous_point (TETRIOInfo.TetraLeagueHistory.Data): 前面的数据点
|
||||
behind_point (TETRIOInfo.TetraLeagueHistory.Data): 后面的数据点
|
||||
point_time (datetime): 要推算的点的位置
|
||||
|
||||
Returns:
|
||||
TETRIOInfo.TetraLeagueHistory.Data: 要推算的点的数据
|
||||
"""
|
||||
# 求两个点的斜率
|
||||
slope = (behind_point.tr - previous_point.tr) / (
|
||||
datetime.timestamp(behind_point.record_at) - datetime.timestamp(previous_point.record_at)
|
||||
)
|
||||
return TETRIOInfo.TetraLeagueHistory.Data(
|
||||
record_at=point_time,
|
||||
tr=previous_point.tr + slope * (datetime.timestamp(point_time) - datetime.timestamp(previous_point.record_at)),
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
'binding',
|
||||
Bind(
|
||||
platform='TETR.IO',
|
||||
status='unknown',
|
||||
user=Bind.People(
|
||||
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'{{"type":"identicon","hash":"{md5(user_info.data.user.id.encode()).hexdigest()}"}}', # noqa: S324
|
||||
name=user_info.data.user.username.upper(),
|
||||
),
|
||||
bot=Bind.People(
|
||||
avatar=bot_avatar,
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='io查我',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
message = UniMessage.image(
|
||||
raw=await screenshot(urlunparse(('http', get_self_netloc(), f'/host/{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())
|
||||
sprint = user_records.data.records.sprint
|
||||
blitz = user_records.data.records.blitz
|
||||
if isinstance(sprint.record, MultiRecord) or isinstance(blitz.record, MultiRecord):
|
||||
raise WhatTheFuckError('单人游戏记录是多人游戏记录')
|
||||
try:
|
||||
return UniMessage.image(raw=await self.make_query_image(self.user, user_info, sprint.record, blitz.record))
|
||||
except TypeError:
|
||||
...
|
||||
# fallback
|
||||
league = user_info.data.user.league
|
||||
user_name = user_info.data.user.username.upper()
|
||||
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 )'
|
||||
if sprint.record is not None:
|
||||
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 ''
|
||||
if blitz.record is not None:
|
||||
ret_message += f'\nBlitz: {blitz.record.endcontext.score}'
|
||||
ret_message += f' ( #{blitz.rank} )' if blitz.rank is not None else ''
|
||||
return UniMessage(ret_message)
|
||||
|
||||
@staticmethod
|
||||
async def query_historical_data(user: User, user_info: InfoSuccess) -> list[TETRIOInfo.TetraLeagueHistory.Data]:
|
||||
today = datetime.now(ZoneInfo('Asia/Shanghai')).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
forward = timedelta(days=9)
|
||||
start_time = (today - forward).astimezone(UTC)
|
||||
async with get_session() as session:
|
||||
historical_data = (
|
||||
await session.scalars(
|
||||
select(HistoricalData)
|
||||
.where(HistoricalData.trigger_time >= start_time)
|
||||
.where(HistoricalData.game_platform == GAME_TYPE)
|
||||
.where(HistoricalData.user_unique_identifier == user.unique_identifier)
|
||||
)
|
||||
).all()
|
||||
if historical_data:
|
||||
extra = (
|
||||
await session.scalars(
|
||||
select(HistoricalData)
|
||||
.where(HistoricalData.game_platform == GAME_TYPE)
|
||||
.where(HistoricalData.user_unique_identifier == user.unique_identifier)
|
||||
.order_by(HistoricalData.id.desc())
|
||||
.where(HistoricalData.id < min([i.id for i in historical_data]))
|
||||
.limit(1)
|
||||
)
|
||||
).one_or_none()
|
||||
if extra is not None:
|
||||
historical_data = list(historical_data)
|
||||
historical_data.append(extra)
|
||||
|
||||
histories = [
|
||||
TETRIOInfo.TetraLeagueHistory.Data(
|
||||
record_at=i.processed_data.user_info.cache.cached_at.astimezone(ZoneInfo('Asia/Shanghai')),
|
||||
tr=i.processed_data.user_info.data.user.league.rating,
|
||||
)
|
||||
for i in historical_data
|
||||
if isinstance(i.processed_data, ProcessedData)
|
||||
and i.processed_data.user_info is not None
|
||||
and isinstance(i.processed_data.user_info.data.user.league, RatedLeague)
|
||||
]
|
||||
|
||||
# 按照时间排序
|
||||
histories = sorted(histories, key=lambda x: x.record_at)
|
||||
for index, value in enumerate(histories):
|
||||
# 在历史记录里找有没有今天0点后的数据
|
||||
if value.record_at > today:
|
||||
histories = histories[:index] + [
|
||||
get_specified_point(histories[index - 1], histories[index], today.replace(microsecond=1000))
|
||||
]
|
||||
break
|
||||
else:
|
||||
histories.append(
|
||||
get_specified_point(
|
||||
histories[-1],
|
||||
TETRIOInfo.TetraLeagueHistory.Data(
|
||||
record_at=user_info.cache.cached_at, tr=user_info.data.user.league.rating
|
||||
),
|
||||
today.replace(microsecond=1000),
|
||||
)
|
||||
)
|
||||
if histories[0].record_at < (today - forward):
|
||||
histories[0] = get_specified_point(
|
||||
histories[0],
|
||||
histories[1],
|
||||
today - forward,
|
||||
)
|
||||
else:
|
||||
histories.insert(0, TETRIOInfo.TetraLeagueHistory.Data(record_at=today - forward, tr=histories[0].tr))
|
||||
return histories
|
||||
|
||||
@staticmethod
|
||||
async def make_query_image(
|
||||
user: User, user_info: InfoSuccess, sprint: SoloRecord | None, blitz: SoloRecord | None
|
||||
) -> bytes:
|
||||
league = user_info.data.user.league
|
||||
if not isinstance(league, RatedLeague) or league.vs is None:
|
||||
raise TypeError
|
||||
user_name = user_info.data.user.username.upper()
|
||||
histories = await Processor.query_historical_data(user, user_info)
|
||||
value_max, value_min = get_value_bounds([i.tr for i in histories])
|
||||
split_value, offset = get_split(value_max, value_min)
|
||||
if sprint is not None:
|
||||
duration = timedelta(milliseconds=sprint.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
|
||||
else:
|
||||
sprint_value = 'N/A'
|
||||
blitz_value = f'{blitz.endcontext.score:,}' if blitz is not None else 'N/A'
|
||||
async with HostPage(
|
||||
await render(
|
||||
'tetrio/info',
|
||||
TETRIOInfo(
|
||||
user=TETRIOInfo.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'{{"type":"identicon","hash":"{md5(user_info.data.user.id.encode()).hexdigest()}"}}', # noqa: S324
|
||||
name=user_name,
|
||||
bio=user_info.data.user.bio,
|
||||
),
|
||||
ranking=TETRIOInfo.Ranking(
|
||||
rating=round(league.glicko, 2),
|
||||
rd=round(league.rd, 2),
|
||||
),
|
||||
tetra_league=TETRIOInfo.TetraLeague(
|
||||
rank=league.rank,
|
||||
tr=round(league.rating, 2),
|
||||
global_rank=league.standing,
|
||||
pps=league.pps,
|
||||
lpm=round(lpm := (league.pps * 24), 2),
|
||||
apm=league.apm,
|
||||
apl=round(league.apm / lpm, 2),
|
||||
vs=league.vs,
|
||||
adpm=round(adpm := (league.vs * 0.6), 2),
|
||||
adpl=round(adpm / lpm, 2),
|
||||
),
|
||||
tetra_league_history=TETRIOInfo.TetraLeagueHistory(
|
||||
data=histories,
|
||||
split_interval=split_value,
|
||||
min_tr=value_min,
|
||||
max_tr=value_max,
|
||||
offset=offset,
|
||||
),
|
||||
radar=TETRIOInfo.Radar(
|
||||
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),
|
||||
),
|
||||
sprint=sprint_value,
|
||||
blitz=blitz_value,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(urlunparse(('http', get_self_netloc(), f'/host/{page_hash}.html', '', '', '')))
|
||||
|
||||
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,59 +0,0 @@
|
||||
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
|
||||
verified: bool
|
||||
country: str | None = None
|
||||
|
||||
|
||||
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,126 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, 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
|
||||
group: str | None = None
|
||||
ts: datetime | Literal[False] | 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
|
||||
|
||||
user: User
|
||||
|
||||
data: Data
|
||||
|
||||
|
||||
NeverPlayedLeague = SuccessModel.Data.User.NeverPlayedLeague
|
||||
NeverRatedLeague = SuccessModel.Data.User.NeverRatedLeague
|
||||
RatedLeague = SuccessModel.Data.User.RatedLeague
|
||||
UserInfo = SuccessModel | FailedModel
|
||||
@@ -1,118 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, 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
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
class Zen(BaseModel):
|
||||
level: int
|
||||
score: int
|
||||
|
||||
records: Records
|
||||
zen: Zen
|
||||
|
||||
data: Data
|
||||
|
||||
|
||||
SoloRecord = BaseModeRecord.SoloRecord
|
||||
MultiRecord = BaseModeRecord.MultiRecord
|
||||
UserRecords = SuccessModel | FailedModel
|
||||
@@ -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,165 +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 Bind, 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,
|
||||
)
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
async with HostPage(
|
||||
await render(
|
||||
'binding',
|
||||
Bind(
|
||||
platform=self.game_platform,
|
||||
status='unknown',
|
||||
user=Bind.People(
|
||||
avatar=await get_avatar(user_info, 'Data URI', None),
|
||||
name=(await self.get_user_name()).upper(),
|
||||
),
|
||||
bot=Bind.People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='top查我',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
message = UniMessage.image(
|
||||
raw=await screenshot(urlunparse(('http', get_self_netloc(), f'/host/{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,197 +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, EventUserInfo, 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 _( # 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, nb_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(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,257 +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 Bind, 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, nb_user_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,
|
||||
)
|
||||
user_info = await self.get_user_info()
|
||||
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
|
||||
async with HostPage(
|
||||
await render(
|
||||
'binding',
|
||||
Bind(
|
||||
platform=self.game_platform,
|
||||
status='unknown',
|
||||
user=Bind.People(
|
||||
avatar=await get_avatar(nb_user_info, 'Data URI', None), name=user_info.data.name
|
||||
),
|
||||
bot=Bind.People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='茶服查我',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
message = UniMessage.image(
|
||||
raw=await screenshot(urlunparse(('http', get_self_netloc(), f'/host/{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,86 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, 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
|
||||
|
||||
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')
|
||||
|
||||
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')
|
||||
|
||||
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]
|
||||
53
nonebot_plugin_tetris_stats/games/__init__.py
Normal file
53
nonebot_plugin_tetris_stats/games/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
|
||||
alc = on_alconna(
|
||||
Alconna(
|
||||
['tetris-stats', 'tstats'],
|
||||
namespace=ns,
|
||||
meta=CommandMeta(
|
||||
description='俄罗斯方块相关游戏数据查询',
|
||||
fuzzy_match=True,
|
||||
),
|
||||
),
|
||||
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):
|
||||
"""处理/验证后的数据"""
|
||||
166
nonebot_plugin_tetris_stats/games/tetrio/__init__.py
Normal file
166
nonebot_plugin_tetris_stats/games/tetrio/__init__.py
Normal file
@@ -0,0 +1,166 @@
|
||||
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
|
||||
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不合法')
|
||||
|
||||
|
||||
alc.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(
|
||||
'rank',
|
||||
Args(Arg('rank', ValidRank, notice='TETR.IO 段位')),
|
||||
help_text='查询 TETR.IO 段位信息',
|
||||
),
|
||||
Subcommand(
|
||||
'config',
|
||||
Option(
|
||||
'--default-template',
|
||||
Arg('template', Template),
|
||||
alias=['-DT', 'DefaultTemplate'],
|
||||
),
|
||||
),
|
||||
dest='TETRIO',
|
||||
help_text='TETR.IO 游戏相关指令',
|
||||
)
|
||||
)
|
||||
|
||||
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(
|
||||
'(?i:io)(?i:段位|段|rank)',
|
||||
{
|
||||
'command': 'tstats TETR.IO rank',
|
||||
'humanized': 'iorank',
|
||||
},
|
||||
)
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:配置|配|config)',
|
||||
{
|
||||
'command': 'tstats TETR.IO config',
|
||||
'humanized': 'io配置',
|
||||
},
|
||||
)
|
||||
|
||||
alc.shortcut(
|
||||
'fkosk',
|
||||
{
|
||||
'command': 'tstats TETR.IO query',
|
||||
'args': ['我'],
|
||||
'fuzzy': False,
|
||||
'humanized': 'An Easter egg!',
|
||||
},
|
||||
)
|
||||
|
||||
add_block_handlers(alc.assign('TETRIO.query'))
|
||||
|
||||
from . import bind, config, query, rank, record # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
'bind',
|
||||
'config',
|
||||
'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
|
||||
17
nonebot_plugin_tetris_stats/games/tetrio/api/models.py
Normal file
17
nonebot_plugin_tetris_stats/games/tetrio/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.base import SuccessModel
|
||||
|
||||
|
||||
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', 'User Records']] = 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)
|
||||
114
nonebot_plugin_tetris_stats/games/tetrio/api/player.py
Normal file
114
nonebot_plugin_tetris_stats/games/tetrio/api/player.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from typing import overload
|
||||
|
||||
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.user import User
|
||||
from .schemas.user_info import UserInfo, UserInfoSuccess
|
||||
from .schemas.user_records import SoloModeRecord, UserRecords, UserRecordsSuccess, Zen
|
||||
|
||||
|
||||
class Player:
|
||||
@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._user_records: UserRecordsSuccess | None = None
|
||||
|
||||
@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 None:
|
||||
user_info = await self.get_info()
|
||||
self.__user = User(
|
||||
ID=user_info.data.user.id,
|
||||
name=user_info.data.user.username,
|
||||
)
|
||||
self.user_id = user_info.data.user.id
|
||||
self.user_name = user_info.data.user.username
|
||||
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
|
||||
|
||||
async def get_records(self) -> UserRecordsSuccess:
|
||||
"""Get User Records"""
|
||||
if self._user_records is None:
|
||||
raw_user_records = await Cache.get(
|
||||
splice_url([BASE_URL, 'users/', f'{self._request_user_parameter}/', 'records'])
|
||||
)
|
||||
user_records: UserRecords = type_validate_json(UserRecords, raw_user_records) # type: ignore[arg-type]
|
||||
if isinstance(user_records, FailedModel):
|
||||
msg = f'用户Solo数据请求错误:\n{user_records.error}'
|
||||
raise RequestError(msg)
|
||||
self._user_records = user_records
|
||||
await anti_duplicate_add(
|
||||
TETRIOHistoricalData,
|
||||
TETRIOHistoricalData(
|
||||
user_unique_identifier=(await self.user).unique_identifier,
|
||||
api_type='User Records',
|
||||
data=user_records,
|
||||
update_time=user_records.cache.cached_at,
|
||||
),
|
||||
)
|
||||
return self._user_records
|
||||
|
||||
@property
|
||||
async def sprint(self) -> SoloModeRecord:
|
||||
return (await self.get_records()).data.records.sprint
|
||||
|
||||
@property
|
||||
async def blitz(self) -> SoloModeRecord:
|
||||
return (await self.get_records()).data.records.blitz
|
||||
|
||||
@property
|
||||
async def zen(self) -> Zen:
|
||||
return (await self.get_records()).data.zen
|
||||
@@ -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
|
||||
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,133 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..typing import Rank
|
||||
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 MetaLeague(BaseModel):
|
||||
decaying: bool
|
||||
|
||||
|
||||
class NeverPlayedLeague(MetaLeague):
|
||||
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 = None
|
||||
pps: None = None
|
||||
vs: None = None
|
||||
|
||||
|
||||
class NeverRatedLeague(MetaLeague):
|
||||
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 | None = None
|
||||
|
||||
|
||||
class RatedLeague(MetaLeague):
|
||||
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
|
||||
|
||||
|
||||
class Discord(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
|
||||
|
||||
class Connections(BaseModel):
|
||||
discord: Discord | None = None
|
||||
|
||||
|
||||
class Distinguishment(BaseModel):
|
||||
type: str
|
||||
|
||||
|
||||
class User(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
|
||||
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
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
user: User
|
||||
|
||||
|
||||
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
|
||||
36
nonebot_plugin_tetris_stats/games/tetrio/api/tetra_league.py
Normal file
36
nonebot_plugin_tetris_stats/games/tetrio/api/tetra_league.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Literal, NamedTuple, overload
|
||||
|
||||
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 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
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Literal
|
||||
|
||||
Rank = Literal[
|
||||
ValidRank = Literal[
|
||||
'x',
|
||||
'u',
|
||||
'ss',
|
||||
@@ -18,5 +18,6 @@ Rank = Literal[
|
||||
'c-',
|
||||
'd+',
|
||||
'd',
|
||||
'z', # 未定级
|
||||
]
|
||||
|
||||
Rank = ValidRank | Literal['z'] # 未定级
|
||||
62
nonebot_plugin_tetris_stats/games/tetrio/bind.py
Normal file
62
nonebot_plugin_tetris_stats/games/tetrio/bind.py
Normal file
@@ -0,0 +1,62 @@
|
||||
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": user_info.data.user.avatar_revision})}'
|
||||
if user_info.data.user.avatar_revision is not None
|
||||
and user_info.data.user.avatar_revision != 0
|
||||
else Avatar(type='identicon', hash=md5(user_info.data.user.id.encode()).hexdigest()), # noqa: S324
|
||||
name=user_info.data.user.username.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()
|
||||
20
nonebot_plugin_tetris_stats/games/tetrio/config.py
Normal file
20
nonebot_plugin_tetris_stats/games/tetrio/config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import async_scoped_session
|
||||
from nonebot_plugin_user import User # type: ignore[import-untyped]
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import alc
|
||||
from .models import TETRIOUserConfig
|
||||
from .typing import Template
|
||||
|
||||
|
||||
@alc.assign('TETRIO.config')
|
||||
async def _(user: User, session: async_scoped_session, 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,9 +1,12 @@
|
||||
from re import compile
|
||||
from typing import Literal
|
||||
|
||||
from .typing import Rank
|
||||
from .api.typing import Rank
|
||||
|
||||
GAME_TYPE: Literal['IO'] = 'IO'
|
||||
|
||||
BASE_URL = 'https://ch.tetr.io/api/'
|
||||
|
||||
RANK_PERCENTILE: dict[Rank, float] = {
|
||||
'x': 1,
|
||||
'u': 5,
|
||||
@@ -23,5 +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}$')
|
||||
@@ -1,12 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
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
|
||||
from .api.typing import Rank
|
||||
from .typing import Template
|
||||
|
||||
|
||||
class IORank(MappedAsDataclass, Model):
|
||||
@@ -28,3 +27,8 @@ class IORank(MappedAsDataclass, Model):
|
||||
index=True,
|
||||
)
|
||||
file_hash: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
|
||||
|
||||
class TETRIOUserConfig(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
query_template: Mapped[Template] = mapped_column(String(2))
|
||||
551
nonebot_plugin_tetris_stats/games/tetrio/query.py
Normal file
551
nonebot_plugin_tetris_stats/games/tetrio/query.py
Normal file
@@ -0,0 +1,551 @@
|
||||
from asyncio import gather
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from hashlib import md5
|
||||
from math import ceil, floor
|
||||
from typing import ClassVar, TypeVar, overload
|
||||
from urllib.parse import urlencode
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiofiles import open
|
||||
from nonebot import get_driver
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.compat import type_validate_json
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import At
|
||||
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_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 as NBUser # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
|
||||
from sqlalchemy import select
|
||||
from zstandard import ZstdDecompressor
|
||||
|
||||
from ...db import query_bind_info, trigger
|
||||
from ...utils.exception import FallbackError
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.metrics import TetrisMetricsProWithPPSVS, get_metrics
|
||||
from ...utils.render import render
|
||||
from ...utils.render.schemas.base import Avatar, Ranking
|
||||
from ...utils.render.schemas.tetrio_info import Data, Radar, TetraLeague, TetraLeagueHistory
|
||||
from ...utils.render.schemas.tetrio_info import Info as V1TemplateInfo
|
||||
from ...utils.render.schemas.tetrio_info import User as V1TemplateUser
|
||||
from ...utils.render.schemas.tetrio_info_v2 import Badge, Blitz, Sprint, Statistic, TetraLeagueStatistic
|
||||
from ...utils.render.schemas.tetrio_info_v2 import Info as V2TemplateInfo
|
||||
from ...utils.render.schemas.tetrio_info_v2 import TetraLeague as V2TemplateTetraLeague
|
||||
from ...utils.render.schemas.tetrio_info_v2 import User as V2TemplateUser
|
||||
from ...utils.screenshot import screenshot
|
||||
from ...utils.typing import Me, Number
|
||||
from ..constant import CANT_VERIFY_MESSAGE
|
||||
from . import alc
|
||||
from .api import Player, User, UserInfoSuccess
|
||||
from .api.models import TETRIOHistoricalData
|
||||
from .api.schemas.tetra_league import TetraLeagueSuccess
|
||||
from .api.schemas.user_info import NeverPlayedLeague, NeverRatedLeague, RatedLeague
|
||||
from .constant import GAME_TYPE, TR_MAX, TR_MIN
|
||||
from .models import IORank, TETRIOUserConfig
|
||||
from .typing import Template
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.query')
|
||||
async def _( # noqa: PLR0913
|
||||
user: NBUser,
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
template: Template | None = None,
|
||||
):
|
||||
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 template is None:
|
||||
template = await session.scalar(
|
||||
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = UniMessage(CANT_VERIFY_MESSAGE)
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
await (message + (await make_query_result(player, template or 'v1'))).finish()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.query')
|
||||
async def _(user: NBUser, account: Player, event_session: EventSession, template: Template | None = None):
|
||||
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:
|
||||
if template is None:
|
||||
template = await session.scalar(
|
||||
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
|
||||
)
|
||||
await (await make_query_result(account, template or 'v1')).finish()
|
||||
|
||||
|
||||
def get_value_bounds(values: list[int | float]) -> tuple[int, int]:
|
||||
value_max = 10 * ceil(max(values) / 10)
|
||||
value_min = 10 * floor(min(values) / 10)
|
||||
return value_max, value_min
|
||||
|
||||
|
||||
def get_split(value_max: int, value_min: int) -> tuple[int, int]:
|
||||
offset = 0
|
||||
overflow = 0
|
||||
|
||||
while True:
|
||||
if (new_max_value := value_max + offset + overflow) > TR_MAX:
|
||||
overflow -= 1
|
||||
continue
|
||||
if (new_min_value := value_min - offset + overflow) < TR_MIN:
|
||||
overflow += 1
|
||||
continue
|
||||
if ((new_max_value - new_min_value) / 40).is_integer():
|
||||
split_value = int((value_max + offset - (value_min - offset)) / 4)
|
||||
break
|
||||
offset += 1
|
||||
return split_value, offset + overflow
|
||||
|
||||
|
||||
def get_specified_point(
|
||||
previous_point: Data,
|
||||
behind_point: Data,
|
||||
point_time: datetime,
|
||||
) -> Data:
|
||||
"""根据给出的 previous_point 和 behind_point, 推算 point_time 点处的数据
|
||||
|
||||
Args:
|
||||
previous_point (Data): 前面的数据点
|
||||
behind_point (Data): 后面的数据点
|
||||
point_time (datetime): 要推算的点的位置
|
||||
|
||||
Returns:
|
||||
Data: 要推算的点的数据
|
||||
"""
|
||||
# 求两个点的斜率
|
||||
slope = (behind_point.tr - previous_point.tr) / (
|
||||
datetime.timestamp(behind_point.record_at) - datetime.timestamp(previous_point.record_at)
|
||||
)
|
||||
return Data(
|
||||
record_at=point_time,
|
||||
tr=previous_point.tr + slope * (datetime.timestamp(point_time) - datetime.timestamp(previous_point.record_at)),
|
||||
)
|
||||
|
||||
|
||||
async def query_historical_data(user: User, user_info: UserInfoSuccess) -> list[Data]:
|
||||
today = datetime.now(ZoneInfo('Asia/Shanghai')).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
forward = timedelta(days=9)
|
||||
start_time = (today - forward).astimezone(UTC)
|
||||
async with get_session() as session:
|
||||
historical_data = (
|
||||
await session.scalars(
|
||||
select(TETRIOHistoricalData)
|
||||
.where(TETRIOHistoricalData.update_time >= start_time)
|
||||
.where(TETRIOHistoricalData.user_unique_identifier == user.unique_identifier)
|
||||
.where(TETRIOHistoricalData.api_type == 'User Info')
|
||||
)
|
||||
).all()
|
||||
if historical_data:
|
||||
extra = (
|
||||
await session.scalars(
|
||||
select(TETRIOHistoricalData)
|
||||
.where(TETRIOHistoricalData.user_unique_identifier == user.unique_identifier)
|
||||
.where(TETRIOHistoricalData.api_type == 'User Info')
|
||||
.order_by(TETRIOHistoricalData.id.desc())
|
||||
.where(TETRIOHistoricalData.id < min([i.id for i in historical_data]))
|
||||
.limit(1)
|
||||
)
|
||||
).one_or_none()
|
||||
if extra is not None:
|
||||
historical_data = list(historical_data)
|
||||
historical_data.append(extra)
|
||||
full_export_data = FullExport.get_data(user.unique_identifier)
|
||||
if not historical_data and not full_export_data:
|
||||
return [
|
||||
Data(record_at=today - forward, tr=user_info.data.user.league.rating),
|
||||
Data(record_at=today.replace(microsecond=1000), tr=user_info.data.user.league.rating),
|
||||
]
|
||||
histories = [
|
||||
Data(
|
||||
record_at=i.update_time.astimezone(ZoneInfo('Asia/Shanghai')),
|
||||
tr=i.data.data.user.league.rating,
|
||||
)
|
||||
for i in historical_data
|
||||
if isinstance(i.data, UserInfoSuccess) and isinstance(i.data.data.user.league, RatedLeague)
|
||||
] + full_export_data
|
||||
|
||||
# 按照时间排序
|
||||
histories = sorted(histories, key=lambda x: x.record_at)
|
||||
for index, value in enumerate(histories):
|
||||
# 在历史记录里找有没有今天0点后的数据, 并且至少要有两个数据点
|
||||
if value.record_at > today and len(histories) >= 2: # noqa: PLR2004
|
||||
histories = histories[:index] + [
|
||||
get_specified_point(histories[index - 1], histories[index], today.replace(microsecond=1000))
|
||||
]
|
||||
break
|
||||
else:
|
||||
histories.append(
|
||||
get_specified_point(
|
||||
histories[-1],
|
||||
Data(record_at=user_info.cache.cached_at, tr=user_info.data.user.league.rating),
|
||||
today.replace(microsecond=1000),
|
||||
)
|
||||
)
|
||||
if histories[0].record_at < (today - forward):
|
||||
histories[0] = get_specified_point(
|
||||
histories[0],
|
||||
histories[1],
|
||||
today - forward,
|
||||
)
|
||||
else:
|
||||
histories.insert(0, Data(record_at=today - forward, tr=histories[0].tr))
|
||||
return histories
|
||||
|
||||
|
||||
L = TypeVar('L', NeverPlayedLeague, NeverRatedLeague, RatedLeague)
|
||||
|
||||
|
||||
@overload
|
||||
def get_league(user_info: UserInfoSuccess, league_type: type[L]) -> L: ...
|
||||
@overload
|
||||
def get_league(
|
||||
user_info: UserInfoSuccess, league_type: None = None
|
||||
) -> NeverPlayedLeague | NeverRatedLeague | RatedLeague: ...
|
||||
def get_league(
|
||||
user_info: UserInfoSuccess, league_type: type[L] | None = None
|
||||
) -> L | NeverPlayedLeague | NeverRatedLeague | RatedLeague:
|
||||
league = user_info.data.user.league
|
||||
if league_type is None:
|
||||
return league
|
||||
if isinstance(league, league_type):
|
||||
return league
|
||||
raise FallbackError
|
||||
|
||||
|
||||
async def make_query_image_v1(player: Player) -> bytes:
|
||||
user, user_info, sprint, blitz = await gather(player.user, player.get_info(), player.sprint, player.blitz)
|
||||
league = get_league(user_info, RatedLeague)
|
||||
if league.vs is None:
|
||||
raise FallbackError
|
||||
histories = await query_historical_data(user, user_info)
|
||||
value_max, value_min = get_value_bounds([i.tr for i in histories])
|
||||
split_value, offset = get_split(value_max, value_min)
|
||||
if sprint.record is not None:
|
||||
duration = timedelta(milliseconds=sprint.record.endcontext.final_time).total_seconds()
|
||||
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
||||
else:
|
||||
sprint_value = 'N/A'
|
||||
blitz_value = f'{blitz.record.endcontext.score:,}' if blitz.record is not None else 'N/A'
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
page=await render(
|
||||
'v1/tetrio/info',
|
||||
V1TemplateInfo(
|
||||
user=V1TemplateUser(
|
||||
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
|
||||
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user_info.data.user.id.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
name=user.name.upper(),
|
||||
bio=user_info.data.user.bio,
|
||||
),
|
||||
ranking=Ranking(
|
||||
rating=round(league.glicko, 2),
|
||||
rd=round(league.rd, 2),
|
||||
),
|
||||
tetra_league=TetraLeague(
|
||||
rank=league.rank,
|
||||
tr=round(league.rating, 2),
|
||||
global_rank=league.standing,
|
||||
pps=league.pps,
|
||||
lpm=round(lpm := (league.pps * 24), 2),
|
||||
apm=league.apm,
|
||||
apl=round(league.apm / lpm, 2),
|
||||
vs=league.vs,
|
||||
adpm=round(adpm := (league.vs * 0.6), 2),
|
||||
adpl=round(adpm / lpm, 2),
|
||||
),
|
||||
tetra_league_history=TetraLeagueHistory(
|
||||
data=histories,
|
||||
split_interval=split_value,
|
||||
min_tr=value_min,
|
||||
max_tr=value_max,
|
||||
offset=offset,
|
||||
),
|
||||
radar=Radar(
|
||||
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),
|
||||
),
|
||||
sprint=sprint_value,
|
||||
blitz=blitz_value,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
|
||||
|
||||
N = TypeVar('N', int, float)
|
||||
|
||||
|
||||
def handling_special_value(value: N) -> N | None:
|
||||
return value if value != -1 else None
|
||||
|
||||
|
||||
async def make_query_image_v2(player: Player) -> bytes:
|
||||
user, user_info, sprint, blitz, zen = await gather(
|
||||
player.user, player.get_info(), player.sprint, player.blitz, player.zen
|
||||
)
|
||||
league = get_league(user_info)
|
||||
|
||||
if sprint.record is not None:
|
||||
duration = timedelta(milliseconds=sprint.record.endcontext.final_time).total_seconds()
|
||||
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
||||
else:
|
||||
sprint_value = 'N/A'
|
||||
|
||||
play_time: str | None
|
||||
if (game_time := handling_special_value(user_info.data.user.gametime)) is not None:
|
||||
if game_time // 3600 > 0:
|
||||
play_time = f'{game_time//3600:.0f}h {game_time % 3600 // 60:.0f}m {game_time % 60:.0f}s'
|
||||
elif game_time // 60 > 0:
|
||||
play_time = f'{game_time//60:.0f}m {game_time % 60:.0f}s'
|
||||
else:
|
||||
play_time = f'{game_time:.0f}s'
|
||||
else:
|
||||
play_time = game_time
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v2/tetrio/info',
|
||||
V2TemplateInfo(
|
||||
user=V2TemplateUser(
|
||||
id=user.ID,
|
||||
name=user.name.upper(),
|
||||
bio=user_info.data.user.bio,
|
||||
banner=f'http://{netloc}/host/resource/tetrio/banners/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
|
||||
if user_info.data.user.banner_revision is not None and user_info.data.user.banner_revision != 0
|
||||
else None,
|
||||
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
|
||||
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user_info.data.user.id.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
badges=[
|
||||
Badge(
|
||||
id=i.id,
|
||||
description=i.label,
|
||||
group=i.group,
|
||||
receive_at=i.ts if isinstance(i.ts, datetime) else None,
|
||||
)
|
||||
for i in user_info.data.user.badges
|
||||
],
|
||||
country=user_info.data.user.country,
|
||||
xp=user_info.data.user.xp,
|
||||
friend_count=user_info.data.user.friend_count or 0,
|
||||
supporter_tier=user_info.data.user.supporter_tier,
|
||||
bad_standing=user_info.data.user.badstanding or False,
|
||||
verified=user_info.data.user.verified,
|
||||
playtime=play_time,
|
||||
join_at=user_info.data.user.ts,
|
||||
),
|
||||
tetra_league=V2TemplateTetraLeague(
|
||||
rank=league.rank,
|
||||
highest_rank=league.bestrank,
|
||||
tr=round(league.rating, 2),
|
||||
glicko=round(league.glicko, 2),
|
||||
rd=round(league.rd, 2),
|
||||
global_rank=handling_special_value(league.standing),
|
||||
country_rank=handling_special_value(league.standing_local),
|
||||
pps=(
|
||||
metrics := get_metrics(pps=league.pps, apm=league.apm, vs=league.vs)
|
||||
if league.vs is not None
|
||||
else get_metrics(pps=league.pps, apm=league.apm)
|
||||
).pps,
|
||||
apm=metrics.apm,
|
||||
apl=metrics.apl,
|
||||
vs=metrics.vs if isinstance(metrics, TetrisMetricsProWithPPSVS) else None,
|
||||
adpl=metrics.adpl if isinstance(metrics, TetrisMetricsProWithPPSVS) else None,
|
||||
statistic=TetraLeagueStatistic(
|
||||
total=league.gamesplayed,
|
||||
wins=league.gameswon,
|
||||
),
|
||||
decaying=league.decaying,
|
||||
)
|
||||
if isinstance(league, RatedLeague)
|
||||
else None,
|
||||
statistic=Statistic(
|
||||
total=handling_special_value(user_info.data.user.gamesplayed),
|
||||
wins=handling_special_value(user_info.data.user.gameswon),
|
||||
),
|
||||
sprint=Sprint(
|
||||
time=sprint_value,
|
||||
global_rank=sprint.rank,
|
||||
play_at=sprint.record.ts,
|
||||
)
|
||||
if sprint.record is not None
|
||||
else None,
|
||||
blitz=Blitz(
|
||||
score=blitz.record.endcontext.score,
|
||||
global_rank=blitz.rank,
|
||||
play_at=blitz.record.ts,
|
||||
)
|
||||
if blitz.record is not None
|
||||
else None,
|
||||
zen=zen,
|
||||
),
|
||||
),
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
|
||||
|
||||
async def make_query_text(player: Player) -> UniMessage:
|
||||
user, user_info, sprint, blitz = await gather(player.user, player.get_info(), player.sprint, player.blitz)
|
||||
league = get_league(user_info)
|
||||
|
||||
user_name = user.name.upper()
|
||||
|
||||
message = ''
|
||||
if isinstance(league, NeverPlayedLeague):
|
||||
message += f'用户 {user_name} 没有排位统计数据'
|
||||
else:
|
||||
if isinstance(league, NeverRatedLeague):
|
||||
message += f'用户 {user_name} 暂未完成定级赛, 最近十场的数据:'
|
||||
else:
|
||||
if league.rank == 'z':
|
||||
message += f'用户 {user_name} 暂无段位, {round(league.rating,2)} TR'
|
||||
else:
|
||||
message += f'{league.rank.upper()} 段用户 {user_name} {round(league.rating,2)} TR (#{league.standing})'
|
||||
message += f', 段位分 {round(league.glicko,2)}±{round(league.rd,2)}, 最近十场的数据:'
|
||||
metrics = (
|
||||
get_metrics(pps=league.pps, apm=league.apm, vs=league.vs)
|
||||
if league.vs is not None
|
||||
else get_metrics(pps=league.pps, apm=league.apm)
|
||||
)
|
||||
message += f"\nL'PM: {metrics.lpm} ( {metrics.pps} pps )"
|
||||
message += f'\nAPM: {metrics.apm} ( x{metrics.apl} )'
|
||||
if isinstance(metrics, TetrisMetricsProWithPPSVS):
|
||||
message += f'\nADPM: {metrics.adpm} ( x{metrics.adpl} ) ( {metrics.vs}vs )'
|
||||
if sprint.record is not None:
|
||||
message += f'\n40L: {round(sprint.record.endcontext.final_time/1000,2)}s'
|
||||
message += f' ( #{sprint.rank} )' if sprint.rank is not None else ''
|
||||
if blitz.record is not None:
|
||||
message += f'\nBlitz: {blitz.record.endcontext.score}'
|
||||
message += f' ( #{blitz.rank} )' if blitz.rank is not None else ''
|
||||
return UniMessage(message)
|
||||
|
||||
|
||||
async def make_query_result(player: Player, template: Template) -> UniMessage:
|
||||
try:
|
||||
if template == 'v1':
|
||||
return UniMessage.image(raw=await make_query_image_v1(player))
|
||||
if template == 'v2':
|
||||
return UniMessage.image(raw=await make_query_image_v2(player))
|
||||
except FallbackError:
|
||||
...
|
||||
return await make_query_text(player)
|
||||
|
||||
|
||||
class FullExport:
|
||||
cache: ClassVar[defaultdict[str, set[tuple[datetime, Number]]]] = defaultdict(set)
|
||||
latest_update: ClassVar[date | None] = None
|
||||
|
||||
@classmethod
|
||||
async def init(cls) -> None:
|
||||
async with get_session() as session:
|
||||
full_exports = (await session.scalars(select(IORank).where(IORank.update_time >= cls.start_time()))).all()
|
||||
await gather(
|
||||
*[
|
||||
cls._load(update_time, file_hash)
|
||||
for file_hash, update_time in {
|
||||
i.file_hash: i.update_time for i in full_exports if i.file_hash is not None
|
||||
}.items()
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def update(cls) -> None:
|
||||
if cls.latest_update == datetime.now(tz=ZoneInfo('Asia/Shanghai')).date():
|
||||
return
|
||||
start_time = cls.start_time()
|
||||
for i in cls.cache:
|
||||
cls.cache[i] = {j for j in cls.cache[i] if j[0] >= start_time}
|
||||
latest_time = max(cls.cache)
|
||||
async with get_session() as session:
|
||||
full_exports = (await session.scalars(select(IORank).where(IORank.update_time > latest_time))).all()
|
||||
await gather(
|
||||
*[
|
||||
cls._load(update_time, file_hash)
|
||||
for file_hash, update_time in {
|
||||
i.file_hash: i.update_time for i in full_exports if i.file_hash is not None
|
||||
}.items()
|
||||
]
|
||||
)
|
||||
cls.latest_update = datetime.now(tz=ZoneInfo('Asia/Shanghai')).date()
|
||||
|
||||
@classmethod
|
||||
def get_data(cls, unique_identifier: str) -> list[Data]:
|
||||
return [Data(record_at=i[0], tr=i[1]) for i in cls.cache[unique_identifier]]
|
||||
|
||||
@classmethod
|
||||
def start_time(cls) -> datetime:
|
||||
return (
|
||||
datetime.now(ZoneInfo('Asia/Shanghai')).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
- timedelta(days=9)
|
||||
).astimezone(UTC)
|
||||
|
||||
@classmethod
|
||||
async def _load(cls, update_time: datetime, file_hash: str) -> None:
|
||||
try:
|
||||
users = type_validate_json(TetraLeagueSuccess, await cls.decompress(file_hash)).data.users
|
||||
except FileNotFoundError:
|
||||
await cls.clear_invalid(file_hash)
|
||||
return
|
||||
update_time = update_time.astimezone(ZoneInfo('Asia/Shanghai'))
|
||||
for i in users:
|
||||
cls.cache[i.id].add((update_time, i.league.rating))
|
||||
|
||||
@classmethod
|
||||
async def decompress(cls, file_hash: str) -> bytes:
|
||||
async with open(get_data_file('nonebot_plugin_tetris_stats', f'{file_hash}.json.zst'), mode='rb') as file:
|
||||
return ZstdDecompressor().decompress(await file.read())
|
||||
|
||||
@classmethod
|
||||
async def clear_invalid(cls, file_hash: str) -> None:
|
||||
async with get_session() as session:
|
||||
full_exports = (await session.scalars(select(IORank).where(IORank.file_hash == file_hash))).all()
|
||||
for i in full_exports:
|
||||
i.file_hash = None
|
||||
await session.commit()
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
async def _():
|
||||
await FullExport.init()
|
||||
scheduler.add_job(FullExport.update, 'interval', hours=1)
|
||||
174
nonebot_plugin_tetris_stats/games/tetrio/rank.py
Normal file
174
nonebot_plugin_tetris_stats/games/tetrio/rank.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha512
|
||||
from math import floor
|
||||
from statistics import mean
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiofiles import open
|
||||
from nonebot import get_driver
|
||||
from nonebot.compat import model_dump
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.utils import run_sync
|
||||
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_session import EventSession # type: ignore[import-untyped]
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from sqlalchemy import func, select
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from ...db import trigger
|
||||
from ...utils.exception import RequestError
|
||||
from ...utils.metrics import get_metrics
|
||||
from ...utils.retry import retry
|
||||
from . import alc
|
||||
from .api.schemas.base import FailedModel
|
||||
from .api.schemas.tetra_league import ValidUser
|
||||
from .api.schemas.user import User
|
||||
from .api.tetra_league import full_export
|
||||
from .api.typing import Rank
|
||||
from .constant import GAME_TYPE, RANK_PERCENTILE
|
||||
from .models import IORank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.rank')
|
||||
async def _(matcher: Matcher, rank: Rank, event_session: EventSession):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='rank',
|
||||
command_args=[],
|
||||
):
|
||||
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)
|
||||
|
||||
|
||||
@scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
|
||||
@retry(exception_type=RequestError, delay=timedelta(minutes=15))
|
||||
async def get_tetra_league_data() -> None:
|
||||
league, original = await full_export(with_original=True)
|
||||
if isinstance(league, FailedModel):
|
||||
msg = f'排行榜数据请求错误:\n{league.error}'
|
||||
raise RequestError(msg)
|
||||
|
||||
def pps(user: ValidUser) -> float:
|
||||
return user.league.pps
|
||||
|
||||
def apm(user: ValidUser) -> float:
|
||||
return user.league.apm
|
||||
|
||||
def vs(user: ValidUser) -> float:
|
||||
return user.league.vs
|
||||
|
||||
def _min(users: list[ValidUser], field: Callable[[ValidUser], float]) -> ValidUser:
|
||||
return min(users, key=field)
|
||||
|
||||
def _max(users: list[ValidUser], field: Callable[[ValidUser], float]) -> ValidUser:
|
||||
return max(users, key=field)
|
||||
|
||||
def build_extremes_data(
|
||||
users: list[ValidUser],
|
||||
field: Callable[[ValidUser], float],
|
||||
sort: Callable[[list[ValidUser], Callable[[ValidUser], float]], ValidUser],
|
||||
) -> tuple[dict[str, str], float]:
|
||||
user = sort(users, field)
|
||||
return model_dump(User(ID=user.id, name=user.username)), field(user)
|
||||
|
||||
data_hash: str | None = await run_sync((await run_sync(sha512)(original)).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)(original))
|
||||
|
||||
users = [i for i in league.data.users if isinstance(i, ValidUser)]
|
||||
rank_to_users: defaultdict[Rank, list[ValidUser]] = 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.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_tetra_league_data()
|
||||
@@ -0,0 +1,6 @@
|
||||
from . import blitz, sprint
|
||||
|
||||
__all__ = [
|
||||
'blitz',
|
||||
'sprint',
|
||||
]
|
||||
124
nonebot_plugin_tetris_stats/games/tetrio/record/blitz.py
Normal file
124
nonebot_plugin_tetris_stats/games/tetrio/record/blitz.py
Normal file
@@ -0,0 +1,124 @@
|
||||
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_user import get_user # type: ignore[import-untyped]
|
||||
|
||||
from ....db import query_bind_info
|
||||
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_record_base import Finesse, Max, Mini, Tspins, User
|
||||
from ....utils.render.schemas.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 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):
|
||||
await UniMessage.image(raw=await make_blitz_image(account)).finish()
|
||||
|
||||
|
||||
async def make_blitz_image(player: Player) -> bytes:
|
||||
user, user_info, blitz = await gather(player.user, player.get_info(), player.blitz)
|
||||
if blitz.record is None:
|
||||
msg = f'未找到用户 {user.name.upper()} 的 40L 记录'
|
||||
raise RecordNotFoundError(msg)
|
||||
endcontext = blitz.record.endcontext
|
||||
clears = endcontext.clears
|
||||
duration = timedelta(milliseconds=endcontext.final_time).total_seconds()
|
||||
metrics = get_metrics(pps=endcontext.piecesplaced / duration)
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
page=await render(
|
||||
'v2/tetrio/record/blitz',
|
||||
Record(
|
||||
user=User(
|
||||
id=user.ID,
|
||||
name=user.name.upper(),
|
||||
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
|
||||
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
),
|
||||
replay_id=blitz.record.replayid,
|
||||
rank=blitz.rank,
|
||||
statistic=Statistic(
|
||||
keys=endcontext.inputs,
|
||||
kpp=round(endcontext.inputs / endcontext.piecesplaced, 2),
|
||||
kps=round(endcontext.inputs / duration, 2),
|
||||
max=Max(
|
||||
combo=max((0, endcontext.combo - 1)),
|
||||
btb=max((0, endcontext.btb - 1)),
|
||||
),
|
||||
pieces=endcontext.piecesplaced,
|
||||
pps=metrics.pps,
|
||||
lines=endcontext.lines,
|
||||
lpm=metrics.lpm,
|
||||
holds=endcontext.holds,
|
||||
score=endcontext.score,
|
||||
spp=round(endcontext.score / endcontext.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.triples,
|
||||
mini=Mini(
|
||||
total=clears.minitspins,
|
||||
single=clears.minitspinsingles,
|
||||
double=clears.minitspindoubles,
|
||||
),
|
||||
),
|
||||
all_clear=clears.allclear,
|
||||
finesse=Finesse(
|
||||
faults=endcontext.finesse.faults,
|
||||
accuracy=round(endcontext.finesse.perfectpieces / endcontext.piecesplaced * 100, 2),
|
||||
),
|
||||
level=endcontext.level,
|
||||
),
|
||||
play_at=blitz.record.ts,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
124
nonebot_plugin_tetris_stats/games/tetrio/record/sprint.py
Normal file
124
nonebot_plugin_tetris_stats/games/tetrio/record/sprint.py
Normal file
@@ -0,0 +1,124 @@
|
||||
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_user import get_user # type: ignore[import-untyped]
|
||||
|
||||
from ....db import query_bind_info
|
||||
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_record_base import Finesse, Max, Mini, Tspins, User
|
||||
from ....utils.render.schemas.tetrio_record_sprint 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.sprint')
|
||||
async def _(
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
):
|
||||
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):
|
||||
await UniMessage.image(raw=await make_sprint_image(account)).finish()
|
||||
|
||||
|
||||
async def make_sprint_image(player: Player) -> bytes:
|
||||
user, user_info, sprint = await gather(player.user, player.get_info(), player.sprint)
|
||||
if sprint.record is None:
|
||||
msg = f'未找到用户 {user.name.upper()} 的 40L 记录'
|
||||
raise RecordNotFoundError(msg)
|
||||
endcontext = sprint.record.endcontext
|
||||
clears = endcontext.clears
|
||||
duration = timedelta(milliseconds=endcontext.final_time).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=endcontext.piecesplaced / duration)
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
page=await render(
|
||||
'v2/tetrio/record/40l',
|
||||
Record(
|
||||
user=User(
|
||||
id=user.ID,
|
||||
name=user.name.upper(),
|
||||
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
|
||||
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
),
|
||||
time=sprint_value,
|
||||
replay_id=sprint.record.replayid,
|
||||
rank=sprint.rank,
|
||||
statistic=Statistic(
|
||||
keys=endcontext.inputs,
|
||||
kpp=round(endcontext.inputs / endcontext.piecesplaced, 2),
|
||||
kps=round(endcontext.inputs / duration, 2),
|
||||
max=Max(
|
||||
combo=max((0, endcontext.combo - 1)),
|
||||
btb=max((0, endcontext.btb - 1)),
|
||||
),
|
||||
pieces=endcontext.piecesplaced,
|
||||
pps=metrics.pps,
|
||||
lines=endcontext.lines,
|
||||
lpm=metrics.lpm,
|
||||
holds=endcontext.holds,
|
||||
score=endcontext.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.triples,
|
||||
mini=Mini(
|
||||
total=clears.minitspins,
|
||||
single=clears.minitspinsingles,
|
||||
double=clears.minitspindoubles,
|
||||
),
|
||||
),
|
||||
all_clear=clears.allclear,
|
||||
finesse=Finesse(
|
||||
faults=endcontext.finesse.faults,
|
||||
accuracy=round(endcontext.finesse.perfectpieces / endcontext.piecesplaced * 100, 2),
|
||||
),
|
||||
),
|
||||
play_at=sprint.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
|
||||
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不合法')
|
||||
|
||||
|
||||
alc.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
|
||||
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不合法')
|
||||
|
||||
|
||||
alc.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)
|
||||
@@ -15,12 +15,12 @@ global_config = driver.config
|
||||
|
||||
@driver.on_startup
|
||||
async def _():
|
||||
await BrowserManager._init_playwright()
|
||||
await BrowserManager.init_playwright()
|
||||
|
||||
|
||||
@driver.on_shutdown
|
||||
async def _():
|
||||
await BrowserManager._close_browser()
|
||||
await BrowserManager.close_browser()
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
@@ -29,9 +29,10 @@ class BrowserManager:
|
||||
_browser: Browser | None = None
|
||||
|
||||
@classmethod
|
||||
async def _init_playwright(cls) -> None:
|
||||
async def init_playwright(cls) -> None:
|
||||
if system() == 'Windows' and getattr(global_config, 'fastapi_reload', False):
|
||||
raise ImportError('加载失败, Windows 必须设置 FASTAPI_RELOAD=false 才能正常运行 playwright')
|
||||
msg = '加载失败, Windows 必须设置 FASTAPI_RELOAD=false 才能正常运行 playwright'
|
||||
raise ImportError(msg)
|
||||
logger.info('开始 安装/更新 playwright 浏览器')
|
||||
environ['PLAYWRIGHT_DOWNLOAD_HOST'] = 'https://npmmirror.com/mirrors/playwright/'
|
||||
if cls._call_playwright(['', 'install', 'firefox']):
|
||||
@@ -46,9 +47,8 @@ class BrowserManager:
|
||||
try:
|
||||
await cls._start_browser()
|
||||
except BaseException as e: # 不知道会有什么异常, 交给用户解决
|
||||
raise ImportError(
|
||||
'playwright 启动失败, 请尝试在命令行运行 playwright install-deps firefox, 如果仍然启动失败, 请参考上面的报错👆'
|
||||
) from e
|
||||
msg = 'playwright 启动失败, 请尝试在命令行运行 playwright install-deps firefox, 如果仍然启动失败, 请参考上面的报错👆'
|
||||
raise ImportError(msg) from e
|
||||
else:
|
||||
logger.success('playwright 启动成功')
|
||||
|
||||
@@ -81,7 +81,7 @@ class BrowserManager:
|
||||
return cls._browser or await cls._start_browser()
|
||||
|
||||
@classmethod
|
||||
async def _close_browser(cls) -> None:
|
||||
async def close_browser(cls) -> None:
|
||||
"""关闭浏览器实例"""
|
||||
if isinstance(cls._browser, Browser):
|
||||
await cls._browser.close()
|
||||
|
||||
@@ -27,6 +27,14 @@ class MessageFormatError(NeedCatchError):
|
||||
"""用户发送的消息格式不正确"""
|
||||
|
||||
|
||||
class RecordNotFoundError(NeedCatchError):
|
||||
"""找不到用户的某种记录"""
|
||||
|
||||
|
||||
class FallbackError(NeedCatchError):
|
||||
"""需要回滚至更通用的方法"""
|
||||
|
||||
|
||||
class DoNotCatchError(TetrisStatsError):
|
||||
"""不应该被捕获的异常基类"""
|
||||
|
||||
@@ -35,5 +43,5 @@ class WhatTheFuckError(DoNotCatchError):
|
||||
"""用于表示不应该出现的情况 ("""
|
||||
|
||||
|
||||
class HandleNotFinishedError(DoNotCatchError):
|
||||
"""任务没有正常完成处理的错误"""
|
||||
class NoFallbackError(DoNotCatchError): # 暂时没用 但是先写了
|
||||
"""没有可用的回退方法"""
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
from hashlib import sha256
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from typing import ClassVar
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import FastAPI, Path, status
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from nonebot import get_app, get_driver
|
||||
from nonebot_plugin_localstore import get_cache_dir # type: ignore[import-untyped]
|
||||
from pydantic import IPvAnyAddress
|
||||
from nonebot.log import logger
|
||||
|
||||
from ..config.config import CACHE_PATH
|
||||
from .image import img_to_png
|
||||
from .request import Request
|
||||
from .templates import templates_dir
|
||||
|
||||
app = get_app()
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import IPvAnyAddress
|
||||
|
||||
app: FastAPI = get_app()
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
global_config = driver.config
|
||||
|
||||
cache_dir = get_cache_dir('nonebot_plugin_tetris_stats')
|
||||
|
||||
if not isinstance(app, FastAPI):
|
||||
raise RuntimeError('本插件需要 FastAPI 驱动器才能运行') # noqa: TRY004
|
||||
msg = '本插件需要 FastAPI 驱动器才能运行'
|
||||
raise RuntimeError(msg) # noqa: TRY004
|
||||
|
||||
NOT_FOUND = HTMLResponse('404 Not Found', status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@@ -39,11 +44,14 @@ class HostPage:
|
||||
self.pages.pop(self.page_hash, None)
|
||||
|
||||
|
||||
app.mount(
|
||||
'/host/assets',
|
||||
StaticFiles(directory=templates_dir / 'assets'),
|
||||
name='assets',
|
||||
)
|
||||
@driver.on_startup
|
||||
def _():
|
||||
app.mount(
|
||||
'/host/assets',
|
||||
StaticFiles(directory=templates_dir / 'assets'),
|
||||
name='assets',
|
||||
)
|
||||
logger.success('assets mounted')
|
||||
|
||||
|
||||
@app.get('/host/{page_hash}.html', status_code=status.HTTP_200_OK)
|
||||
@@ -53,6 +61,22 @@ async def _(page_hash: str) -> HTMLResponse:
|
||||
return NOT_FOUND
|
||||
|
||||
|
||||
@app.get('/host/resource/tetrio/{resource_type}/{user_id}', status_code=status.HTTP_200_OK)
|
||||
async def _(
|
||||
resource_type: Literal['avatars', 'banners'], revision: int, user_id: str = Path(regex=r'^[a-f0-9]{24}$')
|
||||
) -> Response:
|
||||
if not (path := CACHE_PATH / 'tetrio' / resource_type / f'{user_id}_{revision}.png').exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(
|
||||
img_to_png(
|
||||
await Request.request(
|
||||
f'https://tetr.io/user-content/{resource_type}/{user_id}.jpg?rv={revision}', is_json=False
|
||||
)
|
||||
)
|
||||
)
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
def get_self_netloc() -> str:
|
||||
host: IPv4Address | IPv6Address | IPvAnyAddress = global_config.host
|
||||
if isinstance(host, IPv4Address):
|
||||
|
||||
@@ -41,12 +41,22 @@ async def get_avatar(user: UserInfo, scheme: Literal['bytes'], default: str | No
|
||||
async def get_avatar(user: UserInfo, scheme: Literal['Data URI', 'bytes'], default: str | None) -> str | bytes:
|
||||
if user.user_avatar is None:
|
||||
if default is None:
|
||||
raise TypeError("Can't get avatar")
|
||||
msg = "Can't get avatar"
|
||||
raise TypeError(msg)
|
||||
return default
|
||||
bot_avatar = await user.user_avatar.get_image()
|
||||
if scheme == 'Data URI':
|
||||
avatar_format = Image.open(BytesIO(bot_avatar)).format
|
||||
if avatar_format is None:
|
||||
raise TypeError("Can't get avatar format")
|
||||
msg = "Can't get avatar format"
|
||||
raise TypeError(msg)
|
||||
return f'data:{Image.MIME[avatar_format]};base64,{b64encode(bot_avatar).decode()}'
|
||||
return bot_avatar
|
||||
|
||||
|
||||
def img_to_png(image: bytes) -> bytes:
|
||||
"""将图片转换为 PNG 格式"""
|
||||
result = BytesIO()
|
||||
with Image.open(BytesIO(image)) as img:
|
||||
img.save(result, 'PNG')
|
||||
return result.getvalue()
|
||||
@@ -147,133 +147,93 @@ class TetrisMetricsProWithLPMADPM(TetrisMetricsBasicWithLPM, TetrisMetricsBaseWi
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
apm: None = None,
|
||||
vs: None = None,
|
||||
adpm: None = None,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsBaseWithPPS:
|
||||
...
|
||||
) -> TetrisMetricsBaseWithPPS: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
apm: None = None,
|
||||
vs: None = None,
|
||||
adpm: None = None,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsBaseWithLPM:
|
||||
...
|
||||
) -> TetrisMetricsBaseWithLPM: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: None = None,
|
||||
apm: None = None,
|
||||
vs: Number,
|
||||
adpm: None = None,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsBaseWithVS:
|
||||
...
|
||||
) -> TetrisMetricsBaseWithVS: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: None = None,
|
||||
apm: None = None,
|
||||
vs: None = None,
|
||||
adpm: Number,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsBaseWithADPM:
|
||||
...
|
||||
) -> TetrisMetricsBaseWithADPM: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
apm: Number,
|
||||
vs: None = None,
|
||||
adpm: None = None,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsBasicWithPPS:
|
||||
...
|
||||
) -> TetrisMetricsBasicWithPPS: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
apm: Number,
|
||||
vs: None = None,
|
||||
adpm: None = None,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsBasicWithLPM:
|
||||
...
|
||||
) -> TetrisMetricsBasicWithLPM: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
apm: Number,
|
||||
vs: Number,
|
||||
adpm: None = None,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsProWithPPSVS:
|
||||
...
|
||||
) -> TetrisMetricsProWithPPSVS: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: Number,
|
||||
lpm: None = None,
|
||||
apm: Number,
|
||||
vs: None = None,
|
||||
adpm: Number,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsProWithPPSADPM:
|
||||
...
|
||||
) -> TetrisMetricsProWithPPSADPM: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
apm: Number,
|
||||
vs: Number,
|
||||
adpm: None = None,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsProWithLPMVS:
|
||||
...
|
||||
) -> TetrisMetricsProWithLPMVS: ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_metrics(
|
||||
*,
|
||||
pps: None = None,
|
||||
lpm: Number,
|
||||
apm: Number,
|
||||
vs: None = None,
|
||||
adpm: Number,
|
||||
precision: int = 2,
|
||||
) -> TetrisMetricsProWithLPMADPM:
|
||||
...
|
||||
) -> TetrisMetricsProWithLPMADPM: ...
|
||||
|
||||
|
||||
def get_metrics( # noqa: PLR0911, PLR0912, PLR0913
|
||||
def get_metrics( # noqa: PLR0911, PLR0912, PLR0913, C901
|
||||
*,
|
||||
pps: Number | None = None,
|
||||
lpm: Number | None = None,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from nonebot.adapters import Bot
|
||||
|
||||
|
||||
def get_platform(bot: Bot) -> str:
|
||||
try:
|
||||
from nonebot.adapters.onebot.v12 import Bot as OB12Bot
|
||||
|
||||
if isinstance(bot, OB12Bot):
|
||||
return bot.platform
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from nonebot.adapters.satori import Bot as SaBot
|
||||
|
||||
if isinstance(bot, SaBot):
|
||||
return bot.platform
|
||||
except ImportError:
|
||||
pass
|
||||
return bot.type
|
||||
@@ -1,94 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar
|
||||
|
||||
from nonebot import get_driver, get_plugin
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.message import run_postprocessor, run_preprocessor
|
||||
from nonebot_plugin_orm import get_session
|
||||
|
||||
from ..db.models import HistoricalData
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
class Recorder:
|
||||
matchers: ClassVar[set[type[Matcher]]] = set()
|
||||
historical_data: ClassVar[dict[int, tuple[HistoricalData, bool]]] = {}
|
||||
error_event: ClassVar[set[int]] = set()
|
||||
|
||||
@classmethod
|
||||
def create_historical_data(cls, event_id: int, historical_data: HistoricalData) -> None:
|
||||
cls.historical_data[event_id] = (historical_data, False)
|
||||
|
||||
@classmethod
|
||||
def update_historical_data(cls, event_id: int, historical_data: HistoricalData) -> None:
|
||||
if event_id not in cls.historical_data:
|
||||
raise KeyError
|
||||
cls.historical_data[event_id] = (historical_data, True)
|
||||
|
||||
@classmethod
|
||||
def get_historical_data(cls, event_id: int) -> HistoricalData:
|
||||
return cls.historical_data[event_id][0]
|
||||
|
||||
@classmethod
|
||||
async def save_historical_data(cls, event_id: int) -> None:
|
||||
historical_data, completed = cls.del_historical_data(event_id)
|
||||
if completed:
|
||||
async with get_session() as session:
|
||||
session.add(historical_data)
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
def del_historical_data(cls, event_id: int) -> tuple[HistoricalData, bool]:
|
||||
return cls.historical_data.pop(event_id)
|
||||
|
||||
@classmethod
|
||||
def add_error_event(cls, event_id: int) -> None:
|
||||
cls.error_event.add(event_id)
|
||||
|
||||
@classmethod
|
||||
def del_error_event(cls, event_id: int) -> None:
|
||||
cls.error_event.remove(event_id)
|
||||
|
||||
@classmethod
|
||||
def is_error_event(cls, event_id: int) -> bool:
|
||||
return event_id in cls.error_event
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
def _():
|
||||
plugin = get_plugin('nonebot_plugin_tetris_stats')
|
||||
if plugin is not None:
|
||||
Recorder.matchers = plugin.matcher
|
||||
else:
|
||||
raise RuntimeError('获取不到自身插件对象')
|
||||
|
||||
|
||||
@run_preprocessor
|
||||
def _(bot: Bot, event: Event, matcher: Matcher):
|
||||
if isinstance(matcher, tuple(Recorder.matchers)):
|
||||
Recorder.create_historical_data(
|
||||
event_id=id(event),
|
||||
historical_data=HistoricalData(
|
||||
trigger_time=datetime.now(tz=UTC),
|
||||
bot_platform=bot.type,
|
||||
bot_account=bot.self_id,
|
||||
source_type=event.get_type(),
|
||||
source_account=event.get_session_id(),
|
||||
message=event.get_message(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@run_postprocessor
|
||||
async def _(event: Event, matcher: Matcher, exception: Exception | None):
|
||||
if isinstance(matcher, tuple(Recorder.matchers)):
|
||||
event_id = id(event)
|
||||
if exception is not None:
|
||||
Recorder.add_error_event(event_id)
|
||||
Recorder.del_historical_data(event_id)
|
||||
else:
|
||||
await Recorder.save_historical_data(event_id)
|
||||
@@ -1,104 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated, ClassVar, Literal, overload
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..game_data_processor.io_data_processor.typing import Rank
|
||||
from .templates import templates_dir
|
||||
from .typing import Number
|
||||
|
||||
if PYDANTIC_V2:
|
||||
from pydantic import PlainSerializer
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(templates_dir), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True
|
||||
)
|
||||
|
||||
|
||||
def format_datetime_to_timestamp(dt: datetime) -> int:
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
class Bind(BaseModel):
|
||||
class People(BaseModel):
|
||||
avatar: str
|
||||
name: str
|
||||
|
||||
platform: Literal['TETR.IO', 'TOP', 'TOS']
|
||||
status: Literal['error', 'success', 'unknown', 'unlink', 'unverified']
|
||||
user: People
|
||||
bot: People
|
||||
command: str
|
||||
|
||||
|
||||
class TETRIOInfo(BaseModel):
|
||||
class User(BaseModel):
|
||||
avatar: str
|
||||
name: str
|
||||
bio: str | None
|
||||
|
||||
class Ranking(BaseModel):
|
||||
rating: Number
|
||||
rd: Number
|
||||
|
||||
class TetraLeague(BaseModel):
|
||||
rank: Rank
|
||||
tr: Number
|
||||
global_rank: Number
|
||||
pps: Number
|
||||
lpm: Number
|
||||
apm: Number
|
||||
apl: Number
|
||||
vs: Number
|
||||
adpm: Number
|
||||
adpl: Number
|
||||
|
||||
class TetraLeagueHistory(BaseModel):
|
||||
class Data(BaseModel):
|
||||
if PYDANTIC_V2:
|
||||
record_at: Annotated[datetime, PlainSerializer(format_datetime_to_timestamp, return_type=int)]
|
||||
else:
|
||||
record_at: datetime # type: ignore[no-redef]
|
||||
tr: Number
|
||||
|
||||
data: list[Data]
|
||||
split_interval: Number
|
||||
min_tr: Number
|
||||
max_tr: Number
|
||||
offset: Number
|
||||
|
||||
class Radar(BaseModel):
|
||||
app: Number
|
||||
dsps: Number
|
||||
dspp: Number
|
||||
ci: Number
|
||||
ge: Number
|
||||
|
||||
user: User
|
||||
ranking: Ranking
|
||||
tetra_league: TetraLeague
|
||||
tetra_league_history: TetraLeagueHistory
|
||||
radar: Radar
|
||||
sprint: str
|
||||
blitz: str
|
||||
|
||||
if not PYDANTIC_V2:
|
||||
|
||||
class Config:
|
||||
json_encoders: ClassVar[dict] = {datetime: format_datetime_to_timestamp}
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['binding'], data: Bind) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['tetrio/info'], data: TETRIOInfo) -> str: ...
|
||||
|
||||
|
||||
async def render(render_type: Literal['binding', 'tetrio/info'], data: Bind | TETRIOInfo) -> str:
|
||||
if PYDANTIC_V2:
|
||||
return await env.get_template('index.html').render_async(path=render_type, data=data.model_dump_json())
|
||||
return await env.get_template('index.html').render_async(path=render_type, data=data.json())
|
||||
67
nonebot_plugin_tetris_stats/utils/render/__init__.py
Normal file
67
nonebot_plugin_tetris_stats/utils/render/__init__.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from typing import Literal, overload
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
|
||||
from ..templates import templates_dir
|
||||
from .schemas.bind import Bind
|
||||
from .schemas.tetrio_info import Info as TETRIOInfo
|
||||
from .schemas.tetrio_info_v2 import Info as TETRIOInfoV2
|
||||
from .schemas.tetrio_record_blitz import Record as TETRIORecordBlitz
|
||||
from .schemas.tetrio_record_sprint import Record as TETRIORecordSprint
|
||||
from .schemas.top_info import Info as TOPInfo
|
||||
from .schemas.tos_info import Info as TOSInfo
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(templates_dir), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True
|
||||
)
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['v1/binding'], data: Bind) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['v1/tetrio/info'], data: TETRIOInfo) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['v1/top/info'], data: TOPInfo) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['v1/tos/info'], data: TOSInfo) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['v2/tetrio/info'], data: TETRIOInfoV2) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['v2/tetrio/record/40l'], data: TETRIORecordSprint) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def render(render_type: Literal['v2/tetrio/record/blitz'], data: TETRIORecordBlitz) -> str: ...
|
||||
|
||||
|
||||
async def render(
|
||||
render_type: Literal[
|
||||
'v1/binding',
|
||||
'v1/tetrio/info',
|
||||
'v1/top/info',
|
||||
'v1/tos/info',
|
||||
'v2/tetrio/info',
|
||||
'v2/tetrio/record/40l',
|
||||
'v2/tetrio/record/blitz',
|
||||
],
|
||||
data: Bind | TETRIOInfo | TOPInfo | TOSInfo | TETRIOInfoV2 | TETRIORecordSprint | TETRIORecordBlitz,
|
||||
) -> str:
|
||||
if PYDANTIC_V2:
|
||||
return await env.get_template('index.html').render_async(
|
||||
path=render_type, data=data.model_dump_json(by_alias=True)
|
||||
)
|
||||
return await env.get_template('index.html').render_async(path=render_type, data=data.json(by_alias=True))
|
||||
|
||||
|
||||
__all__ = ['render']
|
||||
20
nonebot_plugin_tetris_stats/utils/render/schemas/base.py
Normal file
20
nonebot_plugin_tetris_stats/utils/render/schemas/base.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...typing import Number
|
||||
|
||||
|
||||
class Avatar(BaseModel):
|
||||
type: Literal['identicon']
|
||||
hash: str
|
||||
|
||||
|
||||
class People(BaseModel):
|
||||
avatar: str | Avatar
|
||||
name: str
|
||||
|
||||
|
||||
class Ranking(BaseModel):
|
||||
rating: Number
|
||||
rd: Number
|
||||
13
nonebot_plugin_tetris_stats/utils/render/schemas/bind.py
Normal file
13
nonebot_plugin_tetris_stats/utils/render/schemas/bind.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .base import People
|
||||
|
||||
|
||||
class Bind(BaseModel):
|
||||
platform: Literal['TETR.IO', 'TOP', 'TOS']
|
||||
status: Literal['error', 'success', 'unknown', 'unlink', 'unverified']
|
||||
user: People
|
||||
bot: People
|
||||
command: str
|
||||
@@ -0,0 +1,72 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated, ClassVar
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ....games.tetrio.api.typing import Rank
|
||||
from ...typing import Number
|
||||
from .base import People, Ranking
|
||||
|
||||
if PYDANTIC_V2:
|
||||
from pydantic import PlainSerializer
|
||||
|
||||
|
||||
def format_datetime_to_timestamp(dt: datetime) -> int:
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
class User(People):
|
||||
bio: str | None
|
||||
|
||||
|
||||
class TetraLeague(BaseModel):
|
||||
rank: Rank
|
||||
tr: Number
|
||||
global_rank: Number
|
||||
pps: Number
|
||||
lpm: Number
|
||||
apm: Number
|
||||
apl: Number
|
||||
vs: Number
|
||||
adpm: Number
|
||||
adpl: Number
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
if PYDANTIC_V2:
|
||||
record_at: Annotated[datetime, PlainSerializer(format_datetime_to_timestamp, return_type=int)]
|
||||
else:
|
||||
record_at: datetime # type: ignore[no-redef]
|
||||
tr: Number
|
||||
|
||||
|
||||
class TetraLeagueHistory(BaseModel):
|
||||
data: list[Data]
|
||||
split_interval: Number
|
||||
min_tr: Number
|
||||
max_tr: Number
|
||||
offset: Number
|
||||
|
||||
|
||||
class Radar(BaseModel):
|
||||
app: Number
|
||||
dsps: Number
|
||||
dspp: Number
|
||||
ci: Number
|
||||
ge: Number
|
||||
|
||||
|
||||
class Info(BaseModel):
|
||||
user: User
|
||||
ranking: Ranking
|
||||
tetra_league: TetraLeague
|
||||
tetra_league_history: TetraLeagueHistory
|
||||
radar: Radar
|
||||
sprint: str
|
||||
blitz: str
|
||||
|
||||
if not PYDANTIC_V2:
|
||||
|
||||
class Config:
|
||||
json_encoders: ClassVar[dict] = {datetime: format_datetime_to_timestamp}
|
||||
@@ -0,0 +1,94 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ....games.tetrio.api.schemas.user_records import Zen
|
||||
from ....games.tetrio.api.typing import Rank
|
||||
from ...typing import Number
|
||||
from .base import Avatar
|
||||
|
||||
|
||||
class Badge(BaseModel):
|
||||
id: str
|
||||
description: str
|
||||
group: str | None
|
||||
receive_at: datetime | None
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
country: str | None
|
||||
|
||||
avatar: str | Avatar
|
||||
banner: str | None
|
||||
|
||||
bio: str | None
|
||||
|
||||
friend_count: int
|
||||
supporter_tier: int
|
||||
|
||||
verified: bool
|
||||
bad_standing: bool
|
||||
|
||||
badges: list[Badge]
|
||||
xp: Number
|
||||
|
||||
playtime: str | None
|
||||
join_at: datetime | None
|
||||
|
||||
|
||||
class Statistic(BaseModel):
|
||||
total: int | None
|
||||
wins: int | None
|
||||
|
||||
|
||||
class TetraLeagueStatistic(BaseModel):
|
||||
total: int
|
||||
wins: int
|
||||
|
||||
|
||||
class TetraLeague(BaseModel):
|
||||
rank: Rank
|
||||
highest_rank: Rank
|
||||
|
||||
tr: Number
|
||||
|
||||
glicko: Number
|
||||
rd: Number
|
||||
|
||||
global_rank: int | None
|
||||
country_rank: int | None
|
||||
|
||||
pps: Number
|
||||
|
||||
apm: Number
|
||||
apl: Number
|
||||
|
||||
vs: Number | None
|
||||
adpl: Number | None
|
||||
|
||||
statistic: TetraLeagueStatistic
|
||||
|
||||
decaying: bool
|
||||
|
||||
|
||||
class Sprint(BaseModel):
|
||||
time: str
|
||||
global_rank: int | None
|
||||
play_at: datetime
|
||||
|
||||
|
||||
class Blitz(BaseModel):
|
||||
score: int
|
||||
global_rank: int | None
|
||||
play_at: datetime
|
||||
|
||||
|
||||
class Info(BaseModel):
|
||||
user: User
|
||||
tetra_league: TetraLeague | None
|
||||
statistic: Statistic | None
|
||||
sprint: Sprint | None
|
||||
blitz: Blitz | None
|
||||
zen: Zen
|
||||
@@ -0,0 +1,58 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .base import People
|
||||
|
||||
|
||||
class User(People):
|
||||
id: str
|
||||
|
||||
|
||||
class Max(BaseModel):
|
||||
combo: int
|
||||
btb: int
|
||||
|
||||
|
||||
class Mini(BaseModel):
|
||||
total: int
|
||||
single: int
|
||||
double: int
|
||||
|
||||
|
||||
class Tspins(BaseModel):
|
||||
total: int
|
||||
single: int
|
||||
double: int
|
||||
triple: int
|
||||
|
||||
mini: Mini
|
||||
|
||||
|
||||
class Finesse(BaseModel):
|
||||
faults: int
|
||||
accuracy: float
|
||||
|
||||
|
||||
class RecordStatistic(BaseModel):
|
||||
keys: int
|
||||
kpp: float
|
||||
kps: float
|
||||
|
||||
max: Max
|
||||
|
||||
pieces: int
|
||||
pps: float
|
||||
lines: int
|
||||
lpm: float
|
||||
holds: int | None
|
||||
score: int
|
||||
|
||||
single: int
|
||||
double: int
|
||||
triple: int
|
||||
quad: int
|
||||
|
||||
tspins: Tspins
|
||||
|
||||
all_clear: int
|
||||
|
||||
finesse: Finesse
|
||||
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .tetrio_record_base import RecordStatistic, User
|
||||
|
||||
|
||||
class Statistic(RecordStatistic):
|
||||
spp: float
|
||||
|
||||
level: int
|
||||
|
||||
|
||||
class Record(BaseModel):
|
||||
user: User
|
||||
|
||||
replay_id: str
|
||||
rank: int | None
|
||||
|
||||
statistic: Statistic
|
||||
|
||||
play_at: datetime
|
||||
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .tetrio_record_base import RecordStatistic as Statistic
|
||||
from .tetrio_record_base import User
|
||||
|
||||
|
||||
class Record(BaseModel):
|
||||
user: User
|
||||
|
||||
time: str
|
||||
replay_id: str
|
||||
rank: int | None
|
||||
|
||||
statistic: Statistic
|
||||
|
||||
play_at: datetime
|
||||
17
nonebot_plugin_tetris_stats/utils/render/schemas/top_info.py
Normal file
17
nonebot_plugin_tetris_stats/utils/render/schemas/top_info.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...typing import Number
|
||||
from .base import People
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
pps: Number
|
||||
lpm: Number
|
||||
apm: Number
|
||||
apl: Number
|
||||
|
||||
|
||||
class Info(BaseModel):
|
||||
user: People
|
||||
today: Data
|
||||
history: Data
|
||||
32
nonebot_plugin_tetris_stats/utils/render/schemas/tos_info.py
Normal file
32
nonebot_plugin_tetris_stats/utils/render/schemas/tos_info.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typing import Number
|
||||
from .base import People, Ranking
|
||||
|
||||
|
||||
class Multiplayer(BaseModel):
|
||||
pps: Number
|
||||
lpm: Number
|
||||
apm: Number
|
||||
apl: Number
|
||||
vs: Number
|
||||
adpm: Number
|
||||
adpl: Number
|
||||
|
||||
|
||||
class Radar(BaseModel):
|
||||
app: Number
|
||||
OR: Number = Field(serialization_alias='or')
|
||||
dspp: Number
|
||||
ci: Number
|
||||
ge: Number
|
||||
|
||||
|
||||
class Info(BaseModel):
|
||||
user: People
|
||||
ranking: Ranking
|
||||
multiplayer: Multiplayer
|
||||
radar: Radar
|
||||
sprint: str
|
||||
challenge: str
|
||||
marathon: str
|
||||
@@ -19,13 +19,13 @@ config = get_plugin_config(Config)
|
||||
|
||||
@driver.on_startup
|
||||
async def _():
|
||||
await Request._init_cache()
|
||||
await Request._read_cache()
|
||||
await Request.init_cache()
|
||||
await Request.read_cache()
|
||||
|
||||
|
||||
@driver.on_shutdown
|
||||
async def _():
|
||||
await Request._write_cache()
|
||||
await Request.write_cache()
|
||||
|
||||
|
||||
def splice_url(url_list: list[str]) -> str:
|
||||
@@ -66,7 +66,8 @@ class Request:
|
||||
await page.wait_for_timeout(1000)
|
||||
else:
|
||||
if not isinstance(response, Response):
|
||||
raise RequestError('api请求失败')
|
||||
msg = 'api请求失败'
|
||||
raise RequestError(msg)
|
||||
cls._headers = await response.request.all_headers()
|
||||
try:
|
||||
cls._cookies = {
|
||||
@@ -77,41 +78,42 @@ class Request:
|
||||
except KeyError:
|
||||
cls._cookies = None
|
||||
return await response.body()
|
||||
raise RequestError('绕过五秒盾失败')
|
||||
msg = '绕过五秒盾失败'
|
||||
raise RequestError(msg)
|
||||
|
||||
@classmethod
|
||||
async def _init_cache(cls) -> None:
|
||||
async def init_cache(cls) -> None:
|
||||
"""初始化缓存文件"""
|
||||
if not cls._CACHE_FILE.exists():
|
||||
async with open(file=cls._CACHE_FILE, mode='w', encoding='UTF-8') as file:
|
||||
await file.write(dumps({'headers': cls._headers, 'cookies': cls._cookies}))
|
||||
|
||||
@classmethod
|
||||
async def _read_cache(cls) -> None:
|
||||
async def read_cache(cls) -> None:
|
||||
"""读取缓存文件"""
|
||||
try:
|
||||
async with open(file=cls._CACHE_FILE, mode='r', encoding='UTF-8') as file:
|
||||
json = loads(await file.read())
|
||||
except FileNotFoundError:
|
||||
await cls._init_cache()
|
||||
await cls.init_cache()
|
||||
except (PermissionError, JSONDecodeError):
|
||||
cls._CACHE_FILE.unlink()
|
||||
await cls._init_cache()
|
||||
await cls.init_cache()
|
||||
else:
|
||||
cls._headers = json['headers']
|
||||
cls._cookies = json['cookies']
|
||||
|
||||
@classmethod
|
||||
async def _write_cache(cls) -> None:
|
||||
async def write_cache(cls) -> None:
|
||||
"""写入缓存文件"""
|
||||
try:
|
||||
async with open(file=cls._CACHE_FILE, mode='r+', encoding='UTF-8') as file:
|
||||
await file.write(dumps({'headers': cls._headers, 'cookies': cls._cookies}))
|
||||
except FileNotFoundError:
|
||||
await cls._init_cache()
|
||||
await cls.init_cache()
|
||||
except (PermissionError, JSONDecodeError):
|
||||
cls._CACHE_FILE.unlink()
|
||||
await cls._init_cache()
|
||||
await cls.init_cache()
|
||||
|
||||
@classmethod
|
||||
async def request(cls, url: str, *, is_json: bool = True) -> bytes:
|
||||
@@ -120,15 +122,14 @@ class Request:
|
||||
async with AsyncClient(cookies=cls._cookies, timeout=config.tetris_req_timeout) as session:
|
||||
response = await session.get(url, headers=cls._headers)
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
raise RequestError(
|
||||
f'请求错误 code: {response.status_code} {HTTPStatus(response.status_code).phrase}\n{response.text}',
|
||||
status_code=response.status_code,
|
||||
)
|
||||
msg = f'请求错误 code: {response.status_code} {HTTPStatus(response.status_code).phrase}\n{response.text}'
|
||||
raise RequestError(msg, status_code=response.status_code)
|
||||
if is_json:
|
||||
loads(response.content)
|
||||
return response.content
|
||||
except HTTPError as e:
|
||||
raise RequestError(f'请求错误 \n{e!r}') from e
|
||||
msg = f'请求错误 \n{e!r}'
|
||||
raise RequestError(msg) from e
|
||||
except JSONDecodeError:
|
||||
if urlparse(url).netloc.lower().endswith('tetr.io'):
|
||||
return await cls._anti_cloudflare(url)
|
||||
@@ -162,4 +163,5 @@ class Request:
|
||||
else:
|
||||
raise
|
||||
continue
|
||||
raise RequestError(f'所有地址皆不可用\n{error_list!r}')
|
||||
msg = f'所有地址皆不可用\n{error_list!r}'
|
||||
raise RequestError(msg)
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
from asyncio import sleep
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from typing import TypeVar, cast
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
from nonebot.log import logger
|
||||
from nonebot_plugin_alconna.uniseg import SerializeFailed, UniMessage
|
||||
|
||||
T = TypeVar('T')
|
||||
P = ParamSpec('P')
|
||||
|
||||
|
||||
def retry(
|
||||
max_attempts: int = 3,
|
||||
exception_type: type[BaseException] | tuple[type[BaseException], ...] = Exception,
|
||||
delay: timedelta | None = None,
|
||||
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
||||
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
reply: str | UniMessage | None = None,
|
||||
) -> Callable[[Callable[P, Coroutine[Any, Any, T]]], Callable[P, Coroutine[Any, Any, T]]]:
|
||||
def decorator(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, Coroutine[Any, Any, T]]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> T: # noqa: ANN002, ANN003
|
||||
attempts = 0
|
||||
while attempts < max_attempts + 1:
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
for i in range(max_attempts + 1):
|
||||
if i > 0:
|
||||
message = f'Retrying: {func.__name__} ({i}/{max_attempts})'
|
||||
logger.debug(message)
|
||||
with suppress(SerializeFailed):
|
||||
await UniMessage(reply or message).send()
|
||||
if i == max_attempts:
|
||||
break
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except exception_type as e: # noqa: PERF203
|
||||
except exception_type as e:
|
||||
if i == max_attempts:
|
||||
raise
|
||||
logger.exception(e)
|
||||
attempts += 1
|
||||
if attempts <= max_attempts:
|
||||
if delay is not None:
|
||||
await sleep(delay.total_seconds())
|
||||
logger.debug(f'Retrying: {func.__name__} ({attempts}/{max_attempts})')
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError('Unexpectedly reached the end of the retry loop')
|
||||
if delay is not None:
|
||||
await sleep(delay.total_seconds())
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return cast(Callable[..., Awaitable[T]], wrapper)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from playwright.async_api import TimeoutError
|
||||
|
||||
from .browser import BrowserManager
|
||||
from .retry import retry
|
||||
|
||||
|
||||
@retry(exception_type=TimeoutError, reply='截图失败, 重试中')
|
||||
async def screenshot(url: str) -> bytes:
|
||||
browser = await BrowserManager.get_browser()
|
||||
async with (
|
||||
await browser.new_page(no_viewport=True, viewport={'width': 0, 'height': 0}) as page,
|
||||
await browser.new_page(viewport={'width': 3000, 'height': 3000}) as page,
|
||||
):
|
||||
await page.goto(url)
|
||||
await page.wait_for_load_state('networkidle')
|
||||
return await page.screenshot(full_page=True, type='png')
|
||||
return await page.locator('id=content').screenshot(timeout=5000, type='png')
|
||||
|
||||
@@ -3,21 +3,24 @@ from shutil import rmtree
|
||||
|
||||
from nonebot import get_driver
|
||||
from nonebot.log import logger
|
||||
from nonebot.permission import SUPERUSER
|
||||
from nonebot_plugin_alconna import on_alconna
|
||||
from nonebot_plugin_localstore import get_data_dir # type: ignore[import-untyped]
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
templates_dir = get_data_dir('nonebot_plugin_tetris_stats') / 'templates'
|
||||
|
||||
alc = on_alconna('更新模板', permission=SUPERUSER)
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
async def init_templates() -> None:
|
||||
try:
|
||||
await create_subprocess_exec('git', '--version', stdout=PIPE)
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError(
|
||||
'未找到 git, 请确保 git 已安装并在环境变量中\n安装步骤请参阅: https://git-scm.com/book/zh/v2/%E8%B5%B7%E6%AD%A5-%E5%AE%89%E8%A3%85-Git'
|
||||
) from e
|
||||
msg = '未找到 git, 请确保 git 已安装并在环境变量中\n安装步骤请参阅: https://git-scm.com/book/zh/v2/%E8%B5%B7%E6%AD%A5-%E5%AE%89%E8%A3%85-Git'
|
||||
raise RuntimeError(msg) from e
|
||||
if not templates_dir.exists():
|
||||
logger.info('模板仓库不存在, 正在尝试初始化...')
|
||||
proc = await create_subprocess_exec(
|
||||
@@ -35,7 +38,8 @@ async def init_templates() -> None:
|
||||
if proc.returncode != 0:
|
||||
for i in stderr.decode().splitlines():
|
||||
logger.error(i)
|
||||
raise RuntimeError('初始化模板仓库失败')
|
||||
msg = '初始化模板仓库失败'
|
||||
raise RuntimeError(msg)
|
||||
logger.success('模板仓库初始化成功')
|
||||
return
|
||||
proc = await create_subprocess_exec(
|
||||
@@ -56,5 +60,12 @@ async def init_templates() -> None:
|
||||
if proc.returncode != 0:
|
||||
for i in stderr.decode().splitlines():
|
||||
logger.error(i)
|
||||
raise RuntimeError('更新模板仓库失败')
|
||||
msg = '更新模板仓库失败'
|
||||
raise RuntimeError(msg)
|
||||
logger.success('模板仓库更新成功')
|
||||
|
||||
|
||||
@alc.handle()
|
||||
async def _():
|
||||
await init_templates()
|
||||
await alc.finish('模板仓库更新成功')
|
||||
|
||||
2177
poetry.lock
generated
2177
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user