Compare commits

...

42 Commits

Author SHA1 Message Date
fbe018e56a 🔖 1.2.11 2024-06-08 12:04:44 +08:00
ab046fe786 使用 nonebot-plugin-user 进行身份绑定 close #63 2024-06-08 12:03:07 +08:00
ce95d8f977 完善 retry 装饰器 2024-06-08 11:57:45 +08:00
fa05b80e61 修改截图方式 2024-06-08 11:57:21 +08:00
0ab0d11a98 添加依赖 nonebot-plugin-user 2024-06-08 11:55:47 +08:00
7f469540b2 ️ 删除一个不必要的async 2024-06-08 00:55:42 +08:00
21bee29146 适配 v2 模板 2024-06-07 21:18:12 +08:00
dependabot[bot]
c2dd9c5d86 ⬆️ Bump nonebot-plugin-alconna from 0.46.3 to 0.46.4 (#333)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.46.3 to 0.46.4.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.46.3...v0.46.4)

---
updated-dependencies:
- dependency-name: nonebot-plugin-alconna
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-06 21:33:51 +08:00
dependabot[bot]
5927cb2bb5 ⬆️ Bump pandas-stubs from 2.2.2.240514 to 2.2.2.240603 (#334)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.2.2.240514 to 2.2.2.240603.
- [Changelog](https://github.com/pandas-dev/pandas-stubs/blob/main/docs/release_procedure.md)
- [Commits](https://github.com/pandas-dev/pandas-stubs/compare/v2.2.2.240514...v2.2.2.240603)

---
updated-dependencies:
- dependency-name: pandas-stubs
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-06 21:33:35 +08:00
dependabot[bot]
4a4a215b61 ⬆️ Bump ruff from 0.4.6 to 0.4.8 (#336)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.6 to 0.4.8.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.4.6...v0.4.8)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-06 21:33:15 +08:00
bfe931d3bf 为截图添加自动重试 2024-06-04 20:15:41 +08:00
b7b152d84d 完善 retry 装饰器的类型,并添加消息提示功能 2024-06-04 20:14:19 +08:00
b6f6eb1170 sprint 成绩保留三位小数 2024-06-04 20:12:56 +08:00
934800aae0 🐛 修正 UniMessage 的使用方式 2024-06-04 20:10:57 +08:00
dependabot[bot]
d19c37e99a ⬆️ Bump nonebot-plugin-alconna from 0.46.1 to 0.46.3 (#331)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.46.1 to 0.46.3.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.46.1...v0.46.3)

---
updated-dependencies:
- dependency-name: nonebot-plugin-alconna
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-01 14:50:10 +08:00
dependabot[bot]
43167fe9bd ⬆️ Bump ruff from 0.4.4 to 0.4.6 (#330)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.4 to 0.4.6.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.4.4...v0.4.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-01 14:50:00 +08:00
dependabot[bot]
db8de88667 ⬆️ Bump nonebot-plugin-orm from 0.7.2 to 0.7.3 (#327)
updated-dependencies:
- dependency-name: nonebot-plugin-orm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-01 14:49:44 +08:00
318b42dbd2 🔖 1.2.10 2024-05-22 22:12:46 +08:00
af4a9f33b0 🐛 修复查用户名/id时数据不更新的bug
player实例被alconna缓存
2024-05-22 22:01:19 +08:00
dependabot[bot]
5e5bc4da2c ⬆️ Bump playwright from 1.43.0 to 1.44.0 (#322)
Bumps [playwright](https://github.com/Microsoft/playwright-python) from 1.43.0 to 1.44.0.
- [Release notes](https://github.com/Microsoft/playwright-python/releases)
- [Commits](https://github.com/Microsoft/playwright-python/compare/v1.43.0...v1.44.0)

---
updated-dependencies:
- dependency-name: playwright
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-22 15:27:42 +08:00
dependabot[bot]
594ea9a76f ⬆️ Bump types-pillow from 10.2.0.20240511 to 10.2.0.20240520 (#324)
Bumps [types-pillow](https://github.com/python/typeshed) from 10.2.0.20240511 to 10.2.0.20240520.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-pillow
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-22 15:27:33 +08:00
dependabot[bot]
69e9ca7933 ⬆️ Bump nonebot2 from 2.3.0 to 2.3.1 (#325)
Bumps [nonebot2](https://github.com/nonebot/nonebot2) from 2.3.0 to 2.3.1.
- [Release notes](https://github.com/nonebot/nonebot2/releases)
- [Changelog](https://github.com/nonebot/nonebot2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nonebot/nonebot2/compare/v2.3.0...v2.3.1)

---
updated-dependencies:
- dependency-name: nonebot2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-22 15:27:23 +08:00
dependabot[bot]
b1bc111b7a ⬆️ Bump nonebot-plugin-alconna from 0.45.4 to 0.46.1 (#326)
updated-dependencies:
- dependency-name: nonebot-plugin-alconna
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-22 15:26:35 +08:00
43970f4853 TOS 查数据使用图片回复 2024-05-19 15:04:42 +08:00
48b200697c 添加开发依赖 nonebot2[all] 2024-05-19 13:33:22 +08:00
1a791f5ef8 🎨 重命名 TETRIOInfo 类 2024-05-17 10:45:21 +08:00
9b13a9e87c 🔖 1.2.9 2024-05-16 18:04:48 +08:00
ecad6b8070 🐛 修复 TETR.IO User Records 解析失败的bug 2024-05-16 18:04:05 +08:00
1e6932b3de 🔥 删除无用代码 2024-05-16 06:06:16 +08:00
3ef7605e11 🎨 重命名一些模块 2024-05-16 05:59:18 +08:00
dependabot[bot]
e8539c15cc ⬆️ Bump types-ujson from 5.9.0.0 to 5.10.0.20240515 (#321)
Bumps [types-ujson](https://github.com/python/typeshed) from 5.9.0.0 to 5.10.0.20240515.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-ujson
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-16 05:42:52 +08:00
dependabot[bot]
9ace65f9df ⬆️ Bump pandas-stubs from 2.2.1.240316 to 2.2.2.240514 (#320)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.2.1.240316 to 2.2.2.240514.
- [Changelog](https://github.com/pandas-dev/pandas-stubs/blob/main/docs/release_procedure.md)
- [Commits](https://github.com/pandas-dev/pandas-stubs/compare/v2.2.1.240316...v2.2.2.240514)

---
updated-dependencies:
- dependency-name: pandas-stubs
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-16 05:42:41 +08:00
dependabot[bot]
d727a0bc53 ⬆️ Bump ujson from 5.9.0 to 5.10.0 (#319)
Bumps [ujson](https://github.com/ultrajson/ultrajson) from 5.9.0 to 5.10.0.
- [Release notes](https://github.com/ultrajson/ultrajson/releases)
- [Commits](https://github.com/ultrajson/ultrajson/compare/5.9.0...5.10.0)

---
updated-dependencies:
- dependency-name: ujson
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-16 05:42:30 +08:00
52947556a4 🐛 忘记把注释的代码取消注释了 2024-05-16 05:41:33 +08:00
7fe9a6fd3d 🔖 1.2.8 2024-05-15 14:56:26 +08:00
6dbfd31eab 🐛 修复 TETR.IO User Records 解析失败的bug 2024-05-15 14:53:43 +08:00
1788d40ed2 🔖 1.2.7 2024-05-15 13:34:55 +08:00
18d8e0cdcc 将本地存储的 TetraLeague FullExport 数据聚合进查询图 2024-05-15 13:34:28 +08:00
b37f927be6 添加 debug 依赖 memory-profiler 2024-05-15 09:14:13 +08:00
314bf4c2f0 🔇 忘记删 debug 日志了 2024-05-15 09:12:24 +08:00
c9f6817c6a 🔖 1.2.6 2024-05-15 08:56:46 +08:00
4c7cd00a76 🐛 修复 histories 只有一条时推算数据出现除数为0的bug 2024-05-15 08:56:22 +08:00
54 changed files with 1828 additions and 527 deletions

View File

@@ -5,8 +5,14 @@ require('nonebot_plugin_alconna')
require('nonebot_plugin_apscheduler') require('nonebot_plugin_apscheduler')
require('nonebot_plugin_localstore') require('nonebot_plugin_localstore')
require('nonebot_plugin_orm') require('nonebot_plugin_orm')
require('nonebot_plugin_session')
require('nonebot_plugin_session_orm') require('nonebot_plugin_session_orm')
require('nonebot_plugin_session')
require('nonebot_plugin_user')
from nonebot_plugin_alconna import namespace # noqa: E402
with namespace('tetris_stats') as ns:
ns.enable_message_cache = False
from .config.config import migrations # noqa: E402 from .config.config import migrations # noqa: E402
@@ -21,5 +27,4 @@ __plugin_meta__ = PluginMetadata(
}, },
) )
from . import game_data_processor # noqa: F401, E402 from . import games # noqa: F401, E402
from .utils import host # noqa: F401, E402

View File

@@ -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 ###

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, TypeVar, overload
from nonebot.exception import FinishedException from nonebot.exception import FinishedException
from nonebot.log import logger from nonebot.log import logger
from nonebot_plugin_orm import AsyncSession, get_session from nonebot_plugin_orm import AsyncSession, get_session
from nonebot_plugin_user import User # type: ignore[import-untyped]
from sqlalchemy import select from sqlalchemy import select
from ..utils.typing import CommandType, GameType from ..utils.typing import CommandType, GameType
@@ -16,9 +17,9 @@ from .models import Bind, TriggerHistoricalData
UTC = timezone.utc UTC = timezone.utc
if TYPE_CHECKING: if TYPE_CHECKING:
from ..game_data_processor.io_data_processor.api.models import TETRIOHistoricalData from ..games.tetrio.api.models import TETRIOHistoricalData
from ..game_data_processor.top_data_processor.api.models import TOPHistoricalData from ..games.top.api.models import TOPHistoricalData
from ..game_data_processor.tos_data_processor.api.models import TOSHistoricalData from ..games.tos.api.models import TOSHistoricalData
class BindStatus(Enum): class BindStatus(Enum):
@@ -28,37 +29,28 @@ class BindStatus(Enum):
async def query_bind_info( async def query_bind_info(
session: AsyncSession, session: AsyncSession,
chat_platform: str, user: User,
chat_account: str,
game_platform: GameType, game_platform: GameType,
) -> Bind | None: ) -> Bind | None:
return ( return (
await session.scalars( await session.scalars(select(Bind).where(Bind.user_id == user.id).where(Bind.game_platform == game_platform))
select(Bind)
.where(Bind.chat_platform == chat_platform)
.where(Bind.chat_account == chat_account)
.where(Bind.game_platform == game_platform)
)
).one_or_none() ).one_or_none()
async def create_or_update_bind( async def create_or_update_bind(
session: AsyncSession, session: AsyncSession,
chat_platform: str, user: User,
chat_account: str,
game_platform: GameType, game_platform: GameType,
game_account: str, game_account: str,
) -> BindStatus: ) -> BindStatus:
bind = await query_bind_info( bind = await query_bind_info(
session=session, session=session,
chat_platform=chat_platform, user=user,
chat_account=chat_account,
game_platform=game_platform, game_platform=game_platform,
) )
if bind is None: if bind is None:
bind = Bind( bind = Bind(
chat_platform=chat_platform, user_id=user.id,
chat_account=chat_account,
game_platform=game_platform, game_platform=game_platform,
game_account=game_account, game_account=game_account,
) )

View File

@@ -66,8 +66,7 @@ class PydanticType(TypeDecorator):
class Bind(MappedAsDataclass, Model): class Bind(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(init=False, primary_key=True) id: Mapped[int] = mapped_column(init=False, primary_key=True)
chat_platform: Mapped[str] = mapped_column(String(32), index=True) user_id: Mapped[int] = mapped_column(index=True)
chat_account: Mapped[str] = mapped_column(index=True)
game_platform: Mapped[GameType] = mapped_column(String(32)) game_platform: Mapped[GameType] = mapped_column(String(32))
game_account: Mapped[str] game_account: Mapped[str]

View File

@@ -1,170 +0,0 @@
from asyncio import gather
from dataclasses import dataclass
from typing import Literal
from nonebot.adapters import Bot, 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 ...db import query_bind_info, trigger
from ...utils.metrics import TetrisMetricsProWithLPMADPM, get_metrics
from ...utils.platform import get_platform
from ...utils.typing import Me
from ..constant import CANT_VERIFY_MESSAGE
from . import alc
from .api import Player
from .constant import GAME_TYPE
def add_special_handlers(
teaid_prefix: Literal['onebot-', 'kook-', 'discord-', 'qqguild-'], match_event: type[Event]
) -> None:
@alc.assign('query')
async def _(event: Event, target: At | Me, event_session: EventSession):
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=[],
):
await (
await make_query_text(
Player(
teaid=f'{teaid_prefix}{target.target}'
if isinstance(target, At)
else f'{teaid_prefix}{event.get_user_id()}',
trust=True,
)
)
).finish()
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('query')
async def _(bot: Bot, 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,
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 = CANT_VERIFY_MESSAGE
await (message + await make_query_text(Player(teaid=bind.game_account, trust=True))).finish()
@alc.assign('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 (await make_query_text(account)).finish()
@dataclass
class GameData:
game_num: int
metrics: TetrisMetricsProWithLPMADPM
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
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 >= 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
)
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)
async def make_query_text(player: Player) -> UniMessage:
user_info, game_data = await gather(player.get_info(), get_game_data(player))
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)

View File

@@ -32,11 +32,7 @@ def add_default_handlers(matcher: type[AlconnaMatcher]) -> None:
raise FinishedException raise FinishedException
from . import ( # noqa: F401, E402 from . import tetrio, top, tos # noqa: F401, E402
io_data_processor,
top_data_processor,
tos_data_processor,
)
@run_postprocessor @run_postprocessor

View File

@@ -1,6 +1,7 @@
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
from nonebot_plugin_alconna import At, on_alconna from nonebot_plugin_alconna import At, on_alconna
from ... import ns
from ...utils.exception import MessageFormatError from ...utils.exception import MessageFormatError
from ...utils.typing import Me from ...utils.typing import Me
from .. import add_default_handlers from .. import add_default_handlers
@@ -72,6 +73,7 @@ alc = on_alconna(
compact=True, compact=True,
fuzzy_match=True, fuzzy_match=True,
), ),
namespace=ns,
), ),
skip_for_unmatch=False, skip_for_unmatch=False,
auto_send_output=True, auto_send_output=True,

View File

@@ -36,7 +36,7 @@ class Garbage(BaseModel):
sent: int sent: int
received: int received: int
attack: int | None = None attack: int | None = None
cleared: int cleared: int | None = None
class Finesse(BaseModel): class Finesse(BaseModel):
@@ -96,7 +96,7 @@ class MultiRecord(_Record):
class SoloModeRecord(BaseModel): class SoloModeRecord(BaseModel):
record: SoloRecord record: SoloRecord | None = None
rank: int | None = None rank: int | None = None

View File

@@ -1,17 +1,16 @@
from hashlib import md5 from hashlib import md5
from urllib.parse import urlunparse from urllib.parse import urlunparse
from nonebot.adapters import Bot, Event
from nonebot_plugin_alconna.uniseg import UniMessage from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped] 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_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 nonebot_plugin_userinfo import BotUserInfo, UserInfo # type: ignore[import-untyped]
from ...db import BindStatus, create_or_update_bind, trigger from ...db import BindStatus, create_or_update_bind, trigger
from ...utils.avatar import get_avatar from ...utils.avatar import get_avatar
from ...utils.host import HostPage, get_self_netloc from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform
from ...utils.render import Bind, render from ...utils.render import Bind, render
from ...utils.render.schemas.base import Avatar, People from ...utils.render.schemas.base import Avatar, People
from ...utils.screenshot import screenshot from ...utils.screenshot import screenshot
@@ -21,7 +20,7 @@ from .constant import GAME_TYPE
@alc.assign('bind') @alc.assign('bind')
async def _(bot: Bot, event: Event, account: Player, event_session: EventSession, bot_info: UserInfo = BotUserInfo()): # noqa: B008 async def _(nb_user: User, account: Player, event_session: EventSession, bot_info: UserInfo = BotUserInfo()): # noqa: B008
async with trigger( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
@@ -32,8 +31,7 @@ async def _(bot: Bot, event: Event, account: Player, event_session: EventSession
async with get_session() as session: async with get_session() as session:
bind_status = await create_or_update_bind( bind_status = await create_or_update_bind(
session=session, session=session,
chat_platform=get_platform(bot), user=nb_user,
chat_account=event.get_user_id(),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
game_account=user.unique_identifier, game_account=user.unique_identifier,
) )
@@ -41,7 +39,7 @@ async def _(bot: Bot, event: Event, account: Player, event_session: EventSession
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE): if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
async with HostPage( async with HostPage(
await render( await render(
'binding', 'v1/binding',
Bind( Bind(
platform='TETR.IO', platform='TETR.IO',
status='unknown', status='unknown',

View File

@@ -1,42 +1,54 @@
import contextlib
from asyncio import gather from asyncio import gather
from datetime import datetime, timedelta, timezone from collections import defaultdict
from contextlib import suppress
from datetime import date, datetime, timedelta, timezone
from hashlib import md5 from hashlib import md5
from math import ceil, floor from math import ceil, floor
from typing import ClassVar
from urllib.parse import urlunparse from urllib.parse import urlunparse
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from nonebot.adapters import Bot, Event 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.matcher import Matcher
from nonebot_plugin_alconna import At from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage 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_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped] 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_session_orm import get_session_persist_id # type: ignore[import-untyped]
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
from sqlalchemy import select from sqlalchemy import select
from zstandard import ZstdDecompressor
from ...db import query_bind_info, trigger from ...db import query_bind_info, trigger
from ...utils.host import HostPage, get_self_netloc from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform from ...utils.render import render
from ...utils.render import TETRIOInfo, render from ...utils.render.schemas.base import Avatar, Ranking
from ...utils.render.schemas.base import Avatar from ...utils.render.schemas.tetrio_info import Data, Info, Radar, TetraLeague, TetraLeagueHistory
from ...utils.render.schemas.tetrio_info import Data, Radar, Ranking, TetraLeague, TetraLeagueHistory
from ...utils.render.schemas.tetrio_info import User as TemplateUser from ...utils.render.schemas.tetrio_info import User as TemplateUser
from ...utils.screenshot import screenshot from ...utils.screenshot import screenshot
from ...utils.typing import Me from ...utils.typing import Me, Number
from ..constant import CANT_VERIFY_MESSAGE from ..constant import CANT_VERIFY_MESSAGE
from . import alc from . import alc
from .api import Player, User, UserInfoSuccess from .api import Player, User, UserInfoSuccess
from .api.models import TETRIOHistoricalData from .api.models import TETRIOHistoricalData
from .api.schemas.tetra_league import TetraLeagueSuccess
from .api.schemas.user_info import NeverPlayedLeague, NeverRatedLeague, RatedLeague from .api.schemas.user_info import NeverPlayedLeague, NeverRatedLeague, RatedLeague
from .api.schemas.user_records import SoloModeRecord, SoloRecord from .api.schemas.user_records import SoloModeRecord, SoloRecord
from .constant import GAME_TYPE, TR_MAX, TR_MIN from .constant import GAME_TYPE, TR_MAX, TR_MIN
from .model import IORank
UTC = timezone.utc UTC = timezone.utc
driver = get_driver()
@alc.assign('query') @alc.assign('query')
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me, event_session: EventSession): async def _(event: Event, matcher: Matcher, target: At | Me, event_session: EventSession):
async with trigger( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
@@ -46,8 +58,9 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me, event_ses
async with get_session() as session: async with get_session() as session:
bind = await query_bind_info( bind = await query_bind_info(
session=session, session=session,
chat_platform=get_platform(bot), user=await get_user(
chat_account=(target.target if isinstance(target, At) else event.get_user_id()), event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
) )
if bind is None: if bind is None:
@@ -57,8 +70,8 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me, event_ses
user, user_info, user_records = await gather(player.user, player.get_info(), player.get_records()) user, user_info, user_records = await gather(player.user, player.get_info(), player.get_records())
sprint = user_records.data.records.sprint sprint = user_records.data.records.sprint
blitz = user_records.data.records.blitz blitz = user_records.data.records.blitz
with contextlib.suppress(TypeError): with suppress(TypeError):
message += UniMessage.image(raw=await make_query_image(user, user_info, sprint.record, blitz.record)) message.image(raw=await make_query_image(user, user_info, sprint.record, blitz.record))
await message.finish() await message.finish()
message += make_query_text(user_info, sprint, blitz) message += make_query_text(user_info, sprint, blitz)
await message.finish() await message.finish()
@@ -75,7 +88,7 @@ async def _(account: Player, event_session: EventSession):
user, user_info, user_records = await gather(account.user, account.get_info(), account.get_records()) user, user_info, user_records = await gather(account.user, account.get_info(), account.get_records())
sprint = user_records.data.records.sprint sprint = user_records.data.records.sprint
blitz = user_records.data.records.blitz blitz = user_records.data.records.blitz
with contextlib.suppress(TypeError): with suppress(TypeError):
await UniMessage.image(raw=await make_query_image(user, user_info, sprint.record, blitz.record)).finish() await UniMessage.image(raw=await make_query_image(user, user_info, sprint.record, blitz.record)).finish()
await make_query_text(user_info, sprint, blitz).finish() await make_query_text(user_info, sprint, blitz).finish()
@@ -156,7 +169,8 @@ async def query_historical_data(user: User, user_info: UserInfoSuccess) -> list[
if extra is not None: if extra is not None:
historical_data = list(historical_data) historical_data = list(historical_data)
historical_data.append(extra) historical_data.append(extra)
if not historical_data: full_export_data = FullExport.get_data(user.unique_identifier)
if not historical_data and not full_export_data:
return [ return [
Data(record_at=today - forward, tr=user_info.data.user.league.rating), 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), Data(record_at=today.replace(microsecond=1000), tr=user_info.data.user.league.rating),
@@ -168,13 +182,13 @@ async def query_historical_data(user: User, user_info: UserInfoSuccess) -> list[
) )
for i in historical_data for i in historical_data
if isinstance(i.data, UserInfoSuccess) and isinstance(i.data.data.user.league, RatedLeague) 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) histories = sorted(histories, key=lambda x: x.record_at)
for index, value in enumerate(histories): for index, value in enumerate(histories):
# 在历史记录里找有没有今天0点后的数据 # 在历史记录里找有没有今天0点后的数据, 并且至少要有两个数据点
if value.record_at > today: if value.record_at > today and len(histories) >= 2: # noqa: PLR2004
histories = histories[:index] + [ histories = histories[:index] + [
get_specified_point(histories[index - 1], histories[index], today.replace(microsecond=1000)) get_specified_point(histories[index - 1], histories[index], today.replace(microsecond=1000))
] ]
@@ -210,14 +224,14 @@ async def make_query_image(
split_value, offset = get_split(value_max, value_min) split_value, offset = get_split(value_max, value_min)
if sprint is not None: if sprint is not None:
duration = timedelta(milliseconds=sprint.endcontext.final_time).total_seconds() 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 sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
else: else:
sprint_value = 'N/A' sprint_value = 'N/A'
blitz_value = f'{blitz.endcontext.score:,}' if blitz is not None else 'N/A' blitz_value = f'{blitz.endcontext.score:,}' if blitz is not None else 'N/A'
async with HostPage( async with HostPage(
await render( await render(
'tetrio/info', 'v1/tetrio/info',
TETRIOInfo( Info(
user=TemplateUser( user=TemplateUser(
avatar=f'https://tetr.io/user-content/avatars/{user_info.data.user.id}.jpg?rv={user_info.data.user.avatar_revision}' 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 if user_info.data.user.avatar_revision is not None
@@ -294,3 +308,82 @@ def make_query_text(user_info: UserInfoSuccess, sprint: SoloModeRecord, blitz: S
message += f'\nBlitz: {blitz.record.endcontext.score}' message += f'\nBlitz: {blitz.record.endcontext.score}'
message += f' ( #{blitz.rank} )' if blitz.rank is not None else '' message += f' ( #{blitz.rank} )' if blitz.rank is not None else ''
return UniMessage(message) return UniMessage(message)
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)

View File

@@ -1,6 +1,7 @@
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
from nonebot_plugin_alconna import At, on_alconna from nonebot_plugin_alconna import At, on_alconna
from ... import ns
from ...utils.exception import MessageFormatError from ...utils.exception import MessageFormatError
from ...utils.typing import Me from ...utils.typing import Me
from .. import add_default_handlers from .. import add_default_handlers
@@ -61,6 +62,7 @@ alc = on_alconna(
compact=True, compact=True,
fuzzy_match=True, fuzzy_match=True,
), ),
namespace=ns,
), ),
skip_for_unmatch=False, skip_for_unmatch=False,
auto_send_output=True, auto_send_output=True,

View File

@@ -1,16 +1,15 @@
from urllib.parse import urlunparse from urllib.parse import urlunparse
from nonebot.adapters import Bot
from nonebot_plugin_alconna.uniseg import UniMessage from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped] 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_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 nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo # type: ignore[import-untyped]
from ...db import BindStatus, create_or_update_bind, trigger from ...db import BindStatus, create_or_update_bind, trigger
from ...utils.avatar import get_avatar from ...utils.avatar import get_avatar
from ...utils.host import HostPage, get_self_netloc from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform
from ...utils.render import Bind, render from ...utils.render import Bind, render
from ...utils.render.schemas.base import People from ...utils.render.schemas.base import People
from ...utils.screenshot import screenshot from ...utils.screenshot import screenshot
@@ -21,11 +20,11 @@ from .constant import GAME_TYPE
@alc.assign('bind') @alc.assign('bind')
async def _( async def _(
bot: Bot, nb_user: User,
account: Player, account: Player,
event_session: EventSession, event_session: EventSession,
bot_info: UserInfo = BotUserInfo(), # noqa: B008
event_user_info: UserInfo = EventUserInfo(), # noqa: B008 event_user_info: UserInfo = EventUserInfo(), # noqa: B008
bot_info: UserInfo = BotUserInfo(), # noqa: B008
): ):
async with trigger( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
@@ -37,15 +36,14 @@ async def _(
async with get_session() as session: async with get_session() as session:
bind_status = await create_or_update_bind( bind_status = await create_or_update_bind(
session=session, session=session,
chat_platform=get_platform(bot), user=nb_user,
chat_account=event_user_info.user_id,
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
game_account=user.unique_identifier, game_account=user.unique_identifier,
) )
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE): if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
async with HostPage( async with HostPage(
await render( await render(
'binding', 'v1/binding',
Bind( Bind(
platform=GAME_TYPE, platform=GAME_TYPE,
status='unknown', status='unknown',

View File

@@ -1,14 +1,14 @@
from nonebot.adapters import Bot, Event from nonebot.adapters import Event
from nonebot.matcher import Matcher from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped] 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_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 ...db import query_bind_info, trigger
from ...utils.metrics import get_metrics from ...utils.metrics import get_metrics
from ...utils.platform import get_platform
from ...utils.typing import Me from ...utils.typing import Me
from ..constant import CANT_VERIFY_MESSAGE from ..constant import CANT_VERIFY_MESSAGE
from . import alc from . import alc
@@ -18,7 +18,7 @@ from .constant import GAME_TYPE
@alc.assign('query') @alc.assign('query')
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me, event_session: EventSession): async def _(event: Event, matcher: Matcher, target: At | Me, event_session: EventSession):
async with trigger( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
@@ -28,8 +28,9 @@ async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me, event_ses
async with get_session() as session: async with get_session() as session:
bind = await query_bind_info( bind = await query_bind_info(
session=session, session=session,
chat_platform=get_platform(bot), user=await get_user(
chat_account=(target.target if isinstance(target, At) else event.get_user_id()), event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
) )
if bind is None: if bind is None:

View File

@@ -1,6 +1,7 @@
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
from nonebot_plugin_alconna import At, on_alconna from nonebot_plugin_alconna import At, on_alconna
from ... import ns
from ...utils.exception import MessageFormatError from ...utils.exception import MessageFormatError
from ...utils.typing import Me from ...utils.typing import Me
from .. import add_default_handlers from .. import add_default_handlers
@@ -67,6 +68,7 @@ alc = on_alconna(
compact=True, compact=True,
fuzzy_match=True, fuzzy_match=True,
), ),
namespace=ns,
), ),
skip_for_unmatch=False, skip_for_unmatch=False,
auto_send_output=True, auto_send_output=True,

View File

@@ -1,16 +1,15 @@
from urllib.parse import urlunparse from urllib.parse import urlunparse
from nonebot.adapters import Bot
from nonebot_plugin_alconna.uniseg import UniMessage from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped] 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_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 nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo # type: ignore[import-untyped]
from ...db import BindStatus, create_or_update_bind, trigger from ...db import BindStatus, create_or_update_bind, trigger
from ...utils.avatar import get_avatar from ...utils.avatar import get_avatar
from ...utils.host import HostPage, get_self_netloc from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform
from ...utils.render import Bind, render from ...utils.render import Bind, render
from ...utils.render.schemas.base import People from ...utils.render.schemas.base import People
from ...utils.screenshot import screenshot from ...utils.screenshot import screenshot
@@ -21,11 +20,11 @@ from .constant import GAME_TYPE
@alc.assign('bind') @alc.assign('bind')
async def _( async def _(
bot: Bot, nb_user: User,
account: Player, account: Player,
event_session: EventSession, event_session: EventSession,
bot_info: UserInfo = BotUserInfo(), # noqa: B008
event_user_info: UserInfo = EventUserInfo(), # noqa: B008 event_user_info: UserInfo = EventUserInfo(), # noqa: B008
bot_info: UserInfo = BotUserInfo(), # noqa: B008
): ):
async with trigger( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
@@ -37,8 +36,7 @@ async def _(
async with get_session() as session: async with get_session() as session:
bind_status = await create_or_update_bind( bind_status = await create_or_update_bind(
session=session, session=session,
chat_platform=get_platform(bot), user=nb_user,
chat_account=event_user_info.user_id,
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
game_account=user.unique_identifier, game_account=user.unique_identifier,
) )
@@ -46,7 +44,7 @@ async def _(
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE): if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
async with HostPage( async with HostPage(
await render( await render(
'binding', 'v1/binding',
Bind( Bind(
platform=GAME_TYPE, platform=GAME_TYPE,
status='unknown', status='unknown',

View File

@@ -0,0 +1,253 @@
from asyncio import gather
from datetime import timedelta
from http import HTTPStatus
from typing import Literal, NamedTuple
from urllib.parse import urlunparse
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.avatar import get_avatar
from ...utils.exception import RequestError
from ...utils.host import HostPage, get_self_netloc
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('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('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('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(urlunparse(('http', get_self_netloc(), f'/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)

View File

@@ -5,7 +5,10 @@ from nonebot.compat import PYDANTIC_V2
from ..templates import templates_dir from ..templates import templates_dir
from .schemas.bind import Bind from .schemas.bind import Bind
from .schemas.tetrio_info import TETRIOInfo from .schemas.tetrio_info import Info as TETRIOInfo
from .schemas.tetrio_info_v2 import Info as TETRIOInfoV2
from .schemas.top_info import Info as TOPInfo
from .schemas.tos_info import Info as TOSInfo
env = Environment( env = Environment(
loader=FileSystemLoader(templates_dir), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True loader=FileSystemLoader(templates_dir), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True
@@ -13,17 +16,34 @@ env = Environment(
@overload @overload
async def render(render_type: Literal['binding'], data: Bind) -> str: ... async def render(render_type: Literal['v1/binding'], data: Bind) -> str: ...
@overload @overload
async def render(render_type: Literal['tetrio/info'], data: TETRIOInfo) -> str: ... async def render(render_type: Literal['v1/tetrio/info'], data: TETRIOInfo) -> str: ...
async def render(render_type: Literal['binding', 'tetrio/info'], data: Bind | TETRIOInfo) -> str: @overload
async def render(render_type: Literal['v2/tetrio/info'], data: TETRIOInfoV2) -> 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: ...
async def render(
render_type: Literal['v1/binding', 'v1/tetrio/info', 'v2/tetrio/info', 'v1/top/info', 'v1/tos/info'],
data: Bind | TETRIOInfo | TETRIOInfoV2 | TOPInfo | TOSInfo,
) -> str:
if PYDANTIC_V2: 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(
return await env.get_template('index.html').render_async(path=render_type, data=data.json()) 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', 'Bind', 'TETRIOInfo'] __all__ = ['render', 'Bind']

View File

@@ -2,6 +2,8 @@ from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel
from ...typing import Number
class Avatar(BaseModel): class Avatar(BaseModel):
type: Literal['identicon'] type: Literal['identicon']
@@ -11,3 +13,8 @@ class Avatar(BaseModel):
class People(BaseModel): class People(BaseModel):
avatar: str | Avatar avatar: str | Avatar
name: str name: str
class Ranking(BaseModel):
rating: Number
rd: Number

View File

@@ -4,9 +4,9 @@ from typing import Annotated, ClassVar
from nonebot.compat import PYDANTIC_V2 from nonebot.compat import PYDANTIC_V2
from pydantic import BaseModel from pydantic import BaseModel
from ....game_data_processor.io_data_processor.api.typing import Rank from ....games.tetrio.api.typing import Rank
from ...typing import Number from ...typing import Number
from .base import People from .base import People, Ranking
if PYDANTIC_V2: if PYDANTIC_V2:
from pydantic import PlainSerializer from pydantic import PlainSerializer
@@ -20,11 +20,6 @@ class User(People):
bio: str | None bio: str | None
class Ranking(BaseModel):
rating: Number
rd: Number
class TetraLeague(BaseModel): class TetraLeague(BaseModel):
rank: Rank rank: Rank
tr: Number tr: Number
@@ -62,7 +57,7 @@ class Radar(BaseModel):
ge: Number ge: Number
class TETRIOInfo(BaseModel): class Info(BaseModel):
user: User user: User
ranking: Ranking ranking: Ranking
tetra_league: TetraLeague tetra_league: TetraLeague

View File

@@ -0,0 +1,84 @@
from datetime import datetime
from pydantic import BaseModel
from ....games.tetrio.api.typing import Rank
from ...typing import Number
from .base import Avatar
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[str]
xp: Number
playtime: str
join_at: datetime | None
class Statistic(BaseModel):
total: int
wins: int
class TetraLeague(BaseModel):
rank: Rank
highest_rank: Rank
tr: Number
glicko: Number
rd: Number
global_rank: int
country_rank: int
pps: Number
apm: Number
adpm: Number
vs: Number
adpl: Number
statistic: Statistic
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 Zen(BaseModel):
score: int
level: int
class Info(BaseModel):
user: User
tetra_league: TetraLeague | None
statistic: Statistic | None
sprint: Sprint | None
blitz: Blitz | None
zen: Zen

View 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

View 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

View File

@@ -1,38 +1,44 @@
from asyncio import sleep 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 datetime import timedelta
from functools import wraps from functools import wraps
from typing import TypeVar, cast from typing import Any, ParamSpec, TypeVar
from nonebot.log import logger from nonebot.log import logger
from nonebot_plugin_alconna.uniseg import SerializeFailed, UniMessage
T = TypeVar('T') T = TypeVar('T')
P = ParamSpec('P')
def retry( def retry(
max_attempts: int = 3, max_attempts: int = 3,
exception_type: type[BaseException] | tuple[type[BaseException], ...] = Exception, exception_type: type[BaseException] | tuple[type[BaseException], ...] = Exception,
delay: timedelta | None = None, delay: timedelta | None = None,
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]: reply: str | UniMessage | None = None,
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: ) -> 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) @wraps(func)
async def wrapper(*args, **kwargs) -> T: # noqa: ANN002, ANN003 async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
attempts = 0 for i in range(max_attempts + 1):
while attempts < 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: try:
return await func(*args, **kwargs) 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) logger.exception(e)
attempts += 1 if delay is not None:
if attempts <= max_attempts: await sleep(delay.total_seconds())
if delay is not None: return await func(*args, **kwargs)
await sleep(delay.total_seconds())
logger.debug(f'Retrying: {func.__name__} ({attempts}/{max_attempts})')
continue
raise
msg = 'Unexpectedly reached the end of the retry loop'
raise RuntimeError(msg)
return cast(Callable[..., Awaitable[T]], wrapper) return wrapper
return decorator return decorator

View File

@@ -1,11 +1,15 @@
from playwright.async_api import TimeoutError
from .browser import BrowserManager from .browser import BrowserManager
from .retry import retry
@retry(exception_type=TimeoutError, reply='截图失败, 重试中')
async def screenshot(url: str) -> bytes: async def screenshot(url: str) -> bytes:
browser = await BrowserManager.get_browser() browser = await BrowserManager.get_browser()
async with ( 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.goto(url)
await page.wait_for_load_state('networkidle') await page.wait_for_load_state('networkidle', timeout=5000)
return await page.screenshot(full_page=True, type='png') return await page.locator('id=content').screenshot(timeout=5000, type='png')

1380
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = 'nonebot-plugin-tetris-stats' name = 'nonebot-plugin-tetris-stats'
version = '1.2.5' version = '1.2.11'
description = '一款基于 NoneBot2 的用于查询 Tetris 相关游戏数据的插件' description = '一款基于 NoneBot2 的用于查询 Tetris 相关游戏数据的插件'
authors = ['scdhh <wallfjjd@gmail.com>'] authors = ['scdhh <wallfjjd@gmail.com>']
readme = 'README.md' readme = 'README.md'
@@ -17,6 +17,7 @@ nonebot-plugin-localstore = "^0.6.0"
nonebot-plugin-orm = ">=0.1.1,<0.8.0" nonebot-plugin-orm = ">=0.1.1,<0.8.0"
nonebot-plugin-session = "^0.3.1" nonebot-plugin-session = "^0.3.1"
nonebot-plugin-session-orm = "^0.2.0" nonebot-plugin-session-orm = "^0.2.0"
nonebot-plugin-user = "^0.2.0"
nonebot-plugin-userinfo = "^0.2.4" nonebot-plugin-userinfo = "^0.2.4"
aiocache = "^0.12.2" aiocache = "^0.12.2"
aiofiles = "^23.2.1" aiofiles = "^23.2.1"
@@ -38,14 +39,16 @@ types-lxml = "^2024.2.9"
types-pillow = "^10.2.0.20240423" types-pillow = "^10.2.0.20240423"
types-ujson = '^5.9.0' types-ujson = '^5.9.0'
pandas-stubs = '>=1.5.2,<3.0.0' pandas-stubs = '>=1.5.2,<3.0.0'
nonebot-plugin-orm = { extras = ["default"], version = ">=0.3,<0.8" } nonebot2 = { extras = ["all"], version = "^2.3.0" }
nonebot-adapter-discord = "^0.1.3" nonebot-adapter-discord = "^0.1.3"
nonebot-adapter-kaiheila = "^0.3.4" nonebot-adapter-kaiheila = "^0.3.4"
nonebot-adapter-onebot = "^2.4.1" nonebot-adapter-onebot = "^2.4.1"
nonebot-adapter-qq = "^1.4.4" nonebot-adapter-qq = "^1.4.4"
nonebot-adapter-satori = "^0.11.4" nonebot-adapter-satori = "^0.11.4"
nonebot-plugin-orm = { extras = ["default"], version = ">=0.3,<0.8" }
[tool.poetry.group.debug.dependencies] [tool.poetry.group.debug.dependencies]
memory-profiler = "^0.61.0"
objprint = '^0.2.2' objprint = '^0.2.2'
viztracer = "^0.16.2" viztracer = "^0.16.2"
@@ -126,3 +129,4 @@ quote-style = 'single'
[tool.nonebot] [tool.nonebot]
plugins = ['nonebot_plugin_tetris_stats'] plugins = ['nonebot_plugin_tetris_stats']
# plugins = ['test_user']