Compare commits

..

307 Commits

Author SHA1 Message Date
95d9b74cd7 🔖 1.3.3 2024-06-25 21:50:27 +08:00
4b5f0263e4 🐛 修正 TETR.IO records max 数据异常 2024-06-25 21:49:42 +08:00
7500640330 👷 添加 pre-commit hook 2024-06-25 09:50:50 +08:00
967a028235 🐛 修复 AlconnaMatcher 对 Alconna 的引用变成弱引用导致的问题 2024-06-25 09:24:39 +08:00
dependabot[bot]
abe5e30ede ⬆️ Bump nonebot-adapter-discord from 0.1.7 to 0.1.8 (#349)
Bumps [nonebot-adapter-discord](https://github.com/nonebot/adapter-discord) from 0.1.7 to 0.1.8.
- [Release notes](https://github.com/nonebot/adapter-discord/releases)
- [Commits](https://github.com/nonebot/adapter-discord/compare/v0.1.7...v0.1.8)

---
updated-dependencies:
- dependency-name: nonebot-adapter-discord
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-25 08:49:27 +08:00
dependabot[bot]
d9d3f63118 ⬆️ Bump aiofiles from 23.2.1 to 24.1.0 (#350)
Bumps [aiofiles](https://github.com/Tinche/aiofiles) from 23.2.1 to 24.1.0.
- [Release notes](https://github.com/Tinche/aiofiles/releases)
- [Commits](https://github.com/Tinche/aiofiles/compare/v23.2.1...v24.1.0)

---
updated-dependencies:
- dependency-name: aiofiles
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-25 08:49:09 +08:00
dependabot[bot]
4f864c54bc ⬆️ Bump types-aiofiles from 23.2.0.20240403 to 23.2.0.20240623 (#351)
Bumps [types-aiofiles](https://github.com/python/typeshed) from 23.2.0.20240403 to 23.2.0.20240623.
- [Commits](https://github.com/python/typeshed/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-25 08:49:00 +08:00
dependabot[bot]
2474f77291 ⬆️ Bump nonebot-plugin-alconna from 0.47.1 to 0.47.2 (#352)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.47.1 to 0.47.2.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.47.1...v0.47.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-25 08:48:51 +08:00
渣渣120
6291a2ba70 更新 TETR.IO 模板 (#348)
*  更新路径以匹配模板

*  添加历史数据

* 🎨 优化模板模型代码结构

---------

Co-authored-by: shoucandanghehe <wallfjjd@gmail.com>
2024-06-25 08:47:35 +08:00
77b10a858e 🔖 1.3.2 2024-06-22 11:58:42 +08:00
e908b3b67f 🐛 修复 v2 头图 revision 参数错误 2024-06-22 11:57:58 +08:00
dependabot[bot]
bc98c0a3e6 ⬆️ Bump ruff from 0.4.9 to 0.4.10 (#347)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.9 to 0.4.10.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.4.9...v0.4.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-22 01:26:49 +08:00
渣渣120
f29caf4dc6 ✏️ 修正提示文本 (#344) 2024-06-20 22:43:35 +08:00
dependabot[bot]
a1e88dd1c9 ⬆️ Bump nonebot-adapter-satori from 0.12.2 to 0.12.3 (#342)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.12.2 to 0.12.3.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.12.2...v0.12.3)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-20 08:55:02 +08:00
dependabot[bot]
0dcfa53bcc ⬆️ Bump nonebot-plugin-alconna from 0.46.6 to 0.47.1 (#343)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.46.6 to 0.47.1.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.46.6...v0.47.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-20 08:54:45 +08:00
bf4ccdfd61 🔖 1.3.1 2024-06-18 17:26:45 +08:00
ae65b5140f 🐛 修正 max 数据 2024-06-18 17:26:04 +08:00
95aa5b0419 🐛 修正 spp 算法 2024-06-18 17:22:22 +08:00
b7b92cd785 🔖 1.3.0 2024-06-16 10:48:35 +08:00
f97ae15969 TETR.IO 添加 record 命令 2024-06-16 10:47:50 +08:00
aae43df953 🔥 删除不必要的导入 2024-06-16 09:05:48 +08:00
c58f124f0c 命令历史记录添加两种类型 2024-06-16 09:02:29 +08:00
2f900d0538 🐛 avatar_revision 为 0 时 使用 identicon 2024-06-16 09:01:17 +08:00
3e75a4b4e2 适配 TETR.IO record 模板 2024-06-16 08:11:48 +08:00
e285ccfa15 ⬆️ 更新依赖 2024-06-16 08:11:47 +08:00
dependabot[bot]
d2acbaa0ad ⬆️ Bump nonebot-plugin-alconna from 0.46.5 to 0.46.6 (#340)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.46.5 to 0.46.6.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.46.5...v0.46.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-16 08:11:47 +08:00
dependabot[bot]
c81be48585 ⬆️ Bump ruff from 0.4.8 to 0.4.9 (#339)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.8 to 0.4.9.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.4.8...v0.4.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-16 08:11:47 +08:00
dependabot[bot]
93ec0d8808 ⬆️ Bump nonebot-adapter-satori from 0.11.5 to 0.12.0 (#338)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.11.5 to 0.12.0.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.11.5...v0.12.0)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-16 08:11:46 +08:00
d5e07880fd TETR.IO api 添加一些属性
快捷方式(x
2024-06-16 08:11:46 +08:00
8b370f152d 复用 Zen 的模型 2024-06-14 07:56:05 +08:00
e8527c7ba4 添加 RecordNotFoundError 异常类型 2024-06-14 07:46:51 +08:00
1dd3d310c9 适配 TETR.IO record 模板 2024-06-14 07:45:47 +08:00
b08685086a 🐛 忘记 require nonebot_plugin_userinfo 了 2024-06-14 01:41:22 +08:00
dependabot[bot]
c2b6fe920f ⬆️ Bump nonebot-plugin-alconna from 0.46.4 to 0.46.5 (#337)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.46.4 to 0.46.5.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.46.4...v0.46.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-13 04:53:50 +08:00
a1ad86d0c7 🔖 1.2.15 2024-06-11 02:02:25 +08:00
e6260ce170 添加 TETR.IO rank 的快捷指令 2024-06-11 02:01:23 +08:00
b0e53bc8c8 🔖 1.2.14 2024-06-10 12:03:34 +08:00
2267bc8f14 🐛 修复快捷方式 2024-06-10 12:03:04 +08:00
607a0927bc TETR.IO 默认模板可配置 2024-06-10 11:57:01 +08:00
7b3ca9eb2a 🚚 TETRIOUserConfig 写错地方了 2024-06-10 11:17:49 +08:00
37c12e439c 🔖 1.2.13 2024-06-10 10:58:19 +08:00
504579710e TETR.IO 适配 v2模板 2024-06-10 10:56:56 +08:00
ce94aee0f4 🔖 1.2.12 2024-06-10 02:37:29 +08:00
b9c58ae125 修改插件元数据 2024-06-10 02:37:12 +08:00
92159e93b8 直接使用 fstring 生成链接 2024-06-10 02:35:07 +08:00
f9b11895e2 使用缓存 2024-06-10 02:34:30 +08:00
f7c3d493ea TETR.IO 适配 v2模板 2024-06-10 02:13:03 +08:00
4954ab3d60 TETR.IO 适配 v2模板 2024-06-10 00:48:13 +08:00
bcca869e72 重置命令为shell style,并使用快捷方式保留之前的行为 2024-06-10 00:47:17 +08:00
a4247abdad 添加 TETR.IO 部分资源的缓存 2024-06-10 00:39:36 +08:00
2c1d43601a 🙈 更新.gitignore 2024-06-10 00:31:30 +08:00
c929c463ec 添加 img_to_png 方法 2024-06-10 00:30:54 +08:00
314e1dede3 🔥 去除截图等待状态时的超时 2024-06-10 00:26:26 +08:00
d5b0ef34c5 更新 v2 模板模型 2024-06-10 00:25:21 +08:00
3d9ef841b1 添加两个新异常类型 2024-06-10 00:09:41 +08:00
b98871f170 🔥 删除 HandleNotFinishedError 2024-06-10 00:07:03 +08:00
38ab872dd8 🗃️ 添加 TETRIOUserConfig 2024-06-09 04:24:48 +08:00
f44c0baa2e 🚨 添加一些 type: ignore 2024-06-09 03:59:08 +08:00
9b8d17577e 🔥 删除 get_platform 函数 2024-06-08 12:13:20 +08:00
f301bee2b0 🔥 不小心把测试用注释传上去了) 2024-06-08 12:12:28 +08:00
fbe018e56a 🔖 1.2.11 2024-06-08 12:04:44 +08:00
ab046fe786 使用 nonebot-plugin-user 进行身份绑定 close #63 2024-06-08 12:03:07 +08:00
ce95d8f977 完善 retry 装饰器 2024-06-08 11:57:45 +08:00
fa05b80e61 修改截图方式 2024-06-08 11:57:21 +08:00
0ab0d11a98 添加依赖 nonebot-plugin-user 2024-06-08 11:55:47 +08:00
7f469540b2 ️ 删除一个不必要的async 2024-06-08 00:55:42 +08:00
21bee29146 适配 v2 模板 2024-06-07 21:18:12 +08:00
dependabot[bot]
c2dd9c5d86 ⬆️ Bump nonebot-plugin-alconna from 0.46.3 to 0.46.4 (#333)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.46.3 to 0.46.4.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.46.3...v0.46.4)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-16 05:42:30 +08:00
52947556a4 🐛 忘记把注释的代码取消注释了 2024-05-16 05:41:33 +08:00
7fe9a6fd3d 🔖 1.2.8 2024-05-15 14:56:26 +08:00
6dbfd31eab 🐛 修复 TETR.IO User Records 解析失败的bug 2024-05-15 14:53:43 +08:00
1788d40ed2 🔖 1.2.7 2024-05-15 13:34:55 +08:00
18d8e0cdcc 将本地存储的 TetraLeague FullExport 数据聚合进查询图 2024-05-15 13:34:28 +08:00
b37f927be6 添加 debug 依赖 memory-profiler 2024-05-15 09:14:13 +08:00
314bf4c2f0 🔇 忘记删 debug 日志了 2024-05-15 09:12:24 +08:00
c9f6817c6a 🔖 1.2.6 2024-05-15 08:56:46 +08:00
4c7cd00a76 🐛 修复 histories 只有一条时推算数据出现除数为0的bug 2024-05-15 08:56:22 +08:00
b8cf10b45d 🔖 1.2.5 2024-05-15 04:43:30 +08:00
4ec5c3bde1 🐛 修复 TETR.IO 大写用户名查询失败 2024-05-15 04:42:27 +08:00
270b953bc9 🔖 1.2.4 2024-05-15 04:22:58 +08:00
13bd0da592 🐛 修复去重添加没有正确工作的bug 2024-05-15 04:22:19 +08:00
9545f0b5d0 🔖 1.2.3 2024-05-14 17:26:07 +08:00
12f320cbb4 🐛 修复 PydanticType 过早加载导致获取不到子类的bug 2024-05-14 17:25:42 +08:00
7ff59cfc01 🔖 1.2.2 2024-05-14 17:09:53 +08:00
498781f376 ✏️ 变量名写错了 2024-05-14 17:09:29 +08:00
a3c00dbd93 🔖 1.2.1 2024-05-14 17:00:33 +08:00
069d5953f9 🐛 修复 TETR.IO User Records 解析失败的bug 2024-05-14 17:00:07 +08:00
3721d92f52 🔇 忘记删 debug 日志了 2024-05-14 16:20:57 +08:00
98b58866e1 🔖 1.2.0 2024-05-14 15:40:05 +08:00
dependabot[bot]
189c3999f7 ⬆️ Bump lxml from 5.2.1 to 5.2.2 (#316)
Bumps [lxml](https://github.com/lxml/lxml) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-5.2.1...lxml-5.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-14 15:39:31 +08:00
dependabot[bot]
a2622d5102 ⬆️ Bump types-pillow from 10.2.0.20240423 to 10.2.0.20240511 (#317)
Bumps [types-pillow](https://github.com/python/typeshed) from 10.2.0.20240423 to 10.2.0.20240511.
- [Commits](https://github.com/python/typeshed/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-14 15:04:19 +08:00
呵呵です
c8832bd1c9 ♻️ Refactor (#318)
* 🔧 启用一些 ruff 的新规则

*  添加开发依赖 nonebot-adapter-qq

*  添加依赖 nonebot-plugin-session

*  添加依赖 nonebot-plugin-session-orm

* 🔧 忽略 ruff 规则 ISC001
format 冲突风险

* 🚨 修复 ruff 警报

* ♻️ 重构!

* ♻️ 恢复定时获取 TetraLeague 数据的功能

*  统一处理需要捕获的错误

*  记录用户触发的指令
2024-05-14 15:03:46 +08:00
e6c3a32532 🔖 1.1.5 2024-05-13 04:20:02 +08:00
b3015aaa91 格式化 rating 新增千分位分隔符 2024-05-13 04:19:27 +08:00
dependabot[bot]
abc1038082 ⬆️ Bump ruff from 0.4.3 to 0.4.4 (#315)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.3 to 0.4.4.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.4.3...v0.4.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-11 00:44:54 +08:00
dependabot[bot]
dd91455890 ⬆️ Bump viztracer from 0.16.2 to 0.16.3 (#314)
Bumps [viztracer](https://github.com/gaogaotiantian/viztracer) from 0.16.2 to 0.16.3.
- [Release notes](https://github.com/gaogaotiantian/viztracer/releases)
- [Commits](https://github.com/gaogaotiantian/viztracer/compare/0.16.2...0.16.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-11 00:44:30 +08:00
dependabot[bot]
4b17b0b907 ⬆️ Bump nonebot-plugin-alconna from 0.45.3 to 0.45.4 (#311)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.45.3 to 0.45.4.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.45.3...v0.45.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-10 12:39:57 +08:00
ac4631d1f3 🔖 1.1.4 2024-05-10 12:20:48 +08:00
b0ee7fe6c7 🐛 修复 TETR.IO 默认头像传参错误 2024-05-10 12:20:25 +08:00
5bcecc0623 🔖 1.1.3 2024-05-10 12:11:19 +08:00
9cf048fce4 新增 更新模板 指令 2024-05-10 12:10:45 +08:00
aff2fa120a 🔖 1.1.2 2024-05-10 11:49:05 +08:00
1c057661c2 🐛 防止历史记录一条都没有的数组越界 2024-05-10 11:48:47 +08:00
83bcd14012 🔖 1.1.1 2024-05-10 11:30:08 +08:00
70f53a2c76 🐛 修复 init_templates 和 mount assets 的运行顺序问题 2024-05-10 11:29:46 +08:00
6df70f621e 🔖 1.1.0 2024-05-10 11:14:38 +08:00
8ba3f3c3f4 🎨 拆分函数 2024-05-10 11:13:39 +08:00
a5c4e7df5c 🐛 修正 TETR.IO Badge 模型定义 2024-05-10 09:46:09 +08:00
66db7a8a28 🚨 判断 cookie 是否拥有对应字段 2024-05-10 09:42:15 +08:00
呵呵です
716e392a3a 使用新版模板 (#313)
* 🔥 删除现有模板

*  自动克隆模板仓库

* 🔥 删除 identicon 相关代码

* 🚚 修改静态文件路径

*  使用新模板进行渲染

*  每次渲染都获取一次模板, 以应对实时更新

*  TETR.IO 绑定图使用新模板

* 🚚 修改网络路径

*  TOP 绑定图使用新模板

*  TOS 绑定图使用新模板

* 🐛 防止截图超时

* 🐛 Pydantic V1 会把 float 转换成 int

* ✏️ 模板字段名写错了

*  兼容 Pydantic V1

*  TETR.IO 查询图使用新模板

* 🐛 在查询的用户没有历史记录时不去查询更多记录
2024-05-10 09:41:05 +08:00
呵呵です
e47f1bb6f9 渲染 历史tr 曲线图 (#312) 2024-05-08 18:26:08 +08:00
03d34c5572 🔖 1.0.4 2024-05-07 17:22:46 +08:00
04b480ef52 🗃️ HistoricalData 添加 user_unique_identifier 字段 2024-05-07 17:21:52 +08:00
5563b01937 Revert " 为使用了 alias 的 pydantic model 设置 populate_by_name"
This reverts commit 17690e673f.
2024-05-07 08:51:55 +08:00
504edb08de 🔖 1.0.3 2024-05-07 08:50:53 +08:00
c283f1ca49 🗃️ 更正 HistoricalData 中的数据 2024-05-07 08:50:29 +08:00
0171953264 修改 PydanticType raise 的 Error 类型 2024-05-07 08:48:33 +08:00
7515daccc7 添加依赖 rich 2024-05-07 08:33:03 +08:00
17690e673f 为使用了 alias 的 pydantic model 设置 populate_by_name 2024-05-07 08:32:39 +08:00
e9b3c30a13 🎨 优化模型定义 2024-05-07 06:47:34 +08:00
42484b9c2c 适配 Pydantic V2 2024-05-07 06:47:34 +08:00
42828f23f6 PydanticType dump model 的时候会使用 by_alias=True 2024-05-07 06:47:34 +08:00
d0af2e83c4 更新 Metadata 的 homepage 2024-05-07 06:47:33 +08:00
5534456b22 ⬇️ 错误的使用了 Python3.11 的新特性 2024-05-07 06:47:25 +08:00
dependabot[bot]
1928506021 ⬆️ Bump nonebot-adapter-satori from 0.11.4 to 0.11.5 (#309)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.11.4 to 0.11.5.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.11.4...v0.11.5)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 06:20:54 +08:00
dependabot[bot]
67da935849 ⬆️ Bump ruff from 0.4.2 to 0.4.3 (#308)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.2 to 0.4.3.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.4.2...v0.4.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 06:19:33 +08:00
dependabot[bot]
e1e8743c48 ⬆️ Bump jinja2 from 3.1.3 to 3.1.4 (#307)
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.3...3.1.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 06:17:34 +08:00
e5556bad1d 🔖 1.0.2 2024-05-04 22:10:35 +08:00
889405ea6b 💄 更新数据图样式 2024-05-04 22:06:58 +08:00
66e1850297 使用 UniMessage.finish 2024-05-04 07:54:35 +08:00
f39faced7e 🔖 1.0.1 2024-05-04 07:05:47 +08:00
fffa07dc03 IO查数据使用图片回复 2024-05-04 07:04:57 +08:00
0467b3e5df 💄 微调页脚 2024-05-04 07:04:57 +08:00
f6cc0229ba 💄 新增雷达图数据的 tips 2024-05-04 07:04:57 +08:00
e2708b661d 💄 将雷达图的 OR 替换成 DSPS 2024-05-04 07:04:56 +08:00
65d019a6d3 💄 暂时禁用 TR 折线图 2024-05-04 06:58:31 +08:00
be1b07d5dc 查询图支持处理没有签名的情况 2024-05-04 00:43:06 +08:00
c92bc3aaad 添加开发依赖 types-Pillow 2024-05-03 05:48:39 +08:00
d4b887ef83 💄 css 统一使用 class 2024-05-03 02:12:36 +08:00
dependabot[bot]
695ff13aa2 ⬆️ Bump nonebot-plugin-alconna from 0.45.2 to 0.45.3 (#306)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.45.2 to 0.45.3.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.45.2...v0.45.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-03 00:16:06 +08:00
ec1001b3bb query 也使用 UniMessage 进行发送 2024-05-02 21:29:49 +08:00
b545b12255 将 generate_message 合并到 handle_query 2024-05-02 21:01:50 +08:00
b2505e0979 🔧 更新 ruff 配置 2024-05-02 20:53:27 +08:00
38defe37cd 🚨 删除不需要的 noqa 2024-05-02 20:52:00 +08:00
7a3d7c908c 添加 override 标记 2024-05-02 20:51:27 +08:00
bc37a015d6 🔖 1.0.0 2024-05-02 01:24:29 +08:00
呵呵です
fd85140c99 绑定使用图片回复 #61 (#305)
* 🚧 查数据图初版测试

Co-authored-by: C1ystal <m17687496044@163.com>
Co-authored-by: C29H25N3O5 <michaelgu495@gmail.com>

* 🙈 添加一些 ignore 文件

* 🎨 格式化代码

* 🐛 修复格式化导致的样式爆炸

* 💄 优化曲线图观感

* 💄 将雷达图的指示器名称旋转显示

* 💄 查数据图第二版

Co-authored-by: C29H25N3O5 <michaelgu495@gmail.com>

* ✏️ 修复 typo

* 💄 把用户头像文件的引用放到 html 里

* 💄 账户绑定图第一版

Co-authored-by: C1ystal <m17687496044@163.com>
Co-authored-by: C29H25N3O5 <michaelgu495@gmail.com>

* 🚧 模板化测试

*  添加依赖 fastapi

*  通过 FastAPI 提供静态文件

*  添加依赖 jinja2

* 💄 更新数据图模板 (#291)

* feat(template): show actual value

* feat(template): add user avatar

* feat(template): fix radar

* feat(style): fix name container width fixed caused display misplacement

* feat(style): fix vs value wrap display

* feat(template): make check data length in template

* feat(template): update radar data

* feat(jinja): update data

* fix(template): fix typo

* feat(style): prevent sign too long

* feat(template): turn off echarts animation

* chore(deps): add identicon.js

* fix(template): fix typo

* 🙈 更新.gitignore

* 🏗️ 大部分重构为 flex 布局

---------

Co-authored-by: shoucandanghehe <wallfjjd@gmail.com>
Co-authored-by: 呵呵です <51957264+shoucandanghehe@users.noreply.github.com>

*  添加依赖 nonebot_plugin_userinfo

*  通过 FastAPI 托管渲染后的模板

*  新增头像 api 使用 playwright 生成

*  修正模板资源文件引用路径
被托管后的正确路径

* 💄 将绑定图模板化

* 💄 重命名变量

* 🚚 重命名资源

*  使用 jinja2 渲染模板

*  使用 playwright 渲染网页

* 🩹 渲染模板时对 IO 进行一些额外处理

*  添加依赖 pillow

* 🚚 修改托管页面的路由路径

* 💬 优化绑定图文案

*  新增获取自身网络位置的方法

* 🍱 更新 unknown.svg

*  新增获取用户头像的方法

*  绑定消息使用图片回复

*  为 identicon api 添加缓存

* 🔥 删除旧文件

* 🚚 重命名模板

* 📄 添加字体 License

* 🙈 更新.gitignore

---------

Co-authored-by: C1ystal <m17687496044@163.com>
Co-authored-by: C29H25N3O5 <michaelgu495@gmail.com>
Co-authored-by: 渣渣120 <WOSHIZHAZHA120@qq.com>
2024-05-02 01:22:33 +08:00
dependabot[bot]
80f4316564 ⬆️ Bump nonebot2 from 2.2.1 to 2.3.0 (#304)
Bumps [nonebot2](https://github.com/nonebot/nonebot2) from 2.2.1 to 2.3.0.
- [Release notes](https://github.com/nonebot/nonebot2/releases)
- [Changelog](https://github.com/nonebot/nonebot2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nonebot/nonebot2/compare/v2.2.1...v2.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-02 00:31:44 +08:00
dependabot[bot]
3b9c0c89b1 ⬆️ Bump nonebot-adapter-satori from 0.11.3 to 0.11.4 (#302)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.11.3 to 0.11.4.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.11.3...v0.11.4)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-30 23:25:33 +08:00
c02fdfc47f 🔖 1.0.0.a17 2024-04-30 02:27:31 +08:00
93b169fa40 🐛 修复对 Pydantic V1 的适配 2024-04-30 02:27:08 +08:00
5cb428ed71 🔖 1.0.0.a16 2024-04-30 02:09:34 +08:00
呵呵です
ec392ee384 支持茶服多个api地址的故障转移 (#301)
*  RequestError 新增 status_code 参数

*  新增支持故障转移的请求方法

*  支持茶服多个api地址的故障转移
2024-04-30 01:52:41 +08:00
dependabot[bot]
d037cf6d44 ⬆️ Bump nonebot-adapter-satori from 0.11.2 to 0.11.3 (#300)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.11.2 to 0.11.3.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.11.2...v0.11.3)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-30 00:02:36 +08:00
dependabot[bot]
6964e9b655 ⬆️ Bump nonebot-plugin-orm from 0.7.1 to 0.7.2 (#298)
Bumps [nonebot-plugin-orm](https://github.com/nonebot/plugin-orm) from 0.7.1 to 0.7.2.
- [Release notes](https://github.com/nonebot/plugin-orm/releases)
- [Commits](https://github.com/nonebot/plugin-orm/compare/v0.7.1...v0.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-29 23:56:55 +08:00
dependabot[bot]
7a032bf947 ⬆️ Bump nonebot-adapter-kaiheila from 0.3.3 to 0.3.4 (#299)
Bumps [nonebot-adapter-kaiheila](https://github.com/Tian-que/nonebot-adapter-kaiheila) from 0.3.3 to 0.3.4.
- [Release notes](https://github.com/Tian-que/nonebot-adapter-kaiheila/releases)
- [Commits](https://github.com/Tian-que/nonebot-adapter-kaiheila/compare/v0.3.3...v0.3.4)

---
updated-dependencies:
- dependency-name: nonebot-adapter-kaiheila
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-29 23:56:31 +08:00
dependabot[bot]
9a91e5ef5b ⬆️ Bump nonebot-plugin-alconna from 0.44.0 to 0.45.0 (#297)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.44.0 to 0.45.0.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.44.0...v0.45.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-29 23:56:20 +08:00
dependabot[bot]
5b58697fce ⬆️ Bump nonebot-adapter-satori from 0.11.0 to 0.11.2 (#296)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.11.0 to 0.11.2.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.11.0...v0.11.2)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-28 15:38:57 +08:00
dependabot[bot]
b14cebe832 ⬆️ Bump ruff from 0.4.1 to 0.4.2 (#295)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.1 to 0.4.2.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.4.1...v0.4.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-28 15:38:48 +08:00
dependabot[bot]
4306195ee5 ⬆️ Bump nonebot-plugin-alconna from 0.43.0 to 0.44.0 (#294)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.43.0 to 0.44.0.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.43.0...v0.44.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-28 15:38:39 +08:00
dependabot[bot]
ac9c6e79d9 ⬆️ Bump nonebot-adapter-satori from 0.10.5 to 0.11.0 (#293)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.10.5 to 0.11.0.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.10.5...v0.11.0)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-25 15:53:32 +08:00
dependabot[bot]
ed035c65c1 ⬆️ Bump mypy from 1.9.0 to 1.10.0 (#292)
Bumps [mypy](https://github.com/python/mypy) from 1.9.0 to 1.10.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/1.9.0...v1.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-25 02:29:20 +08:00
dc8bc9b306 🚨 添加一些type: ignore) 2024-04-24 17:56:24 +08:00
454dd57007 🐛 mode写错了 2024-04-24 17:55:17 +08:00
b396a6d450 存储历史IO Rank数据至本地 2024-04-24 17:28:40 +08:00
7f584a46eb 添加依赖 zstandard 2024-04-24 16:56:44 +08:00
27518c0408 适配 Pydantic V2 2024-04-24 16:49:01 +08:00
dependabot[bot]
d2a3801dac ⬆️ Bump nonebot-plugin-alconna from 0.42.5 to 0.43.0 (#290)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.42.5 to 0.43.0.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.42.5...v0.43.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-24 00:53:44 +08:00
563564ac8d 适配 Pydantic V2 2024-04-24 00:52:32 +08:00
87c87ad231 🗃️ 重命名字段 2024-04-24 00:52:31 +08:00
30515d1907 🚨 ruff auto fix 2024-04-23 22:52:04 +08:00
dependabot[bot]
bd0a8ea447 ⬆️ Bump nonebot-plugin-alconna from 0.42.4 to 0.42.5 (#289)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.42.4 to 0.42.5.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.42.4...v0.42.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 22:28:43 +08:00
dependabot[bot]
1db1e6dbba ⬆️ Bump nonebot-adapter-satori from 0.10.4 to 0.10.5 (#288)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.10.4 to 0.10.5.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.10.4...v0.10.5)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 22:28:21 +08:00
dependabot[bot]
9040aa9fba ⬆️ Bump ruff from 0.3.7 to 0.4.1 (#287)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.3.7 to 0.4.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.3.7...v0.4.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 22:27:59 +08:00
呵呵です
3a5f1eb266 🔖 1.0.0.a15 2024-04-17 14:11:45 +08:00
MianSoft
43e927430a 👽️ 修改茶服api地址 (#286) 2024-04-17 14:09:59 +08:00
e1b0918a52 🔖 1.0.0.a14 2024-04-15 17:42:31 +08:00
c86b2eb31b ⬆️ 更新依赖 2024-04-15 17:41:39 +08:00
dependabot[bot]
47b3f3e881 ⬆️ Bump nonebot-plugin-alconna from 0.37.0 to 0.38.2 (#268)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.37.0 to 0.38.2.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.37.0...v0.38.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-14 08:53:59 +08:00
dependabot[bot]
7caee587b4 ⬆️ Bump pandas from 2.2.0 to 2.2.1 (#266)
Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v2.2.0...v2.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-14 08:53:44 +08:00
dependabot[bot]
28ae564e59 ⬆️ Bump nonebot-plugin-orm from 0.6.3 to 0.7.1 (#264)
Bumps [nonebot-plugin-orm](https://github.com/nonebot/plugin-orm) from 0.6.3 to 0.7.1.
- [Release notes](https://github.com/nonebot/plugin-orm/releases)
- [Commits](https://github.com/nonebot/plugin-orm/compare/v0.6.3...v0.7.1)

---
updated-dependencies:
- dependency-name: nonebot-plugin-orm
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-04 14:06:04 +08:00
dependabot[bot]
90dee8402d ⬆️ Bump httpx from 0.26.0 to 0.27.0 (#263)
Bumps [httpx](https://github.com/encode/httpx) from 0.26.0 to 0.27.0.
- [Release notes](https://github.com/encode/httpx/releases)
- [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/httpx/compare/0.26.0...0.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-04 12:02:53 +08:00
dependabot[bot]
8b560e55cb ⬆️ Bump pandas-stubs from 2.1.4.231227 to 2.2.0.240218 (#259)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.1.4.231227 to 2.2.0.240218.
- [Changelog](https://github.com/pandas-dev/pandas-stubs/blob/main/docs/release_procedure.md)
- [Commits](https://github.com/pandas-dev/pandas-stubs/compare/v2.1.4.231227...v2.2.0.240218)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-04 11:53:08 +08:00
dependabot[bot]
3080531503 ⬆️ Bump nonebot-adapter-satori from 0.9.1 to 0.9.3 (#258)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.9.1 to 0.9.3.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.9.1...v0.9.3)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-04 11:52:54 +08:00
dependabot[bot]
fae0088533 ⬆️ Bump ruff from 0.2.1 to 0.3.0 (#265)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.2.1 to 0.3.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.2.1...v0.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-04 11:48:09 +08:00
dependabot[bot]
db9286a369 ⬆️ Bump nonebot-adapter-onebot from 2.4.0 to 2.4.1 (#257)
Bumps [nonebot-adapter-onebot](https://github.com/nonebot/adapter-onebot) from 2.4.0 to 2.4.1.
- [Release notes](https://github.com/nonebot/adapter-onebot/releases)
- [Commits](https://github.com/nonebot/adapter-onebot/compare/v2.4.0...v2.4.1)

---
updated-dependencies:
- dependency-name: nonebot-adapter-onebot
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-17 22:14:59 +08:00
dependabot[bot]
420fb29318 ⬆️ Bump nonebot-adapter-kaiheila from 0.3.0 to 0.3.1 (#256)
Bumps [nonebot-adapter-kaiheila](https://github.com/Tian-que/nonebot-adapter-kaiheila) from 0.3.0 to 0.3.1.
- [Release notes](https://github.com/Tian-que/nonebot-adapter-kaiheila/releases)
- [Commits](https://github.com/Tian-que/nonebot-adapter-kaiheila/compare/v0.03...v0.3.1)

---
updated-dependencies:
- dependency-name: nonebot-adapter-kaiheila
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-17 21:31:20 +08:00
dependabot[bot]
433a6edd3b ⬆️ Bump nonebot-adapter-satori from 0.9.0 to 0.9.1 (#255)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.9.0 to 0.9.1.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.9.0...v0.9.1)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-17 20:56:27 +08:00
dependabot[bot]
fa81231f78 ⬆️ Bump nonebot-plugin-alconna from 0.36.2 to 0.37.0 (#253)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.36.2 to 0.37.0.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.36.2...v0.37.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-17 19:22:12 +08:00
dependabot[bot]
c474cf0af2 ⬆️ Bump nonebot-plugin-apscheduler from 0.3.0 to 0.4.0 (#252)
Bumps [nonebot-plugin-apscheduler](https://github.com/nonebot/plugin-apscheduler) from 0.3.0 to 0.4.0.
- [Release notes](https://github.com/nonebot/plugin-apscheduler/releases)
- [Commits](https://github.com/nonebot/plugin-apscheduler/compare/v0.3.0...v0.4.0)

---
updated-dependencies:
- dependency-name: nonebot-plugin-apscheduler
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-17 10:09:27 +08:00
dependabot[bot]
e38eb5cdff ⬆️ Bump types-lxml from 2023.10.21 to 2024.2.9 (#251)
Bumps [types-lxml](https://github.com/abelcheung/types-lxml) from 2023.10.21 to 2024.2.9.
- [Release notes](https://github.com/abelcheung/types-lxml/releases)
- [Commits](https://github.com/abelcheung/types-lxml/compare/2023.10.21...2024.02.09)

---
updated-dependencies:
- dependency-name: types-lxml
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-14 16:39:41 +08:00
dependabot[bot]
7bacf89840 ⬆️ Bump nonebot-adapter-onebot from 2.3.1 to 2.4.0 (#254)
Bumps [nonebot-adapter-onebot](https://github.com/nonebot/adapter-onebot) from 2.3.1 to 2.4.0.
- [Release notes](https://github.com/nonebot/adapter-onebot/releases)
- [Commits](https://github.com/nonebot/adapter-onebot/compare/v2.3.1...v2.4.0)

---
updated-dependencies:
- dependency-name: nonebot-adapter-onebot
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-14 14:44:14 +08:00
dependabot[bot]
4622e90995 ⬆️ Bump nonebot-adapter-satori from 0.8.1 to 0.9.0 (#250)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.8.1 to 0.9.0.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.8.1...v0.9.0)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 22:42:50 +08:00
dependabot[bot]
fa8c2b11e6 ⬆️ Bump nonebot-plugin-localstore from 0.5.1 to 0.6.0 (#249)
Bumps [nonebot-plugin-localstore](https://github.com/nonebot/plugin-localstore) from 0.5.1 to 0.6.0.
- [Release notes](https://github.com/nonebot/plugin-localstore/releases)
- [Commits](https://github.com/nonebot/plugin-localstore/compare/v0.5.1...v0.6.0)

---
updated-dependencies:
- dependency-name: nonebot-plugin-localstore
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 19:54:44 +08:00
dependabot[bot]
2123b747af ⬆️ Bump playwright from 1.41.1 to 1.41.2 (#246)
Bumps [playwright](https://github.com/Microsoft/playwright-python) from 1.41.1 to 1.41.2.
- [Release notes](https://github.com/Microsoft/playwright-python/releases)
- [Commits](https://github.com/Microsoft/playwright-python/compare/v1.41.1...v1.41.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 08:38:07 +08:00
dependabot[bot]
e65233d09f ⬆️ Bump viztracer from 0.16.1 to 0.16.2 (#245)
Bumps [viztracer](https://github.com/gaogaotiantian/viztracer) from 0.16.1 to 0.16.2.
- [Release notes](https://github.com/gaogaotiantian/viztracer/releases)
- [Commits](https://github.com/gaogaotiantian/viztracer/compare/0.16.1...0.16.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 08:03:30 +08:00
dependabot[bot]
7e81bf6b8b ⬆️ Bump ruff from 0.2.0 to 0.2.1 (#243)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.2.0 to 0.2.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.2.0...v0.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 07:41:37 +08:00
dependabot[bot]
c4614aa006 ⬆️ Bump nonebot2 from 2.1.3 to 2.2.0 (#248)
Bumps [nonebot2](https://github.com/nonebot/nonebot2) from 2.1.3 to 2.2.0.
- [Release notes](https://github.com/nonebot/nonebot2/releases)
- [Changelog](https://github.com/nonebot/nonebot2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nonebot/nonebot2/compare/v2.1.3...v2.2.0)

---
updated-dependencies:
- dependency-name: nonebot2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 07:30:30 +08:00
dependabot[bot]
79a657b9f5 ⬆️ Bump playwright from 1.40.0 to 1.41.1 (#237)
Bumps [playwright](https://github.com/Microsoft/playwright-python) from 1.40.0 to 1.41.1.
- [Release notes](https://github.com/Microsoft/playwright-python/releases)
- [Commits](https://github.com/Microsoft/playwright-python/compare/v1.40.0...v1.41.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-06 07:07:21 +08:00
dependabot[bot]
0164f29c1e ⬆️ Bump pandas from 2.1.4 to 2.2.0 (#235)
Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.1.4 to 2.2.0.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v2.1.4...v2.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-06 07:00:41 +08:00
dependabot[bot]
8db56366df ⬆️ Bump nonebot-plugin-alconna from 0.35.1 to 0.36.2 (#239)
Bumps [nonebot-plugin-alconna](https://github.com/nonebot/plugin-alconna) from 0.35.1 to 0.36.2.
- [Release notes](https://github.com/nonebot/plugin-alconna/releases)
- [Commits](https://github.com/nonebot/plugin-alconna/compare/v0.35.1...v0.36.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-06 07:00:12 +08:00
dependabot[bot]
de0a1e4c73 ⬆️ Bump nonebot-plugin-orm from 0.6.2 to 0.6.3 (#233)
Bumps [nonebot-plugin-orm](https://github.com/nonebot/plugin-orm) from 0.6.2 to 0.6.3.
- [Release notes](https://github.com/nonebot/plugin-orm/releases)
- [Commits](https://github.com/nonebot/plugin-orm/compare/v0.6.2...v0.6.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-06 05:52:35 +08:00
dependabot[bot]
3670ce7221 ⬆️ Bump ruff from 0.1.13 to 0.2.0 (#240)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.13 to 0.2.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.13...v0.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-06 05:51:32 +08:00
dependabot[bot]
101ed737ab ⬆️ Bump fastapi from 0.103.0 to 0.109.1 (#241)
Bumps [fastapi](https://github.com/tiangolo/fastapi) from 0.103.0 to 0.109.1.
- [Release notes](https://github.com/tiangolo/fastapi/releases)
- [Commits](https://github.com/tiangolo/fastapi/compare/0.103.0...0.109.1)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-06 05:51:11 +08:00
呵呵です
1611bf47fa 🔖 1.0.0.a13 2024-01-15 18:51:38 +08:00
dependabot[bot]
e084cdb145 ⬆️ Bump lxml from 5.0.0 to 5.1.0 (#230)
Bumps [lxml](https://github.com/lxml/lxml) from 5.0.0 to 5.1.0.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-5.0.0...lxml-5.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-15 18:51:05 +08:00
呵呵です
27258ab744 👽️ 修改茶服api地址 2024-01-15 18:40:20 +08:00
dependabot[bot]
07324825e6 ⬆️ Bump ruff from 0.1.11 to 0.1.13 (#231)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.11 to 0.1.13.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.11...v0.1.13)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-15 18:35:27 +08:00
dependabot[bot]
472becdfe0 ⬆️ Bump types-aiofiles from 23.2.0.0 to 23.2.0.20240106 (#229)
Bumps [types-aiofiles](https://github.com/python/typeshed) from 23.2.0.0 to 23.2.0.20240106.
- [Commits](https://github.com/python/typeshed/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-15 12:15:09 +08:00
dependabot[bot]
bc87e4b16d ⬆️ Bump ruff from 0.1.9 to 0.1.11 (#228)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.9 to 0.1.11.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.9...v0.1.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-04 06:21:11 +08:00
dependabot[bot]
28e2a46303 ⬆️ Bump nonebot-plugin-orm from 0.6.0 to 0.6.2 (#227)
Bumps [nonebot-plugin-orm](https://github.com/nonebot/plugin-orm) from 0.6.0 to 0.6.2.
- [Release notes](https://github.com/nonebot/plugin-orm/releases)
- [Commits](https://github.com/nonebot/plugin-orm/compare/v0.6.0...v0.6.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-04 06:20:58 +08:00
1324015d58 🔖 1.0.0.a12 2024-01-03 09:52:25 +08:00
e6eae023e7 🐛 修复茶服pb数据处理错误的bug 2024-01-03 09:51:56 +08:00
67cfb07246 🔖 1.0.0.a11 2024-01-03 09:37:36 +08:00
12145a614f ⬇️ 错误的使用了Python3.11的新特性 2024-01-03 09:37:10 +08:00
0b07882a16 🐛 修复事件没有正确结束的bug 2024-01-03 09:27:26 +08:00
呵呵です
9073bf5d0b 🔖 1.0.0.a10 2024-01-03 09:02:22 +08:00
dependabot[bot]
f4dd5fe76f ⬆️ Bump nonebot-plugin-alconna from 0.34.1 to 0.35.1 (#226) 2024-01-03 09:02:20 +08:00
dependabot[bot]
1f44fc9884 ⬆️ Bump nonebot2 from 2.1.2 to 2.1.3 (#225) 2024-01-03 09:02:18 +08:00
dependabot[bot]
44dee7f200 ⬆️ Bump types-ujson from 5.8.0.1 to 5.9.0.0 (#224)
Bumps [types-ujson](https://github.com/python/typeshed) from 5.8.0.1 to 5.9.0.0.
- [Commits](https://github.com/python/typeshed/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-03 09:02:14 +08:00
dependabot[bot]
dc5ade6ffc ⬆️ Bump pandas from 2.1.3 to 2.1.4 (#223)
Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.1.3 to 2.1.4.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v2.1.3...v2.1.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-03 09:02:12 +08:00
dependabot[bot]
05ce329976 ⬆️ Bump pandas-stubs from 2.1.1.230928 to 2.1.4.231227 (#222)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.1.1.230928 to 2.1.4.231227.
- [Changelog](https://github.com/pandas-dev/pandas-stubs/blob/main/docs/release_procedure.md)
- [Commits](https://github.com/pandas-dev/pandas-stubs/compare/v2.1.1.230928...v2.1.4.231227)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-03 09:02:07 +08:00
dependabot[bot]
43cabf2135 ⬆️ Bump nonebot-adapter-discord from 0.1.2 to 0.1.3 (#218) 2024-01-03 09:02:06 +08:00
dependabot[bot]
6767136850 ⬆️ Bump lxml from 4.9.3 to 5.0.0 (#221) 2024-01-03 09:02:06 +08:00
dependabot[bot]
65999b4625 ⬆️ Bump nonebot-adapter-satori from 0.8.0 to 0.8.1 (#217) 2024-01-03 09:02:06 +08:00
dependabot[bot]
9fde62ac9e ⬆️ Bump ujson from 5.8.0 to 5.9.0 (#219) 2024-01-03 09:02:05 +08:00
dependabot[bot]
c74d8b70aa ⬆️ Bump ruff from 0.1.6 to 0.1.9 (#220)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.6 to 0.1.9.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.6...v0.1.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-03 09:02:05 +08:00
dependabot[bot]
0e29b38f9d ⬆️ Bump playwright from 1.39.0 to 1.40.0 (#205) 2024-01-03 09:01:45 +08:00
dependabot[bot]
d040c7dca2 ⬆️ Bump viztracer from 0.16.0 to 0.16.1 (#211) 2024-01-03 09:00:06 +08:00
dependabot[bot]
68ace3a715 ⬆️ Bump httpx from 0.25.1 to 0.26.0 (#214) 2024-01-03 09:00:00 +08:00
dependabot[bot]
e63ac69e0f ⬆️ Bump mypy from 1.7.0 to 1.8.0 (#215) 2024-01-03 08:59:18 +08:00
4afda62782 添加状态码检查 2023-12-30 06:52:45 +08:00
呵呵です
abf4410a00 👽️ 适配 茶服 新赛季 (#216)
* 👽️ 适配 茶服 新赛季

* ✏️ 少个-

*  添加开发依赖 nonebot-adapter-kaiheila

*  适配 kook 茶服查target

* 🐛 修复 onebotv11 查自己 找不到用户的bug

* 🐛 修复 茶服 查绑定 找不到用户的bug

*  kook 茶服查target 添加后备方案

*  添加开发依赖 nonebot-adapter-discord

*  适配 discord 茶服查target
2023-12-30 06:43:06 +08:00
88c2915251 🐛 修复 pydantic model 不能被正确反序列化的bug 2023-11-29 11:43:00 +08:00
546369241a 添加冗余 platform 字段 2023-11-29 11:41:48 +08:00
d59bccbd4d 细化异常 2023-11-29 11:29:46 +08:00
75a6989a7f 使用上下文管理器管理页面 2023-11-29 11:00:55 +08:00
ad635bd37d 🎨 修改错误处理逻辑 2023-11-29 10:59:58 +08:00
呵呵です
b6d63c9e7f 🐛 修复 io record 解析错误的bug (#207) 2023-11-23 20:07:57 +08:00
805da8cd36 🔖 1.0.0.a9 2023-11-22 18:34:07 +08:00
4a13d7807a 🐛 修复计算时间时区不正确的bug 2023-11-22 18:33:42 +08:00
7bbdeacc5e 🔖 1.0.0.a8 2023-11-22 16:11:57 +08:00
dependabot[bot]
782792e455 ⬆️ Bump nonebot-plugin-orm from 0.5.1 to 0.6.0 (#203) 2023-11-22 08:11:15 +00:00
dependabot[bot]
bd10549b4c ⬆️ Bump ruff from 0.1.5 to 0.1.6 (#202) 2023-11-22 08:02:43 +00:00
dependabot[bot]
035e6d4782 ⬆️ Bump nonebot-plugin-alconna from 0.33.3 to 0.33.6 (#201) 2023-11-22 08:02:33 +00:00
003e6619d8 iorank 指令不再去尝试更新数据 2023-11-22 15:58:55 +08:00
c0fa92df30 🚨 fix Incompatible overrides 2023-11-22 15:57:04 +08:00
7cdb0f3547 为 IO Rank 添加重试机制 2023-11-22 15:49:33 +08:00
b773fb44a1 ️ 为 IO 添加缓存 2023-11-22 13:22:18 +08:00
c75c6b73bd 🙈 更新.gitignore 2023-11-22 13:02:17 +08:00
67782c3156 添加依赖 aiocache 2023-11-22 13:01:41 +08:00
1e02858913 💥 🗃️ 将 pydantic 模型序列化后再存数据库 2023-11-21 20:47:56 +08:00
60605d0dca 🐛 修复 IO Z段位 不显示glicko和rd的bug 2023-11-21 13:58:39 +08:00
0d589450bd 将处理过程中的 dataclass 换成 pydantic 2023-11-21 00:50:32 +08:00
dependabot[bot]
2f144acf0c ⬆️ Bump nonebot-adapter-satori from 0.7.0 to 0.8.0 (#200)
Bumps [nonebot-adapter-satori](https://github.com/nonebot/adapter-satori) from 0.7.0 to 0.8.0.
- [Release notes](https://github.com/nonebot/adapter-satori/releases)
- [Commits](https://github.com/nonebot/adapter-satori/compare/v0.7.0...v0.8.0)

---
updated-dependencies:
- dependency-name: nonebot-adapter-satori
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-17 01:49:57 +08:00
87e6a544a2 🔖 1.0.0.a7 2023-11-16 21:34:41 +08:00
74db1931fd 🐛 修复在多个无效参数时 Alconna 自动回复的bug 2023-11-16 21:33:24 +08:00
1ca6d1f86a 使用 zoneinfo 处理时区,并优化数据库查询逻辑 2023-11-16 16:27:06 +08:00
dependabot[bot]
7361789245 ⬆️ Bump nonebot-plugin-orm from 0.5.0 to 0.5.1 (#199) 2023-11-15 15:44:10 +00:00
fe69d8d2fe 🔖 1.0.0.a6 2023-11-15 14:37:41 +08:00
2737119865 🐛 修复 茶服 命令参数设置错误的bug 2023-11-15 14:36:54 +08:00
34a654b5df 🐛 修复只输入主命令时不发送帮助提示的bug 2023-11-15 14:34:27 +08:00
f9f39618a1 💚 修复Release CI 2023-11-15 14:02:22 +08:00
81a3c9cb79 🔖 1.0.0.a5 2023-11-15 11:44:23 +08:00
4a15c45e0a 💚 修复Release CI 2023-11-15 11:44:23 +08:00
e90ad53ee6 🐛 修复在事件响应器异常退出后 Recorder 继续执行的bug 2023-11-15 11:37:12 +08:00
0c968be163 避免 Alconna 在 ParamsUnmatched 时自动回复
🎨 将通用 handle 封装一下
2023-11-15 11:04:09 +08:00
bfadac4f79 添加配置项 请求超时时间 2023-11-15 00:43:12 +08:00
89f09cd66c 🔥 删除配置项 db_url 2023-11-15 00:37:04 +08:00
777703362e 🎨 先断开连接再删文件 2023-11-15 00:28:54 +08:00
ea5308877c 🎨 🔥 去除一个没什么用的函数 2023-11-15 00:26:55 +08:00
3cc93925a6 🐛 修复迁移旧数据库时拿错config字段的bug 2023-11-15 00:14:03 +08:00
e0bd0a9252 🐛 修复 io user info 解析错误的bug 2023-11-15 00:07:05 +08:00
d31ce48a18 💚 修复Release CI 2023-11-14 13:15:51 +08:00
7da38e0346 🔖 Release 1.0.0.a4 2023-11-14 13:11:46 +08:00
呵呵です
84368a16c3 👷 更改 dependabot 推送分支 2023-11-14 13:44:58 +08:00
6a10ede5ba 👷 更新Release CI 2023-11-14 12:56:06 +08:00
4c205e516f 🔥 去除命令解析失败时发送提醒 2023-11-14 12:54:42 +08:00
c1feccd608 🔖 1.0.0.a3 2023-11-14 09:07:08 +08:00
f27d7b4440 🐛 修复 io record 解析错误的bug 2023-11-14 09:02:22 +08:00
7fa498de48 👽️ io record 添加一个pentas字段 2023-11-14 09:00:35 +08:00
ff7c5847a3 🐛 修复 io record 解析错误的bug 2023-11-14 08:59:44 +08:00
103 changed files with 7624 additions and 3050 deletions

View File

@@ -7,6 +7,6 @@ version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
target-branch: "dev"
target-branch: "main"
schedule:
interval: "daily"

View File

@@ -8,18 +8,42 @@ on:
jobs:
release:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v3
- name: Install poetry
run: pipx install poetry
shell: bash
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Poetry
cache: "poetry"
- run: poetry install
shell: bash
- name: Get Version
id: version
run: |
pip install poetry
- name: Build
shell: bash
run: |
poetry install
poetry env use python
poetry publish --build -u ${{ secrets.USERNAME }} -p ${{ secrets.PASSWORD }} -n
echo "VERSION=$(poetry version -s)" >> $GITHUB_OUTPUT
echo "TAG_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Check Version
if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION
run: exit 1
- name: Build Package
run: poetry build
- name: Publish Package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- 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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

14
.gitignore vendored
View File

@@ -5,6 +5,18 @@ Untitled*
*copy*
.vscode
*dev*
*cache*
*_cache*
*backup*
*.pyc
node_modules
.prettier*
package.json
pnpm-lock.yaml
*.drawio.svg
package-lock.json
*Zone.Identifier
.env*
bot.py
TODO
*.fish
extracted_skin_mino_*

22
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +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: master
autoupdate_schedule: monthly
autoupdate_commit_msg: ':arrow_up: auto update by pre-commit hooks'
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.10
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
stages: [commit]
- id: ruff-format
stages: [commit]
- repo: https://github.com/nonebot/nonemoji
rev: v0.1.4
hooks:
- id: nonemoji
stages: [prepare-commit-msg]

View File

@@ -1,22 +1,31 @@
from nonebot import require
from nonebot.plugin import PluginMetadata
require('nonebot_plugin_localstore')
require('nonebot_plugin_orm')
require('nonebot_plugin_alconna')
require('nonebot_plugin_apscheduler')
require('nonebot_plugin_localstore')
require('nonebot_plugin_orm')
require('nonebot_plugin_session_orm')
require('nonebot_plugin_session')
require('nonebot_plugin_user')
require('nonebot_plugin_userinfo')
from .config.config import migrations # noqa: E402
from nonebot_plugin_alconna import namespace # noqa: E402
with namespace('tetris_stats') as ns:
ns.enable_message_cache = False
from .config import migrations # noqa: E402
__plugin_meta__ = PluginMetadata(
name='Tetris Stats',
description='一个用于查询 Tetris 相关游戏玩家数据的插件',
usage='发送 {游戏名} --help 查询使用方法',
usage='发送 tstats --help 查询使用方法',
type='application',
homepage='https://github.com/shoucandanghehe/nonebot-plugin-tetris-stats',
homepage='https://github.com/A-minos/nonebot-plugin-tetris-stats',
extra={
'orm_version_location': migrations,
},
)
from . import game_data_processor # noqa: F401, E402
from . import games # noqa: F401, E402

View File

@@ -3,12 +3,10 @@ from pathlib import Path
from nonebot_plugin_localstore import get_cache_dir # type: ignore[import-untyped]
from pydantic import BaseModel
from . import migrations # noqa: F401
CACHE_PATH: Path = get_cache_dir('nonebot_plugin_tetris_stats')
class Config(BaseModel):
"""配置类"""
db_url: str = 'sqlite://data/nonebot_plugin_tetris_stats/data.db'
tetris_req_timeout: float = 30.0

View File

@@ -0,0 +1,54 @@
"""Rename field
迁移 ID: 09d4bb60160d
父迁移: b9d65badc713
创建时间: 2024-04-23 23:42:04.541672
"""
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 = '09d4bb60160d'
down_revision: str | Sequence[str] | None = 'b9d65badc713'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
batch_op.alter_column('create_time', new_column_name='update_time', existing_type=sa.DateTime())
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_iorank_create_time')
op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_update_time'),
'nonebot_plugin_tetris_stats_iorank',
['update_time'],
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_iorank', schema=None) as batch_op:
batch_op.alter_column('update_time', new_column_name='create_time')
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_update_time'))
op.create_index(
'ix_nonebot_plugin_tetris_stats_iorank_create_time',
'nonebot_plugin_tetris_stats_iorank',
['create_time'],
unique=False,
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,46 @@
"""add field
迁移 ID: 0d50142b780f
父迁移: 09d4bb60160d
创建时间: 2024-04-24 14:55:08.064098
"""
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 = '0d50142b780f'
down_revision: str | Sequence[str] | None = '09d4bb60160d'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('nonebot_plugin_tetris_stats_iorank', schema=None) as batch_op:
batch_op.add_column(sa.Column('file_hash', sa.String(length=128), nullable=True))
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_file_hash'), ['file_hash'], 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_iorank', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_iorank_file_hash'))
batch_op.drop_column('file_hash')
# ### end Alembic commands ###

View File

@@ -0,0 +1,279 @@
"""Refactor Historical
迁移 ID: 3c25a5a8c050
父迁移: b7fbdafc339a
创建时间: 2024-05-14 09:16:35.193001
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
import sqlalchemy as sa
from alembic import op
from nonebot.log import logger
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn, TimeRemainingColumn
from sqlalchemy import desc, select
from sqlalchemy.dialects import sqlite
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session
from ujson import dumps, loads
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '3c25a5a8c050'
down_revision: str | Sequence[str] | None = 'b7fbdafc339a'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def migrate_old_data() -> None:
Base = automap_base() # noqa: N806
Base.prepare(autoload_with=op.get_bind())
OldHistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
TETRIOHistoricalData = Base.classes.nonebot_plugin_tetris_stats_tetriohistoricaldata # noqa: N806
TOSHistoricalData = Base.classes.nonebot_plugin_tetris_stats_toshistoricaldata # noqa: N806
with (
Session(op.get_bind()) as session,
Progress(
TextColumn('[progress.description]{task.description}'),
BarColumn(),
MofNCompleteColumn(),
TaskProgressColumn(),
TimeRemainingColumn(),
) as progress,
):
task_id = progress.add_task('[cyan]Migrating:', total=session.query(OldHistoricalData).count())
pointer = 0
while pointer < session.query(OldHistoricalData).order_by(desc(OldHistoricalData.id)).limit(1).one().id:
result = session.scalars(
select(OldHistoricalData)
.where(OldHistoricalData.id > pointer)
.order_by(OldHistoricalData.id)
.limit(100)
).all()
for j in result:
processed_data: dict[str, Any] = loads(j.processed_data)
if j.game_platform == 'IO':
if (data := processed_data.get('user_info')) is not None:
session.add(
TETRIOHistoricalData(
user_unique_identifier=j.user_unique_identifier,
api_type='User Info',
data=dumps(data),
update_time=datetime.fromisoformat(data['cache']['cached_at']),
)
)
if (data := processed_data.get('user_records')) is not None:
session.add(
TETRIOHistoricalData(
user_unique_identifier=j.user_unique_identifier,
api_type='User Records',
data=dumps(data),
update_time=datetime.fromisoformat(data['cache']['cached_at']),
)
)
if j.game_platform == 'TOS' and not j.user_unique_identifier.isdigit():
if (data := processed_data.get('user_info')) is not None:
session.add(
TOSHistoricalData(
user_unique_identifier=j.user_unique_identifier,
api_type='User Info',
data=dumps(data),
update_time=j.finish_time,
)
)
if (data := processed_data.get('user_profile')) is not None:
for v in data.values():
session.add(
TOSHistoricalData(
user_unique_identifier=j.user_unique_identifier,
api_type='User Profile',
data=dumps(v),
update_time=j.finish_time,
)
)
progress.update(task_id, advance=1)
session.commit()
pointer = result[-1].id
logger.success('Migrate successfully')
def upgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'nonebot_plugin_tetris_stats_tetriohistoricaldata',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
sa.Column('api_type', sa.String(length=16), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('update_time', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetriohistoricaldata')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetriohistoricaldata', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_api_type'), ['api_type'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_update_time'), ['update_time'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_user_unique_identifier'),
['user_unique_identifier'],
unique=False,
)
op.create_table(
'nonebot_plugin_tetris_stats_tophistoricaldata',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
sa.Column('api_type', sa.String(length=16), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('update_time', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tophistoricaldata')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_tophistoricaldata', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_api_type'), ['api_type'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_update_time'), ['update_time'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_user_unique_identifier'),
['user_unique_identifier'],
unique=False,
)
op.create_table(
'nonebot_plugin_tetris_stats_toshistoricaldata',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
sa.Column('api_type', sa.String(length=16), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('update_time', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_toshistoricaldata')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_toshistoricaldata', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_api_type'), ['api_type'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_update_time'), ['update_time'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_user_unique_identifier'),
['user_unique_identifier'],
unique=False,
)
op.create_table(
'nonebot_plugin_tetris_stats_triggerhistoricaldata',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('trigger_time', sa.DateTime(), nullable=False),
sa.Column('session_persist_id', sa.Integer(), nullable=False),
sa.Column('game_platform', sa.String(length=32), nullable=False),
sa.Column('command_type', sa.String(length=16), nullable=False),
sa.Column('command_args', sa.JSON(), nullable=False),
sa.Column('finish_time', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_triggerhistoricaldata')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_triggerhistoricaldata', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_command_type'),
['command_type'],
unique=False,
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_game_platform'),
['game_platform'],
unique=False,
)
migrate_old_data()
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_command_type')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_account')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_type')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier')
op.drop_table('nonebot_plugin_tetris_stats_historicaldata')
# ### end Alembic commands ###
def downgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'nonebot_plugin_tetris_stats_historicaldata',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('trigger_time', sa.DATETIME(), nullable=False),
sa.Column('bot_platform', sa.VARCHAR(length=32), nullable=True),
sa.Column('bot_account', sa.VARCHAR(), nullable=True),
sa.Column('source_type', sa.VARCHAR(length=32), nullable=True),
sa.Column('source_account', sa.VARCHAR(), nullable=True),
sa.Column('message', sa.BLOB(), nullable=True),
sa.Column('game_platform', sa.VARCHAR(length=32), nullable=False),
sa.Column('command_type', sa.VARCHAR(length=16), nullable=False),
sa.Column('command_args', sqlite.JSON(), nullable=False),
sa.Column('game_user', sqlite.JSON(), nullable=False),
sa.Column('processed_data', sqlite.JSON(), nullable=False),
sa.Column('finish_time', sa.DATETIME(), nullable=False),
sa.Column('user_unique_identifier', sa.VARCHAR(length=32), nullable=False),
sa.PrimaryKeyConstraint('id', name='pk_nonebot_plugin_tetris_stats_historicaldata'),
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier',
['user_unique_identifier'],
unique=False,
)
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_source_type', ['source_type'], unique=False
)
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_source_account', ['source_account'], unique=False
)
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_game_platform', ['game_platform'], unique=False
)
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_command_type', ['command_type'], unique=False
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_triggerhistoricaldata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_game_platform'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_triggerhistoricaldata_command_type'))
op.drop_table('nonebot_plugin_tetris_stats_triggerhistoricaldata')
with op.batch_alter_table('nonebot_plugin_tetris_stats_toshistoricaldata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_user_unique_identifier'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_update_time'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_toshistoricaldata_api_type'))
op.drop_table('nonebot_plugin_tetris_stats_toshistoricaldata')
with op.batch_alter_table('nonebot_plugin_tetris_stats_tophistoricaldata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_user_unique_identifier'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_update_time'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tophistoricaldata_api_type'))
op.drop_table('nonebot_plugin_tetris_stats_tophistoricaldata')
with op.batch_alter_table('nonebot_plugin_tetris_stats_tetriohistoricaldata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_user_unique_identifier'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_update_time'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_tetriohistoricaldata_api_type'))
op.drop_table('nonebot_plugin_tetris_stats_tetriohistoricaldata')
# ### end Alembic commands ###

View File

@@ -0,0 +1,69 @@
"""Add redundant platform field
迁移 ID: 6c3206f90cc3
父迁移: 9f6582279ce2
创建时间: 2023-11-26 20:15:56.033892
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from alembic import op
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session
from ujson import dumps, loads
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '6c3206f90cc3'
down_revision: str | Sequence[str] | None = '9f6582279ce2'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
Base = automap_base() # noqa: N806
connection = op.get_bind()
Base.prepare(autoload_with=connection)
HistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
with Session(connection) as session:
for row in session.query(HistoricalData):
platform = row.game_platform
game_user = loads(row.game_user)
processed_data = loads(row.processed_data)
game_user['platform'] = platform
processed_data['platform'] = platform
row.game_user = dumps(game_user)
row.processed_data = dumps(processed_data)
session.add(row)
session.commit()
def downgrade(name: str = '') -> None:
if name:
return
Base = automap_base() # noqa: N806
connection = op.get_bind()
Base.prepare(autoload_with=connection)
HistoricalData = Base.classes.nonebot_plugin_tetris_stats_historicaldata # noqa: N806
with Session(connection) as session:
for row in session.query(HistoricalData):
game_user = loads(row.game_user)
processed_data = loads(row.processed_data)
game_user.pop('platform', None)
processed_data.pop('platform', None)
row.game_user = dumps(game_user)
row.processed_data = dumps(processed_data)
session.add(row)
session.commit()

View File

@@ -0,0 +1,98 @@
"""Correct the data in HistoricalData
迁移 ID: 8a91210ce14d
父迁移: 0d50142b780f
创建时间: 2024-05-06 08:16:38.487214
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from alembic import op
from nonebot.log import logger
from sqlalchemy import select
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '8a91210ce14d'
down_revision: str | Sequence[str] | None = '0d50142b780f'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None: # 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
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
TaskProgressColumn,
TextColumn,
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
if PYDANTIC_V2:
def model_to_json(value: BaseModel) -> str:
return value.model_dump_json(by_alias=True)
else:
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()
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.processed_data)
j.processed_data = model_to_json(model)
progress.update(task_id, advance=1)
session.commit()
logger.success('Corrected HistoricalData')
def downgrade(name: str = '') -> None:
if name:
return

View File

@@ -5,13 +5,17 @@
创建时间: 2023-11-11 16:24:11.826667
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '9866f53ce44f'
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = ('nonebot_plugin_tetris_stats',)

View File

@@ -5,11 +5,12 @@
创建时间: 2023-11-11 16:51:30.718277
"""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from shutil import copyfile
from typing import TYPE_CHECKING
from alembic import op
from nonebot import get_driver
@@ -18,6 +19,9 @@ from sqlalchemy import Connection, create_engine, inspect, text
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '9cd1647db502'
down_revision: str | Sequence[str] | None = '9866f53ce44f'
branch_labels: str | Sequence[str] | None = None
@@ -64,7 +68,7 @@ def upgrade(name: str = '') -> None:
if name:
return
try:
db_path = Path(config.db_path)
db_path = Path(config.db_url)
except AttributeError:
db_path = Path('data/nonebot_plugin_tetris_stats/data.db')
if db_path.exists() is False:
@@ -80,11 +84,12 @@ def upgrade(name: str = '') -> None:
logger.success('nonebot_plugin_tetris_stats: 跳过迁移')
return
if 'IORANK' not in tables:
logger.warning('nonebot_plugin_tetris_stats: 发现过早版本的数据, 请先更新到 0.4.4 版本')
raise RuntimeError('nonebot_plugin_tetris_stats: 请先安装 0.4.4 版本完成迁移之后再升级')
msg = 'nonebot_plugin_tetris_stats: 请先安装 0.4.4 版本完成迁移之后再升级'
logger.warning(msg)
raise RuntimeError(msg)
logger.info('nonebot_plugin_tetris_stats: 发现来自老版本的数据, 正在迁移...')
migrate_old_data(connection)
db_path.unlink()
db_path.unlink()
def downgrade(name: str = '') -> None:

View File

@@ -0,0 +1,114 @@
"""Recreate HistoricalData
迁移 ID: 9f6582279ce2
父迁移: 9cd1647db502
创建时间: 2023-11-21 08:35:50.393246
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import sqlite
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '9f6582279ce2'
down_revision: str | Sequence[str] | None = '9cd1647db502'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_command_type')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_account')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_historicaldata_source_type')
op.drop_table('nonebot_plugin_tetris_stats_historicaldata')
op.create_table(
'nonebot_plugin_tetris_stats_historicaldata',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('trigger_time', sa.DateTime(), nullable=False),
sa.Column('bot_platform', sa.String(length=32), nullable=True),
sa.Column('bot_account', sa.String(), nullable=True),
sa.Column('source_type', sa.String(length=32), nullable=True),
sa.Column('source_account', sa.String(), nullable=True),
sa.Column('message', sa.PickleType(), nullable=True),
sa.Column('game_platform', sa.String(length=32), nullable=False),
sa.Column('command_type', sa.String(length=16), nullable=False),
sa.Column('command_args', sa.JSON(), nullable=False),
sa.Column('game_user', sa.JSON(), nullable=False),
sa.Column('processed_data', sa.JSON(), nullable=False),
sa.Column('finish_time', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_historicaldata')),
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_command_type'), ['command_type'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform'), ['game_platform'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_account'), ['source_account'], unique=False
)
batch_op.create_index(
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_type'), ['source_type'], 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_historicaldata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_type'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_source_account'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_game_platform'))
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_command_type'))
op.drop_table('nonebot_plugin_tetris_stats_historicaldata')
op.create_table(
'nonebot_plugin_tetris_stats_historicaldata',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('trigger_time', sa.DATETIME(), nullable=False),
sa.Column('bot_platform', sa.VARCHAR(length=32), nullable=True),
sa.Column('bot_account', sa.VARCHAR(), nullable=True),
sa.Column('source_type', sa.VARCHAR(length=32), nullable=True),
sa.Column('source_account', sa.VARCHAR(), nullable=True),
sa.Column('message', sa.BLOB(), nullable=True),
sa.Column('game_platform', sa.VARCHAR(length=32), nullable=False),
sa.Column('command_type', sa.VARCHAR(length=16), nullable=False),
sa.Column('command_args', sqlite.JSON(), nullable=False),
sa.Column('game_user', sa.BLOB(), nullable=False),
sa.Column('processed_data', sa.BLOB(), nullable=False),
sa.Column('finish_time', sa.DATETIME(), nullable=False),
sa.PrimaryKeyConstraint('id', name='pk_nonebot_plugin_tetris_stats_historicaldata'),
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_source_type', ['source_type'], unique=False
)
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_source_account', ['source_account'], unique=False
)
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_game_platform', ['game_platform'], unique=False
)
batch_op.create_index(
'ix_nonebot_plugin_tetris_stats_historicaldata_command_type', ['command_type'], unique=False
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,44 @@
"""Add TETRIO user configuration
迁移 ID: a1195e989cc6
父迁移: b15844837693
创建时间: 2024-06-09 04:20:07.819194
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = 'a1195e989cc6'
down_revision: str | Sequence[str] | None = 'b15844837693'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'nonebot_plugin_tetris_stats_tetriouserconfig',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('query_template', sa.String(length=2), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_tetriouserconfig')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
# ### end Alembic commands ###
def downgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('nonebot_plugin_tetris_stats_tetriouserconfig')
# ### end Alembic commands ###

View File

@@ -0,0 +1,71 @@
"""Migrate to nonobot-plugin-user
迁移 ID: b15844837693
父迁移: 3c25a5a8c050
创建时间: 2024-06-08 02:27:35.227596
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = 'b15844837693'
down_revision: str | Sequence[str] | None = '3c25a5a8c050'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_bind_chat_account')
batch_op.drop_index('ix_nonebot_plugin_tetris_stats_bind_chat_platform')
op.drop_table('nonebot_plugin_tetris_stats_bind')
op.create_table(
'nonebot_plugin_tetris_stats_bind',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('game_platform', sa.String(length=32), nullable=False),
sa.Column('game_account', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nonebot_plugin_tetris_stats_bind')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_nonebot_plugin_tetris_stats_bind_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_bind_user_id'))
op.drop_table('nonebot_plugin_tetris_stats_bind')
op.create_table(
'nonebot_plugin_tetris_stats_bind',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('chat_platform', sa.VARCHAR(length=32), nullable=False),
sa.Column('chat_account', sa.VARCHAR(), nullable=False),
sa.Column('game_platform', sa.VARCHAR(length=32), nullable=False),
sa.Column('game_account', sa.VARCHAR(), nullable=False),
sa.PrimaryKeyConstraint('id', name='pk_nonebot_plugin_tetris_stats_bind'),
)
with op.batch_alter_table('nonebot_plugin_tetris_stats_bind', schema=None) as batch_op:
batch_op.create_index('ix_nonebot_plugin_tetris_stats_bind_chat_platform', ['chat_platform'], unique=False)
batch_op.create_index('ix_nonebot_plugin_tetris_stats_bind_chat_account', ['chat_account'], unique=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,107 @@
"""Add user_unique_identifier field to HistoricalData
迁移 ID: b7fbdafc339a
父迁移: 8a91210ce14d
创建时间: 2024-05-07 16:55:29.527215
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from nonebot.log import logger
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = 'b7fbdafc339a'
down_revision: str | Sequence[str] | None = '8a91210ce14d'
branch_labels: str | Sequence[str] | None = None
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 (
BarColumn,
MofNCompleteColumn,
Progress,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
)
from sqlalchemy import select
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(
batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier'),
['user_unique_identifier'],
unique=False,
)
Base = automap_base() # noqa: N806
connection = op.get_bind()
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)
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')
def downgrade(name: str = '') -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('nonebot_plugin_tetris_stats_historicaldata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nonebot_plugin_tetris_stats_historicaldata_user_unique_identifier'))
batch_op.drop_column('user_unique_identifier')
# ### end Alembic commands ###

View File

@@ -0,0 +1,42 @@
"""Del old TOS bind data
迁移 ID: b9d65badc713
父迁移: 6c3206f90cc3
创建时间: 2023-12-30 00:27:40.991704
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from alembic import op
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = 'b9d65badc713'
down_revision: str | Sequence[str] | None = '6c3206f90cc3'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
Base = automap_base() # noqa: N806
connection = op.get_bind()
Base.prepare(autoload_with=connection)
Bind = Base.classes.nonebot_plugin_tetris_stats_bind # noqa: N806
with Session(connection) as session:
session.query(Bind).filter(Bind.game_platform == 'TOS').delete()
session.commit()
def downgrade(name: str = '') -> None:
if name:
return

View File

@@ -1,50 +1,135 @@
from nonebot_plugin_orm import AsyncSession
from asyncio import Lock
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum, auto
from typing import TYPE_CHECKING, Literal, TypeVar, overload
from nonebot.exception import FinishedException
from nonebot.log import logger
from nonebot_plugin_orm import AsyncSession, get_session
from nonebot_plugin_user import User # type: ignore[import-untyped]
from sqlalchemy import select
from ..utils.typing import GameType
from .models import Bind
from ..utils.typing import CommandType, GameType
from .models import Bind, TriggerHistoricalData
UTC = timezone.utc
if TYPE_CHECKING:
from ..games.tetrio.api.models import TETRIOHistoricalData
from ..games.top.api.models import TOPHistoricalData
from ..games.tos.api.models import TOSHistoricalData
class BindStatus(Enum):
SUCCESS = auto()
UPDATE = auto()
async def query_bind_info(
session: AsyncSession,
chat_platform: str,
chat_account: str,
user: User,
game_platform: GameType,
) -> Bind | None:
return (
await session.scalars(
select(Bind)
.where(Bind.chat_platform == chat_platform)
.where(Bind.chat_account == chat_account)
.where(Bind.game_platform == game_platform)
)
await session.scalars(select(Bind).where(Bind.user_id == user.id).where(Bind.game_platform == game_platform))
).one_or_none()
async def create_or_update_bind(
session: AsyncSession,
chat_platform: str,
chat_account: str,
user: User,
game_platform: GameType,
game_account: str,
) -> str:
) -> BindStatus:
bind = await query_bind_info(
session=session,
chat_platform=chat_platform,
chat_account=chat_account,
user=user,
game_platform=game_platform,
)
if bind is None:
bind = Bind(
chat_platform=chat_platform,
chat_account=chat_account,
user_id=user.id,
game_platform=game_platform,
game_account=game_account,
)
session.add(bind)
message = '绑定成功'
message = BindStatus.SUCCESS
else:
bind.game_account = game_account
message = '更新绑定成功'
message = BindStatus.UPDATE
await session.commit()
return message
T = TypeVar('T', 'TETRIOHistoricalData', 'TOPHistoricalData', 'TOSHistoricalData')
lock = Lock()
async def anti_duplicate_add(cls: type[T], model: T) -> None:
async with lock, get_session() as session:
result = (
await session.scalars(
select(cls)
.where(cls.update_time == model.update_time)
.where(cls.user_unique_identifier == model.user_unique_identifier)
.where(cls.api_type == model.api_type)
)
).all()
if result:
for i in result:
if i.data == model.data:
logger.debug('Anti duplicate successfully')
return
session.add(model)
await session.commit()
@asynccontextmanager
@overload
async def trigger(
session_persist_id: int,
game_platform: Literal['IO'],
command_type: CommandType | Literal['rank', 'config', 'record'],
command_args: list[str],
) -> AsyncGenerator:
yield
@asynccontextmanager
@overload
async def trigger(
session_persist_id: int,
game_platform: GameType,
command_type: CommandType,
command_args: list[str],
) -> AsyncGenerator:
yield
@asynccontextmanager
async def trigger(
session_persist_id: int,
game_platform: GameType,
command_type: CommandType | Literal['rank', 'config', 'record'],
command_args: list[str],
) -> AsyncGenerator:
trigger_time = datetime.now(UTC)
try:
yield
except FinishedException:
async with get_session() as session:
session.add(
TriggerHistoricalData(
trigger_time=trigger_time,
session_persist_id=session_persist_id,
game_platform=game_platform,
command_type=command_type,
command_args=command_args,
finish_time=datetime.now(UTC),
)
)
await session.commit()
raise

View File

@@ -1,33 +1,81 @@
from collections.abc import Callable, Sequence
from datetime import datetime
from typing import Any, Literal
from nonebot.adapters import Message
from nonebot.compat import PYDANTIC_V2, type_validate_json
from nonebot_plugin_orm import Model
from sqlalchemy import JSON, DateTime, PickleType, String
from pydantic import BaseModel, ValidationError
from sqlalchemy import JSON, DateTime, Dialect, String, TypeDecorator
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
from typing_extensions import override
from ..game_data_processor import ProcessedData, User
from ..utils.typing import CommandType, GameType
class PydanticType(TypeDecorator):
impl = JSON
@override
def __init__(
self,
get_model: Sequence[Callable[[], Sequence[type[BaseModel]]]],
models: set[type[BaseModel]],
*args: Any,
**kwargs: Any,
):
self.get_model = get_model
self._models = models
super().__init__(*args, **kwargs)
if PYDANTIC_V2:
@override
def process_bind_param(self, value: Any | None, dialect: Dialect) -> str:
# 将 Pydantic 模型实例转换为 JSON
if isinstance(value, tuple(self.models)):
return value.model_dump_json(by_alias=True) # type: ignore[union-attr]
raise TypeError
else:
@override
def process_bind_param(self, value: Any | None, dialect: Dialect) -> str:
# 将 Pydantic 模型实例转换为 JSON
if isinstance(value, tuple(self.models)):
return value.json(by_alias=True) # type: ignore[union-attr]
raise TypeError
@override
def process_result_value(self, value: Any | None, dialect: Dialect) -> BaseModel:
# 将 JSON 转换回 Pydantic 模型实例
if isinstance(value, str | bytes):
for i in self.models:
try:
return type_validate_json(i, value)
except ValidationError: # noqa: PERF203
...
raise ValueError
@property
def models(self) -> tuple[type[BaseModel], ...]:
models: set[type[BaseModel]] = set()
for i in self.get_model:
models.update(i())
models.update(self._models)
return tuple(models)
class Bind(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(init=False, primary_key=True)
chat_platform: Mapped[str] = mapped_column(String(32), index=True)
chat_account: Mapped[str] = mapped_column(index=True)
user_id: Mapped[int] = mapped_column(index=True)
game_platform: Mapped[GameType] = mapped_column(String(32))
game_account: Mapped[str]
class HistoricalData(MappedAsDataclass, Model):
class TriggerHistoricalData(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(init=False, primary_key=True)
trigger_time: Mapped[datetime] = mapped_column(DateTime)
bot_platform: Mapped[str | None] = mapped_column(String(32))
bot_account: Mapped[str | None]
source_type: Mapped[str | None] = mapped_column(String(32), index=True)
source_account: Mapped[str | None] = mapped_column(index=True)
message: Mapped[Message | None] = mapped_column(PickleType)
game_platform: Mapped[GameType] = mapped_column(String(32), index=True, init=False)
command_type: Mapped[CommandType] = mapped_column(String(16), index=True, init=False)
command_args: Mapped[list[str]] = mapped_column(JSON, init=False)
game_user: Mapped[User] = mapped_column(PickleType, init=False)
processed_data: Mapped[ProcessedData] = mapped_column(PickleType, init=False)
finish_time: Mapped[datetime] = mapped_column(DateTime, init=False)
session_persist_id: Mapped[int]
game_platform: Mapped[GameType] = mapped_column(String(32), index=True)
command_type: Mapped[CommandType | Literal['rank', 'config', 'record']] = mapped_column(String(16), index=True)
command_args: Mapped[list[str]] = mapped_column(JSON)
finish_time: Mapped[datetime] = mapped_column(DateTime)

View File

@@ -1,82 +0,0 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import UTC, datetime
from ..utils.typing import CommandType, GameType
@dataclass
class User:
"""游戏用户"""
@dataclass
class RawResponse:
"""原始请求数据"""
@dataclass
class ProcessedData:
"""处理/验证后的数据"""
from ..utils.recorder import Recorder # noqa: E402 避免循环导入
class Processor(ABC):
event_id: int
command_type: CommandType
command_args: list[str]
user: User
raw_response: RawResponse
processed_data: ProcessedData
@abstractmethod
def __init__(
self,
event_id: int,
user: User,
command_args: list[str],
) -> None:
self.event_id = event_id
self.user = user
self.command_args = command_args
@property
@abstractmethod
def game_platform(self) -> GameType:
"""游戏平台"""
raise NotImplementedError
@abstractmethod
async def handle_bind(self, platform: str, account: str) -> str:
"""处理绑定消息"""
raise NotImplementedError
@abstractmethod
async def handle_query(self) -> str:
"""处理查询消息"""
raise NotImplementedError
@abstractmethod
async def generate_message(self) -> str:
"""生成消息"""
raise NotImplementedError
def __del__(self) -> None:
finish_time = datetime.now(tz=UTC)
historical_data = Recorder.get_historical_data(self.event_id)
historical_data.game_platform = self.game_platform
historical_data.command_type = self.command_type
historical_data.command_args = self.command_args
historical_data.game_user = self.user
historical_data.processed_data = self.processed_data
historical_data.finish_time = finish_time
Recorder.update_historical_data(self.event_id, historical_data)
from . import ( # noqa: F401, E402
io_data_processor,
top_data_processor,
tos_data_processor,
)

View File

@@ -1,187 +0,0 @@
from datetime import timedelta
from arclet.alconna import Alconna, Arg, ArgFlag, Args, CommandMeta, Option
from nonebot.adapters import Bot, Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import AlcMatches, At, on_alconna
from nonebot_plugin_orm import get_session
from sqlalchemy import select
from ...db import query_bind_info
from ...utils.exception import MessageFormatError, NeedCatchError
from ...utils.metrics import get_metrics
from ...utils.platform import get_platform
from ...utils.typing import Me
from ..constant import BIND_COMMAND, QUERY_COMMAND
from .constant import GAME_TYPE
from .model import IORank
from .processor import Processor, User, check_rank_data, identify_user_info
from .typing import Rank
alc = on_alconna(
Alconna(
'io',
Option(
BIND_COMMAND[0],
Args(
Arg(
'account',
identify_user_info,
notice='IO 用户名 / ID',
flags=[ArgFlag.HIDDEN],
)
),
alias=BIND_COMMAND[1:],
compact=True,
dest='bind',
help_text='绑定 IO 账号',
),
Option(
QUERY_COMMAND[0],
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 | 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
identify_user_info,
notice='IO 用户名 / ID',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
alias=QUERY_COMMAND[1:],
compact=True,
dest='query',
help_text='查询 IO 游戏信息',
),
Option(
'rank',
Args(Arg('rank', Rank, notice='IO 段位')),
alias={'Rank', 'RANK', '段位'},
compact=True,
dest='rank',
help_text='查询 IO 段位信息',
),
meta=CommandMeta(
description='查询 TETR.IO 的信息',
example='io绑定scdhh\nio查我\niorankx',
compact=True,
fuzzy_match=True,
),
),
skip_for_unmatch=False,
auto_send_output=True,
aliases={'IO'},
)
alc.shortcut('fkosk', {'command': 'io查', 'args': ['']})
@alc.assign('bind')
async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
proc = Processor(
event_id=id(event),
user=account,
command_args=[],
)
try:
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.assign('query')
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
async with get_session() as session:
bind = await query_bind_info(
session=session,
chat_platform=get_platform(bot),
chat_account=(target.target if isinstance(target, At) else event.get_user_id()),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
proc = Processor(
event_id=id(event),
user=User(ID=bind.game_account),
command_args=[],
)
try:
await matcher.finish(message + await proc.handle_query())
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.assign('query')
async def _(event: Event, matcher: Matcher, account: User):
proc = Processor(
event_id=id(event),
user=account,
command_args=[],
)
try:
await matcher.finish(await proc.handle_query())
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.assign('rank')
async def _(event: Event, matcher: Matcher, rank: Rank):
if rank == 'z':
await matcher.finish('暂不支持查询未知段位')
try:
await check_rank_data()
except NeedCatchError as e:
await matcher.finish(str(f'段位信息获取失败\n{e}'))
async with get_session() as session:
data = (
await session.scalars(select(IORank).where(IORank.rank == rank).order_by(IORank.id.desc()).limit(5))
).all()
latest_data = data[0]
message = f'{rank.upper()} 段 分数线 {latest_data.tr_line:.2f} TR, {latest_data.player_count} 名玩家\n'
if len(data) > 1:
message += f'对比 {(latest_data.create_time-data[-1].create_time).total_seconds()/3600:.2f} 小时前趋势: {f"{difference:.2f}" if (difference:=latest_data.tr_line-data[-1].tr_line) > 0 else f"{-difference:.2f}" if difference < 0 else ""}'
else:
message += '暂无对比数据'
avg = get_metrics(pps=latest_data.avg_pps, apm=latest_data.avg_apm, vs=latest_data.avg_vs)
low_pps = get_metrics(pps=latest_data.low_pps[1])
low_vs = get_metrics(vs=latest_data.low_vs[1])
max_pps = get_metrics(pps=latest_data.high_pps[1])
max_vs = get_metrics(vs=latest_data.high_vs[1])
message += (
'\n'
'平均数据:\n'
f"L'PM: {avg.lpm} ( {avg.pps} pps )\n"
f'APM: {avg.apm} ( x{avg.apl} )\n'
f'ADPM: {avg.adpm} ( x{avg.adpl} ) ( {avg.vs}vs )\n'
'\n'
'最低数据:\n'
f"L'PM: {low_pps.lpm} ( {low_pps.pps} pps ) By: {latest_data.low_pps[0]['name'].upper()}\n"
f'APM: {latest_data.low_apm[1]} By: {latest_data.low_apm[0]["name"].upper()}\n'
f'ADPM: {low_vs.adpm} ( {low_vs.vs}vs ) By: {latest_data.low_vs[0]["name"].upper()}\n'
'\n'
'最高数据:\n'
f"L'PM: {max_pps.lpm} ( {max_pps.pps} pps ) By: {latest_data.high_pps[0]['name'].upper()}\n"
f'APM: {latest_data.high_apm[1]} By: {latest_data.high_apm[0]["name"].upper()}\n'
f'ADPM: {max_vs.adpm} ( {max_vs.vs}vs ) By: {latest_data.high_vs[0]["name"].upper()}\n'
'\n'
f'数据更新时间: {(latest_data.create_time+timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S")}'
)
await matcher.finish(message)
@alc.handle()
async def _(matcher: Matcher, account: MessageFormatError):
await matcher.finish(str(account))
@alc.handle()
async def _(matcher: Matcher, matches: AlcMatches):
if matches.head_matched:
await matcher.finish(
f'{matches.error_info!r}\n' if matches.error_info is not None else '' + '输入"io --help"查看帮助'
)

View File

@@ -1,254 +0,0 @@
from collections import defaultdict
from collections.abc import Callable
from dataclasses import asdict, dataclass
from datetime import UTC, datetime, timedelta
from math import floor
from re import match
from statistics import mean
from nonebot import get_driver
from nonebot_plugin_apscheduler import scheduler # type: ignore[import-untyped]
from nonebot_plugin_orm import get_session
from pydantic import parse_raw_as
from sqlalchemy import select
from ...db import create_or_update_bind
from ...utils.exception import MessageFormatError, RequestError, WhatTheFuckError
from ...utils.request import Request, splice_url
from ...utils.typing import GameType
from .. import ProcessedData as ProcessedDataMeta
from .. import Processor as ProcessorMeta
from .. import RawResponse as RawResponseMeta
from .. import User as UserMeta
from .constant import BASE_URL, GAME_TYPE, RANK_PERCENTILE
from .model import IORank
from .schemas.league_all import FailedModel as LeagueAllFailed
from .schemas.league_all import LeagueAll
from .schemas.league_all import ValidUser as LeagueAllUser
from .schemas.user_info import FailedModel as InfoFailed
from .schemas.user_info import (
NeverPlayedLeague,
NeverRatedLeague,
UserInfo,
)
from .schemas.user_info import SuccessModel as InfoSuccess
from .schemas.user_records import FailedModel as RecordsFailed
from .schemas.user_records import SoloRecord, UserRecords
from .schemas.user_records import SuccessModel as RecordsSuccess
from .typing import Rank
driver = get_driver()
@dataclass
class User(UserMeta):
ID: str | None = None
name: str | None = None
@dataclass
class RawResponse(RawResponseMeta):
user_info: bytes | None = None
user_records: bytes | None = None
@dataclass
class ProcessedData(ProcessedDataMeta):
user_info: InfoSuccess | None = None
user_records: RecordsSuccess | None = None
def identify_user_info(info: str) -> User | MessageFormatError:
if match(r'^[a-f0-9]{24}$', info):
return User(ID=info)
if match(r'^[a-zA-Z0-9_-]{3,16}$', info):
return User(name=info.lower())
return MessageFormatError('用户名/ID不合法')
class Processor(ProcessorMeta):
user: User
raw_response: RawResponse
processed_data: ProcessedData
def __init__(self, event_id: int, user: User, command_args: list[str]) -> None:
super().__init__(event_id, user, command_args)
self.raw_response = RawResponse()
self.processed_data = ProcessedData()
@property
def game_platform(self) -> GameType:
return GAME_TYPE
async def handle_bind(self, platform: str, account: str) -> str:
"""处理绑定消息"""
self.command_type = 'bind'
await self.get_user()
if self.user.ID is None:
raise # FIXME: 不知道怎么才能把这类型给变过来了
async with get_session() as session:
return await create_or_update_bind(
session=session,
chat_platform=platform,
chat_account=account,
game_platform=GAME_TYPE,
game_account=self.user.ID,
)
async def handle_query(self) -> str:
"""处理查询消息"""
self.command_type = 'query'
await self.get_user()
return await self.generate_message()
async def get_user(self) -> None:
"""
用于获取 UserName 和 UserID 的函数
"""
if self.user.name is None:
self.user.name = (await self.get_user_info()).data.user.username
if self.user.ID is None:
self.user.ID = (await self.get_user_info()).data.user.id
async def get_user_info(self) -> InfoSuccess:
"""获取用户数据"""
if self.processed_data.user_info is None:
self.raw_response.user_info = await Request.request(
splice_url([BASE_URL, 'users/', f'{self.user.ID or self.user.name}'])
)
user_info: UserInfo = parse_raw_as(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
if isinstance(user_info, InfoFailed):
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
self.processed_data.user_info = user_info
return self.processed_data.user_info
async def get_user_records(self) -> RecordsSuccess:
"""获取Solo数据"""
if self.processed_data.user_records is None:
self.raw_response.user_records = await Request.request(
splice_url(
[
BASE_URL,
'users/',
f'{self.user.ID or self.user.name}/',
'records',
]
)
)
user_records: UserRecords = parse_raw_as(
UserRecords, # type: ignore[arg-type]
self.raw_response.user_records,
)
if isinstance(user_records, RecordsFailed):
raise RequestError(f'用户Solo数据请求错误:\n{user_records.error}')
self.processed_data.user_records = user_records
return self.processed_data.user_records
async def generate_message(self) -> str:
"""生成消息"""
user_info = await self.get_user_info()
user_name = user_info.data.user.username.upper()
league = user_info.data.user.league
ret_message = ''
if isinstance(league, NeverPlayedLeague):
ret_message += f'用户 {user_name} 没有排位统计数据'
else:
if isinstance(league, NeverRatedLeague):
ret_message += f'用户 {user_name} 暂未完成定级赛, 最近十场的数据:'
elif league.rank == 'z':
ret_message += f'用户 {user_name} 暂无段位, {round(league.rating,2)} TR'
else:
ret_message += (
f'{league.rank.upper()} 段用户 {user_name} {round(league.rating,2)} TR (#{league.standing})'
)
ret_message += f', 段位分 {round(league.glicko,2)}±{round(league.rd,2)}, 最近十场的数据:'
lpm = league.pps * 24
ret_message += f"\nL'PM: {round(lpm, 2)} ( {league.pps} pps )"
ret_message += f'\nAPM: {league.apm} ( x{round(league.apm/(league.pps*24),2)} )'
if league.vs is not None:
adpm = league.vs * 0.6
ret_message += f'\nADPM: {round(adpm,2)} ( x{round(adpm/lpm,2)} ) ( {league.vs}vs )'
user_records = await self.get_user_records()
sprint = user_records.data.records.sprint
if sprint.record is not None:
if not isinstance(sprint.record, SoloRecord):
raise WhatTheFuckError('40L记录不是单人记录')
ret_message += f'\n40L: {round(sprint.record.endcontext.final_time/1000,2)}s'
ret_message += f' ( #{sprint.rank} )' if sprint.rank is not None else ''
blitz = user_records.data.records.blitz
if blitz.record is not None:
if not isinstance(blitz.record, SoloRecord):
raise WhatTheFuckError('Blitz记录不是单人记录')
ret_message += f'\nBlitz: {blitz.record.endcontext.score}'
ret_message += f' ( #{blitz.rank} )' if blitz.rank is not None else ''
return ret_message
@scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
async def get_io_rank_data() -> None:
league_all: LeagueAll = parse_raw_as(
LeagueAll, # type: ignore[arg-type]
await Request.request(splice_url([BASE_URL, 'users/lists/league/all'])),
)
if isinstance(league_all, LeagueAllFailed):
raise RequestError(f'用户Solo数据请求错误:\n{league_all.error}')
def pps(user: LeagueAllUser) -> float:
return user.league.pps
def apm(user: LeagueAllUser) -> float:
return user.league.apm
def vs(user: LeagueAllUser) -> float:
return user.league.vs
def _min(users: list[LeagueAllUser], field: Callable[[LeagueAllUser], float]) -> LeagueAllUser:
return min(users, key=field)
def _max(users: list[LeagueAllUser], field: Callable[[LeagueAllUser], float]) -> LeagueAllUser:
return max(users, key=field)
def build_extremes_data(
users: list[LeagueAllUser],
field: Callable[[LeagueAllUser], float],
sort: Callable[[list[LeagueAllUser], Callable[[LeagueAllUser], float]], LeagueAllUser],
) -> tuple[dict[str, str], float]:
user = sort(users, field)
return asdict(User(ID=user.id, name=user.username)), field(user)
users = [i for i in league_all.data.users if isinstance(i, LeagueAllUser)]
rank_to_users: defaultdict[Rank, list[LeagueAllUser]] = defaultdict(list)
for i in users:
rank_to_users[i.league.rank].append(i)
rank_info: list[IORank] = []
for rank, percentile in RANK_PERCENTILE.items():
offset = floor((percentile / 100) * len(users)) - 1
tr_line = users[offset].league.rating
rank_users = rank_to_users[rank]
rank_info.append(
IORank(
rank=rank,
tr_line=tr_line,
player_count=len(rank_users),
low_pps=(build_extremes_data(rank_users, pps, _min)),
low_apm=(build_extremes_data(rank_users, apm, _min)),
low_vs=(build_extremes_data(rank_users, vs, _min)),
avg_pps=mean({i.league.pps for i in rank_users}),
avg_apm=mean({i.league.apm for i in rank_users}),
avg_vs=mean({i.league.vs for i in rank_users}),
high_pps=(build_extremes_data(rank_users, pps, _max)),
high_apm=(build_extremes_data(rank_users, apm, _max)),
high_vs=(build_extremes_data(rank_users, vs, _max)),
)
)
async with get_session() as session:
session.add_all(rank_info)
await session.commit()
@driver.on_startup
async def check_rank_data() -> None:
async with get_session() as session:
latest_time = await session.scalar(select(IORank.create_time).order_by(IORank.id.desc()).limit(1))
if latest_time is None or datetime.now(tz=UTC) - latest_time.replace(tzinfo=UTC) > timedelta(hours=6):
await get_io_rank_data()

View File

@@ -1,63 +0,0 @@
from pydantic import BaseModel, Field
from ..typing import Rank
from .base import FailedModel
from .base import SuccessModel as BaseSuccessModel
class SuccessModel(BaseSuccessModel):
class Data(BaseModel):
class ValidUser(BaseModel):
class League(BaseModel):
gamesplayed: int
gameswon: int
rating: float
glicko: float
rd: float
rank: Rank
bestrank: Rank
apm: float
pps: float
vs: float
decaying: bool
id: str = Field(..., alias='_id')
username: str
role: str
xp: float
league: League
supporter: bool
verified: bool
country: str | None
class InvalidUser(BaseModel):
class League(BaseModel):
gamesplayed: int
gameswon: int
rating: float
glicko: float | None
rd: float | None
rank: Rank
bestrank: Rank
apm: float | None
pps: float | None
vs: float | None
decaying: bool
id: str = Field(..., alias='_id')
username: str
role: str
xp: float
league: League
supporter: bool
verified: bool
country: str | None
users: list[ValidUser | InvalidUser]
data: Data
LeagueAll = SuccessModel | FailedModel
ValidUser = SuccessModel.Data.ValidUser
InvalidUser = SuccessModel.Data.InvalidUser

View File

@@ -1,125 +0,0 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from ..typing import Rank
from .base import FailedModel
from .base import SuccessModel as BaseSuccessModel
class SuccessModel(BaseSuccessModel):
class Data(BaseModel):
class User(BaseModel):
class Badge(BaseModel):
id: str
label: str
ts: datetime | None
class NeverPlayedLeague(BaseModel):
gamesplayed: Literal[0]
gameswon: Literal[0]
rating: Literal[-1]
rank: Literal['z']
standing: Literal[-1]
standing_local: Literal[-1]
next_rank: None
prev_rank: None
next_at: Literal[-1]
prev_at: Literal[-1]
percentile: Literal[-1]
percentile_rank: Literal['z']
apm: None
pps: None
vs: None
decaying: bool
class NeverRatedLeague(BaseModel):
gamesplayed: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9]
gameswon: int
rating: Literal[-1]
rank: Literal['z']
standing: Literal[-1]
standing_local: Literal[-1]
next_rank: None
prev_rank: None
next_at: Literal[-1]
prev_at: Literal[-1]
percentile: Literal[-1]
percentile_rank: Literal['z']
apm: float
pps: float
vs: float
decaying: bool
class RatedLeague(BaseModel):
gamesplayed: int
gameswon: int
rating: float
rank: Rank
bestrank: Rank
standing: int
standing_local: int
next_rank: Rank | None
prev_rank: Rank | None
next_at: int
prev_at: int
percentile: float
percentile_rank: str
glicko: float
rd: float
apm: float
pps: float
vs: float | None
decaying: bool
class Connections(BaseModel):
class Discord(BaseModel):
id: str
username: str
discord: Discord | None
class Distinguishment(BaseModel):
type: str # noqa: A003
id: str = Field(..., alias='_id')
username: str
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
ts: datetime | None
botmaster: str | None
badges: list[Badge]
xp: float
gamesplayed: int
gameswon: int
gametime: float
country: str | None
badstanding: bool | None
supporter: bool | None # osk说是必有, 但实际上不是 fk osk
supporter_tier: int
verified: bool
league: NeverPlayedLeague | NeverRatedLeague | RatedLeague
avatar_revision: int | None
"""This user's avatar ID. Get their avatar at
https://tetr.io/user-content/avatars/{ USERID }.jpg?rv={ AVATAR_REVISION }"""
banner_revision: int | None
"""This user's banner ID. Get their banner at
https://tetr.io/user-content/banners/{ USERID }.jpg?rv={ BANNER_REVISION }
Ignore this field if the user is not a supporter."""
bio: str | None
connections: Connections
friend_count: int
distinguishment: Distinguishment | None
user: User
data: Data
NeverPlayedLeague = SuccessModel.Data.User.NeverPlayedLeague
NeverRatedLeague = SuccessModel.Data.User.NeverRatedLeague
RatedLeague = SuccessModel.Data.User.RatedLeague
UserInfo = SuccessModel | FailedModel

View File

@@ -1,125 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, Field
from .base import FailedModel
from .base import SuccessModel as BaseSuccessModel
class EndContext(BaseModel):
class Time(BaseModel):
start: int
zero: bool
locked: bool
prev: int
frameoffset: int
class Clears(BaseModel):
singles: int
doubles: int
triples: int
quads: int
realtspins: int
minitspins: int
minitspinsingles: int
tspinsingles: int
minitspindoubles: int
tspindoubles: int
tspintriples: int
tspinquads: int
allclear: int
class Garbage(BaseModel):
sent: int
received: int
attack: int
cleared: int
class Finesse(BaseModel):
combo: int
faults: int
perfectpieces: int
seed: int
lines: int
level_lines: int
level_lines_needed: int
inputs: int
holds: int
time: Time
score: int
zenlevel: int
zenprogress: int
level: int
combo: int
currentcombopower: int | None # WTF
topcombo: int
btb: int
topbtb: int
currentbtbchainpower: int | None # WTF * 2
tspins: int
piecesplaced: int
clears: Clears
garbage: Garbage
kills: int
finesse: Finesse
final_time: float = Field(..., alias='finalTime')
gametype: str
class BaseModeRecord(BaseModel):
class SoloRecord(BaseModel):
class User(BaseModel):
id: str = Field(..., alias='_id')
username: str
id: str = Field(..., alias='_id')
stream: str
replayid: str
user: User
ts: datetime
ismulti: bool | None
endcontext: EndContext
class MultiRecord(BaseModel):
class User(BaseModel):
id: str = Field(..., alias='_id')
username: str
id: str = Field(..., alias='_id')
stream: str
replayid: str
user: User
ts: datetime
ismulti: bool | None
endcontext: list[EndContext]
record: SoloRecord | MultiRecord | None
rank: int | None
class SuccessModel(BaseSuccessModel):
class Data(BaseModel):
class Records(BaseModel):
class Sprint(BaseModeRecord):
...
class Blitz(BaseModeRecord):
...
sprint: Sprint = Field(..., alias='40l')
blitz: Blitz
class Zen(BaseModel):
level: int
score: int
records: Records
zen: Zen
data: Data
SoloRecord = BaseModeRecord.SoloRecord
MultiRecord = BaseModeRecord.MultiRecord
UserRecords = SuccessModel | FailedModel

View File

@@ -1,126 +0,0 @@
from arclet.alconna import Alconna, Arg, ArgFlag, Args, CommandMeta, Option
from nonebot.adapters import Bot, Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import AlcMatches, At, on_alconna
from nonebot_plugin_orm import get_session
from ...db import query_bind_info
from ...utils.exception import MessageFormatError, NeedCatchError
from ...utils.platform import get_platform
from ...utils.typing import Me
from ..constant import BIND_COMMAND, QUERY_COMMAND
from .constant import GAME_TYPE
from .processor import Processor, User, identify_user_info
alc = on_alconna(
Alconna(
'top',
Option(
BIND_COMMAND[0],
Args(
Arg(
'account',
identify_user_info,
notice='TOP 用户名',
flags=[ArgFlag.HIDDEN],
)
),
alias=BIND_COMMAND[1:],
compact=True,
dest='bind',
help_text='绑定 TOP 账号',
),
Option(
QUERY_COMMAND[0],
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 | 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
identify_user_info | Me | At,
notice='TOP 用户名',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
alias=QUERY_COMMAND[1:],
compact=True,
dest='query',
help_text='查询 TOP 游戏信息',
),
meta=CommandMeta(
description='查询 TetrisOnline波兰服 的信息',
example='top绑定scdhh\ntop查我',
compact=True,
fuzzy_match=True,
),
),
skip_for_unmatch=False,
auto_send_output=True,
aliases={'TOP'},
)
@alc.assign('bind')
async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
proc = Processor(
event_id=id(event),
user=account,
command_args=[],
)
try:
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.assign('query')
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
async with get_session() as session:
bind = await query_bind_info(
session=session,
chat_platform=get_platform(bot),
chat_account=(target.target if isinstance(target, At) else event.get_user_id()),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
proc = Processor(
event_id=id(event),
user=User(name=bind.game_account),
command_args=[],
)
try:
await matcher.finish(message + await proc.handle_query())
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.assign('query')
async def _(event: Event, matcher: Matcher, account: User):
proc = Processor(
event_id=id(event),
user=account,
command_args=[],
)
try:
await matcher.finish(await proc.handle_query())
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.handle()
async def _(matcher: Matcher, account: MessageFormatError):
await matcher.finish(str(account))
@alc.handle()
async def _(matcher: Matcher, matches: AlcMatches):
if matches.head_matched:
await matcher.finish(
f'{matches.error_info!r}\n' if matches.error_info is not None else '' + '输入"top --help"查看帮助'
)

View File

@@ -1,4 +0,0 @@
from ...utils.typing import GameType
GAME_TYPE: GameType = 'TOP'
BASE_URL = 'http://tetrisonline.pl/top/'

View File

@@ -1,142 +0,0 @@
from contextlib import suppress
from dataclasses import dataclass
from io import StringIO
from re import match
from typing import NoReturn
from urllib.parse import urlencode
from lxml import etree
from nonebot_plugin_orm import get_session
from pandas import read_html
from ...db import create_or_update_bind
from ...utils.exception import MessageFormatError, RequestError
from ...utils.request import Request, splice_url
from ...utils.typing import GameType
from .. import ProcessedData as ProcessedDataMeta
from .. import Processor as ProcessorMeta
from .. import RawResponse as RawResponseMeta
from .. import User as UserMeta
from .constant import BASE_URL, GAME_TYPE
@dataclass
class User(UserMeta):
name: str
@dataclass
class RawResponse(RawResponseMeta):
user_profile: bytes | None = None
@dataclass
class ProcessedData(ProcessedDataMeta):
user_profile: str | None = None
@dataclass
class Data:
lpm: float
apm: float
@dataclass
class GameData:
day: Data | None
total: Data | None
def identify_user_info(info: str) -> User | MessageFormatError:
if match(r'^[a-zA-Z0-9_]{1,16}$', info):
return User(name=info)
return MessageFormatError('用户名不合法')
class Processor(ProcessorMeta):
user: User
raw_response: RawResponse
processed_data: ProcessedData
def __init__(self, event_id: int, user: User, command_args: list[str]) -> None:
super().__init__(event_id, user, command_args)
self.raw_response = RawResponse()
self.processed_data = ProcessedData()
@property
def game_platform(self) -> GameType:
return GAME_TYPE
async def handle_bind(self, platform: str, account: str) -> str:
"""处理绑定消息"""
self.command_type = 'bind'
await self.check_user()
async with get_session() as session:
return await create_or_update_bind(
session=session,
chat_platform=platform,
chat_account=account,
game_platform=GAME_TYPE,
game_account=self.user.name,
)
async def handle_query(self) -> str:
"""处理查询消息"""
self.command_type = 'query'
await self.check_user()
return await self.generate_message()
async def get_user_profile(self) -> str:
"""获取用户信息"""
if self.processed_data.user_profile is None:
url = splice_url([BASE_URL, 'profile.php', f'?{urlencode({"user":self.user.name})}'])
self.raw_response.user_profile = await Request.request(url, is_json=False)
self.processed_data.user_profile = self.raw_response.user_profile.decode()
return self.processed_data.user_profile
async def check_user(self) -> None | NoReturn:
if 'user not found!' in await self.get_user_profile():
raise RequestError('用户不存在!')
return None
async def get_user_name(self) -> str:
"""获取用户名"""
data = etree.HTML(await self.get_user_profile()).xpath('//div[@class="mycontent"]/h1/text()')
return data[0].replace("'s profile", '')
async def get_game_data(self) -> GameData:
"""获取游戏统计数据"""
html = etree.HTML(await self.get_user_profile())
day = None
with suppress(ValueError):
day = Data(
lpm=float(str(html.xpath('//div[@class="mycontent"]/text()[3]')[0]).replace('lpm:', '').strip()),
apm=float(str(html.xpath('//div[@class="mycontent"]/text()[4]')[0]).replace('apm:', '').strip()),
)
table = StringIO(
etree.tostring(
html.xpath('//div[@class="mycontent"]/table[@class="mytable"]')[0],
encoding='utf-8',
).decode()
)
dataframe = read_html(table, encoding='utf-8', header=0)[0]
total = Data(lpm=dataframe['lpm'].mean(), apm=dataframe['apm'].mean()) if len(dataframe) != 0 else None
return GameData(day=day, total=total)
async def generate_message(self) -> str:
"""生成消息"""
game_data = await self.get_game_data()
message = ''
if game_data.day is not None:
message += f'用户 {self.user.name} 24小时内统计数据为: '
message += f"\nL'PM: {round(game_data.day.lpm,2)} ( {round(game_data.day.lpm/24,2)} pps )"
message += f'\nAPM: {round(game_data.day.apm,2)} ( x{round(game_data.day.apm/game_data.day.lpm,2)} )'
else:
message += f'用户 {self.user.name} 暂无24小时内统计数据'
if game_data.total is not None:
message += '\n历史统计数据为: '
message += f"\nL'PM: {round(game_data.total.lpm,2)} ( {round(game_data.total.lpm/24,2)} pps )"
message += f'\nAPM: {round(game_data.total.apm,2)} ( x{round(game_data.total.apm/game_data.total.lpm,2)} )'
else:
message += '\n暂无历史统计数据'
return message

View File

@@ -1,151 +0,0 @@
from arclet.alconna import Alconna, Arg, ArgFlag, Args, CommandMeta, Option
from nonebot.adapters import Bot, Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import AlcMatches, At, on_alconna
from nonebot_plugin_orm import get_session
from ...db import query_bind_info
from ...utils.exception import MessageFormatError, NeedCatchError
from ...utils.platform import get_platform
from ...utils.typing import Me
from ..constant import BIND_COMMAND, QUERY_COMMAND
from .constant import GAME_TYPE
from .processor import Processor, User, identify_user_info
alc = on_alconna(
Alconna(
'茶服',
Option(
BIND_COMMAND[0],
Args(
Arg(
'account',
identify_user_info,
notice='茶服 用户名 / TeaID',
flags=[ArgFlag.HIDDEN],
)
),
alias=BIND_COMMAND[1:],
compact=True,
dest='bind',
help_text='绑定 茶服 账号',
),
Option(
QUERY_COMMAND[0],
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 | 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
identify_user_info,
notice='茶服 用户名 / TeaID',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
# 如果放在一个 Union Args 里, 验证顺序不能保证, 可能出错
),
alias=QUERY_COMMAND[1:],
compact=True,
dest='query',
help_text='查询 茶服 游戏信息',
),
meta=CommandMeta(
description='查询 TetrisOnline茶服 的信息',
example='茶服查我',
compact=True,
fuzzy_match=True,
),
),
skip_for_unmatch=False,
auto_send_output=True,
aliases={'tos', 'TOS'},
)
try:
from nonebot.adapters.onebot.v11 import GROUP, MessageEvent
from nonebot.adapters.onebot.v11 import Bot as OB11Bot
@alc.assign('bind')
async def _(event: MessageEvent, matcher: Matcher):
await matcher.finish('QQ 平台无需绑定')
@alc.assign('query')
async def _(bot: OB11Bot, event: MessageEvent, matcher: Matcher, target: At | Me):
if event.is_tome() and await GROUP(bot, event):
await matcher.finish('不能查询bot的信息')
proc = Processor(
event_id=id(event),
user=User(teaid=target.target if isinstance(target, At) else event.get_user_id()),
command_args=[],
)
try:
await matcher.finish(await proc.handle_query())
except NeedCatchError as e:
await matcher.finish(str(e))
except ImportError:
pass
@alc.assign('bind')
async def _(bot: Bot, event: Event, matcher: Matcher, account: User):
proc = Processor(
event_id=id(event),
user=account,
command_args=[],
)
try:
await matcher.finish(await proc.handle_bind(platform=get_platform(bot), account=event.get_user_id()))
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.assign('query')
async def _(bot: Bot, event: Event, matcher: Matcher, target: At | Me):
async with get_session() as session:
bind = await query_bind_info(
session=session,
chat_platform=get_platform(bot),
chat_account=(target.target if isinstance(target, At) else event.get_user_id()),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'
proc = Processor(
event_id=id(event),
user=User(name=bind.game_account),
command_args=[],
)
try:
await matcher.finish(message + await proc.handle_query())
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.assign('query')
async def _(event: Event, matcher: Matcher, account: User):
proc = Processor(
event_id=id(event),
user=account,
command_args=[],
)
try:
await matcher.finish(await proc.handle_query())
except NeedCatchError as e:
await matcher.finish(str(e))
@alc.handle()
async def _(matcher: Matcher, account: MessageFormatError):
await matcher.finish(str(account))
@alc.handle()
async def _(matcher: Matcher, matches: AlcMatches):
if matches.head_matched:
await matcher.finish(
f'{matches.error_info!r}\n' if matches.error_info is not None else '' + '输入"茶服 --help"查看帮助'
)

View File

@@ -1,4 +0,0 @@
from ...utils.typing import GameType
GAME_TYPE: GameType = 'TOS'
BASE_URL = 'https://teatube.cn:8888/'

View File

@@ -1,221 +0,0 @@
from dataclasses import dataclass
from re import match
from typing import Any
from urllib.parse import urlencode
from nonebot_plugin_orm import get_session
from pydantic import parse_raw_as
from ...db import create_or_update_bind
from ...utils.exception import MessageFormatError, RequestError
from ...utils.request import Request, splice_url
from ...utils.typing import GameType
from .. import ProcessedData as ProcessedDataMeta
from .. import Processor as ProcessorMeta
from .. import RawResponse as RawResponseMeta
from .. import User as UserMeta
from .constant import BASE_URL, GAME_TYPE
from .schemas.user_info import SuccessModel as InfoSuccess
from .schemas.user_info import UserInfo
from .schemas.user_profile import UserProfile
@dataclass
class User(UserMeta):
teaid: str | None = None
name: str | None = None
@dataclass
class RawResponse(RawResponseMeta):
user_profile: dict[frozenset[tuple[str, Any]], bytes]
user_info: bytes | None = None
@dataclass
class ProcessedData(ProcessedDataMeta):
user_profile: dict[frozenset[tuple[str, Any]], UserProfile]
user_info: InfoSuccess | None = None
@dataclass
class GameData:
num: int
pps: float
lpm: float
apm: float
adpm: float
apl: float
adpl: float
vs: float
def identify_user_info(info: str) -> User | MessageFormatError:
if (
match(
r'^(?!\.)(?!com[0-9]$)(?!con$)(?!lpt[0-9]$)(?!nul$)(?!prn$)[^\-][^\+][^\|\*\?\\\s\!:<>/$"]*[^\.\|\*\?\\\s\!:<>/$"]+$',
info,
)
and info.isdigit() is False
and 2 <= len(info) <= 18 # noqa: PLR2004
):
return User(name=info)
if info.isdigit():
return User(teaid=info)
return MessageFormatError('用户名/QQ号不合法')
class Processor(ProcessorMeta):
user: User
raw_response: RawResponse
processed_data: ProcessedData
def __init__(self, event_id: int, user: User, command_args: list[str]) -> None:
super().__init__(event_id, user, command_args)
self.raw_response = RawResponse(user_profile={})
self.processed_data = ProcessedData(user_profile={})
@property
def game_platform(self) -> GameType:
return GAME_TYPE
async def handle_bind(self, platform: str, account: str) -> str:
"""处理绑定消息"""
self.command_type = 'bind'
await self.get_user()
if self.user.name is None:
raise # FIXME: 不知道怎么才能把这类型给变过来了
async with get_session() as session:
return await create_or_update_bind(
session=session,
chat_platform=platform,
chat_account=account,
game_platform=GAME_TYPE,
game_account=self.user.name,
)
async def handle_query(self) -> str:
"""处理查询消息"""
self.command_type = 'query'
await self.get_user()
return await self.generate_message()
async def get_user(self) -> None:
"""
用于获取 UserName 和 UserID 的函数
"""
if self.user.name is None:
self.user.name = (await self.get_user_info()).data.name
if self.user.teaid is None:
self.user.teaid = (await self.get_user_info()).data.teaid
async def get_user_info(self) -> InfoSuccess:
"""获取用户信息"""
if self.processed_data.user_info is None:
if self.user.teaid is not None:
url = splice_url(
[
BASE_URL,
'getTeaIdInfo',
f'?{urlencode({"teaId":self.user.teaid})}',
]
)
else:
url = splice_url(
[
BASE_URL,
'getUsernameInfo',
f'?{urlencode({"username":self.user.name})}',
]
)
self.raw_response.user_info = await Request.request(url)
user_info: UserInfo = parse_raw_as(UserInfo, self.raw_response.user_info) # type: ignore[arg-type]
if not isinstance(user_info, InfoSuccess):
raise RequestError(f'用户信息请求错误:\n{user_info.error}')
self.processed_data.user_info = user_info
return self.processed_data.user_info
async def get_user_profile(self, other_parameter: dict[str, Any] | None = None) -> UserProfile:
"""获取用户数据"""
if other_parameter is None:
other_parameter = {}
fset = frozenset(other_parameter.items())
if self.processed_data.user_profile.get(fset) is None:
self.raw_response.user_profile[fset] = await Request.request(
splice_url(
[
BASE_URL,
'getProfile',
f'?{urlencode({"id":self.user.teaid or self.user.name},**other_parameter)}',
]
)
)
self.processed_data.user_profile[fset] = UserProfile.parse_raw(self.raw_response.user_profile[fset])
return self.processed_data.user_profile[fset]
async def get_game_data(self) -> GameData | None:
"""获取游戏数据"""
user_profile = await self.get_user_profile()
if user_profile.data == []:
return None
weighted_total_lpm = weighted_total_apm = weighted_total_adpm = 0.0
total_time = 0.0
num = 0
for i in user_profile.data:
# 排除单人局和时间为0的游戏
# 茶: 不计算没挖掘的局, 即使apm和lpm也如此
if i.num_players == 1 or i.time == 0 or i.dig is None:
continue
# 加权计算
time = i.time / 1000
lpm = 24 * (i.pieces / time)
apm = (i.attack / time) * 60
adpm = ((i.attack + i.dig) / time) * 60
weighted_total_lpm += lpm * time
weighted_total_apm += apm * time
weighted_total_adpm += adpm * time
total_time += time
num += 1
if num == 50: # noqa: PLR2004 # TODO: 将查询局数作为可选命令参数
break
if num == 0:
return None
# TODO: 如果有效局数不满50, 没有无dig信息的局, 且userData['data']内有50个局, 则继续往前获取信息
lpm = weighted_total_lpm / total_time
apm = weighted_total_apm / total_time
adpm = weighted_total_adpm / total_time
return GameData(
num=num,
pps=round(lpm / 24, 2),
lpm=round(lpm, 2),
apm=round(apm, 2),
adpm=round(adpm, 2),
apl=round((apm / lpm), 2),
adpl=round((adpm / lpm), 2),
vs=round((adpm / 60 * 100), 2),
)
async def generate_message(self) -> str:
"""生成消息"""
user_info = (await self.get_user_info()).data
message = f'用户 {user_info.name} ({user_info.teaid}) '
if user_info.ranked_games == '0':
message += '暂无段位统计数据'
else:
message += f', 段位分 {round(float(user_info.rating_now),2)}±{round(float(user_info.rd_now),2)} ({round(float(user_info.vol_now),2)}) '
game_data = await self.get_game_data()
if game_data is None:
message += ', 暂无游戏数据'
else:
message += f', 最近 {game_data.num} 局数据'
message += f"\nL'PM: {game_data.lpm} ( {game_data.pps} pps )"
message += f'\nAPM: {game_data.apm} ( x{game_data.apl} )'
message += f'\nADPM: {game_data.adpm} ( x{game_data.adpl} ) ( {game_data.vs}vs )'
message += (
f'\n40L: {float(user_info.pb_sprint)/1000:.2f}s'
if user_info.pb_sprint != 2147483647 # noqa: PLR2004
else ''
)
message += f'\nMarathon: {user_info.pb_marathon}' if user_info.pb_marathon != 0 else ''
message += f'\nChallenge: {user_info.pb_challenge}' if user_info.pb_challenge != 0 else ''
return message

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
from collections.abc import Callable
from nonebot.adapters import Bot
from nonebot.matcher import Matcher
from nonebot.message import run_postprocessor
from nonebot.typing import T_Handler
from nonebot_plugin_alconna import AlcMatches, Alconna, At, CommandMeta, on_alconna
from .. import ns
from ..utils.exception import MessageFormatError, NeedCatchError
command: Alconna = Alconna(
['tetris-stats', 'tstats'],
namespace=ns,
meta=CommandMeta(
description='俄罗斯方块相关游戏数据查询',
fuzzy_match=True,
),
)
alc = on_alconna(
command=command,
skip_for_unmatch=False,
auto_send_output=True,
use_origin=True,
)
def add_block_handlers(handler: Callable[[T_Handler], T_Handler]) -> None:
@handler
async def _(bot: Bot, matcher: Matcher, target: At):
if isinstance(target, At) and target.target == bot.self_id:
await matcher.finish('不能查询bot的信息')
from . import tetrio, top, tos # noqa: F401, E402
@alc.handle()
async def _(matcher: Matcher, account: MessageFormatError):
await matcher.finish(str(account))
@alc.handle()
async def _(matcher: Matcher, matches: AlcMatches):
if matches.head_matched and matches.options != {} or matches.main_args == {}:
await matcher.finish(
(f'{matches.error_info!r}\n' if matches.error_info is not None else '')
+ f'输入"{matches.header_result} --help"查看帮助'
)
@run_postprocessor
async def _(matcher: Matcher, exception: NeedCatchError):
await matcher.send(str(exception))

View File

@@ -1,2 +1,3 @@
BIND_COMMAND: list[str] = ['绑定', 'bind']
QUERY_COMMAND: list[str] = ['', '查询', 'query', 'stats']
CANT_VERIFY_MESSAGE = '* 由于无法验证绑定信息, 不能保证查询到的用户为本人\n'

View File

@@ -0,0 +1,23 @@
from abc import ABC, abstractmethod
from pydantic import BaseModel
from ..utils.typing import GameType
class Base(BaseModel):
platform: GameType
class BaseUser(ABC, Base):
"""游戏用户"""
def __eq__(self, __value: object) -> bool:
if isinstance(__value, BaseUser):
return self.unique_identifier == __value.unique_identifier
return False
@property
@abstractmethod
def unique_identifier(self) -> str:
raise NotImplementedError

View File

@@ -0,0 +1,166 @@
from arclet.alconna import Arg, ArgFlag, Args, Option, Subcommand
from nonebot_plugin_alconna import At
from ...utils.exception import MessageFormatError
from ...utils.typing import Me
from .. import add_block_handlers, alc, command
from .api import Player
from .api.typing import ValidRank
from .constant import USER_ID, USER_NAME
from .typing import Template
def get_player(user_id_or_name: str) -> Player | MessageFormatError:
if USER_ID.match(user_id_or_name):
return Player(user_id=user_id_or_name, trust=True)
if USER_NAME.match(user_id_or_name):
return Player(user_name=user_id_or_name, trust=True)
return MessageFormatError('用户名/ID不合法')
command.add(
Subcommand(
'TETR.IO',
Subcommand(
'bind',
Args(
Arg(
'account',
get_player,
notice='TETR.IO 用户名 / ID',
flags=[ArgFlag.HIDDEN],
)
),
help_text='绑定 TETR.IO 账号',
),
Subcommand(
'query',
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 / 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
get_player,
notice='TETR.IO 用户名 / ID',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
Option(
'--template',
Arg('template', Template),
alias=['-T'],
help_text='要使用的查询模板',
),
help_text='查询 TETR.IO 游戏信息',
),
Subcommand(
'record',
Option(
'--40l',
dest='sprint',
),
Option(
'--blitz',
dest='blitz',
),
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 / 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
get_player,
notice='TETR.IO 用户名 / ID',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
),
Subcommand(
'rank',
Args(Arg('rank', ValidRank, notice='TETR.IO 段位')),
help_text='查询 TETR.IO 段位信息',
),
Subcommand(
'config',
Option(
'--default-template',
Arg('template', Template),
alias=['-DT', 'DefaultTemplate'],
),
),
dest='TETRIO',
help_text='TETR.IO 游戏相关指令',
)
)
alc.shortcut(
'(?i:io)(?i:绑定|绑|bind)',
{
'command': 'tstats TETR.IO bind',
'humanized': 'io绑定',
},
)
alc.shortcut(
'(?i:io)(?i:查询|查|query|stats)',
{
'command': 'tstats TETR.IO query',
'humanized': 'io查',
},
)
alc.shortcut(
'(?i:io)(?i:记录|record)(?i:40l)',
{
'command': 'tstats TETR.IO record --40l',
'humanized': 'io记录40l',
},
)
alc.shortcut(
'(?i:io)(?i:记录|record)(?i:blitz)',
{
'command': 'tstats TETR.IO record --blitz',
'humanized': 'io记录blitz',
},
)
alc.shortcut(
'(?i:io)(?i:段位|段|rank)',
{
'command': 'tstats TETR.IO rank',
'humanized': 'iorank',
},
)
alc.shortcut(
'(?i:io)(?i:配置|配|config)',
{
'command': 'tstats TETR.IO config',
'humanized': 'io配置',
},
)
alc.shortcut(
'fkosk',
{
'command': 'tstats TETR.IO query',
'args': [''],
'fuzzy': False,
'humanized': 'An Easter egg!',
},
)
add_block_handlers(alc.assign('TETRIO.query'))
from . import bind, config, query, rank, record # noqa: E402
__all__ = [
'bind',
'config',
'query',
'rank',
'record',
]

View File

@@ -0,0 +1,7 @@
from .player import Player
from .schemas.user import User
from .schemas.user_info import UserInfoSuccess
from .schemas.user_records import UserRecordsSuccess
from .tetra_league import full_export as tetra_league_full_export
__all__ = ['Player', 'User', 'UserInfoSuccess', 'UserRecordsSuccess', 'tetra_league_full_export']

View File

@@ -0,0 +1,35 @@
from asyncio import Lock
from datetime import datetime, timezone
from typing import ClassVar
from weakref import WeakValueDictionary
from aiocache import Cache as ACache # type: ignore[import-untyped]
from nonebot.compat import type_validate_json
from nonebot.log import logger
from ....utils.request import Request
from .schemas.base import FailedModel, SuccessModel
UTC = timezone.utc
class Cache:
cache = ACache(ACache.MEMORY)
task: ClassVar[WeakValueDictionary[str, Lock]] = WeakValueDictionary()
@classmethod
async def get(cls, url: str) -> 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)
parsed_data: SuccessModel | FailedModel = type_validate_json(SuccessModel | FailedModel, response_data) # type: ignore[arg-type]
if isinstance(parsed_data, SuccessModel):
await cls.cache.add(
url,
response_data,
(parsed_data.cache.cached_until - datetime.now(UTC)).total_seconds(),
)
return response_data

View File

@@ -0,0 +1,17 @@
from datetime import datetime
from typing import Literal
from nonebot_plugin_orm import Model
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
from ....db.models import PydanticType
from .schemas.base import SuccessModel
class TETRIOHistoricalData(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(init=False, primary_key=True)
user_unique_identifier: Mapped[str] = mapped_column(String(24), index=True)
api_type: Mapped[Literal['User Info', 'User Records']] = mapped_column(String(16), index=True)
data: Mapped[SuccessModel] = mapped_column(PydanticType(get_model=[SuccessModel.__subclasses__], models=set()))
update_time: Mapped[datetime] = mapped_column(DateTime, index=True)

View File

@@ -0,0 +1,114 @@
from typing import overload
from nonebot.compat import type_validate_json
from ....db import anti_duplicate_add
from ....utils.exception import RequestError
from ....utils.request import splice_url
from ..constant import BASE_URL, USER_ID, USER_NAME
from .cache import Cache
from .models import TETRIOHistoricalData
from .schemas.base import FailedModel
from .schemas.user import User
from .schemas.user_info import UserInfo, UserInfoSuccess
from .schemas.user_records import SoloModeRecord, UserRecords, UserRecordsSuccess, Zen
class Player:
@overload
def __init__(self, *, user_id: str, trust: bool = False): ...
@overload
def __init__(self, *, user_name: str, trust: bool = False): ...
def __init__(self, *, user_id: str | None = None, user_name: str | None = None, trust: bool = False):
self.user_id = user_id
self.user_name = user_name
if not trust:
if self.user_id is not None:
if not USER_ID.match(self.user_id):
msg = 'Invalid user id'
raise ValueError(msg)
elif self.user_name is not None:
if not USER_NAME.match(self.user_name):
msg = 'Invalid user name'
raise ValueError(msg)
else:
msg = 'Invalid user'
raise ValueError(msg)
self.__user: User | None = None
self._user_info: UserInfoSuccess | None = None
self._user_records: UserRecordsSuccess | None = None
@property
def _request_user_parameter(self) -> str:
if self.user_id is not None:
return self.user_id
if self.user_name is not None:
return self.user_name.lower()
msg = 'Invalid user'
raise ValueError(msg)
@property
async def user(self) -> User:
if self.__user is None:
user_info = await self.get_info()
self.__user = User(
ID=user_info.data.user.id,
name=user_info.data.user.username,
)
self.user_id = user_info.data.user.id
self.user_name = user_info.data.user.username
return self.__user
async def get_info(self) -> UserInfoSuccess:
"""Get User Info"""
if self._user_info is None:
raw_user_info = await Cache.get(splice_url([BASE_URL, 'users/', f'{self._request_user_parameter}']))
user_info: UserInfo = type_validate_json(UserInfo, raw_user_info) # type: ignore[arg-type]
if isinstance(user_info, FailedModel):
msg = f'用户信息请求错误:\n{user_info.error}'
raise RequestError(msg)
self._user_info = user_info
await anti_duplicate_add(
TETRIOHistoricalData,
TETRIOHistoricalData(
user_unique_identifier=(await self.user).unique_identifier,
api_type='User Info',
data=user_info,
update_time=user_info.cache.cached_at,
),
)
return self._user_info
async def get_records(self) -> UserRecordsSuccess:
"""Get User Records"""
if self._user_records is None:
raw_user_records = await Cache.get(
splice_url([BASE_URL, 'users/', f'{self._request_user_parameter}/', 'records'])
)
user_records: UserRecords = type_validate_json(UserRecords, raw_user_records) # type: ignore[arg-type]
if isinstance(user_records, FailedModel):
msg = f'用户Solo数据请求错误:\n{user_records.error}'
raise RequestError(msg)
self._user_records = user_records
await anti_duplicate_add(
TETRIOHistoricalData,
TETRIOHistoricalData(
user_unique_identifier=(await self.user).unique_identifier,
api_type='User Records',
data=user_records,
update_time=user_records.cache.cached_at,
),
)
return self._user_records
@property
async def sprint(self) -> SoloModeRecord:
return (await self.get_records()).data.records.sprint
@property
async def blitz(self) -> SoloModeRecord:
return (await self.get_records()).data.records.blitz
@property
async def zen(self) -> Zen:
return (await self.get_records()).data.zen

View File

@@ -0,0 +1,59 @@
from pydantic import BaseModel, Field
from ..typing import Rank
from .base import FailedModel
from .base import SuccessModel as BaseSuccessModel
class _User(BaseModel):
id: str = Field(..., alias='_id')
username: str
role: str
xp: float
supporter: bool
verified: bool
country: str | None = None
class _League(BaseModel):
gamesplayed: int
gameswon: int
rating: float
rank: Rank
bestrank: Rank
decaying: bool
class ValidLeague(_League):
glicko: float
rd: float
apm: float
pps: float
vs: float
class ValidUser(_User):
league: ValidLeague
class InvalidLeague(_League):
glicko: float | None = None
rd: float | None = None
apm: float | None = None
pps: float | None = None
vs: float | None = None
class InvalidUser(_User):
league: InvalidLeague
class Data(BaseModel):
users: list[ValidUser | InvalidUser]
class TetraLeagueSuccess(BaseSuccessModel):
data: Data
TetraLeague = TetraLeagueSuccess | FailedModel

View File

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

View File

@@ -0,0 +1,133 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from ..typing import Rank
from .base import FailedModel
from .base import SuccessModel as BaseSuccessModel
class Badge(BaseModel):
id: str
label: str
group: str | None = None
ts: datetime | Literal[False] | None = None
class MetaLeague(BaseModel):
decaying: bool
class NeverPlayedLeague(MetaLeague):
gamesplayed: Literal[0]
gameswon: Literal[0]
rating: Literal[-1]
rank: Literal['z']
standing: Literal[-1]
standing_local: Literal[-1]
next_rank: None
prev_rank: None
next_at: Literal[-1]
prev_at: Literal[-1]
percentile: Literal[-1]
percentile_rank: Literal['z']
apm: None = None
pps: None = None
vs: None = None
class NeverRatedLeague(MetaLeague):
gamesplayed: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9]
gameswon: int
rating: Literal[-1]
rank: Literal['z']
standing: Literal[-1]
standing_local: Literal[-1]
next_rank: None
prev_rank: None
next_at: Literal[-1]
prev_at: Literal[-1]
percentile: Literal[-1]
percentile_rank: Literal['z']
apm: float
pps: float
vs: float | None = None
class RatedLeague(MetaLeague):
gamesplayed: int
gameswon: int
rating: float
rank: Rank
bestrank: Rank
standing: int
standing_local: int
next_rank: Rank | None = None
prev_rank: Rank | None = None
next_at: int
prev_at: int
percentile: float
percentile_rank: str
glicko: float
rd: float
apm: float
pps: float
vs: float | None = None
class Discord(BaseModel):
id: str
username: str
class Connections(BaseModel):
discord: Discord | None = None
class Distinguishment(BaseModel):
type: str
class User(BaseModel):
id: str = Field(..., alias='_id')
username: str
role: Literal['anon', 'user', 'bot', 'halfmod', 'mod', 'admin', 'sysop', 'banned']
ts: datetime | None = None
botmaster: str | None = None
badges: list[Badge]
xp: float
gamesplayed: int
gameswon: int
gametime: float
country: str | None = None
badstanding: bool | None = None
supporter: bool | None = None # osk说是必有, 但实际上不是 fkosk
supporter_tier: int
verified: bool
league: NeverPlayedLeague | NeverRatedLeague | RatedLeague
avatar_revision: int | None = None
"""This user's avatar ID. Get their avatar at
https://tetr.io/user-content/avatars/{ USERID }.jpg?rv={ AVATAR_REVISION }"""
banner_revision: int | None = None
"""This user's banner ID. Get their banner at
https://tetr.io/user-content/banners/{ USERID }.jpg?rv={ BANNER_REVISION }
Ignore this field if the user is not a supporter."""
bio: str | None = None
connections: Connections
friend_count: int | None = None
distinguishment: Distinguishment | None = None
class Data(BaseModel):
user: User
class UserInfoSuccess(BaseSuccessModel):
data: Data
UserInfo = UserInfoSuccess | FailedModel

View File

@@ -0,0 +1,122 @@
from datetime import datetime
from pydantic import BaseModel, Field
from .....utils.typing import Number
from .base import FailedModel
from .base import SuccessModel as BaseSuccessModel
class Time(BaseModel):
start: int
zero: bool
locked: bool
prev: int
frameoffset: int | None = None
class Clears(BaseModel):
singles: int
doubles: int
triples: int
quads: int
pentas: int | None = None
realtspins: int
minitspins: int
minitspinsingles: int
tspinsingles: int
minitspindoubles: int
tspindoubles: int
tspintriples: int
tspinquads: int
allclear: int
class Garbage(BaseModel):
sent: int
received: int
attack: int | None = None
cleared: int | None = None
class Finesse(BaseModel):
combo: int
faults: int
perfectpieces: int
class EndContext(BaseModel):
seed: Number
lines: int
level_lines: int
level_lines_needed: int
inputs: int
holds: int | None = None
time: Time
score: int
zenlevel: int | None = None
zenprogress: int | None = None
level: int
combo: int
currentcombopower: int | None = None # WTF
topcombo: int
btb: int
topbtb: int
currentbtbchainpower: int | None = None # WTF * 2
tspins: int
piecesplaced: int
clears: Clears
garbage: Garbage
kills: int
finesse: Finesse
final_time: float = Field(..., alias='finalTime')
gametype: str
class _User(BaseModel):
id: str = Field(..., alias='_id')
username: str
class _Record(BaseModel):
id: str = Field(..., alias='_id')
stream: str
replayid: str
user: _User
ts: datetime
ismulti: bool | None = None
class SoloRecord(_Record):
endcontext: EndContext
class MultiRecord(_Record):
endcontext: list[EndContext]
class SoloModeRecord(BaseModel):
record: SoloRecord | None = None
rank: int | None = None
class Records(BaseModel):
sprint: SoloModeRecord = Field(..., alias='40l')
blitz: SoloModeRecord
class Zen(BaseModel):
level: int
score: int
class Data(BaseModel):
records: Records
zen: Zen
class UserRecordsSuccess(BaseSuccessModel):
data: Data
UserRecords = UserRecordsSuccess | FailedModel

View File

@@ -0,0 +1,36 @@
from typing import Literal, NamedTuple, overload
from nonebot.compat import type_validate_json
from ....utils.exception import RequestError
from ....utils.request import splice_url
from ..constant import BASE_URL
from .cache import Cache
from .schemas.base import FailedModel
from .schemas.tetra_league import TetraLeague, TetraLeagueSuccess
class FullExport(NamedTuple):
model: TetraLeagueSuccess
original: bytes
@overload
async def full_export(*, with_original: Literal[False]) -> TetraLeagueSuccess: ...
@overload
async def full_export(*, with_original: Literal[True]) -> FullExport: ...
async def full_export(*, with_original: bool) -> TetraLeagueSuccess | FullExport:
full: TetraLeague = type_validate_json(
TetraLeague, # type: ignore[arg-type]
(data := await Cache.get(splice_url([BASE_URL, 'users/lists/league/all']))),
)
if isinstance(full, FailedModel):
msg = f'排行榜数据请求错误:\n{full.error}'
raise RequestError(msg)
if with_original:
return FullExport(full, data)
return full

View File

@@ -1,6 +1,6 @@
from typing import Literal
Rank = Literal[
ValidRank = Literal[
'x',
'u',
'ss',
@@ -18,5 +18,6 @@ Rank = Literal[
'c-',
'd+',
'd',
'z', # 未定级
]
Rank = ValidRank | Literal['z'] # 未定级

View File

@@ -0,0 +1,62 @@
from asyncio import gather
from hashlib import md5
from urllib.parse import urlencode
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
from nonebot_plugin_user import User # type: ignore[import-untyped]
from nonebot_plugin_userinfo import BotUserInfo, UserInfo # type: ignore[import-untyped]
from ...db import BindStatus, create_or_update_bind, trigger
from ...utils.host import HostPage, get_self_netloc
from ...utils.image import get_avatar
from ...utils.render import Bind, render
from ...utils.render.schemas.base import Avatar, People
from ...utils.screenshot import screenshot
from . import alc
from .api import Player
from .constant import GAME_TYPE
@alc.assign('TETRIO.bind')
async def _(nb_user: User, account: Player, event_session: EventSession, bot_info: UserInfo = BotUserInfo()): # noqa: B008
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='bind',
command_args=[],
):
user, user_info = await gather(account.user, account.get_info())
async with get_session() as session:
bind_status = await create_or_update_bind(
session=session,
user=nb_user,
game_platform=GAME_TYPE,
game_account=user.unique_identifier,
)
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
netloc = get_self_netloc()
async with HostPage(
await render(
'v1/binding',
Bind(
platform='TETR.IO',
status='unknown',
user=People(
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
if user_info.data.user.avatar_revision is not None
and user_info.data.user.avatar_revision != 0
else Avatar(type='identicon', hash=md5(user_info.data.user.id.encode()).hexdigest()), # noqa: S324
name=user_info.data.user.username.upper(),
),
bot=People(
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
name=bot_info.user_name,
),
command='io查我',
),
)
) as page_hash:
await UniMessage.image(raw=await screenshot(f'http://{netloc}/host/{page_hash}.html')).finish()

View File

@@ -0,0 +1,20 @@
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import async_scoped_session
from nonebot_plugin_user import User # type: ignore[import-untyped]
from sqlalchemy import select
from . import alc
from .models import TETRIOUserConfig
from .typing import Template
@alc.assign('TETRIO.config')
async def _(user: User, session: async_scoped_session, template: Template):
config = (await session.scalars(select(TETRIOUserConfig).where(TETRIOUserConfig.id == user.id))).one_or_none()
if config is None:
config = TETRIOUserConfig(id=user.id, query_template=template)
session.add(config)
else:
config.query_template = template
await session.commit()
await UniMessage('配置成功').finish()

View File

@@ -1,8 +1,12 @@
from ...utils.typing import GameType
from .typing import Rank
from re import compile
from typing import Literal
from .api.typing import Rank
GAME_TYPE: Literal['IO'] = 'IO'
GAME_TYPE: GameType = 'IO'
BASE_URL = 'https://ch.tetr.io/api/'
RANK_PERCENTILE: dict[Rank, float] = {
'x': 1,
'u': 5,
@@ -22,3 +26,9 @@ RANK_PERCENTILE: dict[Rank, float] = {
'd+': 97.5,
'd': 100,
}
TR_MIN = 0
TR_MAX = 25000
USER_ID = compile(r'^[a-f0-9]{24}$')
USER_NAME = compile(r'^[a-zA-Z0-9_-]{3,16}$')

View File

@@ -1,10 +1,11 @@
from datetime import UTC, datetime
from datetime import datetime
from nonebot_plugin_orm import Model
from sqlalchemy import JSON, DateTime, String
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
from .typing import Rank
from .api.typing import Rank
from .typing import Template
class IORank(MappedAsDataclass, Model):
@@ -21,9 +22,13 @@ class IORank(MappedAsDataclass, Model):
high_pps: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
high_apm: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
high_vs: Mapped[tuple[dict[str, str], float]] = mapped_column(JSON)
create_time: Mapped[datetime] = mapped_column(
update_time: Mapped[datetime] = mapped_column(
DateTime,
default=lambda: datetime.now(tz=UTC),
index=True,
init=False,
)
file_hash: Mapped[str | None] = mapped_column(String(128), index=True)
class TETRIOUserConfig(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(primary_key=True)
query_template: Mapped[Template] = mapped_column(String(2))

View File

@@ -0,0 +1,553 @@
from asyncio import gather
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
from hashlib import md5
from math import ceil, floor
from typing import ClassVar, TypeVar, overload
from urllib.parse import urlencode
from zoneinfo import ZoneInfo
from aiofiles import open
from nonebot import get_driver
from nonebot.adapters import Event
from nonebot.compat import type_validate_json
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_apscheduler import scheduler # type: ignore[import-untyped]
from nonebot_plugin_localstore import get_data_file # type: ignore[import-untyped]
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
from nonebot_plugin_user import User as NBUser # type: ignore[import-untyped]
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
from sqlalchemy import select
from zstandard import ZstdDecompressor
from ...db import query_bind_info, trigger
from ...utils.exception import FallbackError
from ...utils.host import HostPage, get_self_netloc
from ...utils.metrics import TetrisMetricsProWithPPSVS, get_metrics
from ...utils.render import render
from ...utils.render.schemas.base import Avatar, Ranking
from ...utils.render.schemas.tetrio.tetrio_info import Info as V1TemplateInfo
from ...utils.render.schemas.tetrio.tetrio_info import Radar, TetraLeague, TetraLeagueHistory, TetraLeagueHistoryData
from ...utils.render.schemas.tetrio.tetrio_info import User as V1TemplateUser
from ...utils.render.schemas.tetrio.tetrio_user_info_v2 import Badge, Blitz, Sprint, Statistic, TetraLeagueStatistic
from ...utils.render.schemas.tetrio.tetrio_user_info_v2 import Info as V2TemplateInfo
from ...utils.render.schemas.tetrio.tetrio_user_info_v2 import TetraLeague as V2TemplateTetraLeague
from ...utils.render.schemas.tetrio.tetrio_user_info_v2 import User as V2TemplateUser
from ...utils.screenshot import screenshot
from ...utils.typing import Me, Number
from ..constant import CANT_VERIFY_MESSAGE
from . import alc
from .api import Player, User, UserInfoSuccess
from .api.models import TETRIOHistoricalData
from .api.schemas.tetra_league import TetraLeagueSuccess
from .api.schemas.user_info import NeverPlayedLeague, NeverRatedLeague, RatedLeague
from .constant import GAME_TYPE, TR_MAX, TR_MIN
from .models import IORank, TETRIOUserConfig
from .typing import Template
UTC = timezone.utc
driver = get_driver()
@alc.assign('TETRIO.query')
async def _( # noqa: PLR0913
user: NBUser,
event: Event,
matcher: Matcher,
target: At | Me,
event_session: EventSession,
template: Template | None = None,
):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
):
async with get_session() as session:
bind = await query_bind_info(
session=session,
user=await get_user(
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE,
)
if template is None:
template = await session.scalar(
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = UniMessage(CANT_VERIFY_MESSAGE)
player = Player(user_id=bind.game_account, trust=True)
await (message + (await make_query_result(player, template or 'v1'))).finish()
@alc.assign('TETRIO.query')
async def _(user: NBUser, account: Player, event_session: EventSession, template: Template | None = None):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
):
async with get_session() as session:
if template is None:
template = await session.scalar(
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
)
await (await make_query_result(account, template or 'v1')).finish()
def get_value_bounds(values: list[int | float]) -> tuple[int, int]:
value_max = 10 * ceil(max(values) / 10)
value_min = 10 * floor(min(values) / 10)
return value_max, value_min
def get_split(value_max: int, value_min: int) -> tuple[int, int]:
offset = 0
overflow = 0
while True:
if (new_max_value := value_max + offset + overflow) > TR_MAX:
overflow -= 1
continue
if (new_min_value := value_min - offset + overflow) < TR_MIN:
overflow += 1
continue
if ((new_max_value - new_min_value) / 40).is_integer():
split_value = int((value_max + offset - (value_min - offset)) / 4)
break
offset += 1
return split_value, offset + overflow
def get_specified_point(
previous_point: 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)),
)
async def query_historical_data(user: User, user_info: UserInfoSuccess) -> list[TetraLeagueHistoryData]:
today = datetime.now(ZoneInfo('Asia/Shanghai')).replace(hour=0, minute=0, second=0, microsecond=0)
forward = timedelta(days=9)
start_time = (today - forward).astimezone(UTC)
async with get_session() as session:
historical_data = (
await session.scalars(
select(TETRIOHistoricalData)
.where(TETRIOHistoricalData.update_time >= start_time)
.where(TETRIOHistoricalData.user_unique_identifier == user.unique_identifier)
.where(TETRIOHistoricalData.api_type == 'User Info')
)
).all()
if historical_data:
extra = (
await session.scalars(
select(TETRIOHistoricalData)
.where(TETRIOHistoricalData.user_unique_identifier == user.unique_identifier)
.where(TETRIOHistoricalData.api_type == 'User Info')
.order_by(TETRIOHistoricalData.id.desc())
.where(TETRIOHistoricalData.id < min([i.id for i in historical_data]))
.limit(1)
)
).one_or_none()
if extra is not None:
historical_data = list(historical_data)
historical_data.append(extra)
full_export_data = FullExport.get_data(user.unique_identifier)
if not historical_data and not full_export_data:
return [
TetraLeagueHistoryData(record_at=today - forward, tr=user_info.data.user.league.rating),
TetraLeagueHistoryData(record_at=today.replace(microsecond=1000), tr=user_info.data.user.league.rating),
]
histories = [
TetraLeagueHistoryData(
record_at=i.update_time.astimezone(ZoneInfo('Asia/Shanghai')),
tr=i.data.data.user.league.rating,
)
for i in historical_data
if isinstance(i.data, UserInfoSuccess) and isinstance(i.data.data.user.league, RatedLeague)
] + full_export_data
# 按照时间排序
histories = sorted(histories, key=lambda x: x.record_at)
for index, value in enumerate(histories):
# 在历史记录里找有没有今天0点后的数据, 并且至少要有两个数据点
if value.record_at > today and len(histories) >= 2: # noqa: PLR2004
histories = histories[:index] + [
get_specified_point(histories[index - 1], histories[index], today.replace(microsecond=1000))
]
break
else:
histories.append(
get_specified_point(
histories[-1],
TetraLeagueHistoryData(record_at=user_info.cache.cached_at, tr=user_info.data.user.league.rating),
today.replace(microsecond=1000),
)
)
if histories[0].record_at < (today - forward):
histories[0] = get_specified_point(
histories[0],
histories[1],
today - forward,
)
else:
histories.insert(0, TetraLeagueHistoryData(record_at=today - forward, tr=histories[0].tr))
return histories
L = TypeVar('L', NeverPlayedLeague, NeverRatedLeague, RatedLeague)
@overload
def get_league(user_info: UserInfoSuccess, league_type: type[L]) -> L: ...
@overload
def get_league(
user_info: UserInfoSuccess, league_type: None = None
) -> NeverPlayedLeague | NeverRatedLeague | RatedLeague: ...
def get_league(
user_info: UserInfoSuccess, league_type: type[L] | None = None
) -> L | NeverPlayedLeague | NeverRatedLeague | RatedLeague:
league = user_info.data.user.league
if league_type is None:
return league
if isinstance(league, league_type):
return league
raise FallbackError
async def make_query_image_v1(player: Player) -> bytes:
user, user_info, sprint, blitz = await gather(player.user, player.get_info(), player.sprint, player.blitz)
league = get_league(user_info, RatedLeague)
if league.vs is None:
raise FallbackError
histories = await query_historical_data(user, user_info)
value_max, value_min = get_value_bounds([i.tr for i in histories])
split_value, offset = get_split(value_max, value_min)
if sprint.record is not None:
duration = timedelta(milliseconds=sprint.record.endcontext.final_time).total_seconds()
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
else:
sprint_value = 'N/A'
blitz_value = f'{blitz.record.endcontext.score:,}' if blitz.record is not None else 'N/A'
netloc = get_self_netloc()
async with HostPage(
page=await render(
'v1/tetrio/info',
V1TemplateInfo(
user=V1TemplateUser(
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
else Avatar(
type='identicon',
hash=md5(user_info.data.user.id.encode()).hexdigest(), # noqa: S324
),
name=user.name.upper(),
bio=user_info.data.user.bio,
),
ranking=Ranking(
rating=round(league.glicko, 2),
rd=round(league.rd, 2),
),
tetra_league=TetraLeague(
rank=league.rank,
tr=round(league.rating, 2),
global_rank=league.standing,
pps=league.pps,
lpm=round(lpm := (league.pps * 24), 2),
apm=league.apm,
apl=round(league.apm / lpm, 2),
vs=league.vs,
adpm=round(adpm := (league.vs * 0.6), 2),
adpl=round(adpm / lpm, 2),
),
tetra_league_history=TetraLeagueHistory(
data=histories,
split_interval=split_value,
min_tr=value_min,
max_tr=value_max,
offset=offset,
),
radar=Radar(
app=(app := (league.apm / (60 * league.pps))),
dsps=(dsps := ((league.vs / 100) - (league.apm / 60))),
dspp=(dspp := (dsps / league.pps)),
ci=150 * dspp - 125 * app + 50 * (league.vs / league.apm) - 25,
ge=2 * ((app * dsps) / league.pps),
),
sprint=sprint_value,
blitz=blitz_value,
),
)
) as page_hash:
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
N = TypeVar('N', int, float)
def handling_special_value(value: N) -> N | None:
return value if value != -1 else None
async def make_query_image_v2(player: Player) -> bytes:
user, user_info, sprint, blitz, zen = await gather(
player.user, player.get_info(), player.sprint, player.blitz, player.zen
)
league = get_league(user_info)
histories = await query_historical_data(user, user_info)
if sprint.record is not None:
duration = timedelta(milliseconds=sprint.record.endcontext.final_time).total_seconds()
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
else:
sprint_value = 'N/A'
play_time: str | None
if (game_time := handling_special_value(user_info.data.user.gametime)) is not None:
if game_time // 3600 > 0:
play_time = f'{game_time//3600:.0f}h {game_time % 3600 // 60:.0f}m {game_time % 60:.0f}s'
elif game_time // 60 > 0:
play_time = f'{game_time//60:.0f}m {game_time % 60:.0f}s'
else:
play_time = f'{game_time:.0f}s'
else:
play_time = game_time
netloc = get_self_netloc()
async with HostPage(
await render(
'v2/tetrio/user/info',
V2TemplateInfo(
user=V2TemplateUser(
id=user.ID,
name=user.name.upper(),
bio=user_info.data.user.bio,
banner=f'http://{netloc}/host/resource/tetrio/banners/{user.ID}?{urlencode({"revision": user_info.data.user.banner_revision})}'
if user_info.data.user.banner_revision is not None and user_info.data.user.banner_revision != 0
else None,
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
else Avatar(
type='identicon',
hash=md5(user_info.data.user.id.encode()).hexdigest(), # noqa: S324
),
badges=[
Badge(
id=i.id,
description=i.label,
group=i.group,
receive_at=i.ts if isinstance(i.ts, datetime) else None,
)
for i in user_info.data.user.badges
],
country=user_info.data.user.country,
xp=user_info.data.user.xp,
friend_count=user_info.data.user.friend_count or 0,
supporter_tier=user_info.data.user.supporter_tier,
bad_standing=user_info.data.user.badstanding or False,
verified=user_info.data.user.verified,
playtime=play_time,
join_at=user_info.data.user.ts,
),
tetra_league=V2TemplateTetraLeague(
rank=league.rank,
highest_rank=league.bestrank,
tr=round(league.rating, 2),
glicko=round(league.glicko, 2),
rd=round(league.rd, 2),
global_rank=handling_special_value(league.standing),
country_rank=handling_special_value(league.standing_local),
pps=(
metrics := get_metrics(pps=league.pps, apm=league.apm, vs=league.vs)
if league.vs is not None
else get_metrics(pps=league.pps, apm=league.apm)
).pps,
apm=metrics.apm,
apl=metrics.apl,
vs=metrics.vs if isinstance(metrics, TetrisMetricsProWithPPSVS) else None,
adpl=metrics.adpl if isinstance(metrics, TetrisMetricsProWithPPSVS) else None,
statistic=TetraLeagueStatistic(
total=league.gamesplayed,
wins=league.gameswon,
),
decaying=league.decaying,
history=histories,
)
if isinstance(league, RatedLeague)
else None,
statistic=Statistic(
total=handling_special_value(user_info.data.user.gamesplayed),
wins=handling_special_value(user_info.data.user.gameswon),
),
sprint=Sprint(
time=sprint_value,
global_rank=sprint.rank,
play_at=sprint.record.ts,
)
if sprint.record is not None
else None,
blitz=Blitz(
score=blitz.record.endcontext.score,
global_rank=blitz.rank,
play_at=blitz.record.ts,
)
if blitz.record is not None
else None,
zen=zen,
),
),
) as page_hash:
return await screenshot(f'http://{netloc}/host/{page_hash}.html')
async def make_query_text(player: Player) -> UniMessage:
user, user_info, sprint, blitz = await gather(player.user, player.get_info(), player.sprint, player.blitz)
league = get_league(user_info)
user_name = user.name.upper()
message = ''
if isinstance(league, NeverPlayedLeague):
message += f'用户 {user_name} 没有排位统计数据'
else:
if isinstance(league, NeverRatedLeague):
message += f'用户 {user_name} 暂未完成定级赛, 最近十场的数据:'
else:
if league.rank == 'z':
message += f'用户 {user_name} 暂无段位, {round(league.rating,2)} TR'
else:
message += f'{league.rank.upper()} 段用户 {user_name} {round(league.rating,2)} TR (#{league.standing})'
message += f', 段位分 {round(league.glicko,2)}±{round(league.rd,2)}, 最近十场的数据:'
metrics = (
get_metrics(pps=league.pps, apm=league.apm, vs=league.vs)
if league.vs is not None
else get_metrics(pps=league.pps, apm=league.apm)
)
message += f"\nL'PM: {metrics.lpm} ( {metrics.pps} pps )"
message += f'\nAPM: {metrics.apm} ( x{metrics.apl} )'
if isinstance(metrics, TetrisMetricsProWithPPSVS):
message += f'\nADPM: {metrics.adpm} ( x{metrics.adpl} ) ( {metrics.vs}vs )'
if sprint.record is not None:
message += f'\n40L: {round(sprint.record.endcontext.final_time/1000,2)}s'
message += f' ( #{sprint.rank} )' if sprint.rank is not None else ''
if blitz.record is not None:
message += f'\nBlitz: {blitz.record.endcontext.score}'
message += f' ( #{blitz.rank} )' if blitz.rank is not None else ''
return UniMessage(message)
async def make_query_result(player: Player, template: Template) -> UniMessage:
try:
if template == 'v1':
return UniMessage.image(raw=await make_query_image_v1(player))
if template == 'v2':
return UniMessage.image(raw=await make_query_image_v2(player))
except FallbackError:
...
return await make_query_text(player)
class FullExport:
cache: ClassVar[defaultdict[str, set[tuple[datetime, Number]]]] = defaultdict(set)
latest_update: ClassVar[date | None] = None
@classmethod
async def init(cls) -> None:
async with get_session() as session:
full_exports = (await session.scalars(select(IORank).where(IORank.update_time >= cls.start_time()))).all()
await gather(
*[
cls._load(update_time, file_hash)
for file_hash, update_time in {
i.file_hash: i.update_time for i in full_exports if i.file_hash is not None
}.items()
]
)
@classmethod
async def update(cls) -> None:
if cls.latest_update == datetime.now(tz=ZoneInfo('Asia/Shanghai')).date():
return
start_time = cls.start_time()
for i in cls.cache:
cls.cache[i] = {j for j in cls.cache[i] if j[0] >= start_time}
latest_time = max(cls.cache)
async with get_session() as session:
full_exports = (await session.scalars(select(IORank).where(IORank.update_time > latest_time))).all()
await gather(
*[
cls._load(update_time, file_hash)
for file_hash, update_time in {
i.file_hash: i.update_time for i in full_exports if i.file_hash is not None
}.items()
]
)
cls.latest_update = datetime.now(tz=ZoneInfo('Asia/Shanghai')).date()
@classmethod
def get_data(cls, unique_identifier: str) -> list[TetraLeagueHistoryData]:
return [TetraLeagueHistoryData(record_at=i[0], tr=i[1]) for i in cls.cache[unique_identifier]]
@classmethod
def start_time(cls) -> datetime:
return (
datetime.now(ZoneInfo('Asia/Shanghai')).replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=9)
).astimezone(UTC)
@classmethod
async def _load(cls, update_time: datetime, file_hash: str) -> None:
try:
users = type_validate_json(TetraLeagueSuccess, await cls.decompress(file_hash)).data.users
except FileNotFoundError:
await cls.clear_invalid(file_hash)
return
update_time = update_time.astimezone(ZoneInfo('Asia/Shanghai'))
for i in users:
cls.cache[i.id].add((update_time, i.league.rating))
@classmethod
async def decompress(cls, file_hash: str) -> bytes:
async with open(get_data_file('nonebot_plugin_tetris_stats', f'{file_hash}.json.zst'), mode='rb') as file:
return ZstdDecompressor().decompress(await file.read())
@classmethod
async def clear_invalid(cls, file_hash: str) -> None:
async with get_session() as session:
full_exports = (await session.scalars(select(IORank).where(IORank.file_hash == file_hash))).all()
for i in full_exports:
i.file_hash = None
await session.commit()
@driver.on_startup
async def _():
await FullExport.init()
scheduler.add_job(FullExport.update, 'interval', hours=1)

View File

@@ -0,0 +1,174 @@
from collections import defaultdict
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from hashlib import sha512
from math import floor
from statistics import mean
from zoneinfo import ZoneInfo
from aiofiles import open
from nonebot import get_driver
from nonebot.compat import model_dump
from nonebot.matcher import Matcher
from nonebot.utils import run_sync
from nonebot_plugin_apscheduler import scheduler # type: ignore[import-untyped]
from nonebot_plugin_localstore import get_data_file # type: ignore[import-untyped]
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
from sqlalchemy import func, select
from zstandard import ZstdCompressor
from ...db import trigger
from ...utils.exception import RequestError
from ...utils.metrics import get_metrics
from ...utils.retry import retry
from . import alc
from .api.schemas.base import FailedModel
from .api.schemas.tetra_league import ValidUser
from .api.schemas.user import User
from .api.tetra_league import full_export
from .api.typing import Rank
from .constant import GAME_TYPE, RANK_PERCENTILE
from .models import IORank
UTC = timezone.utc
driver = get_driver()
@alc.assign('TETRIO.rank')
async def _(matcher: Matcher, rank: Rank, event_session: EventSession):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='rank',
command_args=[],
):
if rank == 'z':
await matcher.finish('暂不支持查询未知段位')
async with get_session() as session:
latest_data = (
await session.scalars(select(IORank).where(IORank.rank == rank).order_by(IORank.id.desc()).limit(1))
).one()
compare_data = (
await session.scalars(
select(IORank)
.where(IORank.rank == rank)
.order_by(
func.abs(
func.julianday(IORank.update_time)
- func.julianday(latest_data.update_time - timedelta(hours=24))
)
)
.limit(1)
)
).one()
message = ''
if (datetime.now(UTC) - latest_data.update_time.replace(tzinfo=UTC)) > timedelta(hours=7):
message += 'Warning: 数据超过7小时未更新, 请联系Bot主人查看后台\n'
message += f'{rank.upper()} 段 分数线 {latest_data.tr_line:.2f} TR, {latest_data.player_count} 名玩家\n'
if compare_data.id != latest_data.id:
message += f'对比 {(latest_data.update_time-compare_data.update_time).total_seconds()/3600:.2f} 小时前趋势: {f"{difference:.2f}" if (difference:=latest_data.tr_line-compare_data.tr_line) > 0 else f"{-difference:.2f}" if difference < 0 else ""}'
else:
message += '暂无对比数据'
avg = get_metrics(pps=latest_data.avg_pps, apm=latest_data.avg_apm, vs=latest_data.avg_vs)
low_pps = get_metrics(pps=latest_data.low_pps[1])
low_vs = get_metrics(vs=latest_data.low_vs[1])
max_pps = get_metrics(pps=latest_data.high_pps[1])
max_vs = get_metrics(vs=latest_data.high_vs[1])
message += (
'\n'
'平均数据:\n'
f"L'PM: {avg.lpm} ( {avg.pps} pps )\n"
f'APM: {avg.apm} ( x{avg.apl} )\n'
f'ADPM: {avg.adpm} ( x{avg.adpl} ) ( {avg.vs}vs )\n'
'\n'
'最低数据:\n'
f"L'PM: {low_pps.lpm} ( {low_pps.pps} pps ) By: {latest_data.low_pps[0]['name'].upper()}\n"
f'APM: {latest_data.low_apm[1]} By: {latest_data.low_apm[0]["name"].upper()}\n'
f'ADPM: {low_vs.adpm} ( {low_vs.vs}vs ) By: {latest_data.low_vs[0]["name"].upper()}\n'
'\n'
'最高数据:\n'
f"L'PM: {max_pps.lpm} ( {max_pps.pps} pps ) By: {latest_data.high_pps[0]['name'].upper()}\n"
f'APM: {latest_data.high_apm[1]} By: {latest_data.high_apm[0]["name"].upper()}\n'
f'ADPM: {max_vs.adpm} ( {max_vs.vs}vs ) By: {latest_data.high_vs[0]["name"].upper()}\n'
'\n'
f'数据更新时间: {latest_data.update_time.replace(tzinfo=UTC).astimezone(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")}'
)
await matcher.finish(message)
@scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
@retry(exception_type=RequestError, delay=timedelta(minutes=15))
async def get_tetra_league_data() -> None:
league, original = await full_export(with_original=True)
if isinstance(league, FailedModel):
msg = f'排行榜数据请求错误:\n{league.error}'
raise RequestError(msg)
def pps(user: ValidUser) -> float:
return user.league.pps
def apm(user: ValidUser) -> float:
return user.league.apm
def vs(user: ValidUser) -> float:
return user.league.vs
def _min(users: list[ValidUser], field: Callable[[ValidUser], float]) -> ValidUser:
return min(users, key=field)
def _max(users: list[ValidUser], field: Callable[[ValidUser], float]) -> ValidUser:
return max(users, key=field)
def build_extremes_data(
users: list[ValidUser],
field: Callable[[ValidUser], float],
sort: Callable[[list[ValidUser], Callable[[ValidUser], float]], ValidUser],
) -> tuple[dict[str, str], float]:
user = sort(users, field)
return model_dump(User(ID=user.id, name=user.username)), field(user)
data_hash: str | None = await run_sync((await run_sync(sha512)(original)).hexdigest)()
async with open(get_data_file('nonebot_plugin_tetris_stats', f'{data_hash}.json.zst'), mode='wb') as file:
await file.write(await run_sync(ZstdCompressor(level=12, threads=-1).compress)(original))
users = [i for i in league.data.users if isinstance(i, ValidUser)]
rank_to_users: defaultdict[Rank, list[ValidUser]] = defaultdict(list)
for i in users:
rank_to_users[i.league.rank].append(i)
rank_info: list[IORank] = []
for rank, percentile in RANK_PERCENTILE.items():
offset = floor((percentile / 100) * len(users)) - 1
tr_line = users[offset].league.rating
rank_users = rank_to_users[rank]
rank_info.append(
IORank(
rank=rank,
tr_line=tr_line,
player_count=len(rank_users),
low_pps=(build_extremes_data(rank_users, pps, _min)),
low_apm=(build_extremes_data(rank_users, apm, _min)),
low_vs=(build_extremes_data(rank_users, vs, _min)),
avg_pps=mean({i.league.pps for i in rank_users}),
avg_apm=mean({i.league.apm for i in rank_users}),
avg_vs=mean({i.league.vs for i in rank_users}),
high_pps=(build_extremes_data(rank_users, pps, _max)),
high_apm=(build_extremes_data(rank_users, apm, _max)),
high_vs=(build_extremes_data(rank_users, vs, _max)),
update_time=league.cache.cached_at,
file_hash=data_hash,
)
)
async with get_session() as session:
session.add_all(rank_info)
await session.commit()
@driver.on_startup
async def _() -> None:
async with get_session() as session:
latest_time = await session.scalar(select(IORank.update_time).order_by(IORank.id.desc()).limit(1))
if latest_time is None or datetime.now(tz=UTC) - latest_time.replace(tzinfo=UTC) > timedelta(hours=6):
await get_tetra_league_data()

View File

@@ -0,0 +1,6 @@
from . import blitz, sprint
__all__ = [
'blitz',
'sprint',
]

View File

@@ -0,0 +1,124 @@
from asyncio import gather
from datetime import timedelta
from hashlib import md5
from urllib.parse import urlencode
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
from ....db import query_bind_info
from ....utils.exception import RecordNotFoundError
from ....utils.host import HostPage, get_self_netloc
from ....utils.metrics import get_metrics
from ....utils.render import render
from ....utils.render.schemas.base import Avatar
from ....utils.render.schemas.tetrio.tetrio_record_base import Finesse, Max, Mini, Tspins, User
from ....utils.render.schemas.tetrio.tetrio_record_blitz import Record, Statistic
from ....utils.screenshot import screenshot
from ....utils.typing import Me
from ...constant import CANT_VERIFY_MESSAGE
from .. import alc
from ..api.player import Player
from ..constant import GAME_TYPE
@alc.assign('TETRIO.record.blitz')
async def _(
event: Event,
matcher: Matcher,
target: At | Me,
event_session: EventSession,
):
async with get_session() as session:
bind = await query_bind_info(
session=session,
user=await get_user(
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = UniMessage(CANT_VERIFY_MESSAGE)
player = Player(user_id=bind.game_account, trust=True)
await (message + UniMessage.image(raw=await make_blitz_image(player))).finish()
@alc.assign('TETRIO.record.blitz')
async def _(account: Player):
await UniMessage.image(raw=await make_blitz_image(account)).finish()
async def make_blitz_image(player: Player) -> bytes:
user, user_info, blitz = await gather(player.user, player.get_info(), player.blitz)
if blitz.record is None:
msg = f'未找到用户 {user.name.upper()} 的 Blitz 记录'
raise RecordNotFoundError(msg)
endcontext = blitz.record.endcontext
clears = endcontext.clears
duration = timedelta(milliseconds=endcontext.final_time).total_seconds()
metrics = get_metrics(pps=endcontext.piecesplaced / duration)
netloc = get_self_netloc()
async with HostPage(
page=await render(
'v2/tetrio/record/blitz',
Record(
user=User(
id=user.ID,
name=user.name.upper(),
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
else Avatar(
type='identicon',
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
),
),
replay_id=blitz.record.replayid,
rank=blitz.rank,
statistic=Statistic(
keys=endcontext.inputs,
kpp=round(endcontext.inputs / endcontext.piecesplaced, 2),
kps=round(endcontext.inputs / duration, 2),
max=Max(
combo=max((0, endcontext.topcombo - 1)),
btb=max((0, endcontext.topbtb - 1)),
),
pieces=endcontext.piecesplaced,
pps=metrics.pps,
lines=endcontext.lines,
lpm=metrics.lpm,
holds=endcontext.holds,
score=endcontext.score,
spp=round(endcontext.score / endcontext.piecesplaced, 2),
single=clears.singles,
double=clears.doubles,
triple=clears.triples,
quad=clears.quads,
tspins=Tspins(
total=clears.realtspins,
single=clears.tspinsingles,
double=clears.tspindoubles,
triple=clears.triples,
mini=Mini(
total=clears.minitspins,
single=clears.minitspinsingles,
double=clears.minitspindoubles,
),
),
all_clear=clears.allclear,
finesse=Finesse(
faults=endcontext.finesse.faults,
accuracy=round(endcontext.finesse.perfectpieces / endcontext.piecesplaced * 100, 2),
),
level=endcontext.level,
),
play_at=blitz.record.ts,
),
)
) as page_hash:
return await screenshot(f'http://{netloc}/host/{page_hash}.html')

View File

@@ -0,0 +1,124 @@
from asyncio import gather
from datetime import timedelta
from hashlib import md5
from urllib.parse import urlencode
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
from ....db import query_bind_info
from ....utils.exception import RecordNotFoundError
from ....utils.host import HostPage, get_self_netloc
from ....utils.metrics import get_metrics
from ....utils.render import render
from ....utils.render.schemas.base import Avatar
from ....utils.render.schemas.tetrio.tetrio_record_base import Finesse, Max, Mini, Tspins, User
from ....utils.render.schemas.tetrio.tetrio_record_sprint import Record, Statistic
from ....utils.screenshot import screenshot
from ....utils.typing import Me
from ...constant import CANT_VERIFY_MESSAGE
from .. import alc
from ..api.player import Player
from ..constant import GAME_TYPE
@alc.assign('TETRIO.record.sprint')
async def _(
event: Event,
matcher: Matcher,
target: At | Me,
event_session: EventSession,
):
async with get_session() as session:
bind = await query_bind_info(
session=session,
user=await get_user(
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = UniMessage(CANT_VERIFY_MESSAGE)
player = Player(user_id=bind.game_account, trust=True)
await (message + UniMessage.image(raw=await make_sprint_image(player))).finish()
@alc.assign('TETRIO.record.sprint')
async def _(account: Player):
await UniMessage.image(raw=await make_sprint_image(account)).finish()
async def make_sprint_image(player: Player) -> bytes:
user, user_info, sprint = await gather(player.user, player.get_info(), player.sprint)
if sprint.record is None:
msg = f'未找到用户 {user.name.upper()} 的 40L 记录'
raise RecordNotFoundError(msg)
endcontext = sprint.record.endcontext
clears = endcontext.clears
duration = timedelta(milliseconds=endcontext.final_time).total_seconds()
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
metrics = get_metrics(pps=endcontext.piecesplaced / duration)
netloc = get_self_netloc()
async with HostPage(
page=await render(
'v2/tetrio/record/40l',
Record(
user=User(
id=user.ID,
name=user.name.upper(),
avatar=f'http://{netloc}/host/resource/tetrio/avatars/{user.ID}?{urlencode({"revision": user_info.data.user.avatar_revision})}'
if user_info.data.user.avatar_revision is not None and user_info.data.user.avatar_revision != 0
else Avatar(
type='identicon',
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
),
),
time=sprint_value,
replay_id=sprint.record.replayid,
rank=sprint.rank,
statistic=Statistic(
keys=endcontext.inputs,
kpp=round(endcontext.inputs / endcontext.piecesplaced, 2),
kps=round(endcontext.inputs / duration, 2),
max=Max(
combo=max((0, endcontext.topcombo - 1)),
btb=max((0, endcontext.topbtb - 1)),
),
pieces=endcontext.piecesplaced,
pps=metrics.pps,
lines=endcontext.lines,
lpm=metrics.lpm,
holds=endcontext.holds,
score=endcontext.score,
single=clears.singles,
double=clears.doubles,
triple=clears.triples,
quad=clears.quads,
tspins=Tspins(
total=clears.realtspins,
single=clears.tspinsingles,
double=clears.tspindoubles,
triple=clears.triples,
mini=Mini(
total=clears.minitspins,
single=clears.minitspinsingles,
double=clears.minitspindoubles,
),
),
all_clear=clears.allclear,
finesse=Finesse(
faults=endcontext.finesse.faults,
accuracy=round(endcontext.finesse.perfectpieces / endcontext.piecesplaced * 100, 2),
),
),
play_at=sprint.record.ts,
),
)
) as page_hash:
return await screenshot(f'http://{netloc}/host/{page_hash}.html')

View File

@@ -0,0 +1,3 @@
from typing import Literal
Template = Literal['v1', 'v2']

View File

@@ -0,0 +1,59 @@
from arclet.alconna import Arg, ArgFlag, Args, Subcommand
from nonebot_plugin_alconna import At
from ...utils.exception import MessageFormatError
from ...utils.typing import Me
from .. import add_block_handlers, alc, command
from .api import Player
from .constant import USER_NAME
def get_player(name: str) -> Player | MessageFormatError:
if USER_NAME.match(name):
return Player(user_name=name, trust=True)
return MessageFormatError('用户名/ID不合法')
command.add(
Subcommand(
'TOP',
Subcommand(
'bind',
Args(
Arg(
'account',
get_player,
notice='TOP 用户名 / ID',
flags=[ArgFlag.HIDDEN],
)
),
help_text='绑定 TOP 账号',
),
Subcommand(
'query',
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 / 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
get_player,
notice='TOP 用户名',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
help_text='查询 TOP 游戏信息',
),
help_text='TOP 游戏相关指令',
)
)
alc.shortcut('(?i:top)(?i:绑定|绑|bind)', {'command': 'tstats TOP bind', 'humanized': 'top绑定'})
alc.shortcut('(?i:top)(?i:查询|查|query|stats)', {'command': 'tstats TOP query', 'humanized': 'top查'})
add_block_handlers(alc.assign('TOP.query'))
from . import bind, query # noqa: E402, F401

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
from contextlib import suppress
from datetime import datetime, timezone
from io import StringIO
from urllib.parse import urlencode
from lxml import etree
from pandas import read_html
from ....db import anti_duplicate_add
from ....utils.request import Request, splice_url
from ..constant import BASE_URL, USER_NAME
from .models import TOPHistoricalData
from .schemas.user import User
from .schemas.user_profile import Data, UserProfile
UTC = timezone.utc
class Player:
def __init__(self, *, user_name: str, trust: bool = False) -> None:
self.user_name = user_name
if not trust and not USER_NAME.match(self.user_name):
msg = 'Invalid user name'
raise ValueError(msg)
self.__user: User | None = None
self._user_profile: UserProfile | None = None
@property
async def user(self) -> User:
if self.__user is None:
profile = await self.get_profile()
self.__user = User(user_name=profile.user_name)
return self.__user
async def get_profile(self) -> UserProfile:
"""获取用户信息"""
if self._user_profile is None:
url = splice_url([BASE_URL, 'profile.php', f'?{urlencode({"user":self.user_name})}'])
raw_user_profile = await Request.request(url, is_json=False)
self._user_profile = self._parse_profile(raw_user_profile)
await anti_duplicate_add(
TOPHistoricalData,
TOPHistoricalData(
user_unique_identifier=(await self.user).unique_identifier,
api_type='User Profile',
data=self._user_profile,
update_time=datetime.now(tz=UTC),
),
)
return self._user_profile
def _parse_profile(self, original_user_profile: bytes) -> UserProfile:
html = etree.HTML(original_user_profile)
user_name = html.xpath('//div[@class="mycontent"]/h1/text()')[0].replace("'s profile", '')
today = None
with suppress(ValueError):
today = Data(
lpm=float(str(html.xpath('//div[@class="mycontent"]/text()[3]')[0]).replace('lpm:', '').strip()),
apm=float(str(html.xpath('//div[@class="mycontent"]/text()[4]')[0]).replace('apm:', '').strip()),
)
table = StringIO(
etree.tostring(
html.xpath('//div[@class="mycontent"]/table[@class="mytable"]')[0],
encoding='utf-8',
).decode()
)
dataframe = read_html(table, encoding='utf-8', header=0)[0]
total: list[Data] = []
for _, value in dataframe.iterrows():
total.append(Data(lpm=value['lpm'], apm=value['apm']))
return UserProfile(user_name=user_name, today=today, total=total)

View File

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

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
class Data(BaseModel):
lpm: float
apm: float
class UserProfile(BaseModel):
user_name: str
today: Data | None
total: list[Data] | None

View File

@@ -0,0 +1,62 @@
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
from nonebot_plugin_user import User # type: ignore[import-untyped]
from nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo # type: ignore[import-untyped]
from ...db import BindStatus, create_or_update_bind, trigger
from ...utils.host import HostPage, get_self_netloc
from ...utils.image import get_avatar
from ...utils.render import Bind, render
from ...utils.render.schemas.base import People
from ...utils.screenshot import screenshot
from . import alc
from .api import Player
from .constant import GAME_TYPE
@alc.assign('TOP.bind')
async def _(
nb_user: User,
account: Player,
event_session: EventSession,
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
bot_info: UserInfo = BotUserInfo(), # noqa: B008
):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='bind',
command_args=[],
):
user = await account.user
async with get_session() as session:
bind_status = await create_or_update_bind(
session=session,
user=nb_user,
game_platform=GAME_TYPE,
game_account=user.unique_identifier,
)
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
async with HostPage(
await render(
'v1/binding',
Bind(
platform=GAME_TYPE,
status='unknown',
user=People(
avatar=await get_avatar(event_user_info, 'Data URI', None),
name=user.user_name,
),
bot=People(
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
name=bot_info.user_name,
),
command='top查我',
),
)
) as page_hash:
await UniMessage.image(
raw=await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
).finish()

View File

@@ -0,0 +1,8 @@
from re import compile
from typing import Literal
GAME_TYPE: Literal['TOP'] = 'TOP'
BASE_URL = 'http://tetrisonline.pl/top/'
USER_NAME = compile(r'^[a-zA-Z0-9_]{1,16}$')

View File

@@ -0,0 +1,74 @@
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
from ...db import query_bind_info, trigger
from ...utils.metrics import get_metrics
from ...utils.typing import Me
from ..constant import CANT_VERIFY_MESSAGE
from . import alc
from .api import Player
from .api.schemas.user_profile import UserProfile
from .constant import GAME_TYPE
@alc.assign('TOP.query')
async def _(event: Event, matcher: Matcher, target: At | Me, event_session: EventSession):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
):
async with get_session() as session:
bind = await query_bind_info(
session=session,
user=await get_user(
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = CANT_VERIFY_MESSAGE
await (message + make_query_text(await Player(user_name=bind.game_account, trust=True).get_profile())).finish()
@alc.assign('TOP.query')
async def _(account: Player, event_session: EventSession):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
):
await (make_query_text(await account.get_profile())).finish()
def make_query_text(profile: UserProfile) -> UniMessage:
message = ''
if profile.today is not None:
today = get_metrics(lpm=profile.today.lpm, apm=profile.today.apm)
message += f'用户 {profile.user_name} 24小时内统计数据为: '
message += f"\nL'PM: {today.lpm} ( {today.pps} pps )"
message += f'\nAPM: {today.apm} ( x{today.apl} )'
else:
message += f'用户 {profile.user_name} 暂无24小时内统计数据'
if profile.total is not None:
total_lpm = total_apm = 0.0
for value in profile.total:
total_lpm += value.lpm
total_apm += value.apm
num = len(profile.total)
total = get_metrics(lpm=total_lpm / num, apm=total_apm / num)
message += '\n历史统计数据为: '
message += f"\nL'PM: {total.lpm} ( {total.pps} pps )"
message += f'\nAPM: {total.apm} ( x{total.apl} )'
else:
message += '\n暂无历史统计数据'
return UniMessage(message)

View File

@@ -0,0 +1,64 @@
from arclet.alconna import Arg, ArgFlag, Args, Subcommand
from nonebot_plugin_alconna import At
from ...utils.exception import MessageFormatError
from ...utils.typing import Me
from .. import add_block_handlers, alc, command
from .api import Player
from .constant import USER_NAME
def get_player(teaid_or_name: str) -> Player | MessageFormatError:
if (
teaid_or_name.startswith(('onebot-', 'qqguild-', 'kook-', 'discord-'))
and teaid_or_name.split('-', maxsplit=1)[1].isdigit()
):
return Player(teaid=teaid_or_name, trust=True)
if USER_NAME.match(teaid_or_name) and not teaid_or_name.isdigit() and 2 <= len(teaid_or_name) <= 18: # noqa: PLR2004
return Player(user_name=teaid_or_name, trust=True)
return MessageFormatError('用户名/ID不合法')
command.add(
Subcommand(
'TOS',
Subcommand(
'bind',
Args(
Arg(
'account',
get_player,
notice='茶服 用户名 / ID',
flags=[ArgFlag.HIDDEN],
)
),
help_text='绑定 茶服 账号',
),
Subcommand(
'query',
Args(
Arg(
'target',
At | Me,
notice='@想要查询的人 / 自己',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
Arg(
'account',
get_player,
notice='茶服 用户名 / TeaID',
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
help_text='查询 茶服 游戏信息',
),
help_text='茶服 游戏相关指令',
)
)
alc.shortcut('(?i:tos|茶服)(?i:绑定|绑|bind)', {'command': 'tstats TOS bind', 'humanized': '茶服绑定'})
alc.shortcut('(?i:tos|茶服)(?i:查询|查|query|stats)', {'command': 'tstats TOS query', 'humanized': '茶服查'})
add_block_handlers(alc.assign('TOS.query'))
from . import bind, query # noqa: E402, F401

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
from nonebot_plugin_user import User # type: ignore[import-untyped]
from nonebot_plugin_userinfo import BotUserInfo, EventUserInfo, UserInfo # type: ignore[import-untyped]
from ...db import BindStatus, create_or_update_bind, trigger
from ...utils.host import HostPage, get_self_netloc
from ...utils.image import get_avatar
from ...utils.render import Bind, render
from ...utils.render.schemas.base import People
from ...utils.screenshot import screenshot
from . import alc
from .api import Player
from .constant import GAME_TYPE
@alc.assign('TOS.bind')
async def _(
nb_user: User,
account: Player,
event_session: EventSession,
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
bot_info: UserInfo = BotUserInfo(), # noqa: B008
):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='bind',
command_args=[],
):
user = await account.user
async with get_session() as session:
bind_status = await create_or_update_bind(
session=session,
user=nb_user,
game_platform=GAME_TYPE,
game_account=user.unique_identifier,
)
user_info = await account.get_info()
if bind_status in (BindStatus.SUCCESS, BindStatus.UPDATE):
async with HostPage(
await render(
'v1/binding',
Bind(
platform=GAME_TYPE,
status='unknown',
user=People(
avatar=await get_avatar(event_user_info, 'Data URI', None), name=user_info.data.name
),
bot=People(
avatar=await get_avatar(bot_info, 'Data URI', '../../static/logo/logo.svg'),
name=bot_info.user_remark or bot_info.user_displayname or bot_info.user_name,
),
command='茶服查我',
),
)
) as page_hash:
await UniMessage.image(
raw=await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
).finish()

View File

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

View File

@@ -0,0 +1,252 @@
from asyncio import gather
from datetime import timedelta
from http import HTTPStatus
from typing import Literal, NamedTuple
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_session import EventSession # type: ignore[import-untyped]
from nonebot_plugin_session_orm import get_session_persist_id # type: ignore[import-untyped]
from nonebot_plugin_user import get_user # type: ignore[import-untyped]
from nonebot_plugin_userinfo import EventUserInfo, UserInfo # type: ignore[import-untyped]
from ...db import query_bind_info, trigger
from ...utils.exception import RequestError
from ...utils.host import HostPage, get_self_netloc
from ...utils.image import get_avatar
from ...utils.metrics import TetrisMetricsProWithLPMADPM, get_metrics
from ...utils.render import render
from ...utils.render.schemas.base import People, Ranking
from ...utils.render.schemas.tos_info import Info, Multiplayer, Radar
from ...utils.screenshot import screenshot
from ...utils.typing import Me, Number
from ..constant import CANT_VERIFY_MESSAGE
from . import alc
from .api import Player
from .api.schemas.user_info import UserInfoSuccess
from .constant import GAME_TYPE
def add_special_handlers(
teaid_prefix: Literal['onebot-', 'kook-', 'discord-', 'qqguild-'], match_event: type[Event]
) -> None:
@alc.assign('TOS.query')
async def _(
event: Event,
target: At | Me,
event_session: EventSession,
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
):
if isinstance(event, match_event):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
):
player = Player(
teaid=f'{teaid_prefix}{target.target}'
if isinstance(target, At)
else f'{teaid_prefix}{event.get_user_id()}',
trust=True,
)
try:
user_info, game_data = await gather(player.get_info(), get_game_data(player))
if game_data is not None:
await UniMessage.image(
raw=await make_query_image(user_info, game_data, event_user_info)
).finish()
await make_query_text(user_info, game_data).finish()
except RequestError as e:
if e.status_code == HTTPStatus.BAD_REQUEST and '未找到此用户' in e.message:
return
raise
try:
from nonebot.adapters.onebot.v11 import MessageEvent as OB11MessageEvent
add_special_handlers('onebot-', OB11MessageEvent)
except ImportError:
pass
try:
from nonebot.adapters.qq.event import GuildMessageEvent as QQGuildMessageEvent
from nonebot.adapters.qq.event import QQMessageEvent
add_special_handlers('qqguild-', QQGuildMessageEvent)
add_special_handlers('onebot-', QQMessageEvent)
except ImportError:
pass
try:
from nonebot.adapters.kaiheila.event import MessageEvent as KookMessageEvent
add_special_handlers('kook-', KookMessageEvent)
except ImportError:
pass
try:
from nonebot.adapters.discord import MessageEvent as DiscordMessageEvent
add_special_handlers('discord-', DiscordMessageEvent)
except ImportError:
pass
@alc.assign('TOS.query')
async def _(
event: Event,
matcher: Matcher,
target: At | Me,
event_session: EventSession,
event_user_info: UserInfo = EventUserInfo(), # noqa: B008
):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
):
async with get_session() as session:
bind = await query_bind_info(
session=session,
user=await get_user(
event_session.platform, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish('未查询到绑定信息')
message = CANT_VERIFY_MESSAGE
player = Player(teaid=bind.game_account, trust=True)
user_info, game_data = await gather(player.get_info(), get_game_data(player))
if game_data is not None:
await (
message + UniMessage.image(raw=await make_query_image(user_info, game_data, event_user_info))
).finish()
await (message + make_query_text(user_info, game_data)).finish()
@alc.assign('TOS.query')
async def _(account: Player, event_session: EventSession, event_user_info: UserInfo = EventUserInfo()): # noqa: B008
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
):
user_info, game_data = await gather(account.get_info(), get_game_data(account))
if game_data is not None:
await UniMessage.image(raw=await make_query_image(user_info, game_data, event_user_info)).finish()
await make_query_text(user_info, game_data).finish()
class GameData(NamedTuple):
game_num: int
metrics: TetrisMetricsProWithLPMADPM
OR: Number
dspp: Number
ge: Number
async def get_game_data(player: Player, query_num: int = 50) -> GameData | None:
"""获取游戏数据"""
user_profile = await player.get_profile()
if user_profile.data == []:
return None
weighted_total_lpm = weighted_total_apm = weighted_total_adpm = total_time = 0.0
total_attack = total_dig = total_offset = total_pieses = total_receive = num = 0
for i in user_profile.data:
# 排除单人局和时间为0的游戏
# 茶: 不计算没挖掘的局, 即使apm和lpm也如此
if i.num_players == 1 or i.time == 0 or i.dig is None:
continue
# 加权计算
time = i.time / 1000
lpm = 24 * (i.pieces / time)
apm = (i.attack / time) * 60
adpm = ((i.attack + i.dig) / time) * 60
weighted_total_lpm += lpm * time
weighted_total_apm += apm * time
weighted_total_adpm += adpm * time
total_attack += i.attack
total_dig += i.dig
total_offset += i.offset
total_pieses += i.pieces
total_receive += i.receive
total_time += time
num += 1
if num >= query_num:
break
if num == 0:
return None
# TODO: 如果有效局数小于 {查询数} , 并且没有无dig信息的局, 且 user_profile.data 内有{请求数}个局, 则继续往前获取信息
metrics = get_metrics(
lpm=weighted_total_lpm / total_time, apm=weighted_total_apm / total_time, adpm=weighted_total_adpm / total_time
)
return GameData(
game_num=num,
metrics=metrics,
OR=total_offset / total_receive * 100,
dspp=total_dig / total_pieses,
ge=2 * ((total_attack * total_dig) / total_pieses**2),
)
async def make_query_image(user_info: UserInfoSuccess, game_data: GameData, event_user_info: UserInfo) -> bytes:
metrics = game_data.metrics
duration = timedelta(milliseconds=float(user_info.data.pb_sprint)).total_seconds()
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
async with HostPage(
await render(
'v1/tos/info',
Info(
user=People(avatar=await get_avatar(event_user_info, 'Data URI', None), name=user_info.data.name),
ranking=Ranking(rating=float(user_info.data.ranking), rd=round(float(user_info.data.rd_now), 2)),
multiplayer=Multiplayer(
pps=metrics.pps,
lpm=metrics.lpm,
apm=metrics.apm,
apl=metrics.apl,
vs=metrics.vs,
adpm=metrics.adpm,
adpl=metrics.adpl,
),
radar=Radar(
app=(app := (metrics.apm / (60 * metrics.pps))),
OR=game_data.OR,
dspp=game_data.dspp,
ci=150 * game_data.dspp - 125 * app + 50 * (metrics.vs / metrics.apm) - 25,
ge=game_data.ge,
),
sprint=sprint_value,
challenge=f'{int(user_info.data.pb_challenge):,}' if user_info.data.pb_challenge != '0' else 'N/A',
marathon=f'{int(user_info.data.pb_marathon):,}' if user_info.data.pb_marathon != '0' else 'N/A',
),
)
) as page_hash:
return await screenshot(f'http://{get_self_netloc()}/host/{page_hash}.html')
def make_query_text(user_info: UserInfoSuccess, game_data: GameData | None) -> UniMessage:
user_data = user_info.data
message = f'用户 {user_data.name} ({user_data.teaid}) '
if user_data.ranked_games == '0':
message += '暂无段位统计数据'
else:
message += f', 段位分 {round(float(user_data.rating_now),2)}±{round(float(user_data.rd_now),2)} ({round(float(user_data.vol_now),2)}) '
if game_data is None:
message += ', 暂无游戏数据'
else:
message += f', 最近 {game_data.game_num} 局数据'
message += f"\nL'PM: {game_data.metrics.lpm} ( {game_data.metrics.pps} pps )"
message += f'\nAPM: {game_data.metrics.apm} ( x{game_data.metrics.apl} )'
message += f'\nADPM: {game_data.metrics.adpm} ( x{game_data.metrics.adpl} ) ( {game_data.metrics.vs}vs )'
message += f'\n40L: {float(user_data.pb_sprint)/1000:.2f}s' if user_data.pb_sprint != '2147483647' else ''
message += f'\nMarathon: {user_data.pb_marathon}' if user_data.pb_marathon != '0' else ''
message += f'\nChallenge: {user_data.pb_challenge}' if user_data.pb_challenge != '0' else ''
return UniMessage(message)

View File

@@ -1,6 +1,7 @@
import sys
from os import environ
from platform import system
from re import sub
from nonebot import get_driver
from nonebot.log import logger
@@ -14,12 +15,12 @@ global_config = driver.config
@driver.on_startup
async def _():
await BrowserManager._init_playwright()
await BrowserManager.init_playwright()
@driver.on_shutdown
async def _():
await BrowserManager._close_browser()
await BrowserManager.close_browser()
class BrowserManager:
@@ -28,50 +29,44 @@ class BrowserManager:
_browser: Browser | None = None
@classmethod
async def _init_playwright(cls) -> None:
async def init_playwright(cls) -> None:
if system() == 'Windows' and getattr(global_config, 'fastapi_reload', False):
raise ImportError('加载失败, Windows 必须设置 FASTAPI_RELOAD=false 才能正常运行 playwright')
msg = '加载失败, Windows 必须设置 FASTAPI_RELOAD=false 才能正常运行 playwright'
raise ImportError(msg)
logger.info('开始 安装/更新 playwright 浏览器')
environ['PLAYWRIGHT_DOWNLOAD_HOST'] = 'https://npmmirror.com/mirrors/playwright/'
if cls._handle_error(cls._call_playwright(['', 'install', 'firefox'])):
if cls._call_playwright(['', 'install', 'firefox']):
logger.success('安装/更新 playwright 浏览器成功')
else:
logger.warning('playwright 浏览器 安装/更新 失败, 尝试使用原始仓库下载')
del environ['PLAYWRIGHT_DOWNLOAD_HOST']
if cls._handle_error(cls._call_playwright(['', 'install', 'firefox'])):
if cls._call_playwright(['', 'install', 'firefox']):
logger.success('安装/更新 playwright 浏览器成功')
else:
logger.error('安装/更新 playwright 浏览器失败')
try:
await cls._start_browser()
except BaseException as e: # noqa: BLE001 不知道会有什么异常, 交给用户解决
raise ImportError(
'playwright 启动失败, 请尝试在命令行运行 playwright install-deps firefox, 如果仍然启动失败, 请参考上面的报错👆'
) from e
except BaseException as e: # 不知道会有什么异常, 交给用户解决
msg = 'playwright 启动失败, 请尝试在命令行运行 playwright install-deps firefox, 如果仍然启动失败, 请参考上面的报错👆'
raise ImportError(msg) from e
else:
logger.success('playwright 启动成功')
@classmethod
def _call_playwright(cls, argv: list[str]) -> BaseException:
def _call_playwright(cls, argv: list[str]) -> bool:
"""等价于调用 playwright 的命令行程序"""
argv_backup = sys.argv.copy()
from re import sub
sys.argv[0] = sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.argv = argv
try:
main()
except BaseException as e: # noqa: BLE001 不在这里处理 playwright 的异常
return e
except SystemExit as e:
return e.code == 0
except BaseException: # noqa: BLE001
return False
finally:
sys.argv = argv_backup
return SystemExit(0)
@classmethod
def _handle_error(cls, error: BaseException) -> bool:
if isinstance(error, SystemExit) and error.code == 0:
return True
return False
return True
@classmethod
async def _start_browser(cls) -> Browser:
@@ -86,7 +81,7 @@ class BrowserManager:
return cls._browser or await cls._start_browser()
@classmethod
async def _close_browser(cls) -> None:
async def close_browser(cls) -> None:
"""关闭浏览器实例"""
if isinstance(cls._browser, Browser):
await cls._browser.close()

View File

@@ -15,21 +15,33 @@ class NeedCatchError(TetrisStatsError):
"""需要被捕获的异常基类"""
class DoNotCatchError(TetrisStatsError):
"""不应该被捕获的异常基类"""
class RequestError(NeedCatchError):
"""请求错误"""
def __init__(self, message: str = '', *, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class MessageFormatError(NeedCatchError):
"""用户发送的消息格式不正确"""
class DatabaseVersionError(DoNotCatchError):
"""数据库版本错误"""
class RecordNotFoundError(NeedCatchError):
"""找不到用户的某种记录"""
class FallbackError(NeedCatchError):
"""需要回滚至更通用的方法"""
class DoNotCatchError(TetrisStatsError):
"""不应该被捕获的异常基类"""
class WhatTheFuckError(DoNotCatchError):
"""用于表示不应该出现的情况 ("""
class NoFallbackError(DoNotCatchError): # 暂时没用 但是先写了
"""没有可用的回退方法"""

View File

@@ -0,0 +1,90 @@
from hashlib import sha256
from ipaddress import IPv4Address, IPv6Address
from typing import TYPE_CHECKING, ClassVar, Literal
from fastapi import FastAPI, Path, status
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
from nonebot import get_app, get_driver
from nonebot.log import logger
from ..config.config import CACHE_PATH
from .image import img_to_png
from .request import Request
from .templates import templates_dir
if TYPE_CHECKING:
from pydantic import IPvAnyAddress
app: FastAPI = get_app()
driver = get_driver()
global_config = driver.config
if not isinstance(app, FastAPI):
msg = '本插件需要 FastAPI 驱动器才能运行'
raise RuntimeError(msg) # noqa: TRY004
NOT_FOUND = HTMLResponse('404 Not Found', status_code=status.HTTP_404_NOT_FOUND)
class HostPage:
pages: ClassVar[dict[str, str]] = {}
def __init__(self, page: str) -> None:
self.page_hash = sha256(page.encode()).hexdigest()
self.pages[self.page_hash] = page
async def __aenter__(self) -> str:
return self.page_hash
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
self.pages.pop(self.page_hash, None)
@driver.on_startup
def _():
app.mount(
'/host/assets',
StaticFiles(directory=templates_dir / 'assets'),
name='assets',
)
logger.success('assets mounted')
@app.get('/host/{page_hash}.html', status_code=status.HTTP_200_OK)
async def _(page_hash: str) -> HTMLResponse:
if page_hash in HostPage.pages:
return HTMLResponse(HostPage.pages[page_hash])
return NOT_FOUND
@app.get('/host/resource/tetrio/{resource_type}/{user_id}', status_code=status.HTTP_200_OK)
async def _(
resource_type: Literal['avatars', 'banners'], revision: int, user_id: str = Path(regex=r'^[a-f0-9]{24}$')
) -> Response:
if not (path := CACHE_PATH / 'tetrio' / resource_type / f'{user_id}_{revision}.png').exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(
img_to_png(
await Request.request(
f'https://tetr.io/user-content/{resource_type}/{user_id}.jpg?rv={revision}', is_json=False
)
)
)
return FileResponse(path)
def get_self_netloc() -> str:
host: IPv4Address | IPv6Address | IPvAnyAddress = global_config.host
if isinstance(host, IPv4Address):
if host == IPv4Address('0.0.0.0'): # noqa: S104
host = IPv4Address('127.0.0.1')
netloc = f'{host}:{global_config.port}'
else:
if host == IPv6Address('::'):
host = IPv6Address('::1')
netloc = f'[{host}]:{global_config.port}'
return netloc

View File

@@ -0,0 +1,62 @@
from base64 import b64encode
from io import BytesIO
from typing import Literal, overload
from nonebot_plugin_userinfo import UserInfo # type: ignore[import-untyped]
from PIL import Image
@overload
async def get_avatar(user: UserInfo, scheme: Literal['Data URI'], default: str | None) -> str:
"""获取用户头像的指定格式
Args:
user (UserInfo): 要获取的用户
scheme (Literal[&#39;Data URI&#39;]): 格式
default (str | None): 获取不到时的默认值
Raises:
TypeError: Can't get avatar: 当获取不到头像并且没有设置默认值时抛出
TypeError: Can't get avatar format: 当获取到的头像无法识别格式时抛出
Returns:
str: Data URI 格式的头像
"""
@overload
async def get_avatar(user: UserInfo, scheme: Literal['bytes'], default: str | None) -> bytes:
"""获取用户头像的指定格式
Args:
user (UserInfo): 要获取的用户
scheme (Literal[&#39;bytes&#39;]): 格式
default (str | None): 获取不到时的默认值
Returns:
bytes: bytes 格式的头像
"""
async def get_avatar(user: UserInfo, scheme: Literal['Data URI', 'bytes'], default: str | None) -> str | bytes:
if user.user_avatar is None:
if default is None:
msg = "Can't get avatar"
raise TypeError(msg)
return default
bot_avatar = await user.user_avatar.get_image()
if scheme == 'Data URI':
avatar_format = Image.open(BytesIO(bot_avatar)).format
if avatar_format is None:
msg = "Can't get avatar format"
raise TypeError(msg)
return f'data:{Image.MIME[avatar_format]};base64,{b64encode(bot_avatar).decode()}'
return bot_avatar
def img_to_png(image: bytes) -> bytes:
"""将图片转换为 PNG 格式"""
result = BytesIO()
with Image.open(BytesIO(image)) as img:
img.save(result, 'PNG')
return result.getvalue()

View File

@@ -144,136 +144,96 @@ class TetrisMetricsProWithLPMADPM(TetrisMetricsBasicWithLPM, TetrisMetricsBaseWi
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: Number,
lpm: None = None,
apm: None = None,
vs: None = None,
adpm: None = None,
precision: int = 2,
) -> TetrisMetricsBaseWithPPS:
...
) -> TetrisMetricsBaseWithPPS: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: None = None,
lpm: Number,
apm: None = None,
vs: None = None,
adpm: None = None,
precision: int = 2,
) -> TetrisMetricsBaseWithLPM:
...
) -> TetrisMetricsBaseWithLPM: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: None = None,
lpm: None = None,
apm: None = None,
vs: Number,
adpm: None = None,
precision: int = 2,
) -> TetrisMetricsBaseWithVS:
...
) -> TetrisMetricsBaseWithVS: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: None = None,
lpm: None = None,
apm: None = None,
vs: None = None,
adpm: Number,
precision: int = 2,
) -> TetrisMetricsBaseWithADPM:
...
) -> TetrisMetricsBaseWithADPM: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: Number,
lpm: None = None,
apm: Number,
vs: None = None,
adpm: None = None,
precision: int = 2,
) -> TetrisMetricsBasicWithPPS:
...
) -> TetrisMetricsBasicWithPPS: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: None = None,
lpm: Number,
apm: Number,
vs: None = None,
adpm: None = None,
precision: int = 2,
) -> TetrisMetricsBasicWithLPM:
...
) -> TetrisMetricsBasicWithLPM: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: Number,
lpm: None = None,
apm: Number,
vs: Number,
adpm: None = None,
precision: int = 2,
) -> TetrisMetricsProWithPPSVS:
...
) -> TetrisMetricsProWithPPSVS: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: Number,
lpm: None = None,
apm: Number,
vs: None = None,
adpm: Number,
precision: int = 2,
) -> TetrisMetricsProWithPPSADPM:
...
) -> TetrisMetricsProWithPPSADPM: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: None = None,
lpm: Number,
apm: Number,
vs: Number,
adpm: None = None,
precision: int = 2,
) -> TetrisMetricsProWithLPMVS:
...
) -> TetrisMetricsProWithLPMVS: ...
@overload
def get_metrics( # noqa: PLR0913
def get_metrics(
*,
pps: None = None,
lpm: Number,
apm: Number,
vs: None = None,
adpm: Number,
precision: int = 2,
) -> TetrisMetricsProWithLPMADPM:
...
) -> TetrisMetricsProWithLPMADPM: ...
def get_metrics( # noqa: PLR0911, PLR0912, PLR0913
def get_metrics( # noqa: PLR0911, PLR0912, PLR0913, C901
*,
pps: Number | None = None,
lpm: Number | None = None,

View File

@@ -1,19 +0,0 @@
from nonebot.adapters import Bot
def get_platform(bot: Bot) -> str:
try:
from nonebot.adapters.onebot.v12 import Bot as OB12Bot
if isinstance(bot, OB12Bot):
return bot.platform
except ImportError:
pass
try:
from nonebot.adapters.satori import Bot as SaBot
if isinstance(bot, SaBot):
return bot.platform
except ImportError:
pass
return bot.type

View File

@@ -1,79 +0,0 @@
from datetime import UTC, datetime
from typing import ClassVar
from nonebot import get_driver, get_plugin
from nonebot.adapters import Bot, Event
from nonebot.matcher import Matcher
from nonebot.message import run_postprocessor, run_preprocessor
from nonebot_plugin_orm import get_session
from ..db.models import HistoricalData
driver = get_driver()
class Recorder:
matchers: ClassVar[set[type[Matcher]]] = set()
historical_data: ClassVar[dict[int, tuple[HistoricalData, bool]]] = {}
@classmethod
def create_historical_data(cls, event_id: int, historical_data: HistoricalData) -> None:
cls.historical_data[event_id] = (historical_data, False)
@classmethod
def update_historical_data(cls, event_id: int, historical_data: HistoricalData) -> None:
if event_id not in cls.historical_data:
raise KeyError
cls.historical_data[event_id] = (historical_data, True)
@classmethod
def get_historical_data(cls, event_id: int) -> HistoricalData:
return cls.historical_data[event_id][0]
@classmethod
async def save_historical_data(cls, event_id: int) -> None:
if event_id not in cls.historical_data:
raise KeyError
historical_data, completed = cls.historical_data.pop(event_id)
if completed:
async with get_session() as session:
session.add(historical_data)
await session.commit()
@classmethod
def del_historical_data(cls, event_id: int) -> None:
cls.historical_data.pop(event_id)
@driver.on_startup
def _():
plugin = get_plugin('nonebot_plugin_tetris_stats')
if plugin is not None:
Recorder.matchers = plugin.matcher
else:
raise RuntimeError('获取不到自身插件对象')
@run_preprocessor
def _(bot: Bot, event: Event, matcher: Matcher):
if isinstance(matcher, tuple(Recorder.matchers)):
Recorder.create_historical_data(
event_id=id(event),
historical_data=HistoricalData(
trigger_time=datetime.now(tz=UTC),
bot_platform=bot.type,
bot_account=bot.self_id,
source_type=event.get_type(),
source_account=event.get_session_id(),
message=event.get_message(),
),
)
@run_postprocessor
async def _(event: Event, matcher: Matcher, exception: Exception | None):
if isinstance(matcher, tuple(Recorder.matchers)):
if exception is not None:
Recorder.del_historical_data(id(event))
else:
await Recorder.save_historical_data(id(event))

View File

@@ -0,0 +1,67 @@
from typing import Literal, overload
from jinja2 import Environment, FileSystemLoader
from nonebot.compat import PYDANTIC_V2
from ..templates import templates_dir
from .schemas.bind import Bind
from .schemas.tetrio.tetrio_info import Info as TETRIOInfo
from .schemas.tetrio.tetrio_record_blitz import Record as TETRIORecordBlitz
from .schemas.tetrio.tetrio_record_sprint import Record as TETRIORecordSprint
from .schemas.tetrio.tetrio_user_info_v2 import Info as TETRIOUserInfoV2
from .schemas.top_info import Info as TOPInfo
from .schemas.tos_info import Info as TOSInfo
env = Environment(
loader=FileSystemLoader(templates_dir), autoescape=True, trim_blocks=True, lstrip_blocks=True, enable_async=True
)
@overload
async def render(render_type: Literal['v1/binding'], data: Bind) -> str: ...
@overload
async def render(render_type: Literal['v1/tetrio/info'], data: TETRIOInfo) -> str: ...
@overload
async def render(render_type: Literal['v1/top/info'], data: TOPInfo) -> str: ...
@overload
async def render(render_type: Literal['v1/tos/info'], data: TOSInfo) -> str: ...
@overload
async def render(render_type: Literal['v2/tetrio/user/info'], data: TETRIOUserInfoV2) -> str: ...
@overload
async def render(render_type: Literal['v2/tetrio/record/40l'], data: TETRIORecordSprint) -> str: ...
@overload
async def render(render_type: Literal['v2/tetrio/record/blitz'], data: TETRIORecordBlitz) -> str: ...
async def render(
render_type: Literal[
'v1/binding',
'v1/tetrio/info',
'v1/top/info',
'v1/tos/info',
'v2/tetrio/user/info',
'v2/tetrio/record/40l',
'v2/tetrio/record/blitz',
],
data: Bind | TETRIOInfo | TOPInfo | TOSInfo | TETRIOUserInfoV2 | TETRIORecordSprint | TETRIORecordBlitz,
) -> str:
if PYDANTIC_V2:
return await env.get_template('index.html').render_async(
path=render_type, data=data.model_dump_json(by_alias=True)
)
return await env.get_template('index.html').render_async(path=render_type, data=data.json(by_alias=True))
__all__ = ['render']

View File

@@ -0,0 +1,20 @@
from typing import Literal
from pydantic import BaseModel
from ...typing import Number
class Avatar(BaseModel):
type: Literal['identicon']
hash: str
class People(BaseModel):
avatar: str | Avatar
name: str
class Ranking(BaseModel):
rating: Number
rd: Number

View File

@@ -0,0 +1,13 @@
from typing import Literal
from pydantic import BaseModel
from .base import People
class Bind(BaseModel):
platform: Literal['TETR.IO', 'TOP', 'TOS']
status: Literal['error', 'success', 'unknown', 'unlink', 'unverified']
user: People
bot: People
command: str

View File

@@ -0,0 +1,10 @@
from datetime import datetime
from pydantic import BaseModel
from ....typing import Number
class TetraLeagueHistoryData(BaseModel):
record_at: datetime
tr: Number

View File

@@ -0,0 +1,49 @@
from pydantic import BaseModel
from .....games.tetrio.api.typing import Rank
from ....typing import Number
from ..base import People, Ranking
from .base import TetraLeagueHistoryData
class User(People):
bio: str | None
class TetraLeague(BaseModel):
rank: Rank
tr: Number
global_rank: Number
pps: Number
lpm: Number
apm: Number
apl: Number
vs: Number
adpm: Number
adpl: Number
class TetraLeagueHistory(BaseModel):
data: list[TetraLeagueHistoryData]
split_interval: Number
min_tr: Number
max_tr: Number
offset: Number
class Radar(BaseModel):
app: Number
dsps: Number
dspp: Number
ci: Number
ge: Number
class Info(BaseModel):
user: User
ranking: Ranking
tetra_league: TetraLeague
tetra_league_history: TetraLeagueHistory
radar: Radar
sprint: str
blitz: str

View File

@@ -0,0 +1,58 @@
from pydantic import BaseModel
from ..base import People
class User(People):
id: str
class Max(BaseModel):
combo: int
btb: int
class Mini(BaseModel):
total: int
single: int
double: int
class Tspins(BaseModel):
total: int
single: int
double: int
triple: int
mini: Mini
class Finesse(BaseModel):
faults: int
accuracy: float
class RecordStatistic(BaseModel):
keys: int
kpp: float
kps: float
max: Max
pieces: int
pps: float
lines: int
lpm: float
holds: int | None
score: int
single: int
double: int
triple: int
quad: int
tspins: Tspins
all_clear: int
finesse: Finesse

View File

@@ -0,0 +1,22 @@
from datetime import datetime
from pydantic import BaseModel
from .tetrio_record_base import RecordStatistic, User
class Statistic(RecordStatistic):
spp: float
level: int
class Record(BaseModel):
user: User
replay_id: str
rank: int | None
statistic: Statistic
play_at: datetime

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from pydantic import BaseModel
from .tetrio_record_base import RecordStatistic as Statistic
from .tetrio_record_base import User
class Record(BaseModel):
user: User
time: str
replay_id: str
rank: int | None
statistic: Statistic
play_at: datetime

View File

@@ -0,0 +1,97 @@
from datetime import datetime
from pydantic import BaseModel
from .....games.tetrio.api.schemas.user_records import Zen
from .....games.tetrio.api.typing import Rank
from ....typing import Number
from ..base import Avatar
from .base import TetraLeagueHistoryData
class Badge(BaseModel):
id: str
description: str
group: str | None
receive_at: datetime | None
class User(BaseModel):
id: str
name: str
country: str | None
avatar: str | Avatar
banner: str | None
bio: str | None
friend_count: int
supporter_tier: int
verified: bool
bad_standing: bool
badges: list[Badge]
xp: Number
playtime: str | None
join_at: datetime | None
class Statistic(BaseModel):
total: int | None
wins: int | None
class TetraLeagueStatistic(BaseModel):
total: int
wins: int
class TetraLeague(BaseModel):
rank: Rank
highest_rank: Rank
tr: Number
glicko: Number
rd: Number
global_rank: int | None
country_rank: int | None
pps: Number
apm: Number
apl: Number
vs: Number | None
adpl: Number | None
statistic: TetraLeagueStatistic
decaying: bool
history: list[TetraLeagueHistoryData]
class Sprint(BaseModel):
time: str
global_rank: int | None
play_at: datetime
class Blitz(BaseModel):
score: int
global_rank: int | None
play_at: datetime
class Info(BaseModel):
user: User
tetra_league: TetraLeague | None
statistic: Statistic | None
sprint: Sprint | None
blitz: Blitz | None
zen: Zen

View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel
from ...typing import Number
from .base import People
class Data(BaseModel):
pps: Number
lpm: Number
apm: Number
apl: Number
class Info(BaseModel):
user: People
today: Data
history: Data

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field
from ...typing import Number
from .base import People, Ranking
class Multiplayer(BaseModel):
pps: Number
lpm: Number
apm: Number
apl: Number
vs: Number
adpm: Number
adpl: Number
class Radar(BaseModel):
app: Number
OR: Number = Field(serialization_alias='or')
dspp: Number
ci: Number
ge: Number
class Info(BaseModel):
user: People
ranking: Ranking
multiplayer: Multiplayer
radar: Radar
sprint: str
challenge: str
marathon: str

View File

@@ -1,28 +1,31 @@
from collections.abc import Sequence
from http import HTTPStatus
from urllib.parse import urljoin, urlparse
from aiofiles import open
from httpx import AsyncClient, HTTPError
from nonebot import get_driver
from nonebot import get_driver, get_plugin_config
from nonebot.log import logger
from playwright.async_api import Response
from ujson import JSONDecodeError, dumps, loads
from ..config.config import CACHE_PATH
from ..config.config import CACHE_PATH, Config
from .browser import BrowserManager
from .exception import RequestError
driver = get_driver()
config = get_plugin_config(Config)
@driver.on_startup
async def _():
await Request._init_cache()
await Request._read_cache()
await Request.init_cache()
await Request.read_cache()
@driver.on_shutdown
async def _():
await Request._write_cache()
await Request.write_cache()
def splice_url(url_list: list[str]) -> str:
@@ -37,7 +40,7 @@ def splice_url(url_list: list[str]) -> str:
class Request:
"""网络请求相关类"""
_CACHE_FILE = CACHE_PATH.joinpath('cloudflare_cache.json')
_CACHE_FILE = CACHE_PATH / 'cloudflare_cache.json'
_headers: dict | None = None
_cookies: dict | None = None
@@ -45,84 +48,120 @@ class Request:
async def _anti_cloudflare(cls, url: str) -> bytes:
"""用firefox硬穿五秒盾"""
browser = await BrowserManager.get_browser()
context = await browser.new_context()
page = await context.new_page()
response = await page.goto(url)
attempts = 0
while attempts < 60: # noqa: PLR2004
attempts += 1
text = await page.locator('body').text_content()
if text is None:
await page.wait_for_timeout(1000)
continue
if await page.title() == 'Please Wait... | Cloudflare':
logger.warning('疑似触发了 Cloudflare 的验证码')
break
try:
loads(text)
except JSONDecodeError:
await page.wait_for_timeout(1000)
else:
if not isinstance(response, Response):
raise RequestError('api请求失败')
cls._headers = await response.request.all_headers()
async with await browser.new_context() as context, await context.new_page() as page:
response = await page.goto(url)
attempts = 0
while attempts < 60: # noqa: PLR2004
attempts += 1
text = await page.locator('body').text_content()
if text is None:
await page.wait_for_timeout(1000)
continue
if await page.title() == 'Please Wait... | Cloudflare':
logger.warning('疑似触发了 Cloudflare 的验证码')
break
try:
cls._cookies = {i['name']: i['value'] for i in await context.cookies()}
except KeyError:
cls._cookies = None
await page.close()
await context.close()
return await response.body()
await page.close()
await context.close()
raise RequestError('绕过五秒盾失败')
loads(text)
except JSONDecodeError:
await page.wait_for_timeout(1000)
else:
if not isinstance(response, Response):
msg = 'api请求失败'
raise RequestError(msg)
cls._headers = await response.request.all_headers()
try:
cls._cookies = {
name: value
for i in await context.cookies()
if (name := i.get('name')) is not None and (value := i.get('value')) is not None
}
except KeyError:
cls._cookies = None
return await response.body()
msg = '绕过五秒盾失败'
raise RequestError(msg)
@classmethod
async def _init_cache(cls) -> None:
async def init_cache(cls) -> None:
"""初始化缓存文件"""
if not cls._CACHE_FILE.exists():
async with open(file=cls._CACHE_FILE, mode='w', encoding='UTF-8') as file:
await file.write(dumps({'headers': cls._headers, 'cookies': cls._cookies}))
@classmethod
async def _read_cache(cls) -> None:
async def read_cache(cls) -> None:
"""读取缓存文件"""
try:
async with open(file=cls._CACHE_FILE, mode='r', encoding='UTF-8') as file:
json = loads(await file.read())
except FileNotFoundError:
await cls._init_cache()
await cls.init_cache()
except (PermissionError, JSONDecodeError):
cls._CACHE_FILE.unlink()
await cls._init_cache()
await cls.init_cache()
else:
cls._headers = json['headers']
cls._cookies = json['cookies']
@classmethod
async def _write_cache(cls) -> None:
async def write_cache(cls) -> None:
"""写入缓存文件"""
try:
async with open(file=cls._CACHE_FILE, mode='r+', encoding='UTF-8') as file:
await file.write(dumps({'headers': cls._headers, 'cookies': cls._cookies}))
except FileNotFoundError:
await cls._init_cache()
await cls.init_cache()
except (PermissionError, JSONDecodeError):
cls._CACHE_FILE.unlink()
await cls._init_cache()
await cls.init_cache()
@classmethod
async def request(cls, url: str, *, is_json: bool = True) -> bytes:
"""请求api"""
try:
async with AsyncClient(cookies=cls._cookies) as session:
async with AsyncClient(cookies=cls._cookies, timeout=config.tetris_req_timeout) as session:
response = await session.get(url, headers=cls._headers)
if response.status_code != HTTPStatus.OK:
msg = f'请求错误 code: {response.status_code} {HTTPStatus(response.status_code).phrase}\n{response.text}'
raise RequestError(msg, status_code=response.status_code)
if is_json:
loads(response.content)
return response.content
except HTTPError as e:
raise RequestError(f'请求错误\n{e!r}') from e
msg = f'请求错误 \n{e!r}'
raise RequestError(msg) from e
except JSONDecodeError:
if urlparse(url).netloc.lower().endswith('tetr.io'):
return await cls._anti_cloudflare(url)
raise
@classmethod
async def failover_request(
cls,
urls: Sequence[str],
*,
failover_code: Sequence[int],
failover_exc: tuple[type[BaseException], ...],
is_json: bool = True,
) -> bytes:
error_list: list[RequestError] = []
for i in urls:
logger.debug(f'尝试请求 {i}')
try:
return await cls.request(i, is_json=is_json)
except RequestError as e:
if e.status_code in failover_code: # 如果状态码在 failover_code 中, 则继续尝试下一个URL
error_list.append(e)
continue
# 如果状态码不在故障转移列表中, 则查找异常栈, 如果异常栈内有 failover_exc 内的异常类型, 则继续尝试下一个URL
tb = e.__traceback__
while tb is not None:
if isinstance(tb.tb_frame.f_locals.get('exc_value'), failover_exc):
error_list.append(e)
break
tb = tb.tb_next
else:
raise
continue
msg = f'所有地址皆不可用\n{error_list!r}'
raise RequestError(msg)

View File

@@ -0,0 +1,44 @@
from asyncio import sleep
from collections.abc import Callable, Coroutine
from contextlib import suppress
from datetime import timedelta
from functools import wraps
from typing import Any, ParamSpec, TypeVar
from nonebot.log import logger
from nonebot_plugin_alconna.uniseg import SerializeFailed, UniMessage
T = TypeVar('T')
P = ParamSpec('P')
def retry(
max_attempts: int = 3,
exception_type: type[BaseException] | tuple[type[BaseException], ...] = Exception,
delay: timedelta | None = None,
reply: str | UniMessage | None = None,
) -> Callable[[Callable[P, Coroutine[Any, Any, T]]], Callable[P, Coroutine[Any, Any, T]]]:
def decorator(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, Coroutine[Any, Any, T]]:
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
for i in range(max_attempts + 1):
if i > 0:
message = f'Retrying: {func.__name__} ({i}/{max_attempts})'
logger.debug(message)
with suppress(SerializeFailed):
await UniMessage(reply or message).send()
if i == max_attempts:
break
try:
return await func(*args, **kwargs)
except exception_type as e:
if i == max_attempts:
raise
logger.exception(e)
if delay is not None:
await sleep(delay.total_seconds())
return await func(*args, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,15 @@
from playwright.async_api import TimeoutError
from .browser import BrowserManager
from .retry import retry
@retry(exception_type=TimeoutError, reply='截图失败, 重试中')
async def screenshot(url: str) -> bytes:
browser = await BrowserManager.get_browser()
async with (
await browser.new_page(viewport={'width': 3000, 'height': 3000}) as page,
):
await page.goto(url)
await page.wait_for_load_state('networkidle')
return await page.locator('id=content').screenshot(timeout=5000, type='png')

View File

@@ -0,0 +1,71 @@
from asyncio.subprocess import PIPE, create_subprocess_exec
from shutil import rmtree
from nonebot import get_driver
from nonebot.log import logger
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import on_alconna
from nonebot_plugin_localstore import get_data_dir # type: ignore[import-untyped]
driver = get_driver()
templates_dir = get_data_dir('nonebot_plugin_tetris_stats') / 'templates'
alc = on_alconna('更新模板', permission=SUPERUSER)
@driver.on_startup
async def init_templates() -> None:
try:
await create_subprocess_exec('git', '--version', stdout=PIPE)
except FileNotFoundError as e:
msg = '未找到 git, 请确保 git 已安装并在环境变量中\n安装步骤请参阅: https://git-scm.com/book/zh/v2/%E8%B5%B7%E6%AD%A5-%E5%AE%89%E8%A3%85-Git'
raise RuntimeError(msg) from e
if not templates_dir.exists():
logger.info('模板仓库不存在, 正在尝试初始化...')
proc = await create_subprocess_exec(
'git',
'clone',
'-b',
'gh-pages',
'https://github.com/A-Minos/tetris-stats-templates',
templates_dir,
'--depth=1',
stdout=PIPE,
stderr=PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
for i in stderr.decode().splitlines():
logger.error(i)
msg = '初始化模板仓库失败'
raise RuntimeError(msg)
logger.success('模板仓库初始化成功')
return
proc = await create_subprocess_exec(
'git', 'rev-parse', '--is-inside-work-tree', stdout=PIPE, stderr=PIPE, cwd=templates_dir
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
for i in stderr.decode().splitlines():
logger.error(i)
logger.warning('模板仓库状态异常, 尝试重新初始化')
rmtree(templates_dir)
await init_templates()
return
logger.info('正在更新模板仓库...')
proc = await create_subprocess_exec('git', 'pull', stdout=PIPE, stderr=PIPE, cwd=templates_dir)
stdout, stderr = await proc.communicate()
logger.info(stdout.decode().strip())
if proc.returncode != 0:
for i in stderr.decode().splitlines():
logger.error(i)
msg = '更新模板仓库失败'
raise RuntimeError(msg)
logger.success('模板仓库更新成功')
@alc.handle()
async def _():
await init_templates()
await alc.finish('模板仓库更新成功')

Some files were not shown because too many files have changed in this diff Show More