🎨 重命名一些模块

This commit is contained in:
2024-05-16 05:59:18 +08:00
parent e8539c15cc
commit 3ef7605e11
42 changed files with 6 additions and 10 deletions

View File

@@ -0,0 +1,79 @@
from arclet.alconna import Alconna, AllParam, Arg, ArgFlag, Args, CommandMeta, Option
from nonebot_plugin_alconna import At, on_alconna
from ...utils.exception import MessageFormatError
from ...utils.typing import Me
from .. import add_default_handlers
from ..constant import BIND_COMMAND, QUERY_COMMAND
from .api import Player
from .constant import USER_NAME
def get_player(teaid_or_name: str) -> Player | MessageFormatError:
if (
teaid_or_name.startswith(('onebot-', 'qqguild-', 'kook-', 'discord-'))
and teaid_or_name.split('-', maxsplit=1)[1].isdigit()
):
return Player(teaid=teaid_or_name, trust=True)
if USER_NAME.match(teaid_or_name) and not teaid_or_name.isdigit() and 2 <= len(teaid_or_name) <= 18: # noqa: PLR2004
return Player(user_name=teaid_or_name, trust=True)
return MessageFormatError('用户名/ID不合法')
alc = on_alconna(
Alconna(
'茶服',
Option(
BIND_COMMAND[0],
Args(
Arg(
'account',
get_player,
notice='茶服 用户名 / TeaID',
flags=[ArgFlag.HIDDEN],
)
),
alias=BIND_COMMAND[1:],
compact=True,
dest='bind',
help_text='绑定 茶服 账号',
),
Option(
QUERY_COMMAND[0],
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 | 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
get_player,
notice='茶服 用户名 / TeaID',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
# 如果放在一个 Union Args 里, 验证顺序不能保证, 可能出错
),
alias=QUERY_COMMAND[1:],
compact=True,
dest='query',
help_text='查询 茶服 游戏信息',
),
Arg('other', AllParam, flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL]),
meta=CommandMeta(
description='查询 TetrisOnline茶服 的信息',
example='茶服查我',
compact=True,
fuzzy_match=True,
),
),
skip_for_unmatch=False,
auto_send_output=True,
aliases={'tos', 'TOS'},
)
from . import bind, query # noqa: E402, F401
add_default_handlers(alc)

View File

@@ -0,0 +1,3 @@
from .player import Player
__all__ = ['Player']

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from typing import Literal
from nonebot_plugin_orm import Model
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
from ....db.models import PydanticType
from .schemas.user_info import UserInfoSuccess
from .schemas.user_profile import UserProfile
class TOSHistoricalData(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(init=False, primary_key=True)
user_unique_identifier: Mapped[str] = mapped_column(String(24), index=True)
api_type: Mapped[Literal['User Info', 'User Profile']] = mapped_column(String(16), index=True)
data: Mapped[UserInfoSuccess | UserProfile] = mapped_column(
PydanticType(get_model=[], models={UserInfoSuccess, UserProfile})
)
update_time: Mapped[datetime] = mapped_column(DateTime, index=True)

View File

@@ -0,0 +1,128 @@
from datetime import datetime, timezone
from typing import overload
from urllib.parse import urlencode
from httpx import TimeoutException
from nonebot.compat import type_validate_json
from ....db import anti_duplicate_add
from ....utils.exception import RequestError
from ....utils.request import Request, splice_url
from ..constant import BASE_URL, USER_NAME
from .models import TOSHistoricalData
from .schemas.user import User
from .schemas.user_info import UserInfo, UserInfoSuccess
from .schemas.user_profile import UserProfile
UTC = timezone.utc
class Player:
@overload
def __init__(self, *, teaid: str, trust: bool = False): ...
@overload
def __init__(self, *, user_name: str, trust: bool = False): ...
def __init__(self, *, teaid: str | None = None, user_name: str | None = None, trust: bool = False):
self.teaid = teaid
self.user_name = user_name
if not trust:
if self.teaid is not None:
if (
not self.teaid.startswith(('onebot-', 'qqguild-', 'kook-', 'discord-'))
or not self.teaid.split('-', maxsplit=1)[1].isdigit()
):
msg = 'Invalid teaid'
raise ValueError(msg)
elif self.user_name is not None:
if not USER_NAME.match(self.user_name) or self.user_name.isdigit() or 2 > len(self.user_name) > 18: # noqa: PLR2004
msg = 'Invalid user name'
raise ValueError(msg)
else:
msg = 'Invalid user'
raise ValueError(msg)
self.__user: User | None = None
self._user_info: UserInfoSuccess | None = None
self._user_profile: dict[str, UserProfile] = {}
@property
async def user(self) -> User:
if self.__user is None:
user_info = await self.get_info()
self.__user = User(teaid=user_info.data.teaid, name=user_info.data.name)
self.teaid = user_info.data.teaid
self.user_name = user_info.data.name
return self.__user
async def get_info(self) -> UserInfoSuccess:
"""获取用户信息"""
if self._user_info is None:
if self.teaid is not None:
url = [
splice_url(
[
i,
'getTeaIdInfo',
f'?{urlencode({"teaId":self.teaid})}',
]
)
for i in BASE_URL
]
else:
url = [
splice_url(
[
i,
'getUsernameInfo',
f'?{urlencode({"username":self.user_name})}',
]
)
for i in BASE_URL
]
raw_user_info = await Request.failover_request(url, failover_code=[502], failover_exc=(TimeoutException,))
user_info: UserInfo = type_validate_json(UserInfo, raw_user_info) # type: ignore[arg-type]
if not isinstance(user_info, UserInfoSuccess):
msg = f'用户信息请求错误:\n{user_info.error}'
raise RequestError(msg)
self._user_info = user_info
await anti_duplicate_add(
TOSHistoricalData,
TOSHistoricalData(
user_unique_identifier=(await self.user).unique_identifier,
api_type='User Info',
data=user_info,
update_time=datetime.now(UTC),
),
)
return self._user_info
async def get_profile(self, other_parameter: dict[str, str | bytes] | None = None) -> UserProfile:
"""获取用户数据"""
if other_parameter is None:
other_parameter = {}
params = urlencode(dict(sorted(other_parameter.items())))
if self._user_profile.get(params) is None:
raw_user_profile = await Request.failover_request(
[
splice_url(
[
i,
'getProfile',
f'?{urlencode({"id":self.teaid or self.user_name,**other_parameter})}',
]
)
for i in BASE_URL
],
failover_code=[502],
failover_exc=(TimeoutException,),
)
self._user_profile[params] = type_validate_json(UserProfile, raw_user_profile)
await anti_duplicate_add(
TOSHistoricalData,
TOSHistoricalData(
user_unique_identifier=(await self.user).unique_identifier,
api_type='User Profile',
data=self._user_profile[params],
update_time=datetime.now(UTC),
),
)
return self._user_profile[params]

View File

@@ -0,0 +1,18 @@
from typing import Literal
from typing_extensions import override
from ....schemas import BaseUser
from ...constant import GAME_TYPE
class User(BaseUser):
platform: Literal['TOS'] = GAME_TYPE
teaid: str
name: str
@property
@override
def unique_identifier(self) -> str:
return self.teaid

View File

@@ -0,0 +1,89 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class PeriodMatch(BaseModel):
name: str
teaid: str = Field(..., alias='teaId')
rating: str
rd: str
start_time: datetime = Field(..., alias='startTime')
end_time: datetime = Field(..., alias='endTime')
win: str
lose: str
score: str
class UserDataTotalItem(BaseModel):
time_map: str = Field(..., alias='timeMap')
pieces_map: str = Field(..., alias='piecesMap')
clear_lines_map: str = Field(..., alias='clearLinesMap')
attacks_map: str = Field(..., alias='attacksMap')
dig_map: str = Field(..., alias='digMap')
send_map: str = Field(..., alias='sendMap')
rise_map: str = Field(..., alias='riseMap')
offset_map: str = Field(..., alias='offsetMap')
receive_map: str = Field(..., alias='receiveMap')
games_map: str = Field(..., alias='gamesMap')
tetris_map: str = Field(..., alias='tetrisMap')
combo_map: str = Field(..., alias='comboMap')
tspin_map: str = Field(..., alias='tspinMap')
b2b_map: str = Field(..., alias='b2bMap')
perfect_clear_map: str = Field(..., alias='perfectClearMap')
time_no_map: str = Field(..., alias='timeNoMap')
pieces_no_map: str = Field(..., alias='piecesNoMap')
clear_lines_no_map: str = Field(..., alias='clearLinesNoMap')
attacks_no_map: str = Field(..., alias='attacksNoMap')
dig_no_map: str = Field(..., alias='digNoMap')
send_no_map: str = Field(..., alias='sendNoMap')
rise_no_map: str = Field(..., alias='riseNoMap')
offset_no_map: str = Field(..., alias='offsetNoMap')
receive_no_map: str = Field(..., alias='receiveNoMap')
games_no_map: str = Field(..., alias='gamesNoMap')
tetris_no_map: str = Field(..., alias='tetrisNoMap')
combo_no_map: str = Field(..., alias='comboNoMap')
tspin_no_map: str = Field(..., alias='tspinNoMap')
b2b_no_map: str = Field(..., alias='b2bNoMap')
perfect_clear_no_map: str = Field(..., alias='perfectClearNoMap')
class Data(BaseModel):
teaid: str = Field(..., alias='teaId')
name: str
total_exp: str = Field(..., alias='totalExp')
ranking: str
ranked_games: str = Field(..., alias='rankedGames')
rating_now: str = Field(..., alias='ratingNow')
rd_now: str = Field(..., alias='rdNow')
vol_now: str = Field(..., alias='volNow')
rating_last: str = Field(..., alias='ratingLast')
rd_last: str = Field(..., alias='rdLast')
vol_last: str = Field(..., alias='volLast')
period_matches: list[PeriodMatch] = Field(..., alias='periodMatches')
user_data_total: list[UserDataTotalItem] = Field(..., alias='userDataTotal')
ranking_items: str = Field(..., alias='rankingItems')
ranking_game_items: str = Field(..., alias='rankingGameItems')
training_level: str = Field(..., alias='trainingLevel')
training_wins: str = Field(..., alias='trainingWins')
pb_sprint: str = Field(..., alias='PBSprint')
pb_marathon: str = Field(..., alias='PBMarathon')
pb_challenge: str = Field(..., alias='PBChallenge')
register_date: datetime = Field(..., alias='registerDate')
last_login_date: datetime = Field(..., alias='lastLoginDate')
class UserInfoSuccess(BaseModel):
code: int
success: Literal[True]
data: Data
class FailedModel(BaseModel):
code: int
success: Literal[False]
error: str
UserInfo = UserInfoSuccess | FailedModel

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel
class Data(BaseModel):
idmultiplayergameresult: int
iduser: str
teaid: str
time: int
clear_lines: int
attack: int
send: int
offset: int
receive: int
rise: int
dig: int
pieces: int
max_combo: int
pc_count: int
place: int
num_players: int
fumen_code: Literal['0', '1'] # wtf
rule_set: str
garbage: str
idmultiplayergame: int
datetime: datetime
class UserProfile(BaseModel):
code: int
success: bool
data: list[Data]

View File

@@ -0,0 +1,66 @@
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_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
from . import alc
from .api import Player
from .constant import GAME_TYPE
@alc.assign('bind')
async def _(
bot: Bot,
account: Player,
event_session: EventSession,
bot_info: UserInfo = BotUserInfo(), # noqa: B008
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='bind',
command_args=[],
):
user = await account.user
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,
game_platform=GAME_TYPE,
game_account=user.unique_identifier,
)
user_info = await account.get_info()
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
async with HostPage(
await render(
'binding',
Bind(
platform=GAME_TYPE,
status='unknown',
user=People(
avatar=await get_avatar(event_user_info, 'Data URI', None), name=user_info.data.name
),
bot=People(
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
name=bot_info.user_remark or bot_info.user_displayname or bot_info.user_name,
),
command='茶服查我',
),
)
) as page_hash:
await UniMessage.image(
raw=await screenshot(urlunparse(('http', get_self_netloc(), f'/host/{page_hash}.html', '', '', '')))
).finish()

View File

@@ -0,0 +1,16 @@
from re import compile
from typing import Literal
GAME_TYPE: Literal['TOS'] = 'TOS'
BASE_URL = {
'https://teatube.cn:8888/',
'http://cafuuchino1.studio26f.org:19970',
'http://cafuuchino2.studio26f.org:19970',
'http://cafuuchino3.studio26f.org:19970',
'http://cafuuchino4.studio26f.org:19970',
}
USER_NAME = compile(
r'^(?!\.)(?!com[0-9]$)(?!con$)(?!lpt[0-9]$)(?!nul$)(?!prn$)[^\-][^\+][^\|\*\?\\\s\!:<>/$"]*[^\.\|\*\?\\\s\!:<>/$"]+$'
)

View File

@@ -0,0 +1,170 @@
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)