mirror of
https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
synced 2026-03-05 05:36:54 +08:00
Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9806050e33 | |||
| 461327749f | |||
|
|
208582d313 | ||
|
|
90f655259d | ||
|
|
2ef400ca28 | ||
|
|
616a64bd6a | ||
|
|
bb8943d4c3 | ||
| 5e45db8cf5 | |||
|
|
2020deadac | ||
|
|
ce7bce6e20 | ||
|
|
d4b690f682 | ||
| c4bde71546 | |||
|
|
f56f993c69 | ||
|
|
cfcda6f597 | ||
|
|
96f5d4559d | ||
|
|
23f412b4f4 | ||
|
|
25b0d2bcdc | ||
|
|
a116f9901c | ||
|
|
82befd631e | ||
| 232389dd07 | |||
|
|
ce81015406 | ||
|
|
3d7b903f59 | ||
|
|
c5d499434e | ||
|
|
194fed24c9 | ||
|
|
1173c39e7a | ||
|
|
dfb19f150a | ||
|
|
7555297e1e | ||
|
|
587aa4a0de | ||
| 08a1a427b4 | |||
|
|
d4e91c8521 | ||
| dbde1181ce | |||
|
|
86fe4f0766 | ||
|
|
381f2505d6 | ||
| b3a77f5296 | |||
| 274f30f82a | |||
| efb1ddb260 | |||
| 7e3f49bc9e | |||
| 665772ed66 | |||
|
|
44fda8a19e | ||
|
|
6921bf4e37 | ||
|
|
c3c97c1c8b | ||
|
|
1d33872c9b | ||
|
|
b2d5a1e729 | ||
|
|
a0fd9eaed3 | ||
|
|
593723aa76 | ||
|
|
73d97d8458 | ||
|
|
d6f11655c1 | ||
|
|
376e85e36e | ||
|
|
45116a1418 | ||
|
|
a42d3e3837 | ||
|
|
b333c54c7d | ||
|
|
8840402d2f | ||
|
|
e8b64b23f5 | ||
|
|
40762a3180 | ||
|
|
a2c6ad8328 | ||
|
|
c7d93069ef | ||
| 4b514df2db | |||
| 47c83be1b5 | |||
| 6c0e092f51 | |||
| 04b9cd9eae | |||
|
|
61b5fcb137 | ||
| c0540769c8 | |||
|
|
0e19943046 | ||
|
|
7e1d2e8cb0 | ||
|
|
8931cfb5a7 | ||
|
|
ea8a18c1b1 | ||
| ef1acb0f16 | |||
|
|
f7bb667254 | ||
|
|
fa94c1beeb | ||
|
|
4e1e91a977 | ||
|
|
0f6a00819b | ||
|
|
b56385b412 | ||
|
|
7eea235f52 | ||
|
|
8a06b572ed | ||
| 6867245be3 | |||
|
|
eebff0a8ad | ||
| 74eef41506 | |||
| 5eb4771259 | |||
| 7a3a4d936d | |||
|
|
03ca7c4486 | ||
|
|
b043d1da59 | ||
|
|
c9659201b1 | ||
|
|
617d3ec658 | ||
|
|
57a1992675 | ||
|
|
8d1d2f329e | ||
|
|
fa6cbd5c6d | ||
|
|
9f0f0b87f4 | ||
|
|
96c298b1b8 | ||
|
|
df5ced235d | ||
|
|
af83c7a2d9 | ||
|
|
bc41a91034 | ||
|
|
d97291d1bc | ||
| 5b56de9de1 | |||
| 0898a81331 | |||
|
|
d464059c0a | ||
|
|
6ea8b9328c | ||
|
|
773ff5545c | ||
|
|
94710b938b | ||
| ec09bb734d | |||
|
|
9e9a642847 | ||
|
|
04e0b14e72 | ||
|
|
20ce9c64be | ||
|
|
8af07bf031 | ||
|
|
3a904f67ad | ||
|
|
fc9b751ac4 | ||
| cb4c6b96f0 | |||
|
|
25c3777c0f | ||
|
|
193fd1da2a | ||
|
|
2cd609dd40 | ||
| a206098805 | |||
|
|
d493ba5f0d | ||
|
|
581d1f9674 | ||
|
|
01c99e8a8c | ||
|
|
eb3f4bea04 | ||
|
|
ebbbd68b05 | ||
|
|
10e0eb815e | ||
|
|
a57b04e181 | ||
|
|
cc2e71f1a5 | ||
|
|
3384263bb2 | ||
|
|
68f210dc4f | ||
|
|
00a85fe3e9 | ||
|
|
a10a7584ae | ||
|
|
95aac5e321 | ||
| 89d8c938e2 | |||
| 84db42f1ce | |||
|
|
0a660922bb | ||
|
|
56bc98cc79 | ||
|
|
be61683b51 | ||
| ccd5706a95 | |||
| b69240caa5 | |||
| 49d00f4d0e | |||
| 389a850025 | |||
| 20dcc2bc3d | |||
| 606dddbca2 | |||
| f509b03cd0 | |||
| 6293d088db | |||
| 97e2abed78 | |||
|
|
5ea3fcb234 | ||
|
|
ca33ba1310 | ||
| 3629a2ff4a | |||
| a2108c9776 |
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
target-branch: "main"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
27
.github/workflows/Release.yml
vendored
27
.github/workflows/Release.yml
vendored
@@ -3,7 +3,7 @@ name: Release CI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -12,24 +12,25 @@ jobs:
|
||||
id-token: write
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
shell: bash
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
name: Setup UV
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: "poetry"
|
||||
enable-cache: true
|
||||
|
||||
- run: poetry install
|
||||
- name: 'Set up Python'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: '.python-version'
|
||||
|
||||
- run: uv sync
|
||||
shell: bash
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(poetry version -s)" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=$(uvx pdm show --version)" >> $GITHUB_OUTPUT
|
||||
echo "TAG_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -38,10 +39,10 @@ jobs:
|
||||
run: exit 1
|
||||
|
||||
- name: Build Package
|
||||
run: poetry build
|
||||
run: uv build
|
||||
|
||||
- name: Publish Package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
run: uv publish
|
||||
|
||||
- name: Publish Package to GitHub Release
|
||||
run: gh release create ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl -t "🔖 ${{ steps.version.outputs.TAG_NAME }}" --generate-notes
|
||||
|
||||
58
.github/workflows/Test.yml
vendored
Normal file
58
.github/workflows/Test.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Code Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# python-version: ['3.10', '3.11', '3.12', '3.13']
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
fail-fast: false
|
||||
env:
|
||||
OS: ${{ matrix.os }}
|
||||
PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ env.PYTHON_VERSION }}_${{ env.OS }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
uv python pin ${{ env.PYTHON_VERSION }}
|
||||
uv sync --group test
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest --cov=nonebot_plugin_tetris_stats --cov-report xml
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
env_vars: OS,PYTHON_VERSION
|
||||
|
||||
check:
|
||||
if: always()
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Decide whether the needed jobs succeeded or failed
|
||||
uses: re-actors/alls-green@223e4bb7a751b91f43eda76992bcfbf23b8b0302
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
42
.github/workflows/TypeCheck.yml
vendored
42
.github/workflows/TypeCheck.yml
vendored
@@ -1,27 +1,33 @@
|
||||
name: TypeCheck
|
||||
|
||||
on:
|
||||
push:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
Mypy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
TypeCheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
shell: bash
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
name: Setup UV
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'poetry'
|
||||
- name: 'Set up Python'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: '.python-version'
|
||||
|
||||
- run: poetry install
|
||||
shell: bash
|
||||
- run: uv sync
|
||||
shell: bash
|
||||
|
||||
- name: Run Mypy
|
||||
shell: bash
|
||||
run: |
|
||||
poetry run mypy ./nonebot_plugin_tetris_stats
|
||||
- name: Run Mypy
|
||||
shell: bash
|
||||
run: |
|
||||
uv run mypy ./nonebot_plugin_tetris_stats
|
||||
|
||||
- name: Run BasedPyright
|
||||
shell: bash
|
||||
run: |
|
||||
uv run basedpyright ./nonebot_plugin_tetris_stats/
|
||||
|
||||
61
.github/workflows/codeql-analysis.yml
vendored
61
.github/workflows/codeql-analysis.yml
vendored
@@ -9,14 +9,14 @@
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '17 6 * * 5'
|
||||
|
||||
@@ -32,41 +32,40 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'python' ]
|
||||
language: ['python']
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
||||
546
.gitignore
vendored
546
.gitignore
vendored
@@ -1,24 +1,528 @@
|
||||
.idea
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/linux,macos,python,pycharm,windows,visualstudiocode,node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=linux,macos,python,pycharm,windows,visualstudiocode,node
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### macOS Patch ###
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
test_*
|
||||
Untitled*
|
||||
*copy*
|
||||
.vscode
|
||||
*dev*
|
||||
*_cache*
|
||||
*backup*
|
||||
*.pyc
|
||||
node_modules
|
||||
.prettier*
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
*.drawio.svg
|
||||
package-lock.json
|
||||
*Zone.Identifier
|
||||
.env*
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
### PyCharm ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### PyCharm Patch ###
|
||||
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
|
||||
|
||||
# *.iml
|
||||
# modules.xml
|
||||
# .idea/misc.xml
|
||||
# *.ipr
|
||||
|
||||
# Sonarlint plugin
|
||||
# https://plugins.jetbrains.com/plugin/7973-sonarlint
|
||||
.idea/**/sonarlint/
|
||||
|
||||
# SonarQube Plugin
|
||||
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
|
||||
.idea/**/sonarIssues.xml
|
||||
|
||||
# Markdown Navigator plugin
|
||||
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
|
||||
.idea/**/markdown-navigator.xml
|
||||
.idea/**/markdown-navigator-enh.xml
|
||||
.idea/**/markdown-navigator/
|
||||
|
||||
# Cache file creation bug
|
||||
# See https://youtrack.jetbrains.com/issue/JBR-2257
|
||||
.idea/$CACHE_FILE$
|
||||
|
||||
# CodeStream plugin
|
||||
# https://plugins.jetbrains.com/plugin/12206-codestream
|
||||
.idea/codestream.xml
|
||||
|
||||
# Azure Toolkit for IntelliJ plugin
|
||||
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
|
||||
.idea/**/azureSettings.xml
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/linux,macos,python,pycharm,windows,visualstudiocode,node
|
||||
|
||||
# NoneBot2
|
||||
bot.py
|
||||
TODO
|
||||
*.fish
|
||||
extracted_skin_mino_*
|
||||
sample_*
|
||||
.env*
|
||||
|
||||
# Misc
|
||||
ignore_*
|
||||
*.backup
|
||||
TODO*
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
default_install_hook_types: [pre-commit, prepare-commit-msg]
|
||||
ci:
|
||||
autofix_commit_msg: ':rotating_light: auto fix by pre-commit hooks'
|
||||
autofix_prs: true
|
||||
autoupdate_branch: main
|
||||
autoupdate_schedule: weekly
|
||||
autoupdate_commit_msg: ':arrow_up: auto update by pre-commit hooks'
|
||||
autofix_commit_msg: ':rotating_light: auto fix by pre-commit hooks'
|
||||
autofix_prs: true
|
||||
autoupdate_branch: main
|
||||
autoupdate_schedule: weekly
|
||||
autoupdate_commit_msg: ':arrow_up: auto update by pre-commit hooks'
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
stages: [commit]
|
||||
- id: ruff-format
|
||||
stages: [commit]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
stages: [pre-commit]
|
||||
- id: ruff-format
|
||||
stages: [pre-commit]
|
||||
|
||||
- repo: https://github.com/nonebot/nonemoji
|
||||
rev: v0.1.4
|
||||
hooks:
|
||||
- id: nonemoji
|
||||
stages: [prepare-commit-msg]
|
||||
- repo: https://github.com/nonebot/nonemoji
|
||||
rev: v0.1.4
|
||||
hooks:
|
||||
- id: nonemoji
|
||||
stages: [prepare-commit-msg]
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.10
|
||||
58
CONTRIBUTING.en-US.md
Normal file
58
CONTRIBUTING.en-US.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# How to Contribute?
|
||||
|
||||
## Setting Up the Environment
|
||||
|
||||
### For Developers with Basic Python Knowledge
|
||||
|
||||
First, you need install [uv](https://docs.astral.sh/uv/).
|
||||
Then:
|
||||
|
||||
```bash
|
||||
# Set up the basic Python environment
|
||||
uv python install 3.10
|
||||
|
||||
# Clone the repository
|
||||
git clone https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
|
||||
cd nonebot-plugin-tetris-stats
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Code Development
|
||||
|
||||
1. For static code analysis, use [ruff](https://docs.astral.sh/ruff/). You can install the corresponding plugin for your IDE or use the command line with `ruff check ./nonebot_plugin_tetris_stats/` to check the code.
|
||||
2. For code formatting, use [ruff](https://docs.astral.sh/ruff/). You can install the corresponding plugin for your IDE or use the command line with `ruff format ./nonebot_plugin_tetris_stats/` to format the code.
|
||||
3. For type checking, use both [basedpyright](https://docs.basedpyright.com/latest/) and [mypy](https://www.mypy-lang.org/). You can install the corresponding plugins for your IDE or use the following commands in the terminal to check the code:
|
||||
|
||||
```bash
|
||||
# basedpyright
|
||||
basedpyright ./nonebot_plugin_tetris_stats/
|
||||
|
||||
# mypy
|
||||
mypy ./nonebot_plugin_tetris_stats/
|
||||
```
|
||||
|
||||
### Internationalization
|
||||
|
||||
This project uses [Tarina](https://github.com/ArcletProject/Tarina) for internationalization support.
|
||||
|
||||
#### Adding a New Language
|
||||
|
||||
1. Navigate to the `./nonebot_plugin_tetris_stats/i18n/` directory.
|
||||
2. Run `tarina-lang create {language_code}` \* Please note that the language code should preferably follow the [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag) standard.
|
||||
3. Edit the generated `./nonebot_plugin_tetris_stats/i18n/{language_code}.json` file.
|
||||
|
||||
#### Updating an Existing Language
|
||||
|
||||
1. Navigate to the `./nonebot_plugin_tetris_stats/i18n/` directory.
|
||||
2. Edit the corresponding `./nonebot_plugin_tetris_stats/i18n/{language_code}.json` file.
|
||||
|
||||
#### Adding New Entries
|
||||
|
||||
1. Navigate to the `./nonebot_plugin_tetris_stats/i18n/` directory.
|
||||
2. Edit the `.template.json` file.
|
||||
3. Run `tarina-lang schema && tarina-lang model`.
|
||||
4. Modify the language files, adding new entries at least to `en-US.json`.
|
||||
57
CONTRIBUTING.md
Normal file
57
CONTRIBUTING.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 我该如何参与开发?
|
||||
|
||||
## 配置环境
|
||||
|
||||
首先你需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
然后:
|
||||
|
||||
```bash
|
||||
# 配置基础 Python 环境
|
||||
uv python install 3.10
|
||||
|
||||
# 克隆仓库
|
||||
git clone https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
|
||||
cd nonebot-plugin-tetris-stats
|
||||
|
||||
# 安装依赖
|
||||
uv sync
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 代码开发
|
||||
|
||||
1. 代码静态检查使用 [ruff](https://docs.astral.sh/ruff/),你可以为你的ide安装对应插件来使用,也可以在命令行使用`ruff check ./nonebot_plugin_tetris_stats/`来检查代码。
|
||||
2. 代码格式化使用 [ruff](https://docs.astral.sh/ruff/),你可以为你的ide安装对应插件来使用,也可以在命令行使用`ruff format ./nonebot_plugin_tetris_stats/`来格式化代码。
|
||||
3. 类型检查同时使用 [basedpyright](https://docs.basedpyright.com/latest/) 和 [mypy](https://www.mypy-lang.org/),你可以为你的ide安装对应插件来使用。
|
||||
也可以在命令行使用下面的命令来检查代码:
|
||||
|
||||
```bash
|
||||
# basedpyright
|
||||
basedpyright ./nonebot_plugin_tetris_stats/
|
||||
|
||||
# mypy
|
||||
mypy ./nonebot_plugin_tetris_stats/
|
||||
```
|
||||
|
||||
### 国际化
|
||||
|
||||
本项目使用 [Tarina](https://github.com/ArcletProject/Tarina) 提供国际化支持。
|
||||
|
||||
#### 添加新的语言
|
||||
|
||||
1. 进入 `./nonebot_plugin_tetris_stats/i18n/` 目录。
|
||||
2. 运行 `tarina-lang create {语言代码}` \* 请注意,语言代码最好符合 [IETF语言标签](https://zh.wikipedia.org/wiki/IETF%E8%AF%AD%E8%A8%80%E6%A0%87%E7%AD%BE) 的规范。
|
||||
3. 编辑生成的 `./nonebot_plugin_tetris_stats/i18n/{语言代码}.json` 文件。
|
||||
|
||||
#### 更新已有语言
|
||||
|
||||
1. 进入 `./nonebot_plugin_tetris_stats/i18n/` 目录。
|
||||
2. 编辑对应的 `./nonebot_plugin_tetris_stats/i18n/{语言代码}.json` 文件。
|
||||
|
||||
#### 添加新的条目
|
||||
|
||||
1. 进入 `./nonebot_plugin_tetris_stats/i18n/` 目录。
|
||||
2. 编辑 `.template.json` 文件。
|
||||
3. 运行 `tarina-lang schema && tarina-lang model`。
|
||||
4. 修改语言文件,至少为`en-US.json`添加新的条目。
|
||||
@@ -10,6 +10,7 @@ require_plugins = {
|
||||
'nonebot_plugin_session',
|
||||
'nonebot_plugin_user',
|
||||
'nonebot_plugin_userinfo',
|
||||
'nonebot_plugin_waiter',
|
||||
}
|
||||
|
||||
for i in require_plugins:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from nonebot import get_plugin_config
|
||||
from nonebot_plugin_localstore import get_cache_dir, get_data_dir
|
||||
from nonebot_plugin_localstore import get_plugin_cache_dir, get_plugin_data_dir
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
CACHE_PATH = get_cache_dir('nonebot_plugin_tetris_stats')
|
||||
DATA_PATH = get_data_dir('nonebot_plugin_tetris_stats')
|
||||
CACHE_PATH = get_plugin_cache_dir()
|
||||
DATA_PATH = get_plugin_data_dir()
|
||||
|
||||
|
||||
class Proxy(BaseModel):
|
||||
|
||||
@@ -29,7 +29,7 @@ branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def migrate_old_data() -> None:
|
||||
def migrate_old_data() -> None: # noqa: C901
|
||||
from json import dumps, loads
|
||||
|
||||
Base = automap_base() # noqa: N806
|
||||
@@ -47,6 +47,9 @@ def migrate_old_data() -> None:
|
||||
TimeRemainingColumn(),
|
||||
) as progress,
|
||||
):
|
||||
if session.query(OldHistoricalData).count() == 0:
|
||||
logger.info('空表, 跳过')
|
||||
return
|
||||
task_id = progress.add_task('[cyan]Migrating:', total=session.query(OldHistoricalData).count())
|
||||
pointer = 0
|
||||
while pointer < session.query(OldHistoricalData).order_by(desc(OldHistoricalData.id)).limit(1).one().id:
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""add TETRIOLeagueStats
|
||||
|
||||
迁移 ID: 5a1b93948494
|
||||
父迁移: cfeab6961dce
|
||||
创建时间: 2024-08-24 00:22:41.359500
|
||||
|
||||
"""
|
||||
|
||||
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 = '5a1b93948494'
|
||||
down_revision: str | Sequence[str] | None = 'cfeab6961dce'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_tetrioleaguestats',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('update_time', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetrioleaguestats')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetrioleaguestats', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguestats_update_time'), ['update_time'], unique=False
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_tetrioleaguehistorical',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('request_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('update_time', sa.DateTime(), nullable=False),
|
||||
sa.Column('stats_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
['stats_id'],
|
||||
['nonebot_plugin_tetris_stats_tetrioleaguestats.id'],
|
||||
name=op.f(
|
||||
'fk_nonebot_plugin_tetris_stats_tetrioleaguehistorical_stats_id_nonebot_plugin_tetris_stats_tetrioleaguestats'
|
||||
),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetrioleaguehistorical')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetrioleaguehistorical', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguehistorical_request_id'), ['request_id'], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguehistorical_update_time'),
|
||||
['update_time'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'nonebot_plugin_tetris_stats_tetrioleaguestatsfield',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('rank', sa.String(length=2), nullable=False),
|
||||
sa.Column('tr_line', sa.Float(), nullable=False),
|
||||
sa.Column('player_count', sa.Integer(), nullable=False),
|
||||
sa.Column('low_pps', sa.JSON(), nullable=False),
|
||||
sa.Column('low_apm', sa.JSON(), nullable=False),
|
||||
sa.Column('low_vs', sa.JSON(), nullable=False),
|
||||
sa.Column('avg_pps', sa.Float(), nullable=False),
|
||||
sa.Column('avg_apm', sa.Float(), nullable=False),
|
||||
sa.Column('avg_vs', sa.Float(), nullable=False),
|
||||
sa.Column('high_pps', sa.JSON(), nullable=False),
|
||||
sa.Column('high_apm', sa.JSON(), nullable=False),
|
||||
sa.Column('high_vs', sa.JSON(), nullable=False),
|
||||
sa.Column('stats_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
['stats_id'],
|
||||
['nonebot_plugin_tetris_stats_tetrioleaguestats.id'],
|
||||
name=op.f(
|
||||
'fk_nonebot_plugin_tetris_stats_tetrioleaguestatsfield_stats_id_nonebot_plugin_tetris_stats_tetrioleaguestats'
|
||||
),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetrioleaguestatsfield')),
|
||||
info={'bind_key': 'nonebot_plugin_tetris_stats'},
|
||||
)
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetrioleaguestatsfield', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguestatsfield_rank'), ['rank'], 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_tetrioleaguestatsfield', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguestatsfield_rank'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_tetrioleaguestatsfield')
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetrioleaguehistorical', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguehistorical_update_time'))
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguehistorical_request_id'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_tetrioleaguehistorical')
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetrioleaguestats', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetrioleaguestats_update_time'))
|
||||
|
||||
op.drop_table('nonebot_plugin_tetris_stats_tetrioleaguestats')
|
||||
# ### end Alembic commands ###
|
||||
@@ -28,12 +28,6 @@ depends_on: str | Sequence[str] | None = None
|
||||
def upgrade(name: str = '') -> None: # noqa: C901
|
||||
if name:
|
||||
return
|
||||
from nonebot_plugin_tetris_stats.version import __version__
|
||||
|
||||
if __version__ != '1.0.3':
|
||||
msg = '本迁移需要1.0.3版本, 请先锁定版本至1.0.3版本再执行本迁移'
|
||||
logger.critical(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2, type_validate_json
|
||||
from pydantic import BaseModel, ValidationError
|
||||
@@ -46,10 +40,6 @@ def upgrade(name: str = '') -> None: # noqa: C901
|
||||
TimeRemainingColumn,
|
||||
)
|
||||
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import ( # type: ignore[import-untyped]
|
||||
BaseProcessedData,
|
||||
)
|
||||
|
||||
Base = automap_base() # noqa: N806
|
||||
Base.prepare(autoload_with=op.get_bind())
|
||||
HistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
|
||||
@@ -62,18 +52,33 @@ def upgrade(name: str = '') -> None: # noqa: C901
|
||||
def model_to_json(value: BaseModel) -> str:
|
||||
return value.json(by_alias=True)
|
||||
|
||||
models = BaseProcessedData.__subclasses__()
|
||||
|
||||
def json_to_model(value: str) -> BaseModel:
|
||||
for i in models:
|
||||
try:
|
||||
return type_validate_json(i, value)
|
||||
except ValidationError: # noqa: PERF203
|
||||
...
|
||||
raise ValueError
|
||||
|
||||
with Session(op.get_bind()) as session:
|
||||
count = session.query(HistoricalData).count()
|
||||
if count == 0:
|
||||
logger.info('空表, 跳过')
|
||||
return
|
||||
|
||||
from nonebot_plugin_tetris_stats.version import __version__
|
||||
|
||||
if __version__ != '1.0.3':
|
||||
msg = '本迁移需要1.0.3版本, 请先锁定版本至1.0.3版本再执行本迁移'
|
||||
logger.critical(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import ( # type: ignore[import-untyped]
|
||||
BaseProcessedData,
|
||||
)
|
||||
|
||||
models = BaseProcessedData.__subclasses__()
|
||||
|
||||
def json_to_model(value: str) -> BaseModel:
|
||||
for i in models:
|
||||
try:
|
||||
return type_validate_json(i, value)
|
||||
except ValidationError: # noqa: PERF203
|
||||
...
|
||||
raise ValueError
|
||||
|
||||
with Progress(
|
||||
TextColumn('[progress.description]{task.description}'),
|
||||
BarColumn(),
|
||||
|
||||
@@ -26,12 +26,7 @@ depends_on: str | Sequence[str] | None = None
|
||||
def upgrade(name: str = '') -> None:
|
||||
if name:
|
||||
return
|
||||
from nonebot_plugin_tetris_stats.version import __version__
|
||||
|
||||
if __version__ != '1.0.4':
|
||||
msg = '本迁移需要1.0.4版本, 请先锁定版本至1.0.4版本再执行本迁移'
|
||||
logger.critical(msg)
|
||||
raise RuntimeError(msg)
|
||||
from nonebot.compat import type_validate_json
|
||||
from pydantic import ValidationError
|
||||
from rich.progress import (
|
||||
@@ -46,8 +41,6 @@ def upgrade(name: str = '') -> None:
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import BaseUser # type: ignore[import-untyped]
|
||||
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('user_unique_identifier', sa.String(length=32), nullable=True))
|
||||
batch_op.create_index(
|
||||
@@ -60,37 +53,48 @@ def upgrade(name: str = '') -> None:
|
||||
Base.prepare(autoload_with=connection)
|
||||
HistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
|
||||
|
||||
models: list[type[BaseUser]] = BaseUser.__subclasses__()
|
||||
|
||||
def json_to_model(value: str) -> BaseUser:
|
||||
for i in models:
|
||||
try:
|
||||
return type_validate_json(i, value)
|
||||
except ValidationError: # noqa: PERF203
|
||||
...
|
||||
raise ValueError
|
||||
|
||||
with Session(op.get_bind()) as session:
|
||||
count = session.query(HistoricalData).count()
|
||||
with Progress(
|
||||
TextColumn('[progress.description]{task.description}'),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeRemainingColumn(),
|
||||
) as progress:
|
||||
task_id = progress.add_task('[cyan]Updateing:', total=count)
|
||||
for i in range(0, count, 100):
|
||||
for j in session.scalars(
|
||||
select(HistoricalData).where(HistoricalData.id > i).order_by(HistoricalData.id).limit(100)
|
||||
):
|
||||
model = json_to_model(j.game_user)
|
||||
if count == 0:
|
||||
logger.info('空表, 跳过')
|
||||
else:
|
||||
from nonebot_plugin_tetris_stats.version import __version__
|
||||
|
||||
if __version__ != '1.0.4':
|
||||
msg = '本迁移需要1.0.4版本, 请先锁定版本至1.0.4版本再执行本迁移'
|
||||
logger.critical(msg)
|
||||
raise RuntimeError(msg)
|
||||
from nonebot_plugin_tetris_stats.game_data_processor.schemas import BaseUser # type: ignore[import-untyped]
|
||||
|
||||
models: list[type[BaseUser]] = BaseUser.__subclasses__()
|
||||
|
||||
def json_to_model(value: str) -> BaseUser:
|
||||
for i in models:
|
||||
try:
|
||||
j.user_unique_identifier = model.unique_identifier
|
||||
except ValueError:
|
||||
session.delete(j)
|
||||
progress.update(task_id, advance=1)
|
||||
session.commit()
|
||||
return type_validate_json(i, value)
|
||||
except ValidationError: # noqa: PERF203
|
||||
...
|
||||
raise ValueError
|
||||
|
||||
with Progress(
|
||||
TextColumn('[progress.description]{task.description}'),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeRemainingColumn(),
|
||||
) as progress:
|
||||
task_id = progress.add_task('[cyan]Updateing:', total=count)
|
||||
for i in range(0, count, 100):
|
||||
for j in session.scalars(
|
||||
select(HistoricalData).where(HistoricalData.id > i).order_by(HistoricalData.id).limit(100)
|
||||
):
|
||||
model = json_to_model(j.game_user)
|
||||
try:
|
||||
j.user_unique_identifier = model.unique_identifier
|
||||
except ValueError:
|
||||
session.delete(j)
|
||||
progress.update(task_id, advance=1)
|
||||
session.commit()
|
||||
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
|
||||
batch_op.alter_column('user_unique_identifier', existing_type=sa.VARCHAR(length=32), nullable=False)
|
||||
logger.success('database upgrade success')
|
||||
|
||||
@@ -11,7 +11,7 @@ from nonebot_plugin_orm import AsyncSession, get_session
|
||||
from nonebot_plugin_user import User
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..utils.typing import AllCommandType, BaseCommandType, GameType, TETRIOCommandType
|
||||
from ..utils.typedefs import AllCommandType, BaseCommandType, GameType, TETRIOCommandType
|
||||
from .models import Bind, TriggerHistoricalData
|
||||
|
||||
UTC = timezone.utc
|
||||
@@ -55,12 +55,29 @@ async def create_or_update_bind(
|
||||
game_account=game_account,
|
||||
)
|
||||
session.add(bind)
|
||||
message = BindStatus.SUCCESS
|
||||
status = BindStatus.SUCCESS
|
||||
else:
|
||||
bind.game_account = game_account
|
||||
message = BindStatus.UPDATE
|
||||
status = BindStatus.UPDATE
|
||||
await session.commit()
|
||||
return message
|
||||
return status
|
||||
|
||||
|
||||
async def remove_bind(
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
game_platform: GameType,
|
||||
) -> bool:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
user=user,
|
||||
game_platform=game_platform,
|
||||
)
|
||||
if bind is not None:
|
||||
await session.delete(bind)
|
||||
await session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
T = TypeVar('T', 'TETRIOHistoricalData', 'TOPHistoricalData', 'TOSHistoricalData')
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import JSON, DateTime, Dialect, String, TypeDecorator
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
from typing_extensions import override
|
||||
|
||||
from ..utils.typing import AllCommandType, GameType
|
||||
from ..utils.typedefs import AllCommandType, GameType
|
||||
|
||||
|
||||
class PydanticType(TypeDecorator):
|
||||
|
||||
@@ -7,6 +7,7 @@ from nonebot.typing import T_Handler
|
||||
from nonebot_plugin_alconna import AlcMatches, Alconna, At, CommandMeta, on_alconna
|
||||
|
||||
from .. import ns
|
||||
from ..i18n.model import Lang
|
||||
from ..utils.exception import MessageFormatError, NeedCatchError
|
||||
|
||||
command: Alconna = Alconna(
|
||||
@@ -30,7 +31,7 @@ def add_block_handlers(handler: Callable[[T_Handler], T_Handler]) -> None:
|
||||
@handler
|
||||
async def _(bot: Bot, matcher: Matcher, target: At):
|
||||
if isinstance(target, At) and target.target == bot.self_id:
|
||||
await matcher.finish('不能查询bot的信息')
|
||||
await matcher.finish(Lang.interaction.wrong.query_bot())
|
||||
|
||||
|
||||
from . import tetrio, top, tos # noqa: F401, E402
|
||||
@@ -43,7 +44,7 @@ async def _(matcher: Matcher, account: MessageFormatError):
|
||||
|
||||
@alc.handle()
|
||||
async def _(matcher: Matcher, matches: AlcMatches):
|
||||
if matches.head_matched and matches.options != {} or matches.main_args == {}:
|
||||
if (matches.head_matched and matches.options != {}) or matches.main_args == {}:
|
||||
await matcher.finish(
|
||||
(f'{matches.error_info!r}\n' if matches.error_info is not None else '')
|
||||
+ f'输入"{matches.header_result} --help"查看帮助'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
CANT_VERIFY_MESSAGE = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
|
||||
@@ -3,7 +3,7 @@ from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..utils.typing import GameType
|
||||
from ..utils.typedefs import GameType
|
||||
|
||||
T = TypeVar('T', bound=GameType)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ command = Subcommand(
|
||||
)
|
||||
|
||||
|
||||
from . import bind, config, query, record # noqa: E402
|
||||
from . import bind, config, list, query, rank, record, unbind # noqa: A004, E402
|
||||
|
||||
main_command.add(command)
|
||||
|
||||
@@ -31,6 +31,9 @@ __all__ = [
|
||||
'alc',
|
||||
'bind',
|
||||
'config',
|
||||
'list',
|
||||
'query',
|
||||
'rank',
|
||||
'record',
|
||||
'unbind',
|
||||
]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from .player import Player
|
||||
from .schemas.user import User
|
||||
from .schemas.user_info import UserInfoSuccess
|
||||
from .tetra_league import full_export as tetra_league_full_export
|
||||
|
||||
__all__ = ['Player', 'User', 'UserInfoSuccess', 'tetra_league_full_export']
|
||||
__all__ = ['Player', 'User', 'UserInfoSuccess']
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from asyncio import Lock
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import ClassVar
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
@@ -9,12 +9,15 @@ from nonebot.log import logger
|
||||
from yarl import URL
|
||||
|
||||
from ....config.config import config
|
||||
from ....utils.limit import limit
|
||||
from ....utils.request import Request
|
||||
from .schemas.base import FailedModel, SuccessModel
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
request = Request(config.tetris.proxy.tetrio or config.tetris.proxy.main)
|
||||
request.request = limit(timedelta(seconds=1))(request.request) # type: ignore[method-assign]
|
||||
|
||||
|
||||
class Cache:
|
||||
@@ -22,13 +25,13 @@ class Cache:
|
||||
task: ClassVar[WeakValueDictionary[URL, Lock]] = WeakValueDictionary()
|
||||
|
||||
@classmethod
|
||||
async def get(cls, url: URL) -> bytes:
|
||||
async def get(cls, url: URL, extra_headers: dict | None = None) -> bytes:
|
||||
lock = cls.task.setdefault(url, Lock())
|
||||
async with lock:
|
||||
if (cached_data := await cls.cache.get(url)) is not None:
|
||||
logger.debug(f'{url}: Cache hit!')
|
||||
return cached_data
|
||||
response_data = await request.request(url)
|
||||
response_data = await request.request(url, extra_headers, enable_anti_cloudflare=True)
|
||||
parsed_data: SuccessModel | FailedModel = type_validate_json(SuccessModel | FailedModel, response_data) # type: ignore[arg-type]
|
||||
if isinstance(parsed_data, SuccessModel):
|
||||
await cls.cache.add(
|
||||
|
||||
96
nonebot_plugin_tetris_stats/games/tetrio/api/leaderboards.py
Normal file
96
nonebot_plugin_tetris_stats/games/tetrio/api/leaderboards.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from typing import Literal, overload
|
||||
from uuid import UUID
|
||||
|
||||
from nonebot import __version__ as __nonebot_version__
|
||||
from nonebot.compat import type_validate_json
|
||||
from yarl import URL
|
||||
|
||||
from ....utils.exception import RequestError
|
||||
from ....version import __version__
|
||||
from ..constant import BASE_URL
|
||||
from .cache import Cache
|
||||
from .schemas.base import FailedModel
|
||||
from .schemas.leaderboards import Parameter
|
||||
from .schemas.leaderboards.by import By, BySuccessModel
|
||||
from .schemas.leaderboards.solo import Solo, SoloSuccessModel
|
||||
from .schemas.leaderboards.zenith import Zenith, ZenithSuccessModel
|
||||
|
||||
|
||||
async def by(
|
||||
by_type: Literal['league', 'xp', 'ar'], parameter: Parameter, x_session_id: UUID | None = None
|
||||
) -> BySuccessModel:
|
||||
model: By = type_validate_json(
|
||||
By, # type: ignore[arg-type]
|
||||
await get(
|
||||
BASE_URL / f'users/by/{by_type}',
|
||||
parameter,
|
||||
{
|
||||
'X-Session-ID': str(x_session_id),
|
||||
'User-Agent': f'nonebot-plugin-tetris-stats/{__version__} (Windows NT 10.0; Win64; x64) NoneBot2/{__nonebot_version__}',
|
||||
}
|
||||
if x_session_id is not None
|
||||
else None,
|
||||
),
|
||||
)
|
||||
if isinstance(model, FailedModel):
|
||||
msg = f'排行榜信息请求错误:\n{model.error}'
|
||||
raise RequestError(msg)
|
||||
return model
|
||||
|
||||
|
||||
@overload
|
||||
async def records(
|
||||
records_type: Literal['40l', 'blitz'],
|
||||
scope: str = '_global',
|
||||
revolution_id: str | None = None,
|
||||
*,
|
||||
parameter: Parameter,
|
||||
) -> SoloSuccessModel: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def records(
|
||||
records_type: Literal['zenith', 'zenithex'],
|
||||
scope: str = '_global',
|
||||
revolution_id: str | None = None,
|
||||
*,
|
||||
parameter: Parameter,
|
||||
) -> ZenithSuccessModel: ...
|
||||
|
||||
|
||||
async def records(
|
||||
records_type: Literal['40l', 'blitz', 'zenith', 'zenithex'],
|
||||
scope: str = '_global',
|
||||
revolution_id: str | None = None,
|
||||
*,
|
||||
parameter: Parameter,
|
||||
) -> SoloSuccessModel | ZenithSuccessModel:
|
||||
model: Solo | Zenith
|
||||
match records_type:
|
||||
case '40l' | 'blitz':
|
||||
model = type_validate_json(
|
||||
Solo, # type: ignore[arg-type]
|
||||
await get(
|
||||
BASE_URL / 'records' / f'{records_type}{scope}{revolution_id if revolution_id is not None else ""}',
|
||||
parameter,
|
||||
),
|
||||
)
|
||||
case 'zenith' | 'zenithex':
|
||||
model = type_validate_json(
|
||||
Zenith, # type: ignore[arg-type]
|
||||
await get(
|
||||
BASE_URL / 'records' / f'{records_type}{scope}{revolution_id if revolution_id is not None else ""}',
|
||||
parameter,
|
||||
),
|
||||
)
|
||||
case _:
|
||||
msg = f'records_type: {records_type} is not supported'
|
||||
raise ValueError(msg)
|
||||
if isinstance(model, FailedModel):
|
||||
msg = f'排行榜信息请求错误:\n{model.error}' # type: ignore[attr-defined]
|
||||
raise RequestError(msg)
|
||||
return model
|
||||
|
||||
|
||||
async def get(url: URL, parameter: Parameter, extra_headers: dict | None = None) -> bytes:
|
||||
return await Cache.get(url % parameter.to_params(), extra_headers)
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
|
||||
from ....db.models import PydanticType
|
||||
from .schemas.base import SuccessModel
|
||||
from .typing import Records, Summaries
|
||||
from .typedefs import Records, Summaries
|
||||
|
||||
|
||||
class TETRIOHistoricalData(MappedAsDataclass, Model):
|
||||
|
||||
@@ -11,6 +11,7 @@ from ..constant import BASE_URL, USER_ID, USER_NAME
|
||||
from .cache import Cache
|
||||
from .models import TETRIOHistoricalData
|
||||
from .schemas.base import FailedModel
|
||||
from .schemas.labs.leagueflow import LeagueFlow, LeagueFlowSuccess
|
||||
from .schemas.records.solo import Solo as SoloRecord
|
||||
from .schemas.records.solo import SoloSuccessModel as RecordsSoloSuccessModel
|
||||
from .schemas.summaries import (
|
||||
@@ -26,7 +27,7 @@ from .schemas.summaries.base import User as SummariesUser
|
||||
from .schemas.summaries.league import LeagueSuccessModel
|
||||
from .schemas.user import User
|
||||
from .schemas.user_info import UserInfo, UserInfoSuccess
|
||||
from .typing import Records, Summaries
|
||||
from .typedefs import Records, Summaries
|
||||
|
||||
|
||||
class RecordModeType(str, Enum):
|
||||
@@ -45,7 +46,7 @@ class RecordKey(NamedTuple):
|
||||
record_type: RecordType
|
||||
|
||||
def to_records(self) -> Records:
|
||||
return cast(Records, f'{self.mode_type.value}_{self.record_type.value}')
|
||||
return cast('Records', f'{self.mode_type.value}_{self.record_type.value}')
|
||||
|
||||
|
||||
class Player:
|
||||
@@ -84,10 +85,11 @@ class Player:
|
||||
self._user_info: UserInfoSuccess | None = None
|
||||
self._summaries: dict[Summaries, SummariesModel] = {}
|
||||
self._records: dict[RecordKey, RecordsSoloSuccessModel] = {}
|
||||
self._leagueflow: LeagueFlowSuccess | None = None
|
||||
|
||||
@property
|
||||
def _request_user_parameter(self) -> str:
|
||||
return self.user_id or cast(str, self.user_name).lower()
|
||||
return self.user_id or cast('str', self.user_name).lower()
|
||||
|
||||
@property
|
||||
async def user(self) -> User:
|
||||
@@ -161,6 +163,18 @@ class Player:
|
||||
)
|
||||
return self._summaries[summaries_type]
|
||||
|
||||
async def get_leagueflow(self) -> LeagueFlowSuccess:
|
||||
if self._leagueflow is None:
|
||||
leagueflow: LeagueFlow = type_validate_json(
|
||||
LeagueFlow, # type: ignore[arg-type]
|
||||
await Cache.get(BASE_URL / 'labs/leagueflow' / self._request_user_parameter),
|
||||
)
|
||||
if isinstance(leagueflow, FailedModel):
|
||||
msg = f'League 历史记录请求错误:\n{leagueflow.error}'
|
||||
raise RequestError(msg)
|
||||
self._leagueflow = leagueflow
|
||||
return self._leagueflow
|
||||
|
||||
@property
|
||||
async def sprint(self) -> SummariesSoloSuccessModel:
|
||||
return await self.get_summaries('40l')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typedefs import Prisecter
|
||||
|
||||
|
||||
class AggregateStats(BaseModel):
|
||||
@@ -39,11 +41,31 @@ class Garbage(BaseModel):
|
||||
cleared: int
|
||||
|
||||
|
||||
class P(BaseModel): # what is P
|
||||
class P(BaseModel):
|
||||
pri: float
|
||||
sec: float
|
||||
ter: float
|
||||
|
||||
def to_prisecter(self) -> Prisecter:
|
||||
return Prisecter(f'{self.pri}:{self.sec}:{self.ter}')
|
||||
|
||||
|
||||
# fmt: off
|
||||
class ArCounts(BaseModel):
|
||||
bronze: int | None = Field(default=None, alias='1') # pyright: ignore [reportGeneralTypeIssues]
|
||||
silver: int | None = Field(default=None, alias='2') # pyright: ignore [reportGeneralTypeIssues]
|
||||
gold: int | None = Field(default=None, alias='3') # pyright: ignore [reportGeneralTypeIssues]
|
||||
platinum: int | None = Field(default=None, alias='4') # pyright: ignore [reportGeneralTypeIssues]
|
||||
diamond: int | None = Field(default=None, alias='5') # pyright: ignore [reportGeneralTypeIssues]
|
||||
issued: int | None = Field(default=None, alias='100') # pyright: ignore [reportGeneralTypeIssues]
|
||||
top3: int | None = Field(default=None, alias='t3')
|
||||
top5: int | None = Field(default=None, alias='t5')
|
||||
top10: int | None = Field(default=None, alias='t10')
|
||||
top25: int | None = Field(default=None, alias='t25')
|
||||
top50: int | None = Field(default=None, alias='t50')
|
||||
top100: int | None = Field(default=None, alias='t100')
|
||||
# fmt: on
|
||||
|
||||
|
||||
class Cache(BaseModel):
|
||||
status: str
|
||||
|
||||
@@ -12,11 +12,11 @@ class Time(BaseModel):
|
||||
zero: bool
|
||||
locked: bool
|
||||
prev: int
|
||||
frameoffset: int
|
||||
frameoffset: int | None = None
|
||||
|
||||
|
||||
class Stats(BaseModel):
|
||||
seed: int | None = None # ?: 不知道是之后都没有了还是还会有
|
||||
seed: float | None = None # ?: 不知道是之后都没有了还是还会有
|
||||
lines: int
|
||||
level_lines: int
|
||||
level_lines_needed: int
|
||||
@@ -24,8 +24,8 @@ class Stats(BaseModel):
|
||||
holds: int = 0
|
||||
time: Time | None = None # ?: 不知道是之后都没有了还是还会有
|
||||
score: int
|
||||
zenlevel: int
|
||||
zenprogress: int
|
||||
zenlevel: int | None = None
|
||||
zenprogress: int | None = None
|
||||
level: int
|
||||
combo: int
|
||||
currentcombopower: int | None = None
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime
|
||||
from enum import IntEnum
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..base import FailedModel
|
||||
from ..base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class Result(IntEnum):
|
||||
VICTORY = 1
|
||||
DEFEAT = 2
|
||||
VICTORY_BY_DISQUALIFICATION = 3
|
||||
DEFEAT_BY_DISQUALIFICATION = 4
|
||||
TIE = 5
|
||||
NO_CONTEST = 6
|
||||
MATCH_NULLIFIED = 7
|
||||
|
||||
|
||||
class Point(NamedTuple):
|
||||
timestamp_offset: int
|
||||
result: Result
|
||||
post_match_tr: int
|
||||
opponent_pre_match_tr: int
|
||||
"""If the opponent was unranked, same as post_match_tr."""
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
start_time: datetime = Field(..., alias='startTime')
|
||||
points: list[Point] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class Empty(BaseModel):
|
||||
start_time: Literal[9007199254740991] = Field(..., alias='startTime')
|
||||
points: list = Field(..., max_length=0)
|
||||
|
||||
|
||||
class LeagueFlowSuccess(BaseSuccessModel):
|
||||
data: Data | Empty
|
||||
|
||||
|
||||
LeagueFlow = LeagueFlowSuccess | FailedModel
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import Any
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typedefs import Prisecter
|
||||
|
||||
|
||||
class Parameter(BaseModel):
|
||||
after: Prisecter | None = None
|
||||
before: Prisecter | None = None
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
country: str | None = None
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
if PYDANTIC_V2:
|
||||
return self.model_dump(exclude_defaults=True)
|
||||
return self.dict(exclude_defaults=True)
|
||||
@@ -1,27 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..base import SuccessModel
|
||||
from .base import Entry as BaseEntry
|
||||
|
||||
|
||||
class ArCounts(BaseModel):
|
||||
bronze: int | None = Field(None, alias='1')
|
||||
silver: int | None = Field(None, alias='2')
|
||||
gold: int | None = Field(None, alias='3')
|
||||
platinum: int | None = Field(None, alias='4')
|
||||
diamond: int | None = Field(None, alias='5')
|
||||
issued: int | None = Field(None, alias='100')
|
||||
top10: int | None = Field(None, alias='t10')
|
||||
|
||||
|
||||
class Entry(BaseEntry):
|
||||
ar: int
|
||||
ar_counts: ArCounts
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
entries: list[Entry]
|
||||
|
||||
|
||||
class ArSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
@@ -1,30 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typing import Rank
|
||||
from ..base import P
|
||||
|
||||
|
||||
class League(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: int
|
||||
rank: Rank
|
||||
decaying: bool
|
||||
|
||||
|
||||
class Entry(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: str
|
||||
xp: float
|
||||
league: League
|
||||
supporter: bool | None = None
|
||||
verified: bool
|
||||
country: str | None = None
|
||||
ts: datetime
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
gametime: float
|
||||
p: P
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typedefs import Rank, ValidRank
|
||||
from ..base import ArCounts, FailedModel, P, SuccessModel
|
||||
|
||||
|
||||
class BaseLeague(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
tr: float
|
||||
gxe: float
|
||||
rank: Rank
|
||||
bestrank: ValidRank
|
||||
glicko: float
|
||||
rd: float
|
||||
decaying: bool
|
||||
|
||||
|
||||
class InvalidLeague(BaseLeague):
|
||||
pps: float | None
|
||||
apm: None
|
||||
vs: None
|
||||
|
||||
|
||||
class League(BaseLeague):
|
||||
pps: float
|
||||
apm: float
|
||||
vs: float
|
||||
|
||||
|
||||
class BaseEntry(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop']
|
||||
ts: datetime | None = None
|
||||
xp: float
|
||||
country: str | None = None
|
||||
supporter: bool | None = None
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
gametime: float
|
||||
ar: int
|
||||
ar_counts: ArCounts
|
||||
p: P
|
||||
|
||||
|
||||
class InvalidEntry(BaseEntry):
|
||||
league: InvalidLeague
|
||||
|
||||
|
||||
class Entry(BaseEntry):
|
||||
league: League
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
entries: list[Entry | InvalidEntry]
|
||||
|
||||
|
||||
class BySuccessModel(SuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
By = BySuccessModel | FailedModel
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import SuccessModel
|
||||
from ..base import FailedModel, SuccessModel
|
||||
from ..summaries.solo import Record
|
||||
|
||||
|
||||
@@ -10,3 +10,6 @@ class Data(BaseModel):
|
||||
|
||||
class SoloSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
Solo = SoloSuccessModel | FailedModel
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import SuccessModel
|
||||
from .base import Entry
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
entries: list[Entry]
|
||||
|
||||
|
||||
class XpSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..base import SuccessModel
|
||||
from ..base import FailedModel, SuccessModel
|
||||
from ..summaries.zenith import Record
|
||||
|
||||
|
||||
@@ -10,3 +10,6 @@ class Data(BaseModel):
|
||||
|
||||
class ZenithSuccessModel(SuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
Zenith = ZenithSuccessModel | FailedModel
|
||||
|
||||
@@ -14,8 +14,8 @@ __all__ = [
|
||||
'SoloSuccessModel',
|
||||
'SummariesModel',
|
||||
'Zen',
|
||||
'ZenSuccessModel',
|
||||
'Zenith',
|
||||
'ZenithEx',
|
||||
'ZenithSuccessModel',
|
||||
'ZenSuccessModel',
|
||||
]
|
||||
|
||||
@@ -7,5 +7,4 @@ class User(BaseModel):
|
||||
avatar_revision: int | None
|
||||
banner_revision: int | None
|
||||
country: str | None
|
||||
verified: int | None = None
|
||||
supporter: int
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
from typing import Literal
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typing import Rank, S1Rank, S1ValidRank
|
||||
from ...typedefs import Rank, S1Rank, S1ValidRank
|
||||
from ..base import SuccessModel
|
||||
|
||||
if PYDANTIC_V2:
|
||||
from pydantic import field_validator
|
||||
else:
|
||||
from pydantic import validator
|
||||
|
||||
|
||||
class PastInner(BaseModel):
|
||||
season: str
|
||||
@@ -26,7 +32,7 @@ class PastInner(BaseModel):
|
||||
|
||||
|
||||
class Past(BaseModel):
|
||||
first: PastInner | None = Field(default=None, alias='1')
|
||||
first: PastInner | None = Field(default=None, alias='1') # pyright: ignore [reportGeneralTypeIssues]
|
||||
|
||||
|
||||
class BaseData(BaseModel):
|
||||
@@ -75,6 +81,24 @@ class NeverRatedData(BaseData):
|
||||
percentile: Literal[-1]
|
||||
percentile_rank: Literal['z']
|
||||
|
||||
if PYDANTIC_V2:
|
||||
|
||||
@field_validator('apm', 'pps', 'vs', mode='before')
|
||||
@classmethod
|
||||
def _(cls, value: float | None) -> float:
|
||||
if value is None:
|
||||
return 0
|
||||
return value
|
||||
|
||||
else:
|
||||
|
||||
@validator('apm', 'pps', 'vs', pre=True, always=True)
|
||||
@classmethod
|
||||
def _(cls, value: float | None) -> float:
|
||||
if value is None:
|
||||
return 0
|
||||
return value
|
||||
|
||||
|
||||
class RatedData(BaseData):
|
||||
gamesplayed: int
|
||||
@@ -98,5 +122,9 @@ class RatedData(BaseData):
|
||||
percentile_rank: str
|
||||
|
||||
|
||||
class InvalidData(BaseModel):
|
||||
"""I don't know what osk is doing, but the return value is an empty dictionary"""
|
||||
|
||||
|
||||
class LeagueSuccessModel(SuccessModel):
|
||||
data: NeverPlayedData | NeverRatedData | RatedData
|
||||
data: NeverPlayedData | NeverRatedData | RatedData | InvalidData
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..typing import Rank
|
||||
from .base import FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
class _User(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
username: str
|
||||
role: str
|
||||
xp: float
|
||||
supporter: bool | None = None
|
||||
verified: bool
|
||||
country: str | None = None
|
||||
|
||||
|
||||
class _League(BaseModel):
|
||||
gamesplayed: int
|
||||
gameswon: int
|
||||
rating: float
|
||||
rank: Rank
|
||||
bestrank: Rank
|
||||
decaying: bool
|
||||
|
||||
|
||||
class ValidLeague(_League):
|
||||
glicko: float
|
||||
rd: float
|
||||
apm: float
|
||||
pps: float
|
||||
vs: float
|
||||
|
||||
|
||||
class ValidUser(_User):
|
||||
league: ValidLeague
|
||||
|
||||
|
||||
class InvalidLeague(_League):
|
||||
glicko: float | None = None
|
||||
rd: float | None = None
|
||||
apm: float | None = None
|
||||
pps: float | None = None
|
||||
vs: float | None = None
|
||||
|
||||
|
||||
class InvalidUser(_User):
|
||||
league: InvalidLeague
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
users: list[ValidUser | InvalidUser]
|
||||
|
||||
|
||||
class TetraLeagueSuccess(BaseSuccessModel):
|
||||
data: Data
|
||||
|
||||
|
||||
TetraLeague = TetraLeagueSuccess | FailedModel
|
||||
@@ -3,7 +3,7 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import FailedModel
|
||||
from .base import ArCounts, FailedModel
|
||||
from .base import SuccessModel as BaseSuccessModel
|
||||
|
||||
|
||||
@@ -14,13 +14,19 @@ class Badge(BaseModel):
|
||||
ts: datetime | Literal[False] | None = None
|
||||
|
||||
|
||||
class Discord(BaseModel):
|
||||
class Connection(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
display_username: str
|
||||
|
||||
|
||||
class Connections(BaseModel):
|
||||
discord: Discord | None = None
|
||||
discord: Connection | None = None
|
||||
twitch: Connection | None = None
|
||||
twitter: Connection | None = None
|
||||
reddit: Connection | None = None
|
||||
youtube: Connection | None = None
|
||||
steam: Connection | None = None
|
||||
|
||||
|
||||
class Distinguishment(BaseModel):
|
||||
@@ -28,9 +34,9 @@ class Distinguishment(BaseModel):
|
||||
|
||||
|
||||
class Data(BaseModel):
|
||||
id: str = Field(..., alias='_id')
|
||||
id: str = Field(default=..., alias='_id')
|
||||
username: str
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'hidden', 'banned']
|
||||
ts: datetime | None = None
|
||||
botmaster: str | None = None
|
||||
badges: list[Badge]
|
||||
@@ -42,7 +48,6 @@ class Data(BaseModel):
|
||||
badstanding: bool | None = None
|
||||
supporter: bool | None = None # osk说是必有, 但实际上不是 fkosk
|
||||
supporter_tier: int
|
||||
verified: bool | None = None
|
||||
avatar_revision: int | None = None
|
||||
"""This user's avatar ID. Get their avatar at
|
||||
|
||||
@@ -57,6 +62,9 @@ class Data(BaseModel):
|
||||
connections: Connections
|
||||
friend_count: int | None = None
|
||||
distinguishment: Distinguishment | None = None
|
||||
achievements: list[int]
|
||||
ar: int
|
||||
ar_counts: ArCounts
|
||||
|
||||
|
||||
class UserInfoSuccess(BaseSuccessModel):
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from typing import Literal, NamedTuple, overload
|
||||
|
||||
from msgspec import Struct, to_builtins
|
||||
from nonebot.compat import type_validate_json
|
||||
|
||||
from ....utils.exception import RequestError
|
||||
from ..constant import BASE_URL
|
||||
from .cache import Cache
|
||||
from .schemas.base import FailedModel
|
||||
from .schemas.tetra_league import TetraLeague, TetraLeagueSuccess
|
||||
|
||||
|
||||
class Parameter(Struct, omit_defaults=True):
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
limit: int | None = None
|
||||
country: str | None = None
|
||||
|
||||
|
||||
async def leaderboard(parameter: Parameter | None = None) -> TetraLeagueSuccess:
|
||||
league: TetraLeague = type_validate_json(
|
||||
TetraLeague, # type: ignore[arg-type]
|
||||
(await Cache.get(BASE_URL / 'users/lists/league' % to_builtins(parameter))),
|
||||
)
|
||||
if isinstance(league, FailedModel):
|
||||
msg = f'排行榜数据请求错误:\n{league.error}'
|
||||
raise RequestError(msg)
|
||||
return league
|
||||
|
||||
|
||||
class FullExport(NamedTuple):
|
||||
model: TetraLeagueSuccess
|
||||
original: bytes
|
||||
|
||||
|
||||
@overload
|
||||
async def full_export(*, with_original: Literal[False]) -> TetraLeagueSuccess: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def full_export(*, with_original: Literal[True]) -> FullExport: ...
|
||||
|
||||
|
||||
async def full_export(*, with_original: bool) -> TetraLeagueSuccess | FullExport:
|
||||
full: TetraLeague = type_validate_json(
|
||||
TetraLeague, # type: ignore[arg-type]
|
||||
(data := await Cache.get(BASE_URL / 'users/lists/league/all')),
|
||||
)
|
||||
|
||||
if isinstance(full, FailedModel):
|
||||
msg = f'排行榜数据请求错误:\n{full.error}'
|
||||
raise RequestError(msg)
|
||||
if with_original:
|
||||
return FullExport(full, data)
|
||||
return full
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import Literal
|
||||
from typing import Literal, NewType
|
||||
|
||||
S1ValidRank = Literal[
|
||||
'x+',
|
||||
'x',
|
||||
'u',
|
||||
'ss',
|
||||
@@ -43,3 +42,5 @@ Records = Literal[
|
||||
'blitz_recent',
|
||||
'blitz_progression',
|
||||
]
|
||||
|
||||
Prisecter = NewType('Prisecter', str)
|
||||
@@ -11,7 +11,7 @@ from ...db import trigger
|
||||
from . import alc, command
|
||||
from .constant import GAME_TYPE
|
||||
from .models import TETRIOUserConfig
|
||||
from .typing import Template
|
||||
from .typedefs import Template
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from re import compile
|
||||
from re import compile # noqa: A004
|
||||
from typing import Literal
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from .api.typing import ValidRank
|
||||
from .api.typedefs import ValidRank
|
||||
|
||||
GAME_TYPE: Literal['IO'] = 'IO'
|
||||
|
||||
BASE_URL = URL('https://ch.tetr.io/api/')
|
||||
|
||||
RANK_PERCENTILE: dict[ValidRank, float] = {
|
||||
'x+': 0.2,
|
||||
'x': 1,
|
||||
'u': 5,
|
||||
'ss': 11,
|
||||
|
||||
93
nonebot_plugin_tetris_stats/games/tetrio/list.py
Normal file
93
nonebot_plugin_tetris_stats/games/tetrio/list.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from nonebot_plugin_alconna import Args, Option, Subcommand
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
|
||||
from ...db import trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.metrics import get_metrics
|
||||
from ...utils.render import render
|
||||
from ...utils.render.schemas.tetrio.user.list_v2 import List, TetraLeague, User
|
||||
from ...utils.screenshot import screenshot
|
||||
from .. import alc
|
||||
from . import command
|
||||
from .api.leaderboards import by
|
||||
from .api.schemas.base import P
|
||||
from .api.schemas.leaderboards import Parameter
|
||||
from .api.schemas.leaderboards.by import Entry
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
'list',
|
||||
Option('--max-tr', Args['max_tr', float], help_text='TR的上限'),
|
||||
Option('--min-tr', Args['min_tr', float], help_text='TR的下限'),
|
||||
Option('--limit', Args['limit', int], help_text='查询数量'),
|
||||
Option('--country', Args['country', str], help_text='国家代码'),
|
||||
help_text='查询 TETR.IO 段位排行榜',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@alc.assign('TETRIO.list')
|
||||
async def _(
|
||||
event_session: EventSession,
|
||||
max_tr: float | None = None,
|
||||
min_tr: float | None = None,
|
||||
limit: int | None = None,
|
||||
country: str | None = None,
|
||||
):
|
||||
country = country.upper() if country is not None else None
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='list',
|
||||
command_args=[
|
||||
f'{key} {value}'
|
||||
for key, value in zip(
|
||||
('--max-tr', '--min-tr', '--limit', '--country'), (max_tr, min_tr, limit, country), strict=True
|
||||
)
|
||||
if value is not None
|
||||
],
|
||||
):
|
||||
parameter = Parameter(
|
||||
# ?: 似乎是只需要 pri 至少 league 榜的返回值只有 pri
|
||||
after=P(pri=max_tr, sec=0, ter=0).to_prisecter() if max_tr is not None else None,
|
||||
before=P(pri=min_tr, sec=0, ter=0).to_prisecter() if min_tr is not None else None,
|
||||
limit=limit or 25,
|
||||
country=country,
|
||||
)
|
||||
league = await by('league', parameter)
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v2/tetrio/user/list',
|
||||
List(
|
||||
show_index=True,
|
||||
users=[
|
||||
User(
|
||||
id=i.id,
|
||||
name=i.username.upper(),
|
||||
avatar=f'https://tetr.io/user-content/avatars/{i.id}.jpg',
|
||||
country=i.country,
|
||||
tetra_league=TetraLeague(
|
||||
rank=i.league.rank,
|
||||
tr=round(i.league.tr, 2),
|
||||
glicko=round(i.league.glicko, 2),
|
||||
rd=round(i.league.rd, 2),
|
||||
decaying=i.league.decaying,
|
||||
pps=(metrics := get_metrics(pps=i.league.pps, apm=i.league.apm, vs=i.league.vs)).pps,
|
||||
apm=metrics.apm,
|
||||
apl=metrics.apl,
|
||||
vs=metrics.vs,
|
||||
adpl=metrics.adpl,
|
||||
),
|
||||
xp=i.xp,
|
||||
join_at=None,
|
||||
)
|
||||
for i in league.data.entries
|
||||
if isinstance(i, Entry)
|
||||
],
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
await UniMessage.image(raw=await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')).finish()
|
||||
@@ -1,10 +1,53 @@
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from .typing import Template
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship
|
||||
|
||||
from ...db.models import PydanticType
|
||||
from .api.schemas.leaderboards.by import BySuccessModel, Entry
|
||||
from .api.typedefs import ValidRank
|
||||
from .typedefs import Template
|
||||
|
||||
|
||||
class TETRIOUserConfig(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
query_template: Mapped[Template] = mapped_column(String(2))
|
||||
|
||||
|
||||
class TETRIOLeagueStats(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
raw: Mapped[list['TETRIOLeagueHistorical']] = relationship(back_populates='stats', lazy='noload')
|
||||
fields: Mapped[list['TETRIOLeagueStatsField']] = relationship(back_populates='stats')
|
||||
update_time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
|
||||
|
||||
class TETRIOLeagueHistorical(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
request_id: Mapped[UUID] = mapped_column(index=True)
|
||||
data: Mapped[BySuccessModel] = mapped_column(PydanticType([], {BySuccessModel}))
|
||||
update_time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
stats_id: Mapped[int] = mapped_column(ForeignKey('nonebot_plugin_tetris_stats_tetrioleaguestats.id'), init=False)
|
||||
stats: Mapped['TETRIOLeagueStats'] = relationship(back_populates='raw')
|
||||
|
||||
|
||||
entry_type = PydanticType([], {Entry})
|
||||
|
||||
|
||||
class TETRIOLeagueStatsField(MappedAsDataclass, Model):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
rank: Mapped[ValidRank] = mapped_column(String(2), index=True)
|
||||
tr_line: Mapped[float]
|
||||
player_count: Mapped[int]
|
||||
low_pps: Mapped[Entry] = mapped_column(entry_type)
|
||||
low_apm: Mapped[Entry] = mapped_column(entry_type)
|
||||
low_vs: Mapped[Entry] = mapped_column(entry_type)
|
||||
avg_pps: Mapped[float]
|
||||
avg_apm: Mapped[float]
|
||||
avg_vs: Mapped[float]
|
||||
high_pps: Mapped[Entry] = mapped_column(entry_type)
|
||||
high_apm: Mapped[Entry] = mapped_column(entry_type)
|
||||
high_vs: Mapped[Entry] = mapped_column(entry_type)
|
||||
stats_id: Mapped[int] = mapped_column(ForeignKey('nonebot_plugin_tetris_stats_tetrioleaguestats.id'), init=False)
|
||||
stats: Mapped['TETRIOLeagueStats'] = relationship(back_populates='fields')
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
from asyncio import gather
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import md5
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from arclet.alconna import Arg, ArgFlag
|
||||
from nonebot import get_driver
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import Args, At, Option, Subcommand
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User as NBUser
|
||||
from nonebot_plugin_user import get_user
|
||||
from sqlalchemy import select
|
||||
from yarl import URL
|
||||
|
||||
from ...db import query_bind_info, trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.metrics import get_metrics
|
||||
from ...utils.render import render
|
||||
from ...utils.render.schemas.base import Avatar
|
||||
from ...utils.render.schemas.tetrio.user.info_v2 import (
|
||||
Badge,
|
||||
Blitz,
|
||||
Sprint,
|
||||
Statistic,
|
||||
TetraLeague,
|
||||
TetraLeagueStatistic,
|
||||
Zen,
|
||||
)
|
||||
from ...utils.render.schemas.tetrio.user.info_v2 import Info as V2TemplateInfo
|
||||
from ...utils.render.schemas.tetrio.user.info_v2 import User as V2TemplateUser
|
||||
from ...utils.screenshot import screenshot
|
||||
from ...utils.typing import Me
|
||||
from .. import add_block_handlers, alc
|
||||
from ..constant import CANT_VERIFY_MESSAGE
|
||||
from . import command, get_player
|
||||
from .api import Player
|
||||
from .api.schemas.summaries.league import LeagueSuccessModel, NeverPlayedData, NeverRatedData
|
||||
from .constant import GAME_TYPE
|
||||
from .models import TETRIOUserConfig
|
||||
from .typing import Template
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .api.schemas.summaries import SoloSuccessModel, ZenSuccessModel
|
||||
from .api.schemas.user import User
|
||||
from .api.schemas.user_info import UserInfoSuccess
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
'query',
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 / 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='TETR.IO 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
),
|
||||
Option(
|
||||
'--template',
|
||||
Arg('template', Template),
|
||||
alias=['-T'],
|
||||
help_text='要使用的查询模板',
|
||||
),
|
||||
help_text='查询 TETR.IO 游戏信息',
|
||||
),
|
||||
)
|
||||
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:查询|查|query|stats)',
|
||||
command='tstats TETR.IO query',
|
||||
humanized='io查',
|
||||
)
|
||||
alc.shortcut(
|
||||
'fkosk',
|
||||
command='tstats TETR.IO query',
|
||||
arguments=['我'],
|
||||
fuzzy=False,
|
||||
humanized='An Easter egg!',
|
||||
)
|
||||
|
||||
add_block_handlers(alc.assign('TETRIO.query'))
|
||||
|
||||
|
||||
@alc.assign('TETRIO.query')
|
||||
async def _( # noqa: PLR0913
|
||||
user: NBUser,
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
template: Template | None = None,
|
||||
):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[f'--default-template {template}'] if template is not None else [],
|
||||
):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
user=await get_user(
|
||||
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
|
||||
),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if template is None:
|
||||
template = await session.scalar(
|
||||
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = UniMessage(CANT_VERIFY_MESSAGE)
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
await (message + UniMessage.image(raw=await make_query_image_v2(player))).finish()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.query')
|
||||
async def _(user: NBUser, account: Player, event_session: EventSession, template: Template | None = None):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[f'--default-template {template}'] if template is not None else [],
|
||||
):
|
||||
async with get_session() as session:
|
||||
if template is None:
|
||||
template = await session.scalar(
|
||||
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
|
||||
)
|
||||
await (UniMessage.image(raw=await make_query_image_v2(account))).finish()
|
||||
|
||||
|
||||
N = TypeVar('N', int, float)
|
||||
|
||||
|
||||
def handling_special_value(value: N) -> N | None:
|
||||
return value if value != -1 else None
|
||||
|
||||
|
||||
async def make_query_image_v2(player: Player) -> bytes:
|
||||
user: User
|
||||
user_info: UserInfoSuccess
|
||||
league: LeagueSuccessModel
|
||||
sprint: SoloSuccessModel
|
||||
blitz: SoloSuccessModel
|
||||
zen: ZenSuccessModel
|
||||
avatar_revision: int | None
|
||||
banner_revision: int | None
|
||||
# TODO)) 有没有什么办法能让这类型推导成功)
|
||||
user, user_info, league, sprint, blitz, zen, avatar_revision, banner_revision = await gather( # type: ignore[assignment]
|
||||
player.user,
|
||||
player.get_info(),
|
||||
player.league,
|
||||
player.sprint,
|
||||
player.blitz,
|
||||
player.zen,
|
||||
player.avatar_revision,
|
||||
player.banner_revision,
|
||||
)
|
||||
|
||||
if sprint.data.record is not None:
|
||||
duration = timedelta(milliseconds=sprint.data.record.results.stats.finaltime).total_seconds()
|
||||
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
||||
else:
|
||||
sprint_value = 'N/A'
|
||||
|
||||
play_time: str | None
|
||||
if (game_time := handling_special_value(user_info.data.gametime)) is not None:
|
||||
if game_time // 3600 > 0:
|
||||
play_time = f'{game_time//3600:.0f}h {game_time % 3600 // 60:.0f}m {game_time % 60:.0f}s'
|
||||
elif game_time // 60 > 0:
|
||||
play_time = f'{game_time//60:.0f}m {game_time % 60:.0f}s'
|
||||
else:
|
||||
play_time = f'{game_time:.0f}s'
|
||||
else:
|
||||
play_time = game_time
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v2/tetrio/user/info',
|
||||
V2TemplateInfo(
|
||||
user=V2TemplateUser(
|
||||
id=user.ID,
|
||||
name=user.name.upper(),
|
||||
bio=user_info.data.bio,
|
||||
banner=str(
|
||||
URL(f'http://{netloc}/host/resource/tetrio/banners/{user.ID}') % {'revision': banner_revision}
|
||||
)
|
||||
if banner_revision is not None and banner_revision != 0
|
||||
else None,
|
||||
avatar=str(
|
||||
URL(f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}') % {'revision': avatar_revision}
|
||||
)
|
||||
if avatar_revision is not None and avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
badges=[
|
||||
Badge(
|
||||
id=i.id,
|
||||
description=i.label,
|
||||
group=i.group,
|
||||
receive_at=i.ts if isinstance(i.ts, datetime) else None,
|
||||
)
|
||||
for i in user_info.data.badges
|
||||
],
|
||||
country=user_info.data.country,
|
||||
role=user_info.data.role,
|
||||
xp=user_info.data.xp,
|
||||
friend_count=user_info.data.friend_count,
|
||||
supporter_tier=user_info.data.supporter_tier,
|
||||
bad_standing=user_info.data.badstanding or False,
|
||||
verified=user_info.data.verified or False,
|
||||
playtime=play_time,
|
||||
join_at=user_info.data.ts,
|
||||
),
|
||||
tetra_league=TetraLeague(
|
||||
rank=league.data.rank,
|
||||
highest_rank='z' if isinstance(league.data, NeverRatedData) else league.data.bestrank,
|
||||
tr=round(league.data.tr, 2),
|
||||
glicko=round(league.data.glicko, 2),
|
||||
rd=round(league.data.rd, 2),
|
||||
global_rank=league.data.standing,
|
||||
country_rank=league.data.standing_local,
|
||||
pps=(metrics := get_metrics(pps=league.data.pps, apm=league.data.apm, vs=league.data.vs)).pps,
|
||||
apm=metrics.apm,
|
||||
apl=metrics.apl,
|
||||
vs=metrics.vs,
|
||||
adpl=metrics.adpl,
|
||||
statistic=TetraLeagueStatistic(total=league.data.gamesplayed, wins=league.data.gameswon),
|
||||
decaying=league.data.decaying,
|
||||
history=None,
|
||||
)
|
||||
if not isinstance(league.data, NeverPlayedData)
|
||||
else None,
|
||||
statistic=Statistic(
|
||||
total=handling_special_value(user_info.data.gamesplayed),
|
||||
wins=handling_special_value(user_info.data.gameswon),
|
||||
),
|
||||
sprint=Sprint(
|
||||
time=sprint_value,
|
||||
global_rank=sprint.data.rank,
|
||||
play_at=sprint.data.record.ts,
|
||||
)
|
||||
if sprint.data.record is not None
|
||||
else None,
|
||||
blitz=Blitz(
|
||||
score=blitz.data.record.results.stats.score,
|
||||
global_rank=blitz.data.rank,
|
||||
play_at=blitz.data.record.ts,
|
||||
)
|
||||
if blitz.data.record is not None
|
||||
else None,
|
||||
zen=Zen(level=zen.data.level, score=zen.data.score),
|
||||
),
|
||||
),
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
135
nonebot_plugin_tetris_stats/games/tetrio/query/__init__.py
Normal file
135
nonebot_plugin_tetris_stats/games/tetrio/query/__init__.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from datetime import timezone
|
||||
|
||||
from arclet.alconna import Arg, ArgFlag
|
||||
from nonebot import get_driver
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import Args, At, Option, Subcommand
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User as NBUser
|
||||
from nonebot_plugin_user import get_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from ....db import query_bind_info, trigger
|
||||
from ....i18n import Lang
|
||||
from ....utils.exception import FallbackError
|
||||
from ....utils.typedefs import Me
|
||||
from ... import add_block_handlers, alc
|
||||
from .. import command, get_player
|
||||
from ..api import Player
|
||||
from ..constant import GAME_TYPE
|
||||
from ..models import TETRIOUserConfig
|
||||
from ..typedefs import Template
|
||||
from .v1 import make_query_image_v1
|
||||
from .v2 import make_query_image_v2
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
'query',
|
||||
Args(
|
||||
Arg(
|
||||
'target',
|
||||
At | Me,
|
||||
notice='@想要查询的人 / 自己',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
Arg(
|
||||
'account',
|
||||
get_player,
|
||||
notice='TETR.IO 用户名 / ID',
|
||||
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
|
||||
),
|
||||
),
|
||||
Option(
|
||||
'--template',
|
||||
Arg('template', Template),
|
||||
alias=['-T'],
|
||||
help_text='要使用的查询模板',
|
||||
),
|
||||
help_text='查询 TETR.IO 游戏信息',
|
||||
),
|
||||
)
|
||||
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:查询|查|query|stats)',
|
||||
command='tstats TETR.IO query',
|
||||
humanized='io查',
|
||||
)
|
||||
alc.shortcut(
|
||||
'fkosk',
|
||||
command='tstats TETR.IO query',
|
||||
arguments=['我'],
|
||||
fuzzy=False,
|
||||
humanized='An Easter egg!',
|
||||
)
|
||||
|
||||
add_block_handlers(alc.assign('TETRIO.query'))
|
||||
|
||||
|
||||
async def make_query_result(player: Player, template: Template) -> UniMessage:
|
||||
if template == 'v1':
|
||||
try:
|
||||
return UniMessage.image(raw=await make_query_image_v1(player))
|
||||
except FallbackError:
|
||||
template = 'v2'
|
||||
if template == 'v2':
|
||||
return UniMessage.image(raw=await make_query_image_v2(player))
|
||||
return None
|
||||
|
||||
|
||||
@alc.assign('TETRIO.query')
|
||||
async def _( # noqa: PLR0913
|
||||
user: NBUser,
|
||||
event: Event,
|
||||
matcher: Matcher,
|
||||
target: At | Me,
|
||||
event_session: EventSession,
|
||||
template: Template | None = None,
|
||||
):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[f'--template {template}'] if template is not None else [],
|
||||
):
|
||||
async with get_session() as session:
|
||||
bind = await query_bind_info(
|
||||
session=session,
|
||||
user=await get_user(
|
||||
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
|
||||
),
|
||||
game_platform=GAME_TYPE,
|
||||
)
|
||||
if template is None:
|
||||
template = await session.scalar(
|
||||
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
await (
|
||||
UniMessage.i18n(Lang.interaction.warning.unverified) + await make_query_result(player, template or 'v1')
|
||||
).finish()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.query')
|
||||
async def _(user: NBUser, account: Player, event_session: EventSession, template: Template | None = None):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='query',
|
||||
command_args=[f'--template {template}'] if template is not None else [],
|
||||
):
|
||||
async with get_session() as session:
|
||||
if template is None:
|
||||
template = await session.scalar(
|
||||
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
|
||||
)
|
||||
await (await make_query_result(account, template or 'v1')).finish()
|
||||
56
nonebot_plugin_tetris_stats/games/tetrio/query/tools.py
Normal file
56
nonebot_plugin_tetris_stats/games/tetrio/query/tools.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from typing import TypeVar, overload
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from ....utils.exception import FallbackError
|
||||
from ....utils.render.schemas.tetrio.user.base import TetraLeagueHistoryData
|
||||
from ..api.schemas.labs.leagueflow import Empty, LeagueFlowSuccess
|
||||
from ..api.schemas.summaries.league import InvalidData, LeagueSuccessModel, NeverPlayedData, NeverRatedData, RatedData
|
||||
|
||||
|
||||
def flow_to_history(
|
||||
leagueflow: LeagueFlowSuccess,
|
||||
handle: Callable[[list[TetraLeagueHistoryData]], list[TetraLeagueHistoryData]] | None = None,
|
||||
) -> list[TetraLeagueHistoryData]:
|
||||
if isinstance(leagueflow.data, Empty):
|
||||
raise FallbackError
|
||||
start_time = leagueflow.data.start_time.astimezone(ZoneInfo('Asia/Shanghai'))
|
||||
ret = [
|
||||
TetraLeagueHistoryData(
|
||||
record_at=start_time + timedelta(milliseconds=i.timestamp_offset),
|
||||
tr=i.post_match_tr,
|
||||
)
|
||||
for i in leagueflow.data.points
|
||||
if start_time + timedelta(milliseconds=i.timestamp_offset)
|
||||
]
|
||||
return ret if handle is None else handle(ret)
|
||||
|
||||
|
||||
N = TypeVar('N', int, float)
|
||||
|
||||
|
||||
def handling_special_value(value: N) -> N | None:
|
||||
return value if value != -1 else None
|
||||
|
||||
|
||||
L = TypeVar('L', NeverPlayedData, NeverRatedData, RatedData)
|
||||
|
||||
|
||||
@overload
|
||||
def get_league_data(user_info: LeagueSuccessModel, league_type: type[L]) -> L: ...
|
||||
@overload
|
||||
def get_league_data(
|
||||
user_info: LeagueSuccessModel, league_type: None = None
|
||||
) -> NeverPlayedData | NeverRatedData | RatedData: ...
|
||||
def get_league_data(
|
||||
user_info: LeagueSuccessModel, league_type: type[L] | None = None
|
||||
) -> L | NeverPlayedData | NeverRatedData | RatedData:
|
||||
league = user_info.data
|
||||
if isinstance(league, InvalidData):
|
||||
raise FallbackError
|
||||
if league_type is None:
|
||||
return league
|
||||
if isinstance(league, league_type):
|
||||
return league
|
||||
raise FallbackError
|
||||
197
nonebot_plugin_tetris_stats/games/tetrio/query/v1.py
Normal file
197
nonebot_plugin_tetris_stats/games/tetrio/query/v1.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from asyncio import gather
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import md5
|
||||
from math import ceil, floor
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from ....utils.exception import FallbackError, WhatTheFuckError
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.render import render
|
||||
from ....utils.render.schemas.base import Avatar, Ranking
|
||||
from ....utils.render.schemas.tetrio.user.base import TetraLeagueHistoryData
|
||||
from ....utils.render.schemas.tetrio.user.info_v1 import Info, Radar, TetraLeague, TetraLeagueHistory, User
|
||||
from ....utils.screenshot import screenshot
|
||||
from ..api import Player
|
||||
from ..api.schemas.summaries.league import RatedData
|
||||
from ..constant import TR_MAX, TR_MIN
|
||||
from .tools import flow_to_history, get_league_data
|
||||
|
||||
|
||||
def get_value_bounds(values: list[int | float]) -> tuple[int, int]:
|
||||
value_max = 10 * ceil(max(values) / 10)
|
||||
value_min = 10 * floor(min(values) / 10)
|
||||
return value_max, value_min
|
||||
|
||||
|
||||
def get_split(value_max: int, value_min: int) -> tuple[int, int]:
|
||||
offset = 0
|
||||
overflow = 0
|
||||
|
||||
while True:
|
||||
if (new_max_value := value_max + offset + overflow) > TR_MAX:
|
||||
overflow -= 1
|
||||
continue
|
||||
if (new_min_value := value_min - offset + overflow) < TR_MIN:
|
||||
overflow += 1
|
||||
continue
|
||||
if ((new_max_value - new_min_value) / 40).is_integer():
|
||||
split_value = int((value_max + offset - (value_min - offset)) / 4)
|
||||
break
|
||||
offset += 1
|
||||
return split_value, offset + overflow
|
||||
|
||||
|
||||
def get_specified_point(
|
||||
previous_point: TetraLeagueHistoryData,
|
||||
behind_point: TetraLeagueHistoryData,
|
||||
point_time: datetime,
|
||||
) -> TetraLeagueHistoryData:
|
||||
"""根据给出的 previous_point 和 behind_point, 推算 point_time 点处的数据
|
||||
|
||||
Args:
|
||||
previous_point (Data): 前面的数据点
|
||||
behind_point (Data): 后面的数据点
|
||||
point_time (datetime): 要推算的点的位置
|
||||
|
||||
Returns:
|
||||
Data: 要推算的点的数据
|
||||
"""
|
||||
# 求两个点的斜率
|
||||
slope = (behind_point.tr - previous_point.tr) / (
|
||||
datetime.timestamp(behind_point.record_at) - datetime.timestamp(previous_point.record_at)
|
||||
)
|
||||
return TetraLeagueHistoryData(
|
||||
record_at=point_time,
|
||||
tr=previous_point.tr + slope * (datetime.timestamp(point_time) - datetime.timestamp(previous_point.record_at)),
|
||||
)
|
||||
|
||||
|
||||
def handle_history_data(data: list[TetraLeagueHistoryData]) -> list[TetraLeagueHistoryData]: # noqa: C901, PLR0912
|
||||
# 按照 记录时间 对数据进行排序
|
||||
data.sort(key=lambda x: x.record_at)
|
||||
|
||||
# 定义时间边界, 右边界为当前时间的当天零点, 左边界为右边界前推9天
|
||||
# 返回值的[0]和[-1]分别应满足left_border和right_border
|
||||
zero = datetime.now(ZoneInfo('Asia/Shanghai')).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
left_border = zero - timedelta(days=9)
|
||||
right_border = zero.replace(microsecond=1000)
|
||||
|
||||
lefts: list[TetraLeagueHistoryData] = []
|
||||
in_border: list[TetraLeagueHistoryData] = []
|
||||
rights: list[TetraLeagueHistoryData] = []
|
||||
|
||||
# 根据 记录时间 将数据分类到对应的列表中
|
||||
for i in data:
|
||||
if i.record_at < left_border:
|
||||
lefts.append(i)
|
||||
elif i.record_at < right_border:
|
||||
in_border.append(i)
|
||||
else:
|
||||
rights.append(i)
|
||||
|
||||
ret: list[TetraLeagueHistoryData] = []
|
||||
|
||||
# 处理左边界的点
|
||||
if lefts and in_border: # 如果边界左侧和边界内都有值则推算
|
||||
ret.append(get_specified_point(lefts[-1], in_border[0], left_border))
|
||||
elif lefts and not in_border: # 如果边界左侧有值但是边界内没有值则直接取左侧的最后一个值
|
||||
ret.append(TetraLeagueHistoryData(tr=lefts[-1].tr, record_at=left_border))
|
||||
elif not lefts and in_border: # 如果边界左侧没有值但是边界内有值则直接取边界内的第一个值
|
||||
ret.append(TetraLeagueHistoryData(tr=in_border[0].tr, record_at=left_border))
|
||||
elif not lefts and not in_border and rights: # 如果边界左侧和边界内都没有值但是边界右侧有值则直接取边界右侧的第一个值 # fmt: skip
|
||||
ret.append(TetraLeagueHistoryData(tr=rights[0].tr, record_at=left_border))
|
||||
else: # 暂时没想到其他情况
|
||||
raise WhatTheFuckError
|
||||
|
||||
# 添加边界内数据
|
||||
ret.extend(in_border)
|
||||
|
||||
# 处理右边界的点
|
||||
if in_border and rights: # 如果边界内和边界右侧都有值则推算
|
||||
ret.append(get_specified_point(in_border[-1], rights[0], right_border))
|
||||
elif not in_border and rights: # 如果边界内没有值但是边界右侧有值则直接取右侧的第一个值
|
||||
ret.append(TetraLeagueHistoryData(tr=rights[0].tr, record_at=right_border))
|
||||
elif in_border and not rights: # 如果边界内有值但是边界右侧没有值则直接取边界内的最后一个值
|
||||
ret.append(TetraLeagueHistoryData(tr=in_border[-1].tr, record_at=right_border))
|
||||
elif not in_border and not rights and lefts: # 如果边界内和边界右侧都没有值但是边界左侧有值则直接取边界左侧的最后一个值 # fmt: skip
|
||||
ret.append(TetraLeagueHistoryData(tr=lefts[-1].tr, record_at=right_border))
|
||||
else: # 暂时没想到其他情况
|
||||
raise WhatTheFuckError
|
||||
return ret
|
||||
|
||||
|
||||
async def make_query_image_v1(player: Player) -> bytes:
|
||||
(
|
||||
(user, user_info, league, sprint, blitz, leagueflow),
|
||||
(avatar_revision,),
|
||||
) = await gather(
|
||||
gather(player.user, player.get_info(), player.league, player.sprint, player.blitz, player.get_leagueflow()),
|
||||
gather(player.avatar_revision),
|
||||
)
|
||||
league_data = get_league_data(league, RatedData)
|
||||
if league_data.vs is None:
|
||||
raise FallbackError
|
||||
histories = flow_to_history(leagueflow, handle_history_data)
|
||||
value_max, value_min = get_value_bounds([i.tr for i in histories])
|
||||
split_value, offset = get_split(value_max, value_min)
|
||||
if sprint.data.record is not None:
|
||||
duration = timedelta(milliseconds=sprint.data.record.results.stats.finaltime).total_seconds()
|
||||
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
||||
else:
|
||||
sprint_value = 'N/A'
|
||||
blitz_value = f'{blitz.data.record.results.stats.score:,}' if blitz.data.record is not None else 'N/A'
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
page=await render(
|
||||
'v1/tetrio/info',
|
||||
Info(
|
||||
user=User(
|
||||
avatar=str(
|
||||
URL(f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}') % {'revision': avatar_revision}
|
||||
)
|
||||
if avatar_revision is not None and avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
name=user.name.upper(),
|
||||
bio=user_info.data.bio,
|
||||
),
|
||||
ranking=Ranking(
|
||||
rating=round(league_data.glicko, 2),
|
||||
rd=round(league_data.rd, 2),
|
||||
),
|
||||
tetra_league=TetraLeague(
|
||||
rank=league_data.rank,
|
||||
tr=round(league_data.tr, 2),
|
||||
global_rank=league_data.standing,
|
||||
pps=league_data.pps,
|
||||
lpm=round(lpm := (league_data.pps * 24), 2),
|
||||
apm=league_data.apm,
|
||||
apl=round(league_data.apm / lpm, 2),
|
||||
vs=league_data.vs,
|
||||
adpm=round(adpm := (league_data.vs * 0.6), 2),
|
||||
adpl=round(adpm / lpm, 2),
|
||||
),
|
||||
tetra_league_history=TetraLeagueHistory(
|
||||
data=histories,
|
||||
split_interval=split_value,
|
||||
min_tr=value_min,
|
||||
max_tr=value_max,
|
||||
offset=offset,
|
||||
),
|
||||
radar=Radar(
|
||||
app=(app := (league_data.apm / (60 * league_data.pps))),
|
||||
dsps=(dsps := ((league_data.vs / 100) - (league_data.apm / 60))),
|
||||
dspp=(dspp := (dsps / league_data.pps)),
|
||||
ci=150 * dspp - 125 * app + 50 * (league_data.vs / league_data.apm) - 25,
|
||||
ge=2 * ((app * dsps) / league_data.pps),
|
||||
),
|
||||
sprint=sprint_value,
|
||||
blitz=blitz_value,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
138
nonebot_plugin_tetris_stats/games/tetrio/query/v2.py
Normal file
138
nonebot_plugin_tetris_stats/games/tetrio/query/v2.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from asyncio import gather
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import md5
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from ....utils.exception import FallbackError
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.metrics import get_metrics
|
||||
from ....utils.render import render
|
||||
from ....utils.render.schemas.base import Avatar
|
||||
from ....utils.render.schemas.tetrio.user.info_v2 import (
|
||||
Badge,
|
||||
Blitz,
|
||||
Info,
|
||||
Sprint,
|
||||
Statistic,
|
||||
TetraLeague,
|
||||
TetraLeagueStatistic,
|
||||
User,
|
||||
Zen,
|
||||
)
|
||||
from ....utils.screenshot import screenshot
|
||||
from ..api import Player
|
||||
from ..api.schemas.summaries.league import InvalidData, NeverPlayedData, NeverRatedData
|
||||
from .tools import flow_to_history, handling_special_value
|
||||
|
||||
|
||||
async def make_query_image_v2(player: Player) -> bytes:
|
||||
(
|
||||
(user, user_info, league, sprint, blitz, zen),
|
||||
(avatar_revision, banner_revision, leagueflow),
|
||||
) = await gather(
|
||||
gather(player.user, player.get_info(), player.league, player.sprint, player.blitz, player.zen),
|
||||
gather(player.avatar_revision, player.banner_revision, player.get_leagueflow()),
|
||||
)
|
||||
if sprint.data.record is not None:
|
||||
duration = timedelta(milliseconds=sprint.data.record.results.stats.finaltime).total_seconds()
|
||||
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
||||
else:
|
||||
sprint_value = 'N/A'
|
||||
|
||||
play_time: str | None
|
||||
if (game_time := handling_special_value(user_info.data.gametime)) is not None:
|
||||
if game_time // 3600 > 0:
|
||||
play_time = f'{game_time // 3600:.0f}h {game_time % 3600 // 60:.0f}m {game_time % 60:.0f}s'
|
||||
elif game_time // 60 > 0:
|
||||
play_time = f'{game_time // 60:.0f}m {game_time % 60:.0f}s'
|
||||
else:
|
||||
play_time = f'{game_time:.0f}s'
|
||||
else:
|
||||
play_time = game_time
|
||||
try:
|
||||
history = flow_to_history(leagueflow, lambda x: x[-100:])
|
||||
except FallbackError:
|
||||
history = None
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v2/tetrio/user/info',
|
||||
Info(
|
||||
user=User(
|
||||
id=user.ID,
|
||||
name=user.name.upper(),
|
||||
bio=user_info.data.bio,
|
||||
banner=str(
|
||||
URL(f'http://{netloc}/host/resource/tetrio/banners/{user.ID}') % {'revision': banner_revision}
|
||||
)
|
||||
if banner_revision is not None and banner_revision != 0
|
||||
else None,
|
||||
avatar=str(
|
||||
URL(f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}') % {'revision': avatar_revision}
|
||||
)
|
||||
if avatar_revision is not None and avatar_revision != 0
|
||||
else Avatar(
|
||||
type='identicon',
|
||||
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
||||
),
|
||||
badges=[
|
||||
Badge(
|
||||
id=i.id,
|
||||
description=i.label,
|
||||
group=i.group,
|
||||
receive_at=i.ts if isinstance(i.ts, datetime) else None,
|
||||
)
|
||||
for i in user_info.data.badges
|
||||
],
|
||||
country=user_info.data.country,
|
||||
role=user_info.data.role,
|
||||
xp=user_info.data.xp,
|
||||
friend_count=user_info.data.friend_count,
|
||||
supporter_tier=user_info.data.supporter_tier,
|
||||
bad_standing=user_info.data.badstanding or False,
|
||||
playtime=play_time,
|
||||
join_at=user_info.data.ts,
|
||||
),
|
||||
tetra_league=TetraLeague(
|
||||
rank=league.data.rank,
|
||||
highest_rank='z' if isinstance(league.data, NeverRatedData) else league.data.bestrank,
|
||||
tr=round(league.data.tr, 2),
|
||||
glicko=round(league.data.glicko, 2),
|
||||
rd=round(league.data.rd, 2),
|
||||
global_rank=league.data.standing,
|
||||
country_rank=league.data.standing_local,
|
||||
pps=(metrics := get_metrics(pps=league.data.pps, apm=league.data.apm, vs=league.data.vs)).pps,
|
||||
apm=metrics.apm,
|
||||
apl=metrics.apl,
|
||||
vs=metrics.vs,
|
||||
adpl=metrics.adpl,
|
||||
statistic=TetraLeagueStatistic(total=league.data.gamesplayed, wins=league.data.gameswon),
|
||||
decaying=league.data.decaying,
|
||||
history=history,
|
||||
)
|
||||
if not isinstance(league.data, NeverPlayedData | InvalidData)
|
||||
else None,
|
||||
statistic=Statistic(
|
||||
total=handling_special_value(user_info.data.gamesplayed),
|
||||
wins=handling_special_value(user_info.data.gameswon),
|
||||
),
|
||||
sprint=Sprint(
|
||||
time=sprint_value,
|
||||
global_rank=sprint.data.rank,
|
||||
play_at=sprint.data.record.ts,
|
||||
)
|
||||
if sprint.data.record is not None
|
||||
else None,
|
||||
blitz=Blitz(
|
||||
score=blitz.data.record.results.stats.score,
|
||||
global_rank=blitz.data.rank,
|
||||
play_at=blitz.data.record.ts,
|
||||
)
|
||||
if blitz.data.record is not None
|
||||
else None,
|
||||
zen=Zen(level=zen.data.level, score=zen.data.score),
|
||||
),
|
||||
),
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
|
||||
153
nonebot_plugin_tetris_stats/games/tetrio/rank/__init__.py
Normal file
153
nonebot_plugin_tetris_stats/games/tetrio/rank/__init__.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import floor
|
||||
from statistics import mean
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from nonebot import get_driver
|
||||
from nonebot_plugin_alconna import Subcommand
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
from nonebot_plugin_orm import get_session
|
||||
from sqlalchemy import select
|
||||
|
||||
from ....utils.exception import RequestError
|
||||
from ....utils.retry import retry
|
||||
from .. import alc
|
||||
from .. import command as base_command
|
||||
from ..api.leaderboards import by
|
||||
from ..api.schemas.base import P
|
||||
from ..api.schemas.leaderboards import Parameter
|
||||
from ..api.schemas.leaderboards.by import Entry
|
||||
from ..constant import RANK_PERCENTILE
|
||||
from ..models import TETRIOLeagueHistorical, TETRIOLeagueStats, TETRIOLeagueStatsField
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..api.schemas.leaderboards.by import BySuccessModel
|
||||
from ..api.typedefs import Rank
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
command = Subcommand('rank', help_text='查询 TETR.IO 段位信息')
|
||||
|
||||
|
||||
def wrapper(slot: int | str, content: str | None) -> str | None:
|
||||
if slot == 'rank' and not content:
|
||||
return '--all'
|
||||
if content is not None:
|
||||
return f'--detail {content.lower()}'
|
||||
return content
|
||||
|
||||
|
||||
alc.shortcut(
|
||||
r'(?i:io)(?i:段位|段|rank)\s*(?P<rank>[a-zA-Z+-]{0,2})',
|
||||
command='tstats TETR.IO rank {rank}',
|
||||
humanized='iorank',
|
||||
fuzzy=False,
|
||||
wrapper=wrapper,
|
||||
)
|
||||
|
||||
|
||||
def _pps(user: Entry) -> float:
|
||||
return user.league.pps
|
||||
|
||||
|
||||
def _apm(user: Entry) -> float:
|
||||
return user.league.apm
|
||||
|
||||
|
||||
def _vs(user: Entry) -> float:
|
||||
return user.league.vs
|
||||
|
||||
|
||||
def _min(users: Sequence[Entry], field: Callable[[Entry], float]) -> Entry:
|
||||
return min(users, key=field)
|
||||
|
||||
|
||||
def _max(users: Sequence[Entry], field: Callable[[Entry], float]) -> Entry:
|
||||
return max(users, key=field)
|
||||
|
||||
|
||||
def find_special_player(
|
||||
users: Sequence[Entry],
|
||||
field: Callable[[Entry], float],
|
||||
sort: Callable[[Sequence[Entry], Callable[[Entry], float]], Entry],
|
||||
) -> Entry:
|
||||
return sort(users, field)
|
||||
|
||||
|
||||
@scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
|
||||
async def get_tetra_league_data() -> None:
|
||||
x_session_id = uuid4()
|
||||
retry_by = retry(max_attempts=10, exception_type=RequestError)(by)
|
||||
prisecter = P(pri=9007199254740991, sec=9007199254740991, ter=9007199254740991) # * from ch.tetr.io
|
||||
results: list[BySuccessModel] = []
|
||||
while True:
|
||||
model = await retry_by('league', Parameter(after=prisecter.to_prisecter(), limit=100), x_session_id)
|
||||
prisecter = model.data.entries[-1].p
|
||||
results.append(model)
|
||||
if len(model.data.entries) < 100: # 分页值 # noqa: PLR2004
|
||||
break
|
||||
|
||||
players: list[Entry] = []
|
||||
for result in results:
|
||||
players.extend([i for i in result.data.entries if isinstance(i, Entry)])
|
||||
players.sort(key=lambda x: x.league.tr, reverse=True)
|
||||
|
||||
rank_player_mapping: defaultdict[Rank, list[Entry]] = defaultdict(list)
|
||||
for player in players:
|
||||
rank_player_mapping[player.league.rank].append(player)
|
||||
|
||||
stats = TETRIOLeagueStats(raw=[], fields=[], update_time=datetime.now(UTC))
|
||||
fields: list[TETRIOLeagueStatsField] = []
|
||||
for rank, percentile in RANK_PERCENTILE.items():
|
||||
offset = floor((percentile / 100) * len(players)) - 1
|
||||
tr_line = players[offset].league.tr
|
||||
rank_players = rank_player_mapping[rank]
|
||||
fields.append(
|
||||
TETRIOLeagueStatsField(
|
||||
rank=rank,
|
||||
tr_line=tr_line,
|
||||
player_count=len(rank_players),
|
||||
low_pps=find_special_player(rank_players, _pps, _min),
|
||||
low_apm=find_special_player(rank_players, _apm, _min),
|
||||
low_vs=find_special_player(rank_players, _vs, _min),
|
||||
avg_pps=mean(_pps(i) for i in rank_players),
|
||||
avg_apm=mean(_apm(i) for i in rank_players),
|
||||
avg_vs=mean(_vs(i) for i in rank_players),
|
||||
high_pps=find_special_player(rank_players, _pps, _max),
|
||||
high_apm=find_special_player(rank_players, _apm, _max),
|
||||
high_vs=find_special_player(rank_players, _vs, _max),
|
||||
stats=stats,
|
||||
)
|
||||
)
|
||||
historicals = [
|
||||
TETRIOLeagueHistorical(request_id=x_session_id, data=model, update_time=model.cache.cached_at, stats=stats)
|
||||
for model in results
|
||||
]
|
||||
stats.raw = historicals
|
||||
stats.fields = fields
|
||||
async with get_session() as session:
|
||||
session.add(stats)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
async def _() -> None:
|
||||
async with get_session() as session:
|
||||
latest_time = await session.scalar(
|
||||
select(TETRIOLeagueStats.update_time).order_by(TETRIOLeagueStats.id.desc()).limit(1)
|
||||
)
|
||||
if latest_time is None or datetime.now(tz=UTC) - latest_time.replace(tzinfo=UTC) > timedelta(hours=6):
|
||||
await get_tetra_league_data()
|
||||
|
||||
|
||||
from . import all, detail # noqa: A004, E402
|
||||
|
||||
base_command.add(command)
|
||||
|
||||
__all__ = ['all', 'detail']
|
||||
115
nonebot_plugin_tetris_stats/games/tetrio/rank/all.py
Normal file
115
nonebot_plugin_tetris_stats/games/tetrio/rank/all.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from arclet.alconna import Arg
|
||||
from nonebot_plugin_alconna import Option, Subcommand, UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ....db import trigger
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.metrics import get_metrics
|
||||
from ....utils.render import render
|
||||
from ....utils.render.schemas.tetrio.rank.v1 import Data as DataV1
|
||||
from ....utils.render.schemas.tetrio.rank.v1 import ItemData as ItemDataV1
|
||||
from ....utils.render.schemas.tetrio.rank.v2 import AverageData as AverageDataV2
|
||||
from ....utils.render.schemas.tetrio.rank.v2 import Data as DataV2
|
||||
from ....utils.render.schemas.tetrio.rank.v2 import ItemData as ItemDataV2
|
||||
from ....utils.screenshot import screenshot
|
||||
from .. import alc
|
||||
from ..constant import GAME_TYPE
|
||||
from ..models import TETRIOLeagueStats
|
||||
from ..typedefs import Template
|
||||
from . import command
|
||||
|
||||
command.add(
|
||||
Subcommand(
|
||||
'--all', Option('--template', Arg('template', Template), alias=['-T'], help_text='要使用的查询模板'), dest='all'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@alc.assign('TETRIO.rank.all')
|
||||
async def _(event_session: EventSession, template: Template | None = None):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='rank',
|
||||
command_args=['--all'] + ([f'--template {template}'] if template is not None else []),
|
||||
):
|
||||
async with get_session() as session:
|
||||
latest_data = (
|
||||
await session.scalars(
|
||||
select(TETRIOLeagueStats)
|
||||
.order_by(TETRIOLeagueStats.id.desc())
|
||||
.limit(1)
|
||||
.options(selectinload(TETRIOLeagueStats.fields))
|
||||
)
|
||||
).one()
|
||||
compare_data = (
|
||||
await session.scalars(
|
||||
select(TETRIOLeagueStats)
|
||||
.order_by(
|
||||
func.abs(
|
||||
func.julianday(TETRIOLeagueStats.update_time)
|
||||
- func.julianday(latest_data.update_time - timedelta(hours=24))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.options(selectinload(TETRIOLeagueStats.fields))
|
||||
)
|
||||
).one()
|
||||
match template:
|
||||
case 'v1' | None:
|
||||
await UniMessage.image(raw=await make_image_v1(latest_data, compare_data)).finish()
|
||||
case 'v2':
|
||||
await UniMessage.image(raw=await make_image_v2(latest_data, compare_data)).finish()
|
||||
|
||||
|
||||
async def make_image_v1(latest_data: TETRIOLeagueStats, compare_data: TETRIOLeagueStats) -> bytes:
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/tetrio/rank',
|
||||
DataV1(
|
||||
items={
|
||||
i[0].rank: ItemDataV1(
|
||||
trending=round(i[0].tr_line - i[1].tr_line, 2),
|
||||
require_tr=round(i[0].tr_line, 2),
|
||||
players=i[0].player_count,
|
||||
)
|
||||
for i in zip(latest_data.fields, compare_data.fields, strict=True)
|
||||
},
|
||||
updated_at=latest_data.update_time,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
|
||||
|
||||
|
||||
async def make_image_v2(latest_data: TETRIOLeagueStats, compare_data: TETRIOLeagueStats) -> bytes:
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v2/tetrio/rank',
|
||||
DataV2(
|
||||
items={
|
||||
i[0].rank: ItemDataV2(
|
||||
require_tr=round(i[0].tr_line, 2),
|
||||
trending=round(i[0].tr_line - i[1].tr_line, 2),
|
||||
average_data=AverageDataV2(
|
||||
pps=(metrics := get_metrics(pps=i[0].avg_pps, apm=i[0].avg_apm, vs=i[0].avg_vs)).pps,
|
||||
apm=metrics.apm,
|
||||
apl=metrics.apl,
|
||||
vs=metrics.vs,
|
||||
adpl=metrics.adpl,
|
||||
),
|
||||
players=i[0].player_count,
|
||||
)
|
||||
for i in zip(latest_data.fields, compare_data.fields, strict=True)
|
||||
},
|
||||
updated_at=latest_data.update_time,
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
|
||||
128
nonebot_plugin_tetris_stats/games/tetrio/rank/detail.py
Normal file
128
nonebot_plugin_tetris_stats/games/tetrio/rank/detail.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from datetime import timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from arclet.alconna import Arg
|
||||
from nonebot import get_driver
|
||||
from nonebot_plugin_alconna import Option, UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ....db import trigger
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.metrics import get_metrics
|
||||
from ....utils.render import render
|
||||
from ....utils.render.schemas.tetrio.rank.detail import Data, SpecialData
|
||||
from ....utils.screenshot import screenshot
|
||||
from .. import alc
|
||||
from ..api.typedefs import ValidRank
|
||||
from ..constant import GAME_TYPE
|
||||
from ..models import TETRIOLeagueStats
|
||||
from . import command
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
command.add(Option('--detail', Arg('rank', ValidRank), alias=['-D']))
|
||||
|
||||
|
||||
@alc.assign('TETRIO.rank')
|
||||
async def _(rank: ValidRank, event_session: EventSession):
|
||||
async with trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='rank',
|
||||
command_args=[f'--detail {rank}'],
|
||||
):
|
||||
async with get_session() as session:
|
||||
latest_data = (
|
||||
await session.scalars(
|
||||
select(TETRIOLeagueStats)
|
||||
.order_by(TETRIOLeagueStats.id.desc())
|
||||
.limit(1)
|
||||
.options(selectinload(TETRIOLeagueStats.fields))
|
||||
)
|
||||
).one()
|
||||
compare_data = (
|
||||
await session.scalars(
|
||||
select(TETRIOLeagueStats)
|
||||
.order_by(
|
||||
func.abs(
|
||||
func.julianday(TETRIOLeagueStats.update_time)
|
||||
- func.julianday(latest_data.update_time - timedelta(hours=24))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.options(selectinload(TETRIOLeagueStats.fields))
|
||||
)
|
||||
).one()
|
||||
await UniMessage.image(
|
||||
raw=await make_image(
|
||||
rank,
|
||||
latest_data,
|
||||
compare_data,
|
||||
)
|
||||
).finish()
|
||||
|
||||
|
||||
async def make_image(rank: ValidRank, latest: TETRIOLeagueStats, compare: TETRIOLeagueStats) -> bytes:
|
||||
latest_data = next(filter(lambda x: x.rank == rank, latest.fields))
|
||||
compare_data = next(filter(lambda x: x.rank == rank, compare.fields))
|
||||
avg = get_metrics(pps=latest_data.avg_pps, apm=latest_data.avg_apm, vs=latest_data.avg_vs)
|
||||
low_pps = get_metrics(
|
||||
pps=latest_data.low_pps.league.pps, apm=latest_data.low_pps.league.apm, vs=latest_data.low_pps.league.vs
|
||||
)
|
||||
low_apm = get_metrics(
|
||||
pps=latest_data.low_apm.league.pps, apm=latest_data.low_apm.league.apm, vs=latest_data.low_apm.league.vs
|
||||
)
|
||||
low_vs = get_metrics(
|
||||
pps=latest_data.low_vs.league.pps, apm=latest_data.low_vs.league.apm, vs=latest_data.low_vs.league.vs
|
||||
)
|
||||
max_pps = get_metrics(
|
||||
pps=latest_data.high_pps.league.pps, apm=latest_data.high_pps.league.apm, vs=latest_data.high_pps.league.vs
|
||||
)
|
||||
max_apm = get_metrics(
|
||||
pps=latest_data.high_apm.league.pps, apm=latest_data.high_apm.league.apm, vs=latest_data.high_apm.league.vs
|
||||
)
|
||||
max_vs = get_metrics(
|
||||
pps=latest_data.high_vs.league.pps, apm=latest_data.high_vs.league.apm, vs=latest_data.high_vs.league.vs
|
||||
)
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v2/tetrio/rank/detail',
|
||||
Data(
|
||||
name=latest_data.rank,
|
||||
trending=round(latest_data.tr_line - compare_data.tr_line, 2),
|
||||
require_tr=round(latest_data.tr_line, 2),
|
||||
players=latest_data.player_count,
|
||||
minimum_data=SpecialData(
|
||||
apm=low_apm.apm,
|
||||
pps=low_pps.pps,
|
||||
lpm=low_pps.lpm,
|
||||
vs=low_vs.vs,
|
||||
adpm=low_vs.adpm,
|
||||
apm_holder=latest_data.low_apm.username.upper(),
|
||||
pps_holder=latest_data.low_pps.username.upper(),
|
||||
vs_holder=latest_data.low_vs.username.upper(),
|
||||
),
|
||||
average_data=SpecialData(
|
||||
apm=avg.apm, pps=avg.pps, lpm=avg.lpm, vs=avg.vs, adpm=avg.adpm, apl=avg.apl, adpl=avg.adpl
|
||||
),
|
||||
maximum_data=SpecialData(
|
||||
apm=max_apm.apm,
|
||||
pps=max_pps.pps,
|
||||
lpm=max_pps.lpm,
|
||||
vs=max_vs.vs,
|
||||
adpm=max_vs.adpm,
|
||||
apm_holder=latest_data.high_apm.username.upper(),
|
||||
pps_holder=latest_data.high_pps.username.upper(),
|
||||
vs_holder=latest_data.high_vs.username.upper(),
|
||||
),
|
||||
updated_at=latest.update_time.replace(tzinfo=UTC).astimezone(ZoneInfo('Asia/Shanghai')),
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
return await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
|
||||
@@ -1,7 +1,7 @@
|
||||
from arclet.alconna import Arg, ArgFlag
|
||||
from nonebot_plugin_alconna import Args, At, Subcommand
|
||||
|
||||
from ....utils.typing import Me
|
||||
from ....utils.typedefs import Me
|
||||
from .. import command as base_command
|
||||
from .. import get_player
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from nonebot_plugin_user import get_user
|
||||
from yarl import URL
|
||||
|
||||
from ....db import query_bind_info, trigger
|
||||
from ....i18n import Lang
|
||||
from ....utils.exception import RecordNotFoundError
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.metrics import get_metrics
|
||||
@@ -21,8 +22,7 @@ from ....utils.render.schemas.base import Avatar
|
||||
from ....utils.render.schemas.tetrio.record.base import Finesse, Max, Mini, Tspins, User
|
||||
from ....utils.render.schemas.tetrio.record.blitz import Record, Statistic
|
||||
from ....utils.screenshot import screenshot
|
||||
from ....utils.typing import Me
|
||||
from ...constant import CANT_VERIFY_MESSAGE
|
||||
from ....utils.typedefs import Me
|
||||
from .. import alc
|
||||
from ..api.player import Player
|
||||
from ..constant import GAME_TYPE
|
||||
@@ -60,9 +60,10 @@ async def _(
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = UniMessage(CANT_VERIFY_MESSAGE)
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
await (message + UniMessage.image(raw=await make_blitz_image(player))).finish()
|
||||
await (
|
||||
UniMessage.i18n(Lang.interaction.warning.unverified) + UniMessage.image(raw=await make_blitz_image(player))
|
||||
).finish()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.record.blitz')
|
||||
|
||||
@@ -13,6 +13,7 @@ from nonebot_plugin_user import get_user
|
||||
from yarl import URL
|
||||
|
||||
from ....db import query_bind_info, trigger
|
||||
from ....i18n import Lang
|
||||
from ....utils.exception import RecordNotFoundError
|
||||
from ....utils.host import HostPage, get_self_netloc
|
||||
from ....utils.metrics import get_metrics
|
||||
@@ -21,8 +22,7 @@ from ....utils.render.schemas.base import Avatar
|
||||
from ....utils.render.schemas.tetrio.record.base import Finesse, Max, Mini, Statistic, Tspins, User
|
||||
from ....utils.render.schemas.tetrio.record.sprint import Record
|
||||
from ....utils.screenshot import screenshot
|
||||
from ....utils.typing import Me
|
||||
from ...constant import CANT_VERIFY_MESSAGE
|
||||
from ....utils.typedefs import Me
|
||||
from .. import alc
|
||||
from ..api.player import Player
|
||||
from ..constant import GAME_TYPE
|
||||
@@ -60,9 +60,10 @@ async def _(
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = UniMessage(CANT_VERIFY_MESSAGE)
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
await (message + UniMessage.image(raw=await make_sprint_image(player))).finish()
|
||||
await (
|
||||
UniMessage.i18n(Lang.interaction.warning.unverified) + UniMessage.image(raw=await make_sprint_image(player))
|
||||
).finish()
|
||||
|
||||
|
||||
@alc.assign('TETRIO.record.sprint')
|
||||
|
||||
75
nonebot_plugin_tetris_stats/games/tetrio/unbind.py
Normal file
75
nonebot_plugin_tetris_stats/games/tetrio/unbind.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from hashlib import md5
|
||||
|
||||
from nonebot_plugin_alconna import Subcommand
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User
|
||||
from nonebot_plugin_userinfo import BotUserInfo, UserInfo
|
||||
from nonebot_plugin_waiter import suggest # type: ignore[import-untyped]
|
||||
from yarl import URL
|
||||
|
||||
from ...db import query_bind_info, remove_bind, trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
from ...utils.render import Bind, render
|
||||
from ...utils.render.schemas.base import Avatar, People
|
||||
from ...utils.screenshot import screenshot
|
||||
from . import alc, command
|
||||
from .api import Player
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
command.add(Subcommand('unbind', help_text='解除绑定 TETR.IO 账号'))
|
||||
|
||||
alc.shortcut(
|
||||
'(?i:io)(?i:解除绑定|解绑|unbind)',
|
||||
command='tstats TETR.IO unbind',
|
||||
humanized='io解绑',
|
||||
)
|
||||
|
||||
|
||||
@alc.assign('TETRIO.unbind')
|
||||
async def _(nb_user: User, event_session: EventSession, bot_info: UserInfo = BotUserInfo()): # noqa: B008
|
||||
async with (
|
||||
trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='unbind',
|
||||
command_args=[],
|
||||
),
|
||||
get_session() as session,
|
||||
):
|
||||
if (bind := await query_bind_info(session=session, user=nb_user, game_platform=GAME_TYPE)) is None:
|
||||
await UniMessage('您还未绑定 TETR.IO 账号').finish()
|
||||
resp = await suggest('您确定要解绑吗?', ['是', '否'])
|
||||
if resp is None or resp.extract_plain_text() == '否':
|
||||
return
|
||||
player = Player(user_id=bind.game_account, trust=True)
|
||||
user = await player.user
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/binding',
|
||||
Bind(
|
||||
platform='TETR.IO',
|
||||
status='unlink',
|
||||
user=People(
|
||||
avatar=str(
|
||||
URL(f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}')
|
||||
% {'revision': avatar_revision}
|
||||
)
|
||||
if (avatar_revision := (await player.avatar_revision)) is not None and avatar_revision != 0
|
||||
else Avatar(type='identicon', hash=md5(user.ID.encode()).hexdigest()), # noqa: S324
|
||||
name=user.name.upper(),
|
||||
),
|
||||
bot=People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='io绑定{游戏ID}',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
await UniMessage.image(raw=await screenshot(f'http://{netloc}/host/{page_hash}.html')).send()
|
||||
await remove_bind(session=session, user=nb_user, game_platform=GAME_TYPE)
|
||||
@@ -2,7 +2,7 @@ from arclet.alconna import Arg, ArgFlag
|
||||
from nonebot_plugin_alconna import Args, At, Subcommand
|
||||
|
||||
from ...utils.exception import MessageFormatError
|
||||
from ...utils.typing import Me
|
||||
from ...utils.typedefs import Me
|
||||
from .. import add_block_handlers, alc, command
|
||||
from .api import Player
|
||||
from .constant import USER_NAME
|
||||
@@ -29,6 +29,10 @@ command.add(
|
||||
),
|
||||
help_text='绑定 TOP 账号',
|
||||
),
|
||||
Subcommand(
|
||||
'unbind',
|
||||
help_text='解除绑定 TOP 账号',
|
||||
),
|
||||
Subcommand(
|
||||
'query',
|
||||
Args(
|
||||
@@ -51,9 +55,22 @@ command.add(
|
||||
)
|
||||
)
|
||||
|
||||
alc.shortcut('(?i:top)(?i:绑定|绑|bind)', {'command': 'tstats TOP bind', 'humanized': 'top绑定'})
|
||||
alc.shortcut('(?i:top)(?i:查询|查|query|stats)', {'command': 'tstats TOP query', 'humanized': 'top查'})
|
||||
alc.shortcut(
|
||||
'(?i:top)(?i:绑定|绑|bind)',
|
||||
command='tstats TOP bind',
|
||||
humanized='top绑定',
|
||||
)
|
||||
alc.shortcut(
|
||||
'(?i:top)(?i:解除绑定|解绑|unbind)',
|
||||
command='tstats TOP unbind',
|
||||
humanized='top解绑',
|
||||
)
|
||||
alc.shortcut(
|
||||
'(?i:top)(?i:查询|查|query|stats)',
|
||||
command='tstats TOP query',
|
||||
humanized='top查',
|
||||
)
|
||||
|
||||
add_block_handlers(alc.assign('TOP.query'))
|
||||
|
||||
from . import bind, query # noqa: E402, F401
|
||||
from . import bind, query, unbind # noqa: E402, F401
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from re import compile
|
||||
from re import compile # noqa: A004
|
||||
from typing import Literal
|
||||
|
||||
from yarl import URL
|
||||
|
||||
@@ -8,6 +8,7 @@ from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[im
|
||||
from nonebot_plugin_user import get_user
|
||||
|
||||
from ...db import query_bind_info, trigger
|
||||
from ...i18n import Lang
|
||||
from ...utils.exception import FallbackError
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.metrics import TetrisMetricsBasicWithLPM, get_metrics
|
||||
@@ -17,8 +18,7 @@ from ...utils.render.schemas.base import People
|
||||
from ...utils.render.schemas.top_info import Data as InfoData
|
||||
from ...utils.render.schemas.top_info import Info
|
||||
from ...utils.screenshot import screenshot
|
||||
from ...utils.typing import Me
|
||||
from ..constant import CANT_VERIFY_MESSAGE
|
||||
from ...utils.typedefs import Me
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .api.schemas.user_profile import Data, UserProfile
|
||||
@@ -44,7 +44,7 @@ async def _(event: Event, matcher: Matcher, target: At | Me, event_session: Even
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
await (
|
||||
UniMessage(CANT_VERIFY_MESSAGE)
|
||||
UniMessage.i18n(Lang.interaction.warning.unverified)
|
||||
+ await make_query_result(await Player(user_name=bind.game_account, trust=True).get_profile())
|
||||
).finish()
|
||||
|
||||
|
||||
63
nonebot_plugin_tetris_stats/games/top/unbind.py
Normal file
63
nonebot_plugin_tetris_stats/games/top/unbind.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User
|
||||
from nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo
|
||||
from nonebot_plugin_waiter import suggest # type: ignore[import-untyped]
|
||||
|
||||
from ...db import query_bind_info, remove_bind, trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
from ...utils.render import Bind, render
|
||||
from ...utils.render.schemas.base import People
|
||||
from ...utils.screenshot import screenshot
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TOP.unbind')
|
||||
async def _(
|
||||
nb_user: User,
|
||||
event_session: EventSession,
|
||||
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
|
||||
bot_info: UserInfo = BotUserInfo(), # noqa: B008
|
||||
):
|
||||
async with (
|
||||
trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='unbind',
|
||||
command_args=[],
|
||||
),
|
||||
get_session() as session,
|
||||
):
|
||||
if (bind := await query_bind_info(session=session, user=nb_user, game_platform=GAME_TYPE)) is None:
|
||||
await UniMessage('您还未绑定 TOP 账号').finish()
|
||||
resp = await suggest('您确定要解绑吗?', ['是', '否'])
|
||||
if resp is None or resp.extract_plain_text() == '否':
|
||||
return
|
||||
player = Player(user_name=bind.game_account, trust=True)
|
||||
user = await player.user
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/binding',
|
||||
Bind(
|
||||
platform='TOP',
|
||||
status='unlink',
|
||||
user=People(
|
||||
avatar=await get_avatar(event_user_info, 'Data URI', None),
|
||||
name=user.user_name,
|
||||
),
|
||||
bot=People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='top绑定{游戏ID}',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
await UniMessage.image(raw=await screenshot(f'http://{netloc}/host/{page_hash}.html')).send()
|
||||
await remove_bind(session=session, user=nb_user, game_platform=GAME_TYPE)
|
||||
@@ -2,7 +2,7 @@ from arclet.alconna import Arg, ArgFlag
|
||||
from nonebot_plugin_alconna import Args, At, Subcommand
|
||||
|
||||
from ...utils.exception import MessageFormatError
|
||||
from ...utils.typing import Me
|
||||
from ...utils.typedefs import Me
|
||||
from .. import add_block_handlers, alc, command
|
||||
from .api import Player
|
||||
from .constant import USER_NAME
|
||||
@@ -34,6 +34,10 @@ command.add(
|
||||
),
|
||||
help_text='绑定 茶服 账号',
|
||||
),
|
||||
Subcommand(
|
||||
'unbind',
|
||||
help_text='解除绑定 TOS 账号',
|
||||
),
|
||||
Subcommand(
|
||||
'query',
|
||||
Args(
|
||||
@@ -56,9 +60,22 @@ command.add(
|
||||
)
|
||||
)
|
||||
|
||||
alc.shortcut('(?i:tos|茶服)(?i:绑定|绑|bind)', {'command': 'tstats TOS bind', 'humanized': '茶服绑定'})
|
||||
alc.shortcut('(?i:tos|茶服)(?i:查询|查|query|stats)', {'command': 'tstats TOS query', 'humanized': '茶服查'})
|
||||
alc.shortcut(
|
||||
'(?i:tos|茶服)(?i:绑定|绑|bind)',
|
||||
command='tstats TOS bind',
|
||||
humanized='茶服绑定',
|
||||
)
|
||||
alc.shortcut(
|
||||
'(?i:tos|茶服)(?i:解除绑定|解绑|unbind)',
|
||||
command='tstats TOS unbind',
|
||||
humanized='茶服解绑',
|
||||
)
|
||||
alc.shortcut(
|
||||
'(?i:tos|茶服)(?i:查询|查|query|stats)',
|
||||
command='tstats TOS query',
|
||||
humanized='茶服查',
|
||||
)
|
||||
|
||||
add_block_handlers(alc.assign('TOS.query'))
|
||||
|
||||
from . import bind, query # noqa: E402, F401
|
||||
from . import bind, query, unbind # noqa: E402, F401
|
||||
|
||||
@@ -59,13 +59,14 @@ class Player:
|
||||
async def get_info(self) -> UserInfoSuccess:
|
||||
"""获取用户信息"""
|
||||
if self._user_info is None:
|
||||
path = str(
|
||||
URL('getTeaIdInfo') % {'teaId': self.teaid}
|
||||
if self.teaid is not None
|
||||
else URL('getUsernameInfo') % {'username': cast(str, self.user_name)}
|
||||
)
|
||||
if self.teaid is not None:
|
||||
path = 'getTeaIdInfo'
|
||||
query = {'teaId': self.teaid}
|
||||
else:
|
||||
path = 'getUsernameInfo'
|
||||
query = {'username': cast('str', self.user_name)}
|
||||
raw_user_info = await request.failover_request(
|
||||
[i / path for i in BASE_URL], failover_code=[502], failover_exc=(TimeoutException,)
|
||||
[i / path % query for i in BASE_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):
|
||||
@@ -90,7 +91,7 @@ class Player:
|
||||
if self._user_profile.get(params) is None:
|
||||
raw_user_profile = await request.failover_request(
|
||||
[
|
||||
i / 'getProfile' % {'id': self.teaid or cast(str, self.user_name), **other_parameter}
|
||||
i / 'getProfile' % {'id': self.teaid or cast('str', self.user_name), **other_parameter}
|
||||
for i in BASE_URL
|
||||
],
|
||||
failover_code=[502],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from re import compile
|
||||
from re import compile # noqa: A004
|
||||
from typing import Literal
|
||||
|
||||
from yarl import URL
|
||||
|
||||
@@ -14,6 +14,7 @@ from nonebot_plugin_user import get_user
|
||||
from nonebot_plugin_userinfo import EventUserInfo, UserInfo
|
||||
|
||||
from ...db import query_bind_info, trigger
|
||||
from ...i18n import Lang
|
||||
from ...utils.exception import RequestError
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
@@ -23,8 +24,7 @@ from ...utils.render.avatar import get_avatar as get_random_avatar
|
||||
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 ...utils.typedefs import Me, Number
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .api.schemas.user_info import UserInfoSuccess
|
||||
@@ -124,7 +124,7 @@ async def _(
|
||||
)
|
||||
if bind is None:
|
||||
await matcher.finish('未查询到绑定信息')
|
||||
message = CANT_VERIFY_MESSAGE
|
||||
message = UniMessage.i18n(Lang.interaction.warning.unverified)
|
||||
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:
|
||||
@@ -258,7 +258,7 @@ def make_query_text(user_info: UserInfoSuccess, game_data: GameData | None) -> U
|
||||
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)}) '
|
||||
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:
|
||||
@@ -266,7 +266,7 @@ def make_query_text(user_info: UserInfoSuccess, game_data: GameData | None) -> U
|
||||
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'\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)
|
||||
|
||||
66
nonebot_plugin_tetris_stats/games/tos/unbind.py
Normal file
66
nonebot_plugin_tetris_stats/games/tos/unbind.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
from nonebot_plugin_orm import get_session
|
||||
from nonebot_plugin_session import EventSession
|
||||
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
|
||||
from nonebot_plugin_user import User
|
||||
from nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo
|
||||
from nonebot_plugin_waiter import suggest # type: ignore[import-untyped]
|
||||
|
||||
from ...db import query_bind_info, remove_bind, trigger
|
||||
from ...utils.host import HostPage, get_self_netloc
|
||||
from ...utils.image import get_avatar
|
||||
from ...utils.render import Bind, render
|
||||
from ...utils.render.avatar import get_avatar as get_random_avatar
|
||||
from ...utils.render.schemas.base import People
|
||||
from ...utils.screenshot import screenshot
|
||||
from . import alc
|
||||
from .api import Player
|
||||
from .constant import GAME_TYPE
|
||||
|
||||
|
||||
@alc.assign('TOP.unbind')
|
||||
async def _(
|
||||
nb_user: User,
|
||||
event_session: EventSession,
|
||||
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
|
||||
bot_info: UserInfo = BotUserInfo(), # noqa: B008
|
||||
):
|
||||
async with (
|
||||
trigger(
|
||||
session_persist_id=await get_session_persist_id(event_session),
|
||||
game_platform=GAME_TYPE,
|
||||
command_type='unbind',
|
||||
command_args=[],
|
||||
),
|
||||
get_session() as session,
|
||||
):
|
||||
if (bind := await query_bind_info(session=session, user=nb_user, game_platform=GAME_TYPE)) is None:
|
||||
await UniMessage('您还未绑定 TOP 账号').finish()
|
||||
resp = await suggest('您确定要解绑吗?', ['是', '否'])
|
||||
if resp is None or resp.extract_plain_text() == '否':
|
||||
return
|
||||
player = Player(user_name=bind.game_account, trust=True)
|
||||
user = await player.user
|
||||
netloc = get_self_netloc()
|
||||
async with HostPage(
|
||||
await render(
|
||||
'v1/binding',
|
||||
Bind(
|
||||
platform='TOS',
|
||||
status='unlink',
|
||||
user=People(
|
||||
avatar=await get_avatar(event_user_info, 'Data URI', None)
|
||||
if event_user_info is not None
|
||||
else get_random_avatar(user.teaid),
|
||||
name=user.name,
|
||||
),
|
||||
bot=People(
|
||||
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
|
||||
name=bot_info.user_name,
|
||||
),
|
||||
command='茶服绑定{游戏ID}',
|
||||
),
|
||||
)
|
||||
) as page_hash:
|
||||
await UniMessage.image(raw=await screenshot(f'http://{netloc}/host/{page_hash}.html')).send()
|
||||
await remove_bind(session=session, user=nb_user, game_platform=GAME_TYPE)
|
||||
5
nonebot_plugin_tetris_stats/i18n/.config.json
Normal file
5
nonebot_plugin_tetris_stats/i18n/.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"default": "en-US",
|
||||
"frozen": [],
|
||||
"require": []
|
||||
}
|
||||
72
nonebot_plugin_tetris_stats/i18n/.lang.schema.json
Normal file
72
nonebot_plugin_tetris_stats/i18n/.lang.schema.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"title": "Lang Schema",
|
||||
"description": "Schema for lang file",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interaction": {
|
||||
"title": "Interaction",
|
||||
"description": "Scope 'interaction' of lang item",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"wrong": {
|
||||
"title": "Wrong",
|
||||
"description": "Scope 'wrong' of lang item",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"query_bot": {
|
||||
"title": "query_bot",
|
||||
"description": "value of lang item type 'query_bot'",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"warning": {
|
||||
"title": "Warning",
|
||||
"description": "Scope 'warning' of lang item",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"unverified": {
|
||||
"title": "unverified",
|
||||
"description": "value of lang item type 'unverified'",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "Error",
|
||||
"description": "Scope 'error' of lang item",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"MessageFormatError": {
|
||||
"title": "Messageformaterror",
|
||||
"description": "Scope 'MessageFormatError' of lang item",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"TETR.IO": {
|
||||
"title": "TETR.IO",
|
||||
"description": "value of lang item type 'TETR.IO'",
|
||||
"type": "string"
|
||||
},
|
||||
"TOS": {
|
||||
"title": "TOS",
|
||||
"description": "value of lang item type 'TOS'",
|
||||
"type": "string"
|
||||
},
|
||||
"TOP": {
|
||||
"title": "TOP",
|
||||
"description": "value of lang item type 'TOP'",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
nonebot_plugin_tetris_stats/i18n/.template.json
Normal file
16
nonebot_plugin_tetris_stats/i18n/.template.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": ".template.schema.json",
|
||||
"scopes": [
|
||||
{
|
||||
"scope": "interaction",
|
||||
"types": [
|
||||
{ "subtype": "wrong", "types": ["query_bot"] },
|
||||
{ "subtype": "warning", "types": ["unverified"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"scope": "error",
|
||||
"types": [{ "subtype": "MessageFormatError", "types": ["TETR.IO", "TOS", "TOP"] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
54
nonebot_plugin_tetris_stats/i18n/.template.schema.json
Normal file
54
nonebot_plugin_tetris_stats/i18n/.template.schema.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"title": "Template",
|
||||
"description": "Template for lang items to generate schema for lang files",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scopes": {
|
||||
"title": "Scopes",
|
||||
"description": "All scopes of lang items",
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"title": "Scope",
|
||||
"description": "First level of all lang items",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "Scope name"
|
||||
},
|
||||
"types": {
|
||||
"type": "array",
|
||||
"description": "All types of lang items",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Value of lang item"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subtype": {
|
||||
"type": "string",
|
||||
"description": "Subtype name of lang item"
|
||||
},
|
||||
"types": {
|
||||
"type": "array",
|
||||
"description": "All subtypes of lang items",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/properties/scopes/items/properties/types/items"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
nonebot_plugin_tetris_stats/i18n/__init__.py
Normal file
12
nonebot_plugin_tetris_stats/i18n/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# This file is @generated by tarina.lang CLI tool
|
||||
# It is not intended for manual editing.
|
||||
|
||||
# ruff: noqa: E402, F401, PLC0414
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tarina.lang import lang # type: ignore[import-untyped]
|
||||
|
||||
lang.load(Path(__file__).parent)
|
||||
|
||||
from .model import Lang as Lang
|
||||
16
nonebot_plugin_tetris_stats/i18n/en-US.json
Normal file
16
nonebot_plugin_tetris_stats/i18n/en-US.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": ".lang.schema.json",
|
||||
"interaction": {
|
||||
"wrong": { "query_bot": "Can't query bot's information" },
|
||||
"warning": {
|
||||
"unverified": "* Because I can't verify account linking information, I can't guarantee the info I found is yourself/themself."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"MessageFormatError": {
|
||||
"TETR.IO": "Username/ID is invalid",
|
||||
"TOS": "Username/ID is invalid",
|
||||
"TOP": "Username is invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
nonebot_plugin_tetris_stats/i18n/model.py
Normal file
32
nonebot_plugin_tetris_stats/i18n/model.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# This file is @generated by tarina.lang CLI tool
|
||||
# It is not intended for manual editing.
|
||||
|
||||
from tarina.lang.model import LangItem, LangModel # type: ignore[import-untyped]
|
||||
|
||||
|
||||
class InteractionWrong:
|
||||
query_bot: LangItem = LangItem('interaction', 'wrong.query_bot')
|
||||
|
||||
|
||||
class InteractionWarning:
|
||||
unverified: LangItem = LangItem('interaction', 'warning.unverified')
|
||||
|
||||
|
||||
class Interaction:
|
||||
wrong = InteractionWrong
|
||||
warning = InteractionWarning
|
||||
|
||||
|
||||
class ErrorMessageformaterror:
|
||||
TETR_IO: LangItem = LangItem('error', 'MessageFormatError.TETR.IO')
|
||||
TOS: LangItem = LangItem('error', 'MessageFormatError.TOS')
|
||||
TOP: LangItem = LangItem('error', 'MessageFormatError.TOP')
|
||||
|
||||
|
||||
class Error:
|
||||
MessageFormatError = ErrorMessageformaterror
|
||||
|
||||
|
||||
class Lang(LangModel):
|
||||
interaction = Interaction
|
||||
error = Error
|
||||
14
nonebot_plugin_tetris_stats/i18n/zh-CN.json
Normal file
14
nonebot_plugin_tetris_stats/i18n/zh-CN.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": ".lang.schema.json",
|
||||
"interaction": {
|
||||
"wrong": { "query_bot": "不能查询bot的信息" },
|
||||
"warning": { "unverified": "* 由于无法验证绑定信息, 不能保证查询到的用户为本人" }
|
||||
},
|
||||
"error": {
|
||||
"MessageFormatError": {
|
||||
"TETR.IO": "用户名/ID不合法",
|
||||
"TOS": "用户名/ID不合法",
|
||||
"TOP": "用户名不合法"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import sys
|
||||
from collections.abc import Callable, Coroutine
|
||||
from os import environ
|
||||
from platform import system
|
||||
from re import sub
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from nonebot import get_driver
|
||||
from nonebot.log import logger
|
||||
from playwright.__main__ import main
|
||||
from playwright.async_api import Browser, async_playwright
|
||||
from playwright.async_api import Browser, BrowserContext, async_playwright
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
@@ -27,6 +29,7 @@ class BrowserManager:
|
||||
"""浏览器管理类"""
|
||||
|
||||
_browser: Browser | None = None
|
||||
_contexts: ClassVar[dict[str, BrowserContext]] = {}
|
||||
|
||||
@classmethod
|
||||
async def init_playwright(cls) -> None:
|
||||
@@ -72,7 +75,11 @@ class BrowserManager:
|
||||
async def _start_browser(cls) -> Browser:
|
||||
"""启动浏览器实例"""
|
||||
playwright = await async_playwright().start()
|
||||
cls._browser = await playwright.firefox.launch()
|
||||
cls._browser = await playwright.firefox.launch(
|
||||
firefox_user_prefs={
|
||||
'network.http.max-persistent-connections-per-server': 64,
|
||||
},
|
||||
)
|
||||
return cls._browser
|
||||
|
||||
@classmethod
|
||||
@@ -80,8 +87,26 @@ class BrowserManager:
|
||||
"""获取浏览器实例"""
|
||||
return cls._browser or await cls._start_browser()
|
||||
|
||||
@classmethod
|
||||
async def get_context(
|
||||
cls, context_id: str = 'default', factory: Callable[[], Coroutine[Any, Any, BrowserContext]] | None = None
|
||||
) -> BrowserContext:
|
||||
"""获取浏览器上下文"""
|
||||
return cls._contexts.setdefault(
|
||||
context_id, await factory() if factory is not None else await (await cls.get_browser()).new_context()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def del_context(cls, context_id: str) -> None:
|
||||
"""删除浏览器上下文"""
|
||||
if context_id in cls._contexts:
|
||||
await cls._contexts[context_id].close()
|
||||
del cls._contexts[context_id]
|
||||
|
||||
@classmethod
|
||||
async def close_browser(cls) -> None:
|
||||
"""关闭浏览器实例"""
|
||||
for i in cls._contexts.values():
|
||||
await i.close()
|
||||
if isinstance(cls._browser, Browser):
|
||||
await cls._browser.close()
|
||||
|
||||
@@ -2,9 +2,9 @@ from functools import cache
|
||||
from hashlib import sha256
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from pathlib import Path as FilePath
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
from typing import TYPE_CHECKING, Annotated, ClassVar, Literal
|
||||
|
||||
from aiofiles import open
|
||||
from aiofiles import open as aopen
|
||||
from fastapi import BackgroundTasks, FastAPI, Path, status
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -69,9 +69,9 @@ def _(page_hash: str) -> HTMLResponse:
|
||||
@app.get('/host/resource/tetrio/{resource_type}/{user_id}', status_code=status.HTTP_200_OK)
|
||||
async def _(
|
||||
resource_type: Literal['avatars', 'banners'],
|
||||
user_id: Annotated[str, Path(regex=r'^[a-f0-9]{24}$')],
|
||||
revision: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
user_id: str = Path(regex=r'^[a-f0-9]{24}$'),
|
||||
) -> Response:
|
||||
if not (path := CACHE_PATH / 'tetrio' / resource_type / f'{user_id}_{revision}.png').exists():
|
||||
image = img_to_png(
|
||||
@@ -87,7 +87,7 @@ async def _(
|
||||
|
||||
async def write_cache(path: FilePath, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with open(path, 'wb') as file:
|
||||
async with aopen(path, 'wb') as file:
|
||||
await file.write(data)
|
||||
|
||||
|
||||
|
||||
35
nonebot_plugin_tetris_stats/utils/limit.py
Normal file
35
nonebot_plugin_tetris_stats/utils/limit.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from asyncio import Lock, sleep
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from time import time
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
from nonebot.log import logger
|
||||
|
||||
P = ParamSpec('P')
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def limit(limit: timedelta) -> Callable[[Callable[P, Coroutine[Any, Any, T]]], Callable[P, Coroutine[Any, Any, T]]]:
|
||||
limit_seconds = limit.total_seconds()
|
||||
|
||||
def decorator(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, Coroutine[Any, Any, T]]:
|
||||
last_call = 0.0
|
||||
lock = Lock()
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
nonlocal last_call
|
||||
async with lock:
|
||||
if (diff := (time() - last_call)) < limit_seconds:
|
||||
logger.debug(
|
||||
f'func: {func.__name__} trigger limit, wait {(limit_time := limit_seconds - diff):.3f}s'
|
||||
)
|
||||
await sleep(limit_time)
|
||||
last_call = time()
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import overload
|
||||
|
||||
from .typing import Number
|
||||
from .typedefs import Number
|
||||
|
||||
|
||||
class TetrisMetricsBaseWithPPS:
|
||||
|
||||
@@ -156,7 +156,7 @@ class SkinManager:
|
||||
|
||||
|
||||
class Skin(ABC):
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> Self: # noqa: ANN401, ARG003
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> Self: # noqa: ANN401, ARG004
|
||||
instance = super().__new__(cls)
|
||||
SkinManager.register(instance)
|
||||
return instance
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...typing import Number
|
||||
from ...typedefs import Number
|
||||
|
||||
|
||||
class Avatar(BaseModel):
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ......games.tetrio.api.typing import ValidRank
|
||||
from ......games.tetrio.api.typedefs import ValidRank
|
||||
|
||||
|
||||
class SpecialData(BaseModel):
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ......games.tetrio.api.typing import ValidRank
|
||||
from ......games.tetrio.api.typedefs import ValidRank
|
||||
|
||||
|
||||
class ItemData(BaseModel):
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ......games.tetrio.api.typing import ValidRank
|
||||
from ......games.tetrio.api.typedefs import ValidRank
|
||||
|
||||
|
||||
class AverageData(BaseModel):
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .....typing import Number
|
||||
from .....typedefs import Number
|
||||
|
||||
|
||||
class TetraLeagueHistoryData(BaseModel):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ......games.tetrio.api.typing import Rank
|
||||
from .....typing import Number
|
||||
from ......games.tetrio.api.typedefs import Rank
|
||||
from .....typedefs import Number
|
||||
from ...base import People, Ranking
|
||||
from .base import TetraLeagueHistoryData
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ......games.tetrio.api.typing import Rank
|
||||
from .....typing import Number
|
||||
from ......games.tetrio.api.typedefs import Rank
|
||||
from .....typedefs import Number
|
||||
from ...base import Avatar
|
||||
from .base import TetraLeagueHistoryData
|
||||
|
||||
@@ -21,7 +21,7 @@ class User(BaseModel):
|
||||
name: str
|
||||
country: str | None
|
||||
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
|
||||
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'hidden', 'banned']
|
||||
|
||||
avatar: str | Avatar
|
||||
banner: str | None
|
||||
@@ -31,7 +31,6 @@ class User(BaseModel):
|
||||
friend_count: int | None
|
||||
supporter_tier: int
|
||||
|
||||
verified: bool
|
||||
bad_standing: bool
|
||||
|
||||
badges: list[Badge]
|
||||
|
||||
@@ -2,8 +2,8 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ......games.tetrio.api.typing import Rank
|
||||
from .....typing import Number
|
||||
from ......games.tetrio.api.typedefs import Rank
|
||||
from .....typedefs import Number
|
||||
from ...base import Avatar
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ class User(BaseModel):
|
||||
name: str
|
||||
avatar: str | Avatar
|
||||
country: str | None
|
||||
verified: bool
|
||||
tetra_league: TetraLeague
|
||||
xp: Number
|
||||
join_at: datetime | None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...typing import Number
|
||||
from ...typedefs import Number
|
||||
from .base import People
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...typing import Number
|
||||
from ...typedefs import Number
|
||||
from .base import People, Ranking
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from collections.abc import Sequence
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from fake_useragent import UserAgent
|
||||
from httpx import AsyncClient, HTTPError
|
||||
from msgspec import DecodeError, Struct, json
|
||||
from nonebot import get_driver
|
||||
@@ -113,10 +114,13 @@ class Request:
|
||||
def __init__(self, proxy: str | None) -> None:
|
||||
self.proxy = proxy
|
||||
self.anti_cloudflares: dict[str, AntiCloudflare] = {}
|
||||
self.client = AsyncClient(timeout=config.tetris.request_timeout, proxy=self.proxy)
|
||||
self.ua = UserAgent()
|
||||
|
||||
async def request(
|
||||
self,
|
||||
url: URL,
|
||||
extra_headers: dict | None = None,
|
||||
*,
|
||||
is_json: bool = True,
|
||||
enable_anti_cloudflare: bool = False,
|
||||
@@ -128,15 +132,20 @@ class Request:
|
||||
else:
|
||||
cookies = None
|
||||
headers = None
|
||||
if headers is None:
|
||||
headers = {}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
headers.setdefault('User-Agent', self.ua.random)
|
||||
try:
|
||||
async with AsyncClient(cookies=cookies, timeout=config.tetris.request_timeout, proxy=self.proxy) as session:
|
||||
response = await session.get(str(url), headers=headers)
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
msg = f'请求错误 code: {response.status_code} {HTTPStatus(response.status_code).phrase}\n{response.text}'
|
||||
raise RequestError(msg, status_code=response.status_code)
|
||||
if is_json:
|
||||
decoder.decode(response.content)
|
||||
return response.content
|
||||
response = await self.client.get(str(url), cookies=cookies, headers=headers)
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
msg = (
|
||||
f'请求错误 code: {response.status_code} {HTTPStatus(response.status_code).phrase}\n{response.text}'
|
||||
)
|
||||
raise RequestError(msg, status_code=response.status_code)
|
||||
if is_json:
|
||||
decoder.decode(response.content)
|
||||
except HTTPError as e:
|
||||
msg = f'请求错误 \n{e!r}'
|
||||
raise RequestError(msg) from e
|
||||
@@ -144,6 +153,8 @@ class Request:
|
||||
if enable_anti_cloudflare and url.host is not None:
|
||||
return await self.anti_cloudflares.setdefault(url.host, AntiCloudflare(url.host))(str(url), self.proxy)
|
||||
raise
|
||||
else:
|
||||
return response.content
|
||||
|
||||
async def failover_request(
|
||||
self,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from playwright.async_api import TimeoutError, ViewportSize
|
||||
from playwright.async_api import BrowserContext, TimeoutError, ViewportSize # noqa: A004
|
||||
|
||||
from ..config.config import config
|
||||
from .browser import BrowserManager
|
||||
@@ -6,13 +6,15 @@ from .retry import retry
|
||||
from .time_it import time_it
|
||||
|
||||
|
||||
async def context_factory() -> BrowserContext:
|
||||
return await (await BrowserManager.get_browser()).new_context(device_scale_factor=config.tetris.screenshot_quality)
|
||||
|
||||
|
||||
@retry(exception_type=TimeoutError, reply='截图失败, 重试中')
|
||||
@time_it
|
||||
async def screenshot(url: str) -> bytes:
|
||||
browser = await BrowserManager.get_browser()
|
||||
async with (
|
||||
await browser.new_page(device_scale_factor=config.tetris.screenshot_quality) as page,
|
||||
):
|
||||
context = await BrowserManager.get_context('screenshot', factory=context_factory)
|
||||
async with await context.new_page() as page:
|
||||
await page.goto(url)
|
||||
size: ViewportSize = await page.evaluate("""
|
||||
() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ from shutil import rmtree
|
||||
from time import time_ns
|
||||
from zipfile import ZipFile
|
||||
|
||||
from aiofiles import open
|
||||
from aiofiles import open as aopen
|
||||
from httpx import AsyncClient
|
||||
from nonebot import get_driver
|
||||
from nonebot.log import logger
|
||||
@@ -46,7 +46,7 @@ async def download_templates(tag: str) -> Path:
|
||||
f'https://github.com/A-Minos/tetris-stats-templates/releases/download/{tag}/dist.zip',
|
||||
follow_redirects=True,
|
||||
) as response,
|
||||
open(path, 'wb') as file,
|
||||
aopen(path, 'wb') as file,
|
||||
):
|
||||
response.raise_for_status()
|
||||
progress.update(task_id, total=int(response.headers.get('content-length', 0)) or None)
|
||||
@@ -76,7 +76,7 @@ async def check_hash(hash_file_path: Path) -> bool:
|
||||
if not file_path.is_file():
|
||||
logger.error(f'{file_path.name} 不存在或不是文件')
|
||||
return False
|
||||
async with open(file_path, 'rb') as file:
|
||||
async with aopen(file_path, 'rb') as file:
|
||||
while True:
|
||||
chunk = await file.read(65535)
|
||||
if not chunk:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from typing import Literal
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
Number = float | int
|
||||
GameType = Literal['IO', 'TOP', 'TOS']
|
||||
BaseCommandType = Literal['bind', 'query']
|
||||
TETRIOCommandType = BaseCommandType | Literal['rank', 'config', 'list', 'record']
|
||||
AllCommandType = BaseCommandType | TETRIOCommandType
|
||||
Me = Literal[
|
||||
Number: TypeAlias = float | int
|
||||
GameType: TypeAlias = Literal['IO', 'TOP', 'TOS']
|
||||
BaseCommandType: TypeAlias = Literal['bind', 'unbind', 'query']
|
||||
TETRIOCommandType: TypeAlias = BaseCommandType | Literal['rank', 'config', 'list', 'record']
|
||||
AllCommandType: TypeAlias = BaseCommandType | TETRIOCommandType
|
||||
Me: TypeAlias = Literal[
|
||||
'我',
|
||||
'自己',
|
||||
'我等',
|
||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.3.3"
|
||||
}
|
||||
}
|
||||
24
pnpm-lock.yaml
generated
Normal file
24
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
prettier:
|
||||
specifier: ^3.3.3
|
||||
version: 3.5.3
|
||||
|
||||
packages:
|
||||
|
||||
prettier@3.5.3:
|
||||
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
prettier@3.5.3: {}
|
||||
3708
poetry.lock
generated
3708
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user