Compare commits

...

48 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
b8cf10b45d 🔖 1.2.5 2024-05-15 04:43:30 +08:00
4ec5c3bde1 🐛 修复 TETR.IO 大写用户名查询失败 2024-05-15 04:42:27 +08:00
270b953bc9 🔖 1.2.4 2024-05-15 04:22:58 +08:00
13bd0da592 🐛 修复去重添加没有正确工作的bug 2024-05-15 04:22:19 +08:00
9545f0b5d0 🔖 1.2.3 2024-05-14 17:26:07 +08:00
12f320cbb4 🐛 修复 PydanticType 过早加载导致获取不到子类的bug 2024-05-14 17:25:42 +08:00
54 changed files with 1845 additions and 534 deletions

View File

@@ -5,8 +5,14 @@ require('nonebot_plugin_alconna')
require('nonebot_plugin_apscheduler')
require('nonebot_plugin_localstore')
require('nonebot_plugin_orm')
require('nonebot_plugin_session')
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
@@ -21,5 +27,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

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

@@ -1,3 +1,4 @@
from asyncio import Lock
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime, timezone
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, TypeVar, overload
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 CommandType, GameType
@@ -15,9 +17,9 @@ from .models import Bind, TriggerHistoricalData
UTC = timezone.utc
if TYPE_CHECKING:
from ..game_data_processor.io_data_processor.api.models import TETRIOHistoricalData
from ..game_data_processor.top_data_processor.api.models import TOPHistoricalData
from ..game_data_processor.tos_data_processor.api.models import TOSHistoricalData
from ..games.tetrio.api.models import TETRIOHistoricalData
from ..games.top.api.models import TOPHistoricalData
from ..games.tos.api.models import TOSHistoricalData
class BindStatus(Enum):
@@ -27,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,
)
@@ -72,9 +65,11 @@ async def create_or_update_bind(
T = TypeVar('T', 'TETRIOHistoricalData', 'TOPHistoricalData', 'TOSHistoricalData')
lock = Lock()
async def anti_duplicate_add(cls: type[T], model: T) -> None:
async with get_session() as session:
async with lock, get_session() as session:
result = (
await session.scalars(
select(cls)
@@ -88,8 +83,8 @@ async def anti_duplicate_add(cls: type[T], model: T) -> None:
if i.data == model.data:
logger.debug('Anti duplicate successfully')
return
session.add(model)
await session.commit()
session.add(model)
await session.commit()
@asynccontextmanager

View File

@@ -23,9 +23,8 @@ class PydanticType(TypeDecorator):
*args: Any,
**kwargs: Any,
):
for i in get_model:
models.update(i())
self.models = models
self.get_model = get_model
self._models = models
super().__init__(*args, **kwargs)
if PYDANTIC_V2:
@@ -56,11 +55,18 @@ class PydanticType(TypeDecorator):
...
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]

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
from . import ( # noqa: F401, E402
io_data_processor,
top_data_processor,
tos_data_processor,
)
from . import tetrio, top, tos # noqa: F401, E402
@run_postprocessor

View File

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

View File

@@ -43,7 +43,7 @@ class Player:
if self.user_id is not None:
return self.user_id
if self.user_name is not None:
return self.user_name
return self.user_name.lower()
msg = 'Invalid user'
raise ValueError(msg)

View File

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

View File

@@ -1,17 +1,16 @@
from hashlib import md5
from urllib.parse import urlunparse
from nonebot.adapters import Bot, Event
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.avatar import get_avatar
from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform
from ...utils.render import Bind, render
from ...utils.render.schemas.base import Avatar, People
from ...utils.screenshot import screenshot
@@ -21,7 +20,7 @@ from .constant import GAME_TYPE
@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(
session_persist_id=await get_session_persist_id(event_session),
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:
bind_status = await create_or_update_bind(
session=session,
chat_platform=get_platform(bot),
chat_account=event.get_user_id(),
user=nb_user,
game_platform=GAME_TYPE,
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):
async with HostPage(
await render(
'binding',
'v1/binding',
Bind(
platform='TETR.IO',
status='unknown',

View File

@@ -1,42 +1,54 @@
import contextlib
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 math import ceil, floor
from typing import ClassVar
from urllib.parse import urlunparse
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_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 get_user # type: ignore[import-untyped]
from sqlalchemy import select
from zstandard import ZstdDecompressor
from ...db import query_bind_info, trigger
from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform
from ...utils.render import TETRIOInfo, render
from ...utils.render.schemas.base import Avatar
from ...utils.render.schemas.tetrio_info import Data, Radar, Ranking, TetraLeague, TetraLeagueHistory
from ...utils.render import render
from ...utils.render.schemas.base import Avatar, Ranking
from ...utils.render.schemas.tetrio_info import Data, Info, Radar, TetraLeague, TetraLeagueHistory
from ...utils.render.schemas.tetrio_info import User as TemplateUser
from ...utils.screenshot import screenshot
from ...utils.typing import Me
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 .api.schemas.user_records import SoloModeRecord, SoloRecord
from .constant import GAME_TYPE, TR_MAX, TR_MIN
from .model import IORank
UTC = timezone.utc
driver = get_driver()
@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(
session_persist_id=await get_session_persist_id(event_session),
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:
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()),
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:
@@ -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())
sprint = user_records.data.records.sprint
blitz = user_records.data.records.blitz
with contextlib.suppress(TypeError):
message += UniMessage.image(raw=await make_query_image(user, user_info, sprint.record, blitz.record))
with suppress(TypeError):
message.image(raw=await make_query_image(user, user_info, sprint.record, blitz.record))
await message.finish()
message += make_query_text(user_info, sprint, blitz)
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())
sprint = user_records.data.records.sprint
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 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:
historical_data = list(historical_data)
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 [
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),
@@ -168,13 +182,13 @@ async def query_historical_data(user: User, user_info: UserInfoSuccess) -> list[
)
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:
# 在历史记录里找有没有今天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))
]
@@ -210,14 +224,14 @@ async def make_query_image(
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
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.endcontext.score:,}' if blitz is not None else 'N/A'
async with HostPage(
await render(
'tetrio/info',
TETRIOInfo(
'v1/tetrio/info',
Info(
user=TemplateUser(
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
@@ -294,3 +308,82 @@ def make_query_text(user_info: UserInfoSuccess, sprint: SoloModeRecord, blitz: S
message += f'\nBlitz: {blitz.record.endcontext.score}'
message += f' ( #{blitz.rank} )' if blitz.rank is not None else ''
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 nonebot_plugin_alconna import At, on_alconna
from ... import ns
from ...utils.exception import MessageFormatError
from ...utils.typing import Me
from .. import add_default_handlers
@@ -61,6 +62,7 @@ alc = on_alconna(
compact=True,
fuzzy_match=True,
),
namespace=ns,
),
skip_for_unmatch=False,
auto_send_output=True,

View File

@@ -1,16 +1,15 @@
from urllib.parse import urlunparse
from nonebot.adapters import Bot
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.avatar import get_avatar
from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform
from ...utils.render import Bind, render
from ...utils.render.schemas.base import People
from ...utils.screenshot import screenshot
@@ -21,11 +20,11 @@ from .constant import GAME_TYPE
@alc.assign('bind')
async def _(
bot: Bot,
nb_user: User,
account: Player,
event_session: EventSession,
bot_info: UserInfo = BotUserInfo(), # noqa: B008
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),
@@ -37,15 +36,14 @@ async def _(
async with get_session() as session:
bind_status = await create_or_update_bind(
session=session,
chat_platform=get_platform(bot),
chat_account=event_user_info.user_id,
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(
'binding',
'v1/binding',
Bind(
platform=GAME_TYPE,
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_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.platform import get_platform
from ...utils.typing import Me
from ..constant import CANT_VERIFY_MESSAGE
from . import alc
@@ -18,7 +18,7 @@ from .constant import GAME_TYPE
@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(
session_persist_id=await get_session_persist_id(event_session),
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:
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()),
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:

View File

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

View File

@@ -1,16 +1,15 @@
from urllib.parse import urlunparse
from nonebot.adapters import Bot
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.avatar import get_avatar
from ...utils.host import HostPage, get_self_netloc
from ...utils.platform import get_platform
from ...utils.render import Bind, render
from ...utils.render.schemas.base import People
from ...utils.screenshot import screenshot
@@ -21,11 +20,11 @@ from .constant import GAME_TYPE
@alc.assign('bind')
async def _(
bot: Bot,
nb_user: User,
account: Player,
event_session: EventSession,
bot_info: UserInfo = BotUserInfo(), # noqa: B008
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),
@@ -37,8 +36,7 @@ async def _(
async with get_session() as session:
bind_status = await create_or_update_bind(
session=session,
chat_platform=get_platform(bot),
chat_account=event_user_info.user_id,
user=nb_user,
game_platform=GAME_TYPE,
game_account=user.unique_identifier,
)
@@ -46,7 +44,7 @@ async def _(
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
async with HostPage(
await render(
'binding',
'v1/binding',
Bind(
platform=GAME_TYPE,
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 .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(
loader=FileSystemLoader(templates_dir), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True
@@ -13,17 +16,34 @@ env = Environment(
@overload
async def render(render_type: Literal['binding'], data: Bind) -> str: ...
async def render(render_type: Literal['v1/binding'], data: Bind) -> str: ...
@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:
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())
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', 'Bind', 'TETRIOInfo']
__all__ = ['render', 'Bind']

View File

@@ -2,6 +2,8 @@ from typing import Literal
from pydantic import BaseModel
from ...typing import Number
class Avatar(BaseModel):
type: Literal['identicon']
@@ -11,3 +13,8 @@ class Avatar(BaseModel):
class People(BaseModel):
avatar: str | Avatar
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 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 .base import People
from .base import People, Ranking
if PYDANTIC_V2:
from pydantic import PlainSerializer
@@ -20,11 +20,6 @@ class User(People):
bio: str | None
class Ranking(BaseModel):
rating: Number
rd: Number
class TetraLeague(BaseModel):
rank: Rank
tr: Number
@@ -62,7 +57,7 @@ class Radar(BaseModel):
ge: Number
class TETRIOInfo(BaseModel):
class Info(BaseModel):
user: User
ranking: Ranking
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 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
msg = 'Unexpectedly reached the end of the retry loop'
raise RuntimeError(msg)
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

View File

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