Compare commits

...

136 Commits

Author SHA1 Message Date
eff25435e3 让 MAN 使用坏枪的 Markdown 处理器
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-11-11 01:47:10 +08:00
df28fad697 调整信息 2025-11-11 01:24:29 +08:00
561f6981aa 答题必须 At bot
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-11 01:20:24 +08:00
2632215af9 补充 MAN
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-11 01:06:11 +08:00
bfde559892 添加一个可供管理的订阅制模块,并且接入 KonaPH 2025-11-11 00:53:17 +08:00
857f8c5955 Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot 2025-11-10 22:12:33 +08:00
500053e630 更稳定的 MarkDown 和 LaTeX 生成!
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-10 21:59:45 +08:00
30cfb4cadd 添加 Justfile 相关库,简化项目启动流程 2025-11-10 21:23:41 +08:00
e2f99af73b 将浏览器依赖放在最最前面安装,以保证依赖更新时,尽可能不用重装浏览器
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-10 05:00:51 +08:00
e09de9eeb6 更改使用 uv 而非 poetry 管理 Docker 内部依赖
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-10 04:41:05 +08:00
4a3b49ce79 德摩根律(
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-09 23:47:26 +08:00
03900f4416 成语接龙接入 LLM 和 MarkDown、LaTeX 接入
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-11-09 23:12:04 +08:00
62f4195e46 Merge pull request '让豆包水印使用相对大小' (#47) from feature/doubao-watermark into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #47
2025-11-07 21:17:16 +08:00
751297e3bc Merge branch 'master' into feature/doubao-watermark 2025-11-07 21:17:09 +08:00
b450998f3f 让豆包水印使用相对大小 2025-11-07 21:15:19 +08:00
ae6297b98d Merge pull request '添加豆包水印' (#46) from feature/doubao-watermark into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #46
2025-11-07 19:18:41 +08:00
dacae29054 添加豆包水印 2025-11-07 19:18:24 +08:00
8acb546c6a Merge pull request '让浏览器等久一点' (#42) from feature/konaweb into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #42
2025-11-07 02:41:49 +08:00
49e0914416 让浏览器等久一点 2025-11-07 02:41:33 +08:00
5b74c78ec3 Merge pull request '更新 web_render 模块并支持前端渲染' (#41) from feature/konaweb into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #41
2025-11-07 02:31:06 +08:00
c911410276 更新 web_render 模块并支持前端渲染 2025-11-07 02:30:46 +08:00
37ca4bf11f Merge pull request '西多说 by 姬嵇' (#40) from feature/memepack/kiosay into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #40
2025-11-06 23:35:48 +08:00
8ef084c22a 西多说 by 姬嵇 2025-11-06 23:35:20 +08:00
57f0cd728f Merge pull request '使用 Discord Proxy 选项来下载图片' (#39) from bugfix/discord-image-download into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #39
2025-11-06 00:12:32 +08:00
627a29f57e 使用 Discord Proxy 选项来下载图片 2025-11-06 00:12:08 +08:00
650c500f47 Merge pull request '监听更广泛的 Discord 消息' (#38) from bugfix/discord-image-download into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #38
2025-11-06 00:06:49 +08:00
86acbe51e9 Merge remote-tracking branch 'origin/master' into bugfix/discord-image-download 2025-11-06 00:05:20 +08:00
4900a7e0ad Merge branch 'bugfix/discord-image-download' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot into bugfix/discord-image-download 2025-11-06 00:05:08 +08:00
34da08126b 监听更广泛的 event 2025-11-06 00:04:57 +08:00
00f416c8bc Merge pull request '改为使用 proxy url' (#37) from bugfix/discord-image-download into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #37
2025-11-05 23:59:54 +08:00
9c7d0a4486 Merge branch 'master' into bugfix/discord-image-download 2025-11-05 23:59:43 +08:00
e3b9d6723f 改为使用 proxy url 2025-11-05 23:58:55 +08:00
ef80399a90 Merge pull request '尝试解决 Discord 无法读取图片的问题' (#30) from bugfix/discord-image-download into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #30
2025-11-05 23:17:25 +08:00
bfbfa9d9be 尝试解决这个问题 2025-11-05 23:15:30 +08:00
6b7be4d3b0 Merge pull request '添加基础的 LLM 支持' (#29) from feature/LLM-base into master
Reviewed-on: #29
2025-11-05 20:40:40 +08:00
7c19c52d9f 添加关于 LLM 配置的文档 2025-11-05 20:36:51 +08:00
a5f4ae9bdc 添加基础的 LLM 支持 2025-11-05 18:40:13 +08:00
9320815d3f 修复无法更改图片的问题
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-11-01 20:59:58 +08:00
795300cb83 在每日答题情况添加记录点显示
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-11-01 18:42:47 +08:00
0231aa04f4 添加中间答案功能
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-11-01 18:29:10 +08:00
01fe33eb9f 部分解耦了 konaph 的一些层 2025-11-01 17:52:05 +08:00
adfbac7d90 支持正义 utf-8 2025-11-01 13:48:48 +08:00
994c1412da 为 Watchfiles 添加更可配置的过滤器
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-01 12:40:01 +08:00
8780dfec6f 在 Tag 成功后也进行 ntfy 通知
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-30 16:52:55 +08:00
490d807e7a 添加一些对题解提交空格的情况判定
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-30 16:48:09 +08:00
fa208199ab 我不小心多加了一个 s
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-29 22:01:13 +08:00
38a17f42a3 添加 Ntfy 构建消息的报告
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-29 21:59:11 +08:00
37179fc4d7 添加显示提交记录的指令
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-27 15:31:12 +08:00
56e0aabbf3 优化 UX,添加 preview 指令 2025-10-27 15:20:40 +08:00
ce2b7fd6f6 空调调温优化与排行榜,浏览器添加本地HTML支持
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-27 00:04:27 +08:00
b28f8f85a2 Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot 2025-10-26 22:49:04 +08:00
0acffea86d 添加排行榜,优化 UX
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-26 12:45:29 +08:00
3e395f8a35 更少的量,更解耦的数据,更健壮的系统
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-26 11:56:03 +08:00
312e203bbe 忘记把这个答题情况通知加上了
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-26 04:07:26 +08:00
f9deabfce0 修复 Query 逻辑
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-26 04:03:51 +08:00
0a822bf440 优化 konaph UX 并添加文档
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-26 03:55:31 +08:00
534a2c9e75 解密厨来了2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-26 03:42:28 +08:00
a03cef4124 解密厨来了 2025-10-26 03:23:51 +08:00
7a20c3fe2f 空调指数概率损坏与空调、骰子gif图的背景优化 2025-10-26 01:06:26 +08:00
16351792b6 修复成语接龙大家没被扣分的BUG
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-25 23:59:41 +08:00
7bbd4f81ee 成语接龙5.0、群空调功能
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-25 23:39:32 +08:00
4d5678efac Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot 2025-10-25 22:00:57 +08:00
c7229bb763 new render 2025-10-25 21:54:38 +08:00
6abc963ccf 优化提醒 UX
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-25 00:28:29 +08:00
881f38d187 调整 Web Renderer 的代码风格,完善类型注解
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-24 23:49:32 +08:00
56d32bc9f4 BA
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 23:25:00 +08:00
76f19f9eac 添加 Emoji 字体
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 22:11:46 +08:00
1479d8f8da 添加 CJK 字体依赖 2025-10-24 22:10:16 +08:00
18785f034b 调整依赖,不再在运行时安装依赖
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 21:50:16 +08:00
7ba1a92623 解决依赖问题,容器体积什么的以后再修
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-24 20:40:05 +08:00
f6670eb672 先推上去看看有缺什么依赖
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-24 20:18:42 +08:00
eb32c1af9a new
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-24 19:39:06 +08:00
e0c55545ec 添加此方提醒的 CURD 和 ntfy 联动
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 05:08:54 +08:00
164305e81f 调整 man 2025-10-24 02:27:56 +08:00
96679033f3 不再有 fortune
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 02:21:56 +08:00
afda0680ec 调整衰减函数
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 01:59:41 +08:00
021133954e 调整 man 默认范围
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 01:33:59 +08:00
7baa04dbc2 添加罗文提示 2025-10-24 01:33:01 +08:00
e55bdbdf4a 怪话不可为空!!!
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 01:27:40 +08:00
a30c7b8093 添加怪话过滤功能
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-24 01:21:54 +08:00
3da2c2266f 说怪话 bot
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-24 01:08:46 +08:00
96e3c3fe17 让 Onebot private channel 也有 ID 2025-10-24 00:46:05 +08:00
851c9eb3c7 修复程序退出耗时太久的问题 2025-10-24 00:01:13 +08:00
11269b2a5a 在罗文被念错时提醒他 2025-10-23 23:32:56 +08:00
875e0efc2f Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-23 22:12:41 +08:00
4f43312663 升级 ptimeparse 2025-10-23 22:12:34 +08:00
b2f4768573 优化判定与计分规则
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-21 23:52:05 +08:00
bc6263ec31 Merge pull request '添加安安展示' (#27) from tnot/konabot:添加安安展示 into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #27
2025-10-21 22:07:19 +08:00
bc9d025836 修好了bug的安安展示 2025-10-21 22:02:41 +08:00
b552aacf89 添加安安展示 2025-10-21 21:33:32 +08:00
f9a0249772 优化 giftool 的截取逻辑
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-21 18:31:14 +08:00
c94db33b11 更新 ptimeparse 到 0.2.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-19 22:45:34 +08:00
67382a0c0a 在我写的模块采用更安全的 asyncio 锁写法
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-19 20:27:18 +08:00
fd4c9302c2 async with lock
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-19 20:24:47 +08:00
f30ad0cb7d 判定部分优化
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-19 18:48:10 +08:00
f7afe48680 精度修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-19 18:36:27 +08:00
b42385f780 修复成语接龙
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-19 18:24:03 +08:00
6cae38dea9 提升 LongTask 的健壮性
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-19 16:54:59 +08:00
8594b59783 修复 LongTask 在 Discord 和控制台无法正确返回是否顺利完成任务的问题 2025-10-19 16:51:22 +08:00
f768c91430 完善 LongTask 模块
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-19 16:47:50 +08:00
a65cb118cc 接入我写的模块来获得群上下文
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-19 04:51:49 +08:00
75c6bbd23f Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot 2025-10-19 04:45:26 +08:00
aaf0a75d65 添加若干有用的小模块 2025-10-19 04:45:15 +08:00
8f560ce1ba 新成语接龙
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-19 01:25:34 +08:00
9f3f79f51d 自动同意小团体的好友请求
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-17 01:04:34 +08:00
92048aeff7 让 wzq 东西在 wzq 群不可用 2025-10-17 00:54:14 +08:00
81aac10665 添加文档并修复问题
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-16 23:27:42 +08:00
3ce230adfe 优化卵总展示光影
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-16 22:43:54 +08:00
4f885554ca 添加卵总展示
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-16 22:29:07 +08:00
7ebcb8add4 Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-16 19:13:51 +08:00
e18cc82792 修复 av/bv 号无法直接被筛选读取的问题 2025-10-16 19:13:36 +08:00
eb28cd0a0c 更正 Giftool 错误的文档 2025-10-16 18:44:22 +08:00
2d688a6ed6 new
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-14 12:43:25 +08:00
e9aac52200 chengyu update 2025-10-14 01:23:49 +00:00
4305548ab5 submodule
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 22:53:44 +08:00
99382a3bf5 Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 22:48:17 +08:00
92e43785bf submodule 2025-10-13 22:46:30 +08:00
fc5b11c5e8 调整 notify 的强制退出
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 22:16:50 +08:00
0ec66988fa 更新投票存储位置
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 22:05:21 +08:00
e5c3081c22 Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 22:02:44 +08:00
14b356120a 成语接龙 2025-10-13 22:02:33 +08:00
a208302cb9 添加依赖
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 21:35:44 +08:00
01ffa451bb Merge pull request '投票功能和二维码生成(从 testpilot 移植)' (#26) from wzq02/konabot:master into master
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #26
2025-10-13 21:33:03 +08:00
2b6c2e84bd Merge branch 'master' into master 2025-10-13 21:31:40 +08:00
4f0a9af2dc 成语接龙
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 21:10:18 +08:00
4a4aa6b243 Add submodule: THUOCL 2025-10-13 21:10:05 +08:00
4c8625ae02 小完善(添加对应的 man) 2025-10-13 21:08:32 +08:00
c5f820a1f9 投票功能和二维码生成(从 testpilot 移植) 2025-10-13 20:49:56 +08:00
a3dd2dbbda 添加更加宽松的匹配规则
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 18:28:32 +08:00
8d4f74dafe 添加 Bilibili 视频解析的插件
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 18:12:39 +08:00
7c1bac64c9 修复在 log 文件中没有空格的问题
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 17:03:39 +08:00
e09fa13d0f 修复 Notify 的通知信息
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 16:55:50 +08:00
990a622cf6 添加一些日志用于调试 Notify 功能
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 11:48:22 +08:00
6144563d4d 添加 giftool 倒放选项 2025-10-13 11:34:06 +08:00
a6413c9809 添加报错和日志
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-12 21:52:35 +08:00
af566888ab fix2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-12 15:24:49 +08:00
108 changed files with 709561 additions and 1721 deletions

View File

@ -10,6 +10,10 @@ trigger:
- master
steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --init --recursive
- name: 构建 Docker 镜像
image: plugins/docker:latest
privileged: true
@ -34,6 +38,17 @@ steps:
path: /var/run/docker.sock
commands:
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python scripts/test_plugin_load.py
- name: 发送构建结果到 ntfy
image: parrazam/drone-ntfy
when:
status: [success, failure]
settings:
url: https://ntfy.service.jazzwhom.top
topic: drone_ci
tags:
- drone-ci
token:
from_secret: NTFY_TOKEN
volumes:
- name: docker-socket
@ -50,6 +65,10 @@ trigger:
- tag
steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --init --recursive
- name: 构建并推送 Release Docker 镜像
image: plugins/docker:latest
privileged: true
@ -66,6 +85,17 @@ steps:
volumes:
- name: docker-socket
path: /var/run/docker.sock
- name: 发送构建结果到 ntfy
image: parrazam/drone-ntfy
when:
status: [success, failure]
settings:
url: https://ntfy.service.jazzwhom.top
topic: drone_ci
tags:
- drone-ci
token:
from_secret: NTFY_TOKEN
volumes:
- name: docker-socket

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "assets/lexicon/THUOCL"]
path = assets/lexicon/THUOCL
url = https://github.com/thunlp/THUOCL.git

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"python.REPL.enableREPLSmartSend": false
}

View File

@ -1,33 +1,42 @@
# copied from https://www.martinrichards.me/post/python_poetry_docker/
FROM python:3.13-slim AS base
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
PATH="/app/.venv/bin:$PATH" \
PLAYWRIGHT_BROWSERS_PATH=/usr/lib/pw-browsers
# 安装所有都需要的底层依赖
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libfontconfig1 \
libgl1 \
libegl1 \
libglvnd0 \
mesa-vulkan-drivers \
libfontconfig1 libgl1 libegl1 libglvnd0 mesa-vulkan-drivers at-spi2-common fontconfig \
libasound2-data libavahi-client3 libavahi-common-data libavahi-common3 libdatrie1 \
libfontenc1 libfribidi0 libgraphite2-3 libharfbuzz0b libice6 libpixman-1-0 \
libsm6 libthai-data libthai0 libunwind8 libxaw7 libxcb-render0 libxfont2 libxi6 \
libxkbfile1 libxmu6 libxpm4 libxrender1 libxt6t64 x11-common x11-xkb-utils \
xfonts-encodings xfonts-utils xkb-data xserver-common libnspr4 libatk1.0-0t64 \
libatk-bridge2.0-0t64 libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 \
libxkbcommon0 libasound2t64 libnss3 fonts-noto-cjk fonts-noto-cjk-extra \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir playwright \
&& python -m playwright install chromium \
&& pip uninstall -y playwright
FROM base AS builder
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# 安装构建依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv
WORKDIR /app
RUN pip install poetry
COPY pyproject.toml poetry.lock ./
RUN python -m poetry install --no-root && rm -rf $POETRY_CACHE_DIR
RUN uv sync --no-install-project

View File

@ -1,4 +1,4 @@
# 此方 bot
# konabot
在 MTTU 内部使用的 bot 一只。
@ -63,12 +63,20 @@ code .
配置 `ENABLE_CONSOLE=false`
#### 配置并支持 LLM大语言模型
详见[LLM 配置文档](/docs/LLM.md)。
#### 配置 konabot-web 以支持更高级的图片渲染
详见[konabot-web 配置文档](/docs/konabot-web.md)
### 运行
使用命令行手动启动 Bot
```bash
poetry run watchfiles bot.main konabot
poetry run just watch
```
如果你不希望自动重载,只是想运行 Bot可以直接运行

BIN
assets/img/ac/ac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
assets/img/ac/broken_ac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/img/ac/frozen_ac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
assets/img/dog/haha_dog.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

BIN
assets/img/meme/doubao.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
assets/img/meme/kiosay.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
assets/img/meme/snaur_1_base.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/img/meme/snaur_1_top.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

BIN
assets/img/other/boom.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

1
assets/json/poll.json Normal file
View File

@ -0,0 +1 @@
{"poll": {"0": {"create": 1760357553, "expiry": 1760443953, "options": {"0": "此方bot", "1": "testpilot", "2": "小镜bot", "3": "可怜bot"}, "polldata": {}, "qq": "2975499623", "title": "我~是~谁~"}}}

1
assets/lexicon/THUOCL Submodule

Submodule assets/lexicon/THUOCL added at a30ce79d89

1
assets/lexicon/ci.json Normal file

File diff suppressed because one or more lines are too long

360393
assets/lexicon/common.txt Normal file

File diff suppressed because it is too large Load Diff

339847
assets/lexicon/idiom.json Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,76 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>空调炸炸排行榜</title>
</head>
<body>
<div class="box">
<div class="text">位居全球第 <span id="ranking" class="ranking">200</span></div>
<div class="text-2">您的群总共坏了 <span id="number" class="number">200</span> 台空调</div>
<img class="background" src="./assets/background.png" alt="空调炸炸排行榜">
</div>
</body>
<style>
.box {
position: relative;
width: 1024px;
}
.number {
font-size: 2em;
color: #ffdd00;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
font-weight: bold;
font-stretch: 50%;
max-width: 520px;
word-wrap: break-word;
line-height: 0.8em;
}
.background {
width: 1024px;
}
.text {
position: absolute;
top: 125px;
width: 100%;
font-size: 72px;
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
font-weight: bolder;
display: flex;
align-items: baseline;
justify-content: center;
}
.text-2 {
position: absolute;
top: 50px;
width: 100%;
font-size: 48px;
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
font-weight: bolder;
display: flex;
align-items: baseline;
justify-content: center;
}
.ranking {
font-size: 2em;
color: #ff0000;
-webkit-text-stroke: #ffffff 2px;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
font-weight: bold;
font-stretch: 50%;
}
</style>
<script>
// 从 URL 参数中获取 number 的值
const urlParams = new URLSearchParams(window.location.search);
const number = urlParams.get('number');
// 将 number 显示在页面上
document.getElementById('number').textContent = number;
// 从 URL 参数中获取 ranking 的值
const ranking = urlParams.get('ranking');
// 将 ranking 显示在页面上
document.getElementById('ranking').textContent = ranking;
</script>
</html>

14
bot.py
View File

@ -7,6 +7,10 @@ from nonebot.adapters.discord import Adapter as DiscordAdapter
from nonebot.adapters.minecraft import Adapter as MinecraftAdapter
from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter
from konabot.common.log import init_logger
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.path import LOG_PATH
dotenv.load_dotenv()
env = os.environ.get("ENVIRONMENT", "prod")
env_enable_console = os.environ.get("ENABLE_CONSOLE", "none")
@ -14,7 +18,16 @@ env_enable_qq = os.environ.get("ENABLE_QQ", "none")
env_enable_discord = os.environ.get("ENABLE_DISCORD", "none")
env_enable_minecraft = os.environ.get("ENABLE_MINECRAFT", "none")
def main():
if env.upper() == 'DEBUG' or env.upper() == 'DEV':
console_log_level = 'DEBUG'
else:
console_log_level = 'INFO'
init_logger(LOG_PATH, [
BotExceptionMessage,
], console_log_level=console_log_level)
nonebot.init()
driver = nonebot.get_driver()
@ -33,6 +46,7 @@ def main():
# nonebot.load_builtin_plugin("echo")
nonebot.load_plugins("konabot/plugins")
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
nonebot.run()

65
docs/LLM.md Normal file
View File

@ -0,0 +1,65 @@
# 大语言模型平台接入
为实现更多神秘小功能,此方 Bot 需要接入 AI。如果你需要参与开发或测试涉及 AI 的相关功能,麻烦请根据下面的文档继续操作。
## 配置项目接入 AI
AI 相关的配置文件在 `data/config/llm.json` 文件中。示例格式如下,这也将是到时候在云端的配置文件格式(给出的模型都会有):
```json
{
"llms": {
"Qwen2.5-7B-Instruct": {
"base_url": "https://api.siliconflow.cn/v1",
"api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"model_name": "Qwen/Qwen2.5-7B-Instruct"
},
"qwen3-max": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"model_name": "qwen3-max"
}
},
"default_llm": "Qwen2.5-7B-Instruct"
}
```
其中,形如 `qwen3-max` 的名称,是你在程序中调用 LLM 使用的键名。若不给出,则会默认使用配置文件中指定的默认模型。
```python
from konabot.common.llm import get_llm
llm = get_llm() # 获得的是 Qwen2.5-7B-Instruct 模型
llm = get_llm("qwen3-max") # 获得的是 qwen3-max 模型
message = await llm.chat([
{ "role": "system", "content": "你是一只猫娘" },
{ "role": "user", "content": "晚上好呀!" },
], timeout=None, max_tokens=16384)
# 获得了的是 openai.types.chat.ChatCompletionMessage 对象
print(f"AI 返回值:{message.content}") # 注意 content 可能为 None需要做空值检测
client = llm.get_openai_client() # 获得的是一个 OpenAI Client 对象,可以做更多操作
# 例如,调用 Embedding 模型来做知识库向量化等工作
```
## 本项目使用的模型清单
为了便利大家使用,我在这里给出该项目将会使用的模型清单,请根据你的开发需求注册并选择你最喜欢的模型。如果需要接入新的模型,或者使用到文档之外的模型,欢迎在这里给出!
### 硅基流动 Qwen/Qwen2.5-7B-Instruct
一个 7B 大小的 AI 模型。其性能不太能指望,但是它小,而且比较快,可以做一些轻量的操作。
该模型是免费的,但是也需要你注册[硅基流动](https://cloud.siliconflow.cn/me/models)账号,并生成 `api_key` 添加到配置文件中。
### 通义千问 qwen3-max
贵但是很先进的最新模型,其能力可以信赖。但是不要拿它做大量工作哦!
在[百炼大模型平台](https://bailian.console.aliyun.com/)注册账号并申请 `api_key`,新用户会赠送 1M tokens足够做测试了。
## 安全须知
请注意提防 AI 越狱等情况。

18
docs/konabot-web.md Normal file
View File

@ -0,0 +1,18 @@
# konabot-web 配置文档
本文档教你配置一个此方 Bot 的 Web 服务器。
## 安装并运行 konabot-web
按照 [konabot-web README](https://gitea.service.jazzwhom.top/mttu-developers/konabot-web) 安装并运行 konabot-web 实例。
## 指定 konabot-web 实例地址
如果你的 Web 服务器的端口不是 5173或者你有特殊的网络结构你需要手动设置 konabot-web。编辑 `.env` 文件:
```
MODULE_WEB_RENDER_WEBURL=http://web-server:port
MODULE_WEB_RENDER_INSTANCE=http://konabot-server:port
```
替换 web-server 为你的前端服务器地址konabot-server 为后端服务器地址port 为端口号。

4
justfile Normal file
View File

@ -0,0 +1,4 @@
watch:
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter

View File

@ -0,0 +1,36 @@
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Generic, TypeVar
from pydantic import BaseModel, ValidationError
T = TypeVar("T", bound=BaseModel)
class DataManager(Generic[T]):
def __init__(self, cls: type[T], fp: Path) -> None:
self.cls = cls
self.fp = fp
self._aio_lock = asyncio.Lock()
self._data: T | None = None
def load(self) -> T:
if not self.fp.exists():
return self.cls()
try:
return self.cls.model_validate_json(self.fp.read_text("utf-8"))
except ValidationError:
return self.cls()
def save(self, data: T):
self.fp.write_text(data.model_dump_json(), "utf-8")
@asynccontextmanager
async def get_data(self):
await self._aio_lock.acquire()
self._data = self.load()
yield self._data
self.save(self._data)
self._data = None
self._aio_lock.release()

View File

@ -0,0 +1,64 @@
from typing import Any
import openai
from loguru import logger
from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam
from pydantic import BaseModel, Field
from konabot.common.path import CONFIG_PATH
LLM_CONFIG_PATH = CONFIG_PATH / 'llm.json'
if not LLM_CONFIG_PATH.exists():
LLM_CONFIG_PATH.write_text("{}")
class LLMInfo(BaseModel):
base_url: str
api_key: str
model_name: str
def get_openai_client(self):
return openai.AsyncClient(
api_key=self.api_key,
base_url=self.base_url,
)
async def chat(
self,
messages: list[ChatCompletionMessageParam],
timeout: float | None = 30.0,
max_tokens: int | None = None,
**kwargs: Any,
) -> ChatCompletionMessage:
logger.info(f"调用 LLM: BASE_URL={self.base_url} MODEL_NAME={self.model_name}")
completion: ChatCompletion = await self.get_openai_client().chat.completions.create(
messages=messages,
model=self.model_name,
max_tokens=max_tokens,
timeout=timeout,
stream=False,
**kwargs,
)
choice = completion.choices[0]
logger.info(
f"调用 LLM 完成: BASE_URL={self.base_url} MODEL_NAME={self.model_name} REASON={choice.finish_reason}"
)
return choice.message
class LLMConfig(BaseModel):
llms: dict[str, LLMInfo] = Field(default_factory=dict)
default_llm: str = "Qwen2.5-7B-Instruct"
llm_config = LLMConfig.model_validate_json(LLM_CONFIG_PATH.read_text())
def get_llm(llm_model: str | None = None):
if llm_model is None:
llm_model = llm_config.default_llm
if llm_model not in llm_config.llms:
raise NotImplementedError("LLM 未配置,该功能无法使用")
return llm_config.llms[llm_model]

80
konabot/common/log.py Normal file
View File

@ -0,0 +1,80 @@
import sys
from pathlib import Path
from typing import TYPE_CHECKING, List, Type
from loguru import logger
if TYPE_CHECKING:
from loguru import Record
def file_exception_filter(
record: "Record",
ignored_exceptions: tuple[Type[Exception], ...]
) -> bool:
"""
一个自定义的 Loguru 过滤器函数。
如果日志记录包含异常信息,并且该异常的类型在 ignored_exceptions 中,则返回 False忽略
否则,返回 True允许记录
"""
exception_info = record.get("exception")
if exception_info:
exception_type = exception_info[0]
if exception_type and issubclass(exception_type, ignored_exceptions):
return False
return True
def init_logger(
log_dir: Path,
ignored_exceptions: List[Type[Exception]],
console_log_level: str = "INFO",
) -> None:
"""
配置全局 Loguru Logger。
Args:
log_dir (Path): 存放日志文件的文件夹路径,会自动创建。
ignored_exceptions (List[Type[Exception]]): 在 WARNING 级别文件日志中需要忽略的异常类型列表。
"""
ignored_exceptions_tuple = tuple(ignored_exceptions)
logger.remove()
log_dir.mkdir(parents=True, exist_ok=True)
logger.add(
sys.stderr,
level=console_log_level,
colorize=True,
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
)
info_log_path = log_dir / "log.log"
logger.add(
str(info_log_path),
level="INFO",
rotation="10 MB",
retention="7 days",
enqueue=True,
backtrace=False,
diagnose=False,
)
warning_error_log_path = log_dir / "error.log"
logger.add(
str(warning_error_log_path),
level="WARNING",
rotation="10 MB",
compression="zip",
enqueue=True,
filter=lambda record: file_exception_filter(record, ignored_exceptions_tuple),
backtrace=True,
diagnose=True,
)
logger.info("Loguru Logger 初始化完成!")
logger.info(f"控制台日志级别: {console_log_level}")

302
konabot/common/longtask.py Normal file
View File

@ -0,0 +1,302 @@
from __future__ import annotations
from contextlib import asynccontextmanager
import datetime
import json
from typing import Annotated, Any, Callable, Coroutine, cast
import asyncio as asynkio
import uuid
from loguru import logger
import nonebot
from nonebot.params import Depends
from nonebot.adapters import Event as BaseEvent
from nonebot.adapters import Bot as BaseBot
from nonebot.adapters.onebot.v11 import Bot as OBBot
from nonebot.adapters.onebot.v11 import GroupMessageEvent as OBGroupMessageEvent
from nonebot.adapters.onebot.v11 import PrivateMessageEvent as OBPrivateMessageEvent
from nonebot.adapters.console import Bot as ConsoleBot
from nonebot.adapters.console import MessageEvent as ConsoleMessageEvent
from nonebot.adapters.discord import MessageEvent as DCMessageEvent
from nonebot.adapters.discord import Bot as DCBot
from nonebot_plugin_alconna import UniMessage
from pydantic import BaseModel, ValidationError
from .path import DATA_PATH
LONGTASK_DATA_DIR = DATA_PATH / "longtasks.json"
QQ_PRIVATE_CHAT_CHANNEL_PREFIX = "_CHANNEL_QQ_PRIVATE_"
class LongTaskTarget(BaseModel):
"""
用于定义长期任务的目标沟通对象,一般通过 DepLongTaskTarget 依赖注入获取:
```python
@cmd.handle()
async def _(target: DepLongTaskTarget):
...
```
"""
platform: str
"沟通对象所在的平台"
self_id: str
"进行沟通的对象自己的 ID"
channel_id: str
"沟通对象所在的群或者 Discord Channel。若为空则代表是私聊"
target_id: str
"沟通对象的 ID"
@property
def is_private_chat(self):
return self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX)
async def send_message(self, msg: UniMessage | str, at: bool = True) -> bool:
try:
bot = nonebot.get_bot(self.self_id)
except KeyError:
logger.warning(f"试图访问了不存在的 Bot。ID={self.self_id}")
return False
if isinstance(msg, str):
msg = UniMessage.text(msg)
if self.platform == "qq":
if not isinstance(bot, OBBot):
logger.warning(
f"编号对应的平台并非期望的平台 ID={self.self_id} PLATFORM={
self.platform
} BOT_CLASS={bot.__class__.__name__}"
)
return False
if self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX) or not self.channel_id.strip():
# 私聊模式
await bot.send_private_msg(
user_id=int(self.target_id),
message=cast(Any, await msg.export(bot)),
auto_escape=False,
)
return True
else:
if at:
msg = UniMessage().at(self.target_id).text(" ") + msg
await bot.send_group_msg(
group_id=int(self.channel_id),
message=cast(Any, await msg.export(bot)),
auto_escape=False,
)
return True
if self.platform == "console":
if not isinstance(bot, ConsoleBot):
logger.warning(
f"编号对应的平台并非期望的平台 ID={self.self_id} PLATFORM={
self.platform
} BOT_CLASS={bot.__class__.__name__}"
)
return False
await bot.send_message(self.channel_id, cast(Any, await msg.export()))
return True
if self.platform == "discord":
if not isinstance(bot, DCBot):
logger.warning(
f"编号对应的平台并非期望的平台 ID={self.self_id} PLATFORM={
self.platform
} BOT_CLASS={bot.__class__.__name__}"
)
return False
await bot.send_to(
channel_id=int(self.channel_id),
message=cast(
Any, await (UniMessage().at(self.target_id) + msg).export()
),
tts=False,
)
return True
logger.warning(f"没有一个平台是期望的平台 PLATFORM={self.platform}")
return False
class LongTask(BaseModel):
uuid: str
data_json: str
target: LongTaskTarget
callback: str
deadline: datetime.datetime
_aio_task: asynkio.Task | None = None
async def run(self):
now = datetime.datetime.now()
if self.deadline < now:
await self._run_task()
return
await asynkio.sleep((self.deadline - now).total_seconds())
async with longtask_data() as data:
if self.uuid not in data.to_handle[self.callback]:
return
await self._run_task()
async def _run_task(self):
hdl = registered_long_task_handler.get(self.callback, None)
if hdl is None:
logger.warning(
f"Callback {self.callback} 未曾被注册,但是被期待调用,已忽略"
)
async with longtask_data() as datafile:
del datafile.to_handle[self.callback][self.uuid]
datafile.unhandled.setdefault(self.callback, []).append(self)
return
success = False
try:
await hdl(self)
success = True
except Exception as e:
logger.exception(e)
async with longtask_data() as datafile:
del datafile.to_handle[self.callback][self.uuid]
if not success:
datafile.unhandled.setdefault(self.callback, []).append(self)
logger.info(
f"LongTask 执行失败 UUID={self.uuid} callback={self.callback}"
)
else:
logger.info(
f"LongTask 工作完成 UUID={self.uuid} callback={self.callback}"
)
def clean(self):
self._aio_task = None
@property
def data(self):
return json.loads(self.data_json)
async def start(self):
self._aio_task = asynkio.Task(self.run())
self._aio_task.add_done_callback(lambda _: self.clean())
class LongTaskModuleData(BaseModel):
to_handle: dict[str, dict[str, LongTask]]
unhandled: dict[str, list[LongTask]]
async def get_long_task_target(event: BaseEvent, bot: BaseBot) -> LongTaskTarget | None:
if isinstance(event, OBGroupMessageEvent):
return LongTaskTarget(
platform="qq",
self_id=str(event.self_id),
channel_id=str(event.group_id),
target_id=str(event.user_id),
)
if isinstance(event, OBPrivateMessageEvent):
return LongTaskTarget(
platform="qq",
self_id=str(event.self_id),
channel_id=f"{QQ_PRIVATE_CHAT_CHANNEL_PREFIX}{event.self_id}",
target_id=str(event.user_id),
)
if isinstance(event, ConsoleMessageEvent):
return LongTaskTarget(
platform="console",
self_id=str(event.self_id),
channel_id=str(event.channel.id),
target_id=str(event.user.id),
)
if isinstance(event, DCMessageEvent):
self_id = ""
if isinstance(bot, DCBot):
self_id = str(bot.self_id)
return LongTaskTarget(
platform="discord",
self_id=self_id,
channel_id=str(event.channel_id),
target_id=str(event.user_id),
)
_TaskHandler = Callable[[LongTask], Coroutine[Any, Any, Any]]
registered_long_task_handler: dict[str, _TaskHandler] = {}
longtask_lock = asynkio.Lock()
def handle_long_task(callback_id: str):
def _decorator(func: _TaskHandler):
assert callback_id not in registered_long_task_handler, (
"有长任务的 ID 出现冲突,请换个名字!"
)
registered_long_task_handler[callback_id] = func
return func
return _decorator
def _load_longtask_data() -> LongTaskModuleData:
try:
txt = LONGTASK_DATA_DIR.read_text("utf-8")
return LongTaskModuleData.model_validate_json(txt)
except (FileNotFoundError, ValidationError) as e:
logger.info(f"取得 LongTask 数据时出现问题:{e}")
return LongTaskModuleData(
to_handle={},
unhandled={},
)
def _save_longtask_data(data: LongTaskModuleData):
LONGTASK_DATA_DIR.write_text(data.model_dump_json(), "utf-8")
@asynccontextmanager
async def longtask_data():
async with longtask_lock:
data = _load_longtask_data()
yield data
_save_longtask_data(data)
async def create_longtask(
handler: str,
data: dict[str, Any],
target: LongTaskTarget,
deadline: datetime.datetime,
):
task = LongTask(
uuid=str(uuid.uuid4()),
data_json=json.dumps(data),
target=target,
callback=handler,
deadline=deadline,
)
logger.info(f"创建了新的 LongTask UUID={task.uuid} CALLBACK={task.callback}")
await task.start()
async with longtask_data() as d:
d.to_handle.setdefault(handler, {})[task.uuid] = task
return task
async def init_longtask():
counter = 0
req: set[str] = set()
async with longtask_data() as data:
for v in data.to_handle.values():
for t in v.values():
await t.start()
counter += 1
req.add(t.callback)
logger.info(f"LongTask 启动了任务 数量={counter} 期望的门类=[{','.join(req)}]")
DepLongTaskTarget = Annotated[LongTaskTarget, Depends(get_long_task_target)]

View File

@ -8,22 +8,38 @@ import nonebot
from nonebot.matcher import Matcher
from nonebot.adapters import Bot, Event, Message
from nonebot.adapters.discord import Bot as DiscordBot
from nonebot.adapters.discord import MessageEvent as DiscordMessageEvent
from nonebot.adapters.discord.config import Config as DiscordConfig
from nonebot.adapters.onebot.v11 import Bot as OnebotV11Bot
from nonebot.adapters.onebot.v11 import Message as OnebotV11Message
from nonebot.adapters.onebot.v11 import MessageEvent as OnebotV11MessageEvent
import nonebot.params
from nonebot_plugin_alconna import Image, RefNode, Reply, UniMessage
from PIL import UnidentifiedImageError
from pydantic import BaseModel
from returns.result import Failure, Result, Success
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.path import ASSETS_PATH
async def download_image_bytes(url: str) -> Result[bytes, str]:
discordConfig = nonebot.get_plugin_config(DiscordConfig)
class ExtractImageConfig(BaseModel):
module_extract_image_no_download: bool = False
"要不要算了,不下载了,直接爆炸算了,适用于一些比较奇怪的网络环境,无法从协议端下载文件"
module_config = nonebot.get_plugin_config(ExtractImageConfig)
async def download_image_bytes(url: str, proxy: str | None = None) -> Result[bytes, str]:
# if "/matcha/cache/" in url:
# url = url.replace('127.0.0.1', '10.126.126.101')
if module_config.module_extract_image_no_download:
return Success((ASSETS_PATH / "img" / "other" / "boom.jpg").read_bytes())
logger.debug(f"开始从 {url} 下载图片")
async with httpx.AsyncClient() as c:
async with httpx.AsyncClient(proxy=proxy) as c:
try:
response = await c.get(url)
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
@ -123,6 +139,14 @@ async def extract_image_from_message(
logger.debug('获取图片的路径 Fallback 到 QQ 模块')
return await extract_image_from_qq_message(msg, evt, bot, allow_reply)
if isinstance(evt, DiscordMessageEvent):
logger.debug('获取图片的路径方式走 Discord')
for a in evt.attachments:
if "image/" not in a.content_type:
continue
url = a.proxy_url
return (await download_image_bytes(url, discordConfig.discord_proxy)).bind(bytes_to_pil)
for seg in UniMessage.of(msg, bot):
logger.info(seg)
if isinstance(seg, Image):

View File

@ -0,0 +1,33 @@
from typing import Any, cast
import nonebot
from nonebot.adapters.onebot.v11 import Bot as OBBot
from nonebot_plugin_alconna import UniMessage
async def qq_broadcast(groups: list[str], msg: UniMessage[Any] | str):
if isinstance(msg, str):
msg = UniMessage.text(msg)
bots: dict[str, OBBot] = {}
# group_id -> bot_id
availabilities: dict[str, str] = {}
for bot_id, bot in nonebot.get_bots().items():
if not isinstance(bot, OBBot):
continue
bots[bot_id] = bot
gl = await bot.get_group_list()
for g in gl:
gid = str(g.get("group_id", -1))
if gid in groups:
availabilities[gid] = bot_id
for group in groups:
if group in availabilities:
bot = bots[availabilities[group]]
await bot.send_group_msg(
group_id=int(group),
message=cast(Any, await msg.export(bot)),
auto_escape=False,
)

View File

@ -0,0 +1,34 @@
from typing import cast
from nonebot import get_bot, get_plugin_config, logger
from nonebot.adapters import Event as BaseEvent
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot.adapters.onebot.v11.bot import Bot as OnebotBot
from nonebot.rule import Rule
from pydantic import BaseModel
class WZQConflictConfig(BaseModel):
wzq_bot_qq: int = 0
config = get_plugin_config(WZQConflictConfig)
async def no_wzqbot(evt: BaseEvent):
if config.wzq_bot_qq <= 0:
return True
if not isinstance(evt, GroupMessageEvent):
return True
gid = evt.group_id
sid = evt.self_id
bot = cast(OnebotBot, get_bot(str(sid)))
members = await bot.get_group_member_list(group_id=gid)
members = set((m.get("user_id", -1) for m in members))
if config.wzq_bot_qq in members:
return False
return True
no_wzqbot_rule = Rule(no_wzqbot)

76
konabot/common/pager.py Normal file
View File

@ -0,0 +1,76 @@
from dataclasses import dataclass
from math import ceil
from typing import Any, Callable
from nonebot_plugin_alconna import UniMessage
@dataclass
class PagerQuery:
page_index: int
page_size: int
def apply[T](self, ls: list[T]) -> "PagerResult[T]":
if self.page_size <= 0:
return PagerResult(
success=False,
message="每页元素数量应该大于 0",
data=[],
page_count=-1,
query=self,
)
page_count = ceil(len(ls) / self.page_size)
if self.page_index <= 0 or self.page_size <= 0:
return PagerResult(
success=False,
message="页数必须大于 0",
data=[],
page_count=page_count,
query=self,
)
data = ls[(self.page_index - 1) * self.page_size: self.page_index * self.page_size]
if len(data) > 0:
return PagerResult(
success=True,
message="",
data=data,
page_count=page_count,
query=self,
)
return PagerResult(
success=False,
message="指定的页数超过最大页数",
data=data,
page_count=page_count,
query=self,
)
@dataclass
class PagerResult[T]:
data: list[T]
success: bool
message: str
page_count: int
query: PagerQuery
def to_unimessage(
self,
formatter: Callable[[T], str | UniMessage[Any]] = str,
title: str = '查询结果',
list_indicator: str = '- ',
) -> UniMessage[Any]:
msg = UniMessage.text(f'===== {title} =====\n\n')
if not self.success:
msg = msg.text(f'⚠️ {self.message}\n')
else:
for obj in self.data:
msg = msg.text(list_indicator)
msg += formatter(obj)
msg += '\n'
msg = msg.text(f'\n===== 第 {self.query.page_index} 页,共 {self.page_count} 页 =====')
return msg

View File

@ -4,9 +4,21 @@ ASSETS_PATH = Path(__file__).resolve().parent.parent.parent / "assets"
FONTS_PATH = ASSETS_PATH / "fonts"
SRC_PATH = Path(__file__).resolve().parent.parent
DATA_PATH = SRC_PATH.parent / "data"
LOG_PATH = DATA_PATH / "logs"
CONFIG_PATH = DATA_PATH / "config"
DOCS_PATH = SRC_PATH / "docs"
DOCS_PATH_MAN1 = DOCS_PATH / "user"
DOCS_PATH_MAN3 = DOCS_PATH / "lib"
DOCS_PATH_MAN7 = DOCS_PATH / "concepts"
DOCS_PATH_MAN8 = DOCS_PATH / "sys"
if not DATA_PATH.exists():
DATA_PATH.mkdir()
if not LOG_PATH.exists():
LOG_PATH.mkdir()
CONFIG_PATH.mkdir(exist_ok=True)

View File

@ -0,0 +1,653 @@
import re
import datetime
from typing import Tuple, Optional, Dict, Any
from .err import MultipleSpecificationException, TokenUnhandledException
class Parser:
def __init__(self, now: Optional[datetime.datetime] = None):
self.now = now or datetime.datetime.now()
def digest_chinese_number(self, text: str) -> Tuple[str, int]:
if not text:
return text, 0
# Handle "两" at start
if text.startswith(""):
next_char = text[1] if len(text) > 1 else ''
if not next_char or next_char in "十百千万亿":
return text[1:], 2
s = "零一二三四五六七八九"
digits = {c: i for i, c in enumerate(s)}
i = 0
while i < len(text) and text[i] in s + "十百千万亿":
i += 1
if i == 0:
return text, 0
num_str = text[:i]
rest = text[i:]
def parse(s):
if not s:
return 0
if s == "":
return 0
if "亿" in s:
a, b = s.split("亿", 1)
return parse(a) * 100000000 + parse(b)
if "" in s:
a, b = s.split("", 1)
return parse(a) * 10000 + parse(b)
n = 0
t = 0
for c in s:
if c == "":
continue
if c in digits:
t = digits[c]
elif c == "":
if t == 0:
t = 1
n += t * 10
t = 0
elif c == "":
if t == 0:
t = 1
n += t * 100
t = 0
elif c == "":
if t == 0:
t = 1
n += t * 1000
t = 0
n += t
return n
return rest, parse(num_str)
def parse(self, text: str) -> datetime.datetime:
text = text.strip()
if not text:
raise TokenUnhandledException("Empty input")
ctx = {
"date": None,
"time": None,
"relative_delta": None,
"am_pm": None,
"period_word": None,
"has_time": False,
"has_date": False,
"ambiguous_hour": False,
"is_24hour": False,
"has_relative_date": False,
}
rest = self._parse_all(text, ctx)
if rest.strip():
raise TokenUnhandledException(f"Unparsed tokens: {rest.strip()}")
return self._apply_context(ctx)
def _parse_all(self, text: str, ctx: Dict[str, Any]) -> str:
rest = text.lstrip()
while True:
for parser in [
self._parse_absolute_date,
self._parse_relative_date,
self._parse_relative_time,
self._parse_period,
self._parse_time,
]:
new_rest = parser(rest, ctx)
if new_rest != rest:
rest = new_rest.lstrip()
break
else:
break
return rest
def _add_delta(self, ctx, delta):
if ctx["relative_delta"] is None:
ctx["relative_delta"] = delta
else:
ctx["relative_delta"] += delta
def _parse_absolute_date(self, text: str, ctx: Dict[str, Any]) -> str:
text = text.lstrip()
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2}):(\d{2})", text)
if m:
y, mth, d, h, minute = map(int, m.groups())
ctx["date"] = datetime.date(y, mth, d)
ctx["time"] = datetime.time(h, minute)
ctx["has_date"] = True
ctx["has_time"] = True
ctx["is_24hour"] = True
return text[m.end():]
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})", text)
if m:
y, mth, d = map(int, m.groups())
ctx["date"] = datetime.date(y, mth, d)
ctx["has_date"] = True
return text[m.end():]
m = re.match(r"^(\d{4})/(\d{1,2})/(\d{1,2})", text)
if m:
y, mth, d = map(int, m.groups())
ctx["date"] = datetime.date(y, mth, d)
ctx["has_date"] = True
return text[m.end():]
m = re.match(r"^(\d{4})年(\d{1,2})月(\d{1,2})[日号]", text)
if m:
y, mth, d = map(int, m.groups())
ctx["date"] = datetime.date(y, mth, d)
ctx["has_date"] = True
return text[m.end():]
m = re.match(r"^(\d{1,2})月(\d{1,2})[日号]", text)
if m:
mth, d = map(int, m.groups())
ctx["date"] = datetime.date(self.now.year, mth, d)
ctx["has_date"] = True
return text[m.end():]
m = re.match(r"^(.{1,3})月(.{1,3})[日号]", text)
if m:
m_str, d_str = m.groups()
_, mth = self.digest_chinese_number(m_str)
_, d = self.digest_chinese_number(d_str)
if mth == 0:
mth = 1
if d == 0:
d = 1
ctx["date"] = datetime.date(self.now.year, mth, d)
ctx["has_date"] = True
return text[m.end():]
return text
def _parse_relative_date(self, text: str, ctx: Dict[str, Any]) -> str:
text = text.lstrip()
# Handle "今天", "今晚", "今早", etc.
today_variants = [
("今晚上", "PM"),
("今晚", "PM"),
("今早", "AM"),
("今天早上", "AM"),
("今天早晨", "AM"),
("今天上午", "AM"),
("今天下午", "PM"),
("今天晚上", "PM"),
("今天", None),
]
for variant, period in today_variants:
if text.startswith(variant):
self._add_delta(ctx, datetime.timedelta(days=0))
ctx["has_relative_date"] = True
rest = text[len(variant):]
if period is not None and ctx["am_pm"] is None:
ctx["am_pm"] = period
ctx["period_word"] = variant
return rest
mapping = {
"明天": 1,
"后天": 2,
"大后天": 3,
"昨天": -1,
"前天": -2,
"大前天": -3,
}
for word, days in mapping.items():
if text.startswith(word):
self._add_delta(ctx, datetime.timedelta(days=days))
ctx["has_relative_date"] = True
return text[len(word):]
m = re.match(r"^(\d+|[零一二三四五六七八九十两]+)天(后|前|以后|之后)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
n = int(num_str)
else:
_, n = self.digest_chinese_number(num_str)
days = n if direction in ("", "以后", "之后") else -n
self._add_delta(ctx, datetime.timedelta(days=days))
ctx["has_relative_date"] = True
return text[m.end():]
m = re.match(r"^(本|上|下)周([一二三四五六日])", text)
if m:
scope, day = m.groups()
weekday_map = {"": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6}
target = weekday_map[day]
current = self.now.weekday()
if scope == "":
delta = target - current
elif scope == "":
delta = target - current - 7
else:
delta = target - current + 7
self._add_delta(ctx, datetime.timedelta(days=delta))
ctx["has_relative_date"] = True
return text[m.end():]
return text
def _parse_period(self, text: str, ctx: Dict[str, Any]) -> str:
text = text.lstrip()
period_mapping = {
"上午": "AM",
"早晨": "AM",
"早上": "AM",
"": "AM",
"中午": "PM",
"下午": "PM",
"晚上": "PM",
"": "PM",
"凌晨": "AM",
}
for word, tag in period_mapping.items():
if text.startswith(word):
if ctx["am_pm"] is not None:
raise MultipleSpecificationException("Multiple periods")
ctx["am_pm"] = tag
ctx["period_word"] = word
return text[len(word):]
return text
def _parse_time(self, text: str, ctx: Dict[str, Any]) -> str:
if ctx["has_time"]:
return text
text = text.lstrip()
# 1. H:MM pattern
m = re.match(r"^(\d{1,2}):(\d{2})", text)
if m:
h, minute = int(m.group(1)), int(m.group(2))
if 0 <= h <= 23 and 0 <= minute <= 59:
ctx["time"] = datetime.time(h, minute)
ctx["has_time"] = True
ctx["ambiguous_hour"] = 1 <= h <= 12
ctx["is_24hour"] = h > 12 or h == 0
return text[m.end():]
# 2. Parse hour part
hour = None
rest_after_hour = text
is_24hour_format = False
# Try Chinese number + 点/时
temp_rest, num = self.digest_chinese_number(text)
if num >= 0:
temp_rest_stripped = temp_rest.lstrip()
if temp_rest_stripped.startswith(""):
hour = num
is_24hour_format = False
rest_after_hour = temp_rest_stripped[1:]
elif temp_rest_stripped.startswith(""):
hour = num
is_24hour_format = True
rest_after_hour = temp_rest_stripped[1:]
if hour is None:
m = re.match(r"^(\d{1,2})\s*([点时])", text)
if m:
hour = int(m.group(1))
is_24hour_format = m.group(2) == ""
rest_after_hour = text[m.end():]
if hour is None:
if ctx.get("am_pm") is not None:
temp_rest, num = self.digest_chinese_number(text)
if 0 <= num <= 23:
hour = num
is_24hour_format = False
rest_after_hour = temp_rest.lstrip()
else:
m = re.match(r"^(\d{1,2})", text)
if m:
h_val = int(m.group(1))
if 0 <= h_val <= 23:
hour = h_val
is_24hour_format = False
rest_after_hour = text[m.end():].lstrip()
if hour is None:
return text
if not (0 <= hour <= 23):
return text
# Parse minutes
rest = rest_after_hour.lstrip()
minute = 0
minute_spec_count = 0
if rest.startswith(""):
rest = rest[1:].lstrip()
has_zheng = False
if rest.startswith(""):
has_zheng = True
rest = rest[1:].lstrip()
if rest.startswith(""):
minute = 30
minute_spec_count += 1
rest = rest[1:].lstrip()
if rest.startswith(""):
rest = rest[1:].lstrip()
if rest.startswith(""):
rest = rest[1:].lstrip()
if rest.startswith("一刻"):
minute = 15
minute_spec_count += 1
rest = rest[2:].lstrip()
if rest.startswith(""):
rest = rest[1:].lstrip()
if rest.startswith("过一刻"):
minute = 15
minute_spec_count += 1
rest = rest[3:].lstrip()
if rest.startswith(""):
rest = rest[1:].lstrip()
m = re.match(r"^(\d+|[零一二三四五六七八九十]+)分", rest)
if m:
minute_spec_count += 1
m_str = m.group(1)
if m_str.isdigit():
minute = int(m_str)
else:
_, minute = self.digest_chinese_number(m_str)
rest = rest[m.end():].lstrip()
if minute_spec_count == 0:
temp_rest, num = self.digest_chinese_number(rest)
if num > 0 and num <= 59:
minute = num
minute_spec_count += 1
rest = temp_rest.lstrip()
else:
m = re.match(r"^(\d{1,2})", rest)
if m:
m_val = int(m.group(1))
if 0 <= m_val <= 59:
minute = m_val
minute_spec_count += 1
rest = rest[m.end():].lstrip()
if has_zheng and minute_spec_count == 0:
minute_spec_count = 1
if minute_spec_count > 1:
raise MultipleSpecificationException("Multiple minute specifications")
if not (0 <= minute <= 59):
return text
# Hours 13-23 are always 24-hour, even with "点"
if hour >= 13:
is_24hour_format = True
ctx["time"] = datetime.time(hour, minute)
ctx["has_time"] = True
ctx["ambiguous_hour"] = 1 <= hour <= 12 and not is_24hour_format
ctx["is_24hour"] = is_24hour_format
return rest
def _parse_relative_time(self, text: str, ctx: Dict[str, Any]) -> str:
text = text.lstrip()
# 半小时
m = re.match(r"^(半)(?:个)?小时?(后|前|以后|之后)", text)
if m:
direction = m.group(2)
hours = 0.5
delta = datetime.timedelta(
hours=hours if direction in ("", "以后", "之后") else -hours
)
self._add_delta(ctx, delta)
return text[m.end():]
# X个半
m = re.match(r"^([0-9零一二三四五六七八九十两]+)个半(?:小时?)?(后|前|以后|之后)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
base_hours = int(num_str)
else:
_, base_hours = self.digest_chinese_number(num_str)
if base_hours == 0 and num_str != "":
return text
if base_hours <= 0:
return text
hours = base_hours + 0.5
delta = datetime.timedelta(
hours=hours if direction in ("", "以后", "之后") else -hours
)
self._add_delta(ctx, delta)
return text[m.end():]
# 一个半
m = re.match(r"^(一个半)小时?(后|前|以后|之后)", text)
if m:
direction = m.group(2)
hours = 1.5
delta = datetime.timedelta(
hours=hours if direction in ("", "以后", "之后") else -hours
)
self._add_delta(ctx, delta)
return text[m.end():]
# X小时
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时?(后|前|以后|之后)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
hours = int(num_str)
else:
_, hours = self.digest_chinese_number(num_str)
if hours == 0 and num_str != "":
return text
if hours <= 0:
return text
delta = datetime.timedelta(
hours=hours if direction in ("", "以后", "之后") else -hours
)
self._add_delta(ctx, delta)
return text[m.end():]
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时(后|前)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
hours = int(num_str)
else:
_, hours = self.digest_chinese_number(num_str)
if hours == 0 and num_str != "":
return text
if hours <= 0:
return text
delta = datetime.timedelta(
hours=hours if direction == "" else -hours
)
self._add_delta(ctx, delta)
return text[m.end():]
# X分钟
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前|以后|之后)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
minutes = int(num_str)
else:
_, minutes = self.digest_chinese_number(num_str)
if minutes == 0 and num_str != "":
return text
if minutes <= 0:
return text
delta = datetime.timedelta(
minutes=minutes if direction in ("", "以后", "之后") else -minutes
)
self._add_delta(ctx, delta)
return text[m.end():]
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前|以后|之后)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
minutes = int(num_str)
else:
_, minutes = self.digest_chinese_number(num_str)
if minutes == 0 and num_str != "":
return text
if minutes <= 0:
return text
delta = datetime.timedelta(
minutes=minutes if direction in ("", "以后", "之后") else -minutes
)
self._add_delta(ctx, delta)
return text[m.end():]
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
minutes = int(num_str)
else:
_, minutes = self.digest_chinese_number(num_str)
if minutes == 0 and num_str != "":
return text
if minutes <= 0:
return text
delta = datetime.timedelta(
minutes=minutes if direction == "" else -minutes
)
self._add_delta(ctx, delta)
return text[m.end():]
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
minutes = int(num_str)
else:
_, minutes = self.digest_chinese_number(num_str)
if minutes == 0 and num_str != "":
return text
if minutes <= 0:
return text
delta = datetime.timedelta(
minutes=minutes if direction == "" else -minutes
)
self._add_delta(ctx, delta)
return text[m.end():]
# === 秒级支持 ===
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前|以后|之后)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
seconds = int(num_str)
else:
_, seconds = self.digest_chinese_number(num_str)
if seconds == 0 and num_str != "":
return text
if seconds <= 0:
return text
delta = datetime.timedelta(
seconds=seconds if direction in ("", "以后", "之后") else -seconds
)
self._add_delta(ctx, delta)
return text[m.end():]
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前)", text)
if m:
num_str, direction = m.groups()
if num_str.isdigit():
seconds = int(num_str)
else:
_, seconds = self.digest_chinese_number(num_str)
if seconds == 0 and num_str != "":
return text
if seconds <= 0:
return text
delta = datetime.timedelta(
seconds=seconds if direction == "" else -seconds
)
self._add_delta(ctx, delta)
return text[m.end():]
return text
def _apply_context(self, ctx: Dict[str, Any]) -> datetime.datetime:
result = self.now
has_date = ctx["has_date"]
has_time = ctx["has_time"]
has_delta = ctx["relative_delta"] is not None
has_relative_date = ctx["has_relative_date"]
if has_delta:
result = result + ctx["relative_delta"]
if has_date:
result = result.replace(
year=ctx["date"].year,
month=ctx["date"].month,
day=ctx["date"].day,
)
if has_time:
h = ctx["time"].hour
m = ctx["time"].minute
if ctx["is_24hour"]:
# "10 时" → 10:00, no conversion
pass
elif ctx["am_pm"] == "AM":
if h == 12:
h = 0
elif ctx["am_pm"] == "PM":
if h == 12:
if ctx.get("period_word") in ("晚上", ""):
h = 0
result += datetime.timedelta(days=1)
else:
h = 12
elif 1 <= h <= 11:
h += 12
else:
# No period and not 24-hour (i.e., "点" format)
if ctx["has_relative_date"]:
# "明天五点" → 05:00 AM
if h == 12:
h = 0
# keep h as AM hour (1-11 unchanged)
else:
# Infer from current time
am_hour = 0 if h == 12 else h
candidate_am = result.replace(hour=am_hour, minute=m, second=0, microsecond=0)
if candidate_am < self.now:
# AM time is in the past, so use PM
if h == 12:
h = 12
else:
h += 12
# else: keep as AM (h unchanged)
if h > 23:
h = h % 24
result = result.replace(hour=h, minute=m, second=0, microsecond=0)
else:
if has_date or (has_relative_date and not has_time):
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
return result
def parse(text: str) -> datetime.datetime:
return Parser().parse(text)

View File

@ -0,0 +1,11 @@
class PTimeParseException(Exception):
...
class TokenUnhandledException(PTimeParseException):
...
class MultipleSpecificationException(PTimeParseException):
...
class OutOfRangeSpecificationException(PTimeParseException):
...

View File

@ -0,0 +1,54 @@
import re
import nonebot
from nonebot.adapters.onebot.v11 import Bot as OBBot
class UsernameManager:
grouped_data: dict[int, dict[int, str]]
individual_data: dict[int, str]
def __init__(self) -> None:
self.grouped_data = {}
self.individual_data = {}
async def update(self):
for bot in nonebot.get_bots().values():
if isinstance(bot, OBBot):
for user in await bot.get_friend_list():
uid = user["user_id"]
nickname = user["nickname"]
self.individual_data[uid] = nickname
for group in await bot.get_group_list():
gid = group["group_id"]
for member in await bot.get_group_member_list(group_id=gid):
uid = member["user_id"]
card = member.get("card", "")
nickname = member.get("nickname", "")
if card:
self.grouped_data.setdefault(gid, {})[uid] = card
if nickname:
self.individual_data[uid] = nickname
def get(self, qqid: int, groupid: int | None = None) -> str:
if groupid is not None and groupid in self.grouped_data:
n = self.grouped_data[groupid].get(qqid)
if n is not None:
return n
if qqid in self.individual_data:
return self.individual_data[qqid]
return str(qqid)
manager = UsernameManager()
def get_username(qqid: int | str, group: int | str | None = None):
if isinstance(group, str):
group = None if not re.match(r"^\d+$", group) else int(group)
if isinstance(qqid, str):
if re.match(r"^\d+$", qqid):
qqid = int(qqid)
else:
return qqid
return manager.get(qqid, group)

View File

@ -0,0 +1,17 @@
import asyncio
import functools
from typing import Awaitable, Callable, ParamSpec, TypeVar
TA = ParamSpec("TA")
T = TypeVar("T")
def make_async(func: Callable[TA, T]) -> Callable[TA, Awaitable[T]]:
@functools.wraps(func, assigned=("__module__", "__name__", "__qualname__", "__doc__", "__annotations__"))
async def wrapper(*args: TA.args, **kwargs: TA.kwargs):
return await asyncio.to_thread(func, *args, **kwargs)
return wrapper

View File

@ -0,0 +1,9 @@
from .config import web_render_config
from .core import WebRenderer as WebRenderer
from .core import WebRendererInstance as WebRendererInstance
def konaweb(sub_url: str):
sub_url = '/' + sub_url.removeprefix('/')
return web_render_config.module_web_render_weburl.removesuffix('/') + sub_url

View File

@ -0,0 +1,20 @@
import nonebot
from pydantic import BaseModel
class Config(BaseModel):
module_web_render_weburl: str = "localhost:5173"
module_web_render_instance: str = ""
module_web_render_playwright_ws: str = ""
def get_instance_baseurl(self):
if self.module_web_render_instance:
return self.module_web_render_instance.removesuffix('/')
config = nonebot.get_driver().config
ip = str(config.host)
if ip == "0.0.0.0":
ip = "127.0.0.1"
port = config.port
return f'http://{ip}:{port}'
web_render_config = nonebot.get_plugin_config(Config)

View File

@ -0,0 +1,403 @@
from abc import ABC, abstractmethod
import asyncio
import queue
from typing import Any, Callable, Coroutine, Generic, TypeVar
from loguru import logger
from playwright.async_api import (
Page,
Playwright,
async_playwright,
Browser,
BrowserContext,
Error as PlaywrightError,
)
from .config import web_render_config
from playwright.async_api import ConsoleMessage, Page
T = TypeVar("T")
TFunction = Callable[[T], Coroutine[Any, Any, Any]]
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
class WebRenderer:
browser_pool: queue.Queue["WebRendererInstance"] = queue.Queue()
context_pool: dict[int, BrowserContext] = {} # 长期挂载的浏览器上下文池
page_pool: dict[str, Page] = {} # 长期挂载的页面池
@classmethod
async def get_browser_instance(cls) -> "WebRendererInstance":
if cls.browser_pool.empty():
if web_render_config.module_web_render_playwright_ws:
instance = await RemotePlaywrightInstance.create(
web_render_config.module_web_render_playwright_ws
)
else:
instance = await LocalPlaywrightInstance.create()
cls.browser_pool.put(instance)
instance = cls.browser_pool.get()
cls.browser_pool.put(instance)
return instance
@classmethod
async def render(
cls,
url: str,
target: str,
params: dict = {},
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes:
"""
访问指定URL并返回截图
:param url: 目标URL
:param target: 渲染目标,如 ".box""#main" 等CSS选择器
:param timeout: 页面加载超时时间,单位秒
:param params: URL键值对参数
:param other_function: 其他自定义操作函数接受page参数
:return: 截图的字节数据
"""
instance = await cls.get_browser_instance()
logger.debug(
f"Using WebRendererInstance {id(instance)} to render {url} targeting {target}"
)
return await instance.render(
url, target, params=params, other_function=other_function, timeout=timeout
)
@classmethod
async def render_file(
cls,
file_path: str,
target: str,
params: dict = {},
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes:
"""
访问指定本地文件URL并返回截图
:param file_path: 目标文件路径
:param target: 渲染目标,如 ".box""#main" 等CSS选择器
:param timeout: 页面加载超时时间,单位秒
:param params: URL键值对参数
:param other_function: 其他自定义操作函数接受page参数
:return: 截图的字节数据
"""
instance = await cls.get_browser_instance()
logger.debug(
f"Using WebRendererInstance {id(instance)} to render file {file_path} targeting {target}"
)
return await instance.render_file(
file_path,
target,
params=params,
other_function=other_function,
timeout=timeout,
)
@classmethod
async def render_with_persistent_page(
cls,
page_id: str,
url: str,
target: str,
params: dict = {},
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes:
"""
使用长期挂载的页面进行渲染
:param page_id: 页面唯一标识符
:param target: 渲染目标,如 ".box""#main" 等CSS选择器
:param timeout: 页面加载超时时间,单位秒
:param params: URL键值对参数
:param other_function: 其他自定义操作函数接受page参数
:return: 截图的字节数据
"""
instance = await cls.get_browser_instance()
logger.debug(
f"Using WebRendererInstance {id(instance)} to render with persistent page {page_id} targeting {target}"
)
return await instance.render_with_persistent_page(
page_id,
url,
target,
params=params,
other_function=other_function,
timeout=timeout,
)
@classmethod
async def get_persistent_page(cls, page_id: str, url: str) -> Page:
"""
获取长期挂载的页面,如果不存在则创建一个新的页面并存储
"""
if page_id in cls.page_pool:
return cls.page_pool[page_id]
async def on_console(msg: ConsoleMessage):
logger.debug(f"WEB CONSOLE {msg.text}")
instance = await cls.get_browser_instance()
if isinstance(instance, RemotePlaywrightInstance):
context = await instance.browser.new_context()
page = await context.new_page()
await page.goto(url)
cls.page_pool[page_id] = page
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
page.on('console', on_console)
return page
elif isinstance(instance, LocalPlaywrightInstance):
context = await instance.browser.new_context()
page = await context.new_page()
await page.goto(url)
cls.page_pool[page_id] = page
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
page.on('console', on_console)
return page
else:
raise NotImplementedError("Unsupported WebRendererInstance type")
@classmethod
async def close_persistent_page(cls, page_id: str) -> None:
"""
关闭并移除长期挂载的页面
:param page_id: 页面唯一标识符
"""
if page_id in cls.page_pool:
page = cls.page_pool[page_id]
await page.close()
del cls.page_pool[page_id]
logger.debug(f"Closed and removed persistent page for page_id {page_id}")
class WebRendererInstance(ABC, Generic[T]):
@abstractmethod
async def render(
self,
url: str,
target: str,
index: int = 0,
params: dict[str, Any] | None = None,
other_function: TFunction | None = None,
timeout: int = 30,
) -> bytes: ...
@abstractmethod
async def render_file(
self,
file_path: str,
target: str,
index: int = 0,
params: dict[str, Any] | None = None,
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes: ...
@abstractmethod
async def render_with_persistent_page(
self,
page_id: str,
url: str,
target: str,
params: dict = {},
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes: ...
class PlaywrightInstance(WebRendererInstance[Page]):
def __init__(self) -> None:
super().__init__()
self.lock = asyncio.Lock()
@property
@abstractmethod
def browser(self) -> Browser: ...
async def render(
self,
url: str,
target: str,
index: int = 0,
params: dict[str, Any] | None = None,
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes:
"""
访问指定URL并返回截图
:param url: 目标URL
:param target: 渲染目标,如 ".box""#main" 等CSS选择器
:param timeout: 页面加载超时时间,单位秒
:param index: 如果目标是一个列表,指定要截图的元素索引
:param params: URL键值对参数
:param other_function: 其他自定义操作函数接受page参数
:return: 截图的字节数据
"""
async with self.lock:
context = await self.browser.new_context()
page = await context.new_page()
screenshot = await self.inner_render(
page, url, target, index, params or {}, other_function, timeout
)
await page.close()
await context.close()
return screenshot
async def render_file(
self,
file_path: str,
target: str,
index: int = 0,
params: dict[str, Any] | None = None,
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes:
file_path = "file:///" + str(file_path).replace("\\", "/")
return await self.render(
file_path, target, index, params or {}, other_function, timeout
)
async def render_with_persistent_page(
self,
page_id: str,
url: str,
target: str,
params: dict = {},
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes:
page = await WebRenderer.get_persistent_page(page_id, url)
screenshot = await self.inner_render(
page, url, target, 0, params, other_function, timeout
)
return screenshot
async def inner_render(
self,
page: Page,
url: str,
target: str,
index: int = 0,
params: dict = {},
other_function: PageFunction | None = None,
timeout: int = 30,
) -> bytes:
logger.debug(f"Navigating to {url} with timeout {timeout}")
url_with_params = url + (
"?" + "&".join(f"{k}={v}" for k, v in params.items()) if params else ""
)
await page.goto(url_with_params, timeout=timeout * 1000, wait_until="load")
logger.debug("Page loaded successfully")
# 等待目标元素出现
await page.wait_for_selector(target, timeout=timeout * 1000)
logger.debug(f"Target element '{target}' found, taking screenshot")
if other_function:
await other_function(page)
elements = await page.query_selector_all(target)
if not elements:
logger.warning(f"Target element '{target}' not found on the page.")
elements = await page.query_selector_all('body')
if index >= len(elements):
logger.warning(f"Index {index} out of range for elements matching '{target}'")
index = 0
element = elements[index]
screenshot = await element.screenshot()
logger.debug("Screenshot taken successfully")
return screenshot
class LocalPlaywrightInstance(PlaywrightInstance):
def __init__(self):
self._playwright: Playwright | None = None
self._browser: Browser | None = None
super().__init__()
@property
def playwright(self) -> Playwright:
assert self._playwright is not None
return self._playwright
@property
def browser(self) -> Browser:
assert self._browser is not None
return self._browser
async def init(self):
self._playwright = await async_playwright().start()
self._browser = await self.playwright.chromium.launch(headless=True)
@classmethod
async def create(cls) -> "WebRendererInstance":
instance = cls()
await instance.init()
return instance
async def close(self):
await self.browser.close()
await self.playwright.stop()
class RemotePlaywrightInstance(PlaywrightInstance):
def __init__(self, ws_endpoint: str) -> None:
self._playwright: Playwright | None = None
self._browser: Browser | None = None
self._ws_endpoint = ws_endpoint
super().__init__()
@property
def playwright(self) -> Playwright:
assert self._playwright is not None, "Playwright must be initialized by calling init()."
return self._playwright
@property
def browser(self) -> Browser:
assert self._browser is not None, "Browser must be connected by calling init()."
return self._browser
async def init(self):
logger.info(f"尝试连接远程 Playwright 服务器: {self._ws_endpoint}")
self._playwright = await async_playwright().start()
try:
self._browser = await self.playwright.chromium.connect(
self._ws_endpoint
)
logger.info("成功连接到远程 Playwright 服务器。")
except PlaywrightError as e:
await self.playwright.stop()
raise ConnectionError(
f"无法连接到远程 Playwright 服务器 ({self._ws_endpoint}){e}"
) from e
@classmethod
async def create(cls, ws_endpoint: str) -> "RemotePlaywrightInstance":
"""
创建并初始化远程 Playwright 实例的工厂方法。
"""
instance = cls(ws_endpoint)
await instance.init()
return instance
async def close(self):
"""
断开与远程浏览器的连接并停止本地 Playwright 实例。
"""
if self._browser:
await self.browser.close()
if self._playwright:
await self.playwright.stop()
print("已断开远程连接,本地 Playwright 实例已停止。")

View File

@ -0,0 +1,66 @@
import asyncio
import tempfile
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from fastapi import HTTPException
from fastapi.responses import FileResponse
import nanoid
import nonebot
from nonebot.drivers.fastapi import Driver as FastAPIDriver
from .config import web_render_config
app = cast(FastAPIDriver, nonebot.get_driver()).asgi
hosted_tempdirs: dict[str, Path] = {}
hosted_tempdirs_lock = asyncio.Lock()
@dataclass
class TempDir:
path: Path
url_base: str
def url_of(self, file: Path):
assert file.is_relative_to(self.path)
relative_path = file.relative_to(self.path)
url_path_segment = str(relative_path).replace("\\", "/")
return f"{self.url_base}/{url_path_segment}"
@asynccontextmanager
async def host_tempdir():
with tempfile.TemporaryDirectory() as tempdir:
fp = Path(tempdir)
nid = nanoid.generate(size=10)
async with hosted_tempdirs_lock:
hosted_tempdirs[nid] = fp
yield TempDir(
path=fp,
url_base=f"{web_render_config.get_instance_baseurl()}/tempdir/{nid}",
)
async with hosted_tempdirs_lock:
del hosted_tempdirs[nid]
@app.get("/tempdir/{nid}/{file_path:path}")
async def _(nid: str, file_path: str):
async with hosted_tempdirs_lock:
base_path = hosted_tempdirs.get(nid)
if base_path is None:
raise HTTPException(404)
full_path = base_path / file_path
try:
if not full_path.resolve().is_relative_to(base_path.resolve()):
raise HTTPException(status_code=403, detail="Access denied.")
except Exception:
raise HTTPException(status_code=403, detail="Access denied.")
if not full_path.is_file():
raise HTTPException(status_code=404, detail="File not found.")
return FileResponse(full_path.resolve())

View File

@ -0,0 +1,11 @@
# 关于「中间答案」或者「提示」
在 KonaPH 中,当有人发送「提交答案 答案」时,会检查答案是否符合你设置的中间答案的 pattern。这个 pattern 可以有两种方式:
- 纯文本的完整匹配:你设置的 pattern 如果和提交的答案完全相等,则会触发提示。
- regex 匹配:你设置的 pattern 如果以斜杠(/)开头和结尾,就会检查提交的答案是否匹配正则表达式。注意 ^ 和 $ 符号的使用。
- 例如:/^commit$/ 会匹配 commit不会匹配 acommit、Commit 等。
- 而如果是 /commit/,则会匹配 commit、acommit而不会匹配 Commit。
- 无法使用 Javascript 的字符串声明模式,例如,/case_insensitive/i 就不会被视作一个正则表达式。
一个提示是提示,还是中间答案,取决于它是否有 checkpoint 标记。如果有 checkpoint 标记,则会提示用户「你回答了一个中间答案」,并且这个中间答案的回答会在排行榜中显示。

View File

@ -0,0 +1,3 @@
# 关于罗文和洛温
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。

View File

@ -1,45 +1,48 @@
指令介绍
is_admin - 用于判断当前事件是否来自管理员的内部权限校验函数
# 指令介绍
格式
from konabot.common.nb.is_admin import is_admin
from nonebot import on
from nonebot.adapters import Event
from loguru import logger
`is_admin` - 用于判断当前事件是否来自管理员的内部权限校验函数
@on().handle()
async def _(event: Event):
if is_admin(event):
logger.info("管理员发送了消息")
# 格式
说明
is_admin 是 Bot 内部用于权限控制的核心函数根据事件来源QQ、Discord、控制台及插件配置判断触发事件的用户或群组是否具有管理员权限。
```python
from konabot.common.nb.is_admin import is_admin
from nonebot import on
from nonebot.adapters import Event
from loguru import logger
@on().handle()
async def _(event: Event):
if is_admin(event):
logger.info("管理员发送了消息")
```
# 说明
is_admin 是 Bot 内部用于权限控制的核心函数根据事件来源QQ、Discord、控制台及插件配置判断触发事件的用户或群组是否具有管理员权限。
支持的适配器与判定逻辑:
• OneBot V11QQ
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
• Discord
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
- OneBot V11QQ
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
- Discord
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
- Console控制台
- 所有控制台输入均默认视为管理员操作,自动返回 True
• Console控制台
- 所有控制台输入均默认视为管理员操作,自动返回 True
# 配置项(位于插件配置中
配置项(位于插件配置中)
ADMIN_QQ_GROUP: list[int]
允许的管理员 QQ 群 ID 列表
- `ADMIN_QQ_GROUP`: `list[int]`
- 允许的管理员 QQ 群 ID 列表
- `ADMIN_QQ_ACCOUNT`: `list[int]`
- 允许的管理员 QQ 账号 ID 列表
- `ADMIN_DISCORD_CHANNEL`: `list[int]`
- 允许的管理员 Discord 频道 ID 列表
- `ADMIN_DISCORD_ACCOUNT`: `list[int]`
- 允许的管理员 Discord 用户 ID 列表
ADMIN_QQ_ACCOUNT: list[int]
允许的管理员 QQ 账号 ID 列表
# 注意事项
ADMIN_DISCORD_CHANNEL: list[int]
允许的管理员 Discord 频道 ID 列表
ADMIN_DISCORD_ACCOUNT: list[int]
允许的管理员 Discord 用户 ID 列表
注意事项
- 若未在配置文件中设置任何管理员 ID该函数对所有非控制台事件返回 False
- 控制台事件始终拥有管理员权限,便于本地调试与运维
- 若未在配置文件中设置任何管理员 ID该函数对所有非控制台事件返回 False
- 控制台事件始终拥有管理员权限,便于本地调试与运维

View File

@ -0,0 +1,5 @@
# 指令介绍
`konaph` - KonaBot 的 PuzzleHunt 管理工具
详细介绍请直接输入 konaph 获取使用指引(该指令权限仅对部分人开放。如果你有权限的话才有响应。建议在此方 BOT 私聊使用该指令。)

View File

@ -1,59 +1,83 @@
指令介绍
giftool - 对 GIF 动图进行裁剪、抽帧等处理
# giftool - 对 GIF 动图进行裁剪、抽帧等处理
格式
giftool [图片] [选项]
## 格式
示例
回复一张 GIF 并发送:
`giftool --ss 1.5 -t 2.0`
从 1.5 秒处开始,截取 2 秒长度的片段。
```bash
giftool [图片] [选项]
```
`giftool [图片] --ss 0:10 -to 0:15`
截取从 10 秒到 15 秒之间的片段(支持 MM:SS 或 HH:MM:SS 格式)。
## 示例
`giftool [图片] --frames:v 10`
将整张 GIF 均匀抽帧,最终保留 10 帧。
- **回复一张 GIF 并发送:**
`giftool [图片] --ss 2 --frames:v 5`
从第 2 秒开始截取,并将结果抽帧为 5 帧。
```bash
giftool --ss 1.5 -t 2.0
```
参数说明
图片(必需)
- 必须是 GIF 动图。
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
从 1.5 秒处开始,截取 2 秒长度的片段。
--ss <时间戳>(可选)
- 指定开始时间(单位:秒),可使用以下格式:
• 纯数字(如 `1.5` 表示 1.5 秒)
• 分秒格式(如 `1:30` 表示 1 分 30 秒)
• 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
- 默认从开头开始0 秒)。
- ```bash
giftool [图片] --ss 0:10 -to 0:15
```
-t <持续时间>(可选)
- 指定截取的持续时间(单位:秒),格式同 --ss。
- 与 --ss 配合使用:截取 [ss, ss + t] 区间。
- 不能与 --to 同时使用。
截取从 10 秒到 15 秒之间的片段(支持 `MM:SS` 或 `HH:MM:SS` 格式)。
--to <时间戳>(可选)
- 指定结束时间(单位:秒),格式同 --ss。
- 与 --ss 配合使用:截取 [ss, to] 区间。
- 不能与 -t 同时使用。
- ```bash
giftool [图片] --frames:v 10
```
--frames:v <帧数>(可选)
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
- 帧数必须为正整数(> 0
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
将整张 GIF 均匀抽帧,最终保留 10 帧。
--s <速度>(可选)
- 调整 gif 图的速度
- ```bash
giftool [图片] --ss 2 --frames:v 5
```
使用方式
1. 发送指令前,请确保:
- 消息中附带一张 GIF 动图,或
- 回复一条包含 GIF 动图的消息后再发送指令。
2. 插件会自动:
- 解析 GIF 的每一帧及其持续时间duration
- 根据时间参数转换为帧索引进行裁剪
- 如指定抽帧,则对裁剪后的片段均匀采样
- 生成新的 GIF 并保持原始循环设置loop=0
从第 2 秒开始截取,并将结果抽帧为 5 帧。
## 参数说明
### 图片(必需)
- 必须是 GIF 动图。
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
### `--ss <时间戳>`(可选)
- 指定开始时间(单位:秒),可使用以下格式:
- 纯数字(如 `1.5` 表示 1.5 秒)
- 分秒格式(如 `1:30` 表示 1 分 30 秒)
- 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
- 默认从开头开始0 秒)。
### `-t <持续时间>`(可选)
- 指定截取的持续时间(单位:秒),格式同 `--ss`。
- 与 `--ss` 配合使用:截取 `[ss, ss + t]` 区间。
- **不能与 `--to` 同时使用。**
### `--to <时间戳>`(可选)
- 指定结束时间(单位:秒),格式同 `--ss`。
- 与 `--ss` 配合使用:截取 `[ss, to]` 区间。
- **不能与 `-t` 同时使用。**
### `--frames:v <帧数>`(可选)
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
- 帧数必须为正整数(> 0
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
### `--speed <速度>`(可选)
- 调整 GIF 图的速度。若为负数,则代表倒放。
## 使用方式
1. 发送指令前,请确保:
- 消息中附带一张 GIF 动图,**或**
- 回复一条包含 GIF 动图的消息后再发送指令。
2. 插件会自动:
- 解析 GIF 的每一帧及其持续时间duration
- 根据时间参数转换为帧索引进行裁剪
- 如指定抽帧,则对裁剪后的片段均匀采样
- 生成新的 GIF 并保持原始循环设置(`loop=0`

View File

@ -1,20 +1,33 @@
指令介绍
man - 用于展示此方 BOT 使用手册的指令
# 指令介绍
格式
man 文档类型
man [文档类型] <指令>
`man` - 用于展示此方 BOT 使用手册的指令
示例
`man` 查看所有有文档的指令清单
`man 3` 列举所有可读文档的库函数清单
`man 喵` 查看指令「喵」的使用说明
`man 8 out` 查看管理员指令「out」的使用说明
## 格式
文档类型
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为这些种类:
```
man 文档类型
man [文档类型] <指令>
```
- 1 用户态指令,用于日常使用的指令
- 3 库函数指令,用于 Bot 开发用的函数查询
- 7 概念指令,用于概念解释
- 8 系统指令,仅管理员可用
## 示例
- ``man``
查看所有有文档的指令清单
- ``man 3``
列举所有可读文档的库函数清单
- ``man 喵``
查看指令「喵」的使用说明
- ``man 8 out``
查看管理员指令「out」的使用说明
## 文档类型
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为以下种类:
- **1** 用户态指令:用于日常使用的指令
- **3** 库函数指令:用于 Bot 开发用的函数查询
- **7** 概念指令:用于概念解释
- **8** 系统指令:仅管理员可用

View File

@ -0,0 +1,16 @@
## 指令介绍
**`ntfy`** - 配置使用 [ntfy](https://ntfy.sh/) 来更好地为你通知此方 BOT 的代办事项。
## 指令示例
- **`ntfy 创建`**
创建一个随机的 ntfy 订阅主题来提醒代办。此方 Bot 将会给你使用指引。你可以前往 [https://ntfy.sh/](https://ntfy.sh/) 官网下载 ntfy APP或者使用网页版 ntfy。
- **`ntfy 创建 kagami-notice`**
创建一个名称包含 `kagami-notice` 的 ntfy 订阅主题。
- **`ntfy 删除`**
清除配置,不再使用 ntfy 向你发送通知。
## 另见
[`提醒我(1)`](#) [`查询提醒(1)`](#) [`删除提醒(1)`](#)

View File

@ -1,21 +1,39 @@
指令介绍
openssl - 用于生成指定长度的加密安全随机数据
# 指令介绍
格式
openssl rand <模式> <字节数>
`openssl rand` — 用于生成指定长度的加密安全随机数据。
示例
`openssl rand -hex 16` 生成 16 字节的十六进制随机数
`openssl rand -base64 32` 生成 32 字节并以 Base64 编码输出的随机数据
## 格式
说明
该指令使用 Python 的 secrets 模块生成加密安全的随机字节,并支持以十六进制(-hex或 Base64-base64格式输出。
```bash
openssl rand <模式> <字节数>
```
参数说明
模式mode
- -hex :以十六进制字符串形式输出随机数据
- -base64 :以 Base64 编码字符串形式输出随机数据
## 示例
字节数num
- 必须为正整数
- 最大支持 256 字节
- ```bash
openssl rand -hex 16
```
生成 16 字节的十六进制随机数。
- ```bash
openssl rand -base64 32
```
生成 32 字节并以 Base64 编码输出的随机数据。
## 说明
该指令使用 Python 的 `secrets` 模块生成加密安全的随机字节,并支持以以下格式输出:
- 十六进制(`-hex`
- Base64 编码(`-base64`
## 参数说明
### 模式mode
- `-hex`:以十六进制字符串形式输出随机数据
- `-base64`:以 Base64 编码字符串形式输出随机数据
### 字节数num
- 必须为正整数
- 最大支持 256 字节

View File

@ -1,47 +1,55 @@
指令介绍
shadertool - 使用 SkSLSkia Shader Language代码实时渲染并生成 GIF 动画
# 指令介绍
`shadertool` - 使用 SkSLSkia Shader Language代码实时渲染并生成 GIF 动画
格式
shadertool [选项] <SkSL 代码>
## 格式
```bash
shadertool [选项] <SkSL 代码>
```
示例
shadertool """
uniform float u_time;
uniform float2 u_resolution;
## 示例
```bash
shadertool """
uniform float u_time;
uniform float2 u_resolution;
half4 main(float2 coord) {
return half4(
1.0,
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
coord.x / u_resolution.x,
1.0
);
}
"""
half4 main(float2 coord) {
return half4(
1.0,
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
coord.x / u_resolution.x,
1.0
);
}
"""
```
参数说明
SkSL 代码(必填)
- 类型:字符串(建议用英文双引号包裹)
- 内容:符合 SkSL 语法的片段着色器代码,必须包含 `void main()` 函数,并为 `sk_FragColor` 赋值。
- 注意:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
## 参数说明
--width <整数>(可选)
- 默认值320
- 作用:输出 GIF 的宽度(像素),必须大于 0
### SkSL 代码(必填)
- **类型**:字符串(建议用英文双引号包裹)
- **内容**:符合 SkSL 语法的片段着色器代码,必须包含 `main` 函数,并返回 `half4` 类型的颜色值
- **注意**:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
--height <整数>(可选)
- 默认值180
- 作用:输出 GIF 的度(像素),必须大于 0。
### `--width <整数>`(可选)
- **默认值**`320`
- **作用**:输出 GIF 的度(像素),必须大于 0。
--duration <浮点数>(可选)
- 默认值1.0
- 作用:动画总时长(秒),必须大于 0。
- 限制:`duration × fps` 必须 ≥ 1 且 ≤ 100即至少 1 帧,最多 100 帧)。
### `--height <整数>`(可选)
- **默认值**`180`
- **作用**:输出 GIF 的高度(像素),必须大于 0。
--fps <浮点数>(可选)
- 默认值15.0
- 作用:每秒帧数,控制动画流畅度,必须大于 0。
- 常见值10低配流畅、15默认、24/30电影/视频级)。
### `--duration <浮点数>`(可选)
- **默认值**`1.0`
- **作用**:动画总时长(秒),必须大于 0。
- **限制**`duration × fps` 必须 ≥ 1 且 ≤ 100即至少 1 帧,最多 100 帧)。
使用方式
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
### `--fps <浮点数>`(可选)
- **默认值**`15.0`
- **作用**:每秒帧数,控制动画流畅度,必须大于 0。
- **常见值**
- `10`:低配流畅
- `15`:默认
- `24` / `30`:电影/视频级流畅度
## 使用方式
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。

View File

@ -1,41 +1,72 @@
指令介绍
ytpgif - 生成来回镜像翻转的仿 YTPMV 动图
# `ytpgif` 指令说明
格式
ytpgif [倍速]
## 功能简介
`ytpgif` 用于生成来回镜像翻转的仿 YTPMVYouTube Poop Music Video风格动图。
示例
`ytpgif`
使用默认倍速1.0)处理你发送或回复的图片,生成镜像动图。
---
`ytpgif 2.5`
以 2.5 倍速处理图片,生成更快节奏的镜像动图。
## 命令格式
```bash
ytpgif [倍速]
```
回复一张图片并发送 `ytpgif 0.5`
以慢速0.5 倍)生成镜像动图。
---
参数说明
倍速(可选)
- 类型:浮点数
- 默认值1.0
- 有效范围0.1 20.0
- 作用:
• 对于静态图:控制镜像切换的快慢(值越大,切换越快)。
• 对于动图:控制截取原始动图正向和反向片段的时长(值越大,截取的片段越长)。
## 使用示例
使用方式
发送指令前,请确保:
- 直接在消息中附带一张图片,或
- 回复一条包含图片的消息后再发送指令。
- **默认倍速**
```bash
ytpgif
```
使用默认倍速1.0)处理你发送或回复的图片,生成镜像动图。
插件会自动:
- 下载并识别图片(支持静态图和 GIF 动图)
- 自动缩放至最大边长不超过 256 像素(保持宽高比)
- 静态图 → 生成“原图↔镜像”循环动图
- 动图 → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图
- 保留透明通道(如原图含透明),否则转为 RGB 避免颜色异常
- **指定倍速(较快)**
```bash
ytpgif 2.5
```
以 2.5 倍速处理图片,生成节奏更快的镜像动图
注意事项
- 图片过大、格式损坏或网络问题可能导致处理失败。
- 动图帧数过多或单帧过短可能无法生成有效输出。
- 输出 GIF 最大单段帧数限制为 500 帧,以防资源耗尽。
- **指定倍速(较慢)**
回复一张图片并发送:
```bash
ytpgif 0.5
```
以 0.5 倍速生成慢节奏的镜像动图。
---
## 参数说明
### `倍速`(可选)
- **类型**:浮点数
- **默认值**`1.0`
- **有效范围**`0.1 20.0`
#### 作用:
- **静态图**:控制“原图 ↔ 镜像”切换的速度(值越大,切换越快)。
- **GIF 动图**:控制截取原始动图正向与反向片段的时长(值越大,截取的片段越长)。
---
## 使用方式
在发送指令前,请确保满足以下任一条件:
- 在消息中**直接附带一张图片**,或
- **回复一条包含图片的消息**后再发送指令。
插件将自动执行以下操作:
1. 下载并识别图片(支持静态图和 GIF 动图)。
2. 自动缩放图像,**最大边长不超过 256 像素**(保持宽高比)。
3. 根据图片类型处理:
- **静态图** → 生成“原图 ↔ 镜像”循环动图。
- **GIF 动图** → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图。
4. **保留透明通道**(若原图含透明),否则转为 RGB 模式以避免颜色异常。
---
## 注意事项
⚠️ 以下情况可能导致处理失败或效果不佳:
- 图片过大、格式损坏或网络问题;
- 动图帧数过多或单帧持续时间过短;
- 输出 GIF 单段帧数超过 **500 帧**(系统将自动限制以防资源耗尽)。

View File

@ -0,0 +1,9 @@
## 指令介绍
**删除提醒** - 删除在 [`查询提醒(1)`](查询提醒(1)) 中查到的提醒
## 指令示例
`删除提醒 1`
在查询提醒后,删除编号为 1 的提醒
## 另见
[`提醒我(1)`](提醒我(1)) [`查询提醒(1)`](查询提醒(1)) [`ntfy(1)`](ntfy(1))

View File

@ -0,0 +1,24 @@
# 指令介绍
**卵总展示** - 让卵总举起你的图片
## 格式
```
<引用图片> 卵总展示 [选项]
卵总展示 [选项] <图片>
```
## 选项
- `--whiteness <number>` **白度**
将原图进行指数变换,以调整它的白的程度,默认为 `0.0`。
- `--black-level <number>` **黑色等级**
将原图减淡,数值越大越淡,范围 `0.01.0`,默认为 `0.2`。
- `--opacity <number>` **不透明度**
将你的图片叠放在图片上的不透明度,默认为 `0.8`。
- `--saturation <number>` **饱和度**
调整原图的饱和度,应大于 `0.0`,默认为 `0.85`。

View File

@ -0,0 +1,16 @@
### 指令介绍
**发起投票** - 发起一个投票
### 格式
```
发起投票 <投票标题> <选项1> <选项2> ...
```
### 示例
`发起投票 这是一个投票 A B C`
发起标题为“这是一个投票”选项为“A”、“B”、“C”的投票。
### 说明
- 投票的各个选项之间用空格分隔。
- 选项数量必须为 **2 到 15 项**。
- 投票的默认有效期为 **24 小时**。

View File

@ -1,2 +1,3 @@
指令介绍
喵 - 你发喵,此方就会回复喵
# 指令介绍
喵 - 你发喵,此方就会回复喵

View File

@ -0,0 +1,16 @@
## 指令介绍
**投票** - 参与已发起的投票
## 格式
```
投票 <投票ID/标题> <选项文本>
```
## 示例
- `投票 1 A`
在 ID 为 1 的投票中,投给 “A”
- `投票 这是一个投票 B`
在标题为 “这是一个投票” 的投票中,投给 “B”
## 说明
目前不支持单人多投,每个人只能投一项。

View File

@ -0,0 +1,18 @@
## 指令介绍
**提醒我** - 在指定的时间提醒人事项的工具
## 使用示例
- `下午五点提醒我吃饭`
创建一个下午五点的提醒,提醒你吃饭
- `两分钟后提醒我睡觉`
创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉
- `2026年4月25日20点整提醒我生日快乐`
创建一个指定日期和时间的提醒
## 另见
[`查询提醒(1)`](查询提醒) [`删除提醒(1)`](删除提醒) [`ntfy(1)`](ntfy)

View File

@ -1,7 +1,13 @@
指令介绍
摇数字 - 生成一个随机数字并发送
## 指令介绍
示例
`摇数字` 随机生成一个 1-6 的数字
**摇数字** - 生成一个随机数字并发送
该指令不接受任何参数,直接调用即可。
### 示例
```
摇数字
```
随机生成一个 1-6 的数字。
> 该指令不接受任何参数,直接调用即可。

View File

@ -1,22 +1,33 @@
指令介绍
摇骰子 - 用于生成随机数并以骰子图像形式展示的指令
# 指令介绍
格式
摇骰子 [最小值] [最大值]
**摇骰子** - 用于生成随机数并以骰子图像形式展示的指令
示例
`摇骰子` 随机生成一个 1-6 的数字,并显示对应的骰子图像
`摇骰子 10` 生成 1 到 10 之间的随机整数
`摇骰子 0.5` 生成 0 到 0.5 之间的随机小数
`摇骰子 -5 5` 生成 -5 到 5 之间的随机数
## 格式
说明
该指令支持以下几种调用方式:
- 不带参数:使用默认范围生成随机数
- 仅指定一个参数 f1
- 若 f1 > 1则生成 [1, f1] 范围内的随机数
- 若 0 < f1 ≤ 1则生成 [0, f1] 范围内的随机数
- 若 f1 ≤ 0则生成 [f1, 0] 范围内的随机数
- 指定两个参数 f1 和 f2生成 [f1, f2] 范围内的随机数(顺序无关,内部会自动处理大小)
```
摇骰子 [最小值] [最大值]
```
## 示例
- `摇骰子`
随机生成一个 16 的数字,并显示对应的骰子图像
- `摇骰子 10`
生成 1 到 10 之间的随机整数
- `摇骰子 0.5`
生成 0 到 0.5 之间的随机小数
- `摇骰子 -5 5`
生成 -5 到 5 之间的随机数
## 说明
该指令支持以下几种调用方式:
- **不带参数**使用默认范围16生成随机数
- **仅指定一个参数 `f1`**
- 若 `f1 > 1`,则生成 `[1, f1]` 范围内的随机数
- 若 `0 < f1 ≤ 1`,则生成 `[0, f1]` 范围内的随机数
- 若 `f1 ≤ 0`,则生成 `[f1, 0]` 范围内的随机数
- **指定两个参数 `f1` 和 `f2`**:生成 `[f1, f2]` 范围内的随机数(顺序无关,内部会自动处理大小)
返回结果将以骰子样式的图像形式展示生成的随机数值。

View File

@ -0,0 +1,22 @@
# 指令介绍
**查看投票** - 查看已发起的投票
## 格式
```
查看投票 <投票ID或标题>
```
## 示例
- `查看投票 1`
查看 ID 为 1 的投票
- `查看投票 这是一个投票`
查看标题为“这是一个投票”的投票
## 说明
- 投票进行中时,使用此命令可查看投票的各个选项;
- 投票结束后,可查看各选项的最终票数。

View File

@ -0,0 +1,9 @@
# 指令介绍
**查询提醒** - 查询已经创建的提醒
## 指令格式
- `查询提醒`:查询提醒
- `查询提醒 2`:查询第二页提醒
## 另见
[提醒我(1)]()[删除提醒(1)]()[ntfy(1)]()

View File

@ -0,0 +1,17 @@
## 指令介绍
**生成二维码** - 将文本内容转换为二维码
### 格式
```
生成二维码 <文本内容>
```
### 示例
```
生成二维码 嗨嗨嗨
```
生成扫描结果为“嗨嗨嗨”的二维码图片

View File

@ -0,0 +1,30 @@
# 指令介绍
**订阅** - 收听此方 BOT 的自动消息发送。
---
## 格式
- `订阅 <频道名称>`
- `取消订阅 <频道名称>`
- `查询订阅 [页码]`
- `可用订阅 [页码]`
---
## 示例
- **`订阅 此方谜题`**
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 konaph(8) 管理的)。
- 如果你是私聊,则能够每天发送此方谜题到你的私聊;
- 如果在群聊中使用该指令,则会每天发送题目到这个群里面。
- **`取消订阅 此方谜题`**
取消订阅「此方谜题」频道。
- **`查询订阅`**
查询当前聊天上下文订阅的所有频道。
- **`可用订阅 2`**
查询所有可用的订阅的第二页。

View File

@ -1,13 +1,20 @@
指令介绍
雷达回波 - 用于获取指定地区的天气雷达回波图像
# 指令介绍
格式
雷达回波 <地区>
**雷达回波** - 用于获取指定地区的天气雷达回波图像。
示例
`雷达回波 华南` 获取华南地区的天气雷达回波图
`雷达回波 全国` 获取全国的天气雷达回波图
## 格式
说明
该指令通过查询中国气象局 https://www.nmc.cn/publish/radar/chinaall.html ,获取指定地区的实时天气雷达回波图像。
支持的地区有:全国 华北 东北 华东 华中 华南 西南 西北。
```
雷达回波 <地区>
```
## 示例
- `雷达回波 华南`:获取华南地区的天气雷达回波图
- `雷达回波 全国`:获取全国的天气雷达回波图
## 说明
该指令通过查询中国气象局 [https://www.nmc.cn/publish/radar/chinaall.html](https://www.nmc.cn/publish/radar/chinaall.html),获取指定地区的实时天气雷达回波图像。
支持的地区有:**全国**、**华北**、**东北**、**华东**、**华中**、**华南**、**西南**、**西北**。

View File

@ -1,5 +1,7 @@
指令介绍
黑白 - 将图片经过一个黑白滤镜的处理
## 指令介绍
示例
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
**黑白** - 将图片经过一个黑白滤镜的处理
## 示例
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可

View File

@ -0,0 +1,162 @@
from io import BytesIO
from typing import Optional, Union
import cv2
from nonebot.adapters import Event as BaseEvent
from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
from nonebot_plugin_alconna import Alconna, AlconnaMatcher, Args, UniMessage, on_alconna
from PIL import Image
import numpy as np
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import ASSETS_PATH
from konabot.common.web_render import WebRenderer
from konabot.plugins.air_conditioner.ac import AirConditioner, CrashType, generate_ac_image, wiggle_transform
import random
import math
def get_ac(id: str) -> AirConditioner:
ac = AirConditioner.air_conditioners.get(id)
if ac is None:
ac = AirConditioner(id)
return ac
async def send_ac_image(event: type[AlconnaMatcher], ac: AirConditioner):
if(ac.burnt == True):
# 打开坏掉的空调图片
with open(ASSETS_PATH / "img" / "ac" / "broken_ac.png", "rb") as f:
# 将其转为 GIF 格式发送
output = BytesIO()
Image.open(f).save(output, format="GIF")
output.seek(0)
await event.send(await UniMessage().image(raw=output).export())
return
if(ac.frozen == True):
# 打开坏掉的空调图片
with open(ASSETS_PATH / "img" / "ac" / "frozen_ac.png", "rb") as f:
# 将其转为 GIF 格式发送
output = BytesIO()
Image.open(f).save(output, format="GIF")
output.seek(0)
await event.send(await UniMessage().image(raw=output).export())
return
ac_image = await generate_ac_image(ac)
await event.send(await UniMessage().image(raw=ac_image).export())
evt = on_alconna(Alconna(
"群空调"
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
ac = get_ac(id)
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
"开空调"
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
ac = get_ac(id)
ac.on = True
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
"关空调"
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
ac = get_ac(id)
ac.on = False
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
"空调升温",
Args["temp?", Optional[Union[int, float]]] # 可选参数升温的度数默认为1
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
if temp <= 0:
return
id = target.channel_id
ac = get_ac(id)
if not ac.on or ac.burnt == True or ac.frozen == True:
await send_ac_image(evt, ac)
return
ac.temperature += temp
if ac.temperature > 40:
# 根据温度随机出是否爆炸40度开始呈指数增长
possibility = -math.e ** ((40-ac.temperature) / 50) + 1
if random.random() < possibility:
# 打开爆炸图片
with open(ASSETS_PATH / "img" / "other" / "boom.jpg", "rb") as f:
output = BytesIO()
# 爆炸抖动
frames = wiggle_transform(np.array(Image.open(f)), intensity=5)
pil_frames = [Image.fromarray(frame) for frame in frames]
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=35, disposal=2)
output.seek(0)
await evt.send(await UniMessage().image(raw=output).export())
ac.broke_ac(CrashType.BURNT)
await evt.send("太热啦,空调炸了!")
return
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
"空调降温",
Args["temp?", Optional[Union[int, float]]] # 可选参数降温的度数默认为1
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
if temp <= 0:
return
id = target.channel_id
ac = get_ac(id)
if not ac.on or ac.burnt == True or ac.frozen == True:
await send_ac_image(evt, ac)
return
ac.temperature -= temp
if ac.temperature < 0:
# 根据温度随机出是否冻结0度开始呈指数增长
possibility = -math.e ** (ac.temperature / 50) + 1
if random.random() < possibility:
ac.broke_ac(CrashType.FROZEN)
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
"换空调"
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
ac = get_ac(id)
ac.change_ac()
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
"空调炸炸排行榜",
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
ac = get_ac(id)
number, ranking = ac.get_crashes_and_ranking()
params = {
"number": number,
"ranking": ranking
}
image = await WebRenderer.render_file(
file_path=ASSETS_PATH / "webpage" / "ac" / "index.html",
target=".box",
params=params
)
await evt.send(await UniMessage().image(raw=image).export())

View File

@ -0,0 +1,288 @@
from enum import Enum
from io import BytesIO
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from konabot.common.path import ASSETS_PATH, FONTS_PATH
from konabot.common.path import DATA_PATH
import json
class CrashType(Enum):
BURNT = 0
FROZEN = 1
class AirConditioner:
air_conditioners: dict[str, "AirConditioner"] = {}
def __init__(self, id: str) -> None:
self.id = id
self.on = False
self.temperature = 24 # 默认温度
self.burnt = False
self.frozen = False
AirConditioner.air_conditioners[id] = self
def change_ac(self):
self.burnt = False
self.frozen = False
self.on = False
self.temperature = 24 # 重置为默认温度
def broke_ac(self, crash_type: CrashType):
'''
让空调坏掉,并保存数据
:param crash_type: CrashType 枚举,表示空调坏掉的类型
'''
match crash_type:
case CrashType.BURNT:
self.burnt = True
case CrashType.FROZEN:
self.frozen = True
self.save_crash_data(crash_type)
def save_crash_data(self, crash_type: CrashType):
'''
如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
'''
data_file = DATA_PATH / "ac_crash_data.json"
crash_data = {}
if data_file.exists():
with open(data_file, "r", encoding="utf-8") as f:
crash_data = json.load(f)
if self.id not in crash_data:
crash_data[self.id] = {"burnt": 0, "frozen": 0}
match crash_type:
case CrashType.BURNT:
crash_data[self.id]["burnt"] += 1
case CrashType.FROZEN:
crash_data[self.id]["frozen"] += 1
with open(data_file, "w", encoding="utf-8") as f:
json.dump(crash_data, f, ensure_ascii=False, indent=4)
def get_crashes_and_ranking(self) -> tuple[int, int]:
'''
获取该群在全国空调损坏的数量与排行榜的位置
'''
data_file = DATA_PATH / "ac_crash_data.json"
if not data_file.exists():
return 0, 1
with open(data_file, "r", encoding="utf-8") as f:
crash_data = json.load(f)
ranking_list = []
for gid, record in crash_data.items():
total = record.get("burnt", 0) + record.get("frozen", 0)
ranking_list.append((gid, total))
ranking_list.sort(key=lambda x: x[1], reverse=True)
total_crashes = crash_data.get(self.id, {}).get("burnt", 0) + crash_data.get(self.id, {}).get("frozen", 0)
rank = 1
for gid, total in ranking_list:
if gid == self.id:
break
rank += 1
return total_crashes, rank
def text_to_transparent_image(text, font_size=40, padding=0, text_color=(0, 0, 0)):
"""
将文本转换为带透明背景的图像,图像大小刚好包含文本
"""
# 创建临时图像来计算文本尺寸
temp_image = Image.new('RGB', (1, 1), (255, 255, 255))
temp_draw = ImageDraw.Draw(temp_image)
font = ImageFont.truetype(FONTS_PATH / "montserrat.otf", font_size)
# 获取文本边界框
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 计算图像大小(文本大小 + 内边距)
image_width = int(text_width + 2 * padding)
image_height = int(text_height + 2 * padding)
# 创建RGBA模式的空白图像带透明通道
image = Image.new('RGBA', (image_width, image_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
# 绘制文本(考虑内边距)
x = padding - bbox[0] # 调整起始位置
y = padding - bbox[1]
# 设置文本颜色(带透明度)
if len(text_color) == 3:
text_color = text_color + (255,) # 添加完全不透明的alpha值
draw.text((x, y), text, fill=text_color, font=font)
# 转换为OpenCV格式BGRA
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGBA2BGRA)
return image_cv
def perspective_transform(image, target, corners):
"""
对图像进行透视变换(保持透明通道)
target: 画布
corners: 四个角点的坐标,顺序为 [左上, 右上, 右下, 左下]
"""
height, width = image.shape[:2]
# 源点(原始图像的四个角)
src_points = np.array([
[0, 0], # 左上
[width-1, 0], # 右上
[width-1, height-1], # 右下
[0, height-1] # 左下
], dtype=np.float32)
# 目标点(变换后的四个角)
dst_points = np.array(corners, dtype=np.float32)
# 计算透视变换矩阵
matrix = cv2.getPerspectiveTransform(src_points, dst_points)
# 获取画布大小
target_height, target_width = target.shape[:2]
# 应用透视变换保持所有通道包括alpha
transformed = cv2.warpPerspective(image, matrix, (target_width, target_height), flags=cv2.INTER_LINEAR)
return transformed, matrix
def blend_with_transparency(background, foreground, position):
"""
将带透明通道的前景图像合成到背景图像上
position: 前景图像在背景图像上的位置 (x, y)
"""
bg = background.copy()
# 如果背景没有alpha通道添加一个
if bg.shape[2] == 3:
bg = cv2.cvtColor(bg, cv2.COLOR_BGR2BGRA)
bg[:, :, 3] = 255 # 完全不透明
x, y = position
fg_height, fg_width = foreground.shape[:2]
bg_height, bg_width = bg.shape[:2]
# 确保位置在图像范围内
x = max(0, min(x, bg_width - fg_width))
y = max(0, min(y, bg_height - fg_height))
# 提取前景的alpha通道并归一化
alpha_foreground = foreground[:, :, 3] / 255.0
# 对于每个颜色通道进行合成
for c in range(3):
bg_region = bg[y:y+fg_height, x:x+fg_width, c]
fg_region = foreground[:, :, c]
# alpha混合公式
bg[y:y+fg_height, x:x+fg_width, c] = (
alpha_foreground * fg_region +
(1 - alpha_foreground) * bg_region
)
# 更新背景的alpha通道如果需要
bg_alpha_region = bg[y:y+fg_height, x:x+fg_width, 3]
bg[y:y+fg_height, x:x+fg_width, 3] = np.maximum(bg_alpha_region, foreground[:, :, 3])
return bg
def precise_blend_with_perspective(background, foreground, corners):
"""
精确合成:根据四个角点将前景图像透视合成到背景上
"""
# 创建与背景相同大小的空白图像
bg_height, bg_width = background.shape[:2]
# 如果背景没有alpha通道转换为BGRA
if background.shape[2] == 3:
background_bgra = cv2.cvtColor(background, cv2.COLOR_BGR2BGRA)
else:
background_bgra = background.copy()
# 创建与背景相同大小的前景图层
foreground_layer = np.zeros((bg_height, bg_width, 4), dtype=np.uint8)
# 计算前景图像在背景中的边界框
min_x = int(min(corners[:, 0]))
max_x = int(max(corners[:, 0]))
min_y = int(min(corners[:, 1]))
max_y = int(max(corners[:, 1]))
# 将变换后的前景图像放置到对应位置
fg_height, fg_width = foreground.shape[:2]
if min_y + fg_height <= bg_height and min_x + fg_width <= bg_width:
foreground_layer[min_y:min_y+fg_height, min_x:min_x+fg_width] = foreground
# 创建掩码(只在前景有内容的地方合成)
mask = (foreground_layer[:, :, 3] > 0)
# 合成图像
result = background_bgra.copy()
for c in range(3):
result[:, :, c][mask] = foreground_layer[:, :, c][mask]
result[:, :, 3][mask] = foreground_layer[:, :, 3][mask]
return result
def wiggle_transform(image, intensity=2) -> list[np.ndarray]:
'''
返回一组图像振动的帧组,模拟空调运作时的抖动效果
'''
frames = []
height, width = image.shape[:2]
shifts = [(-intensity, 0), (intensity, 0), (0, -intensity), (0, intensity), (0, 0)]
for dx, dy in shifts:
M = np.float32([[1, 0, dx], [0, 1, dy]])
shifted = cv2.warpAffine(image, M, (width, height))
frames.append(shifted)
return frames
async def generate_ac_image(ac: AirConditioner) -> BytesIO:
# 找到空调底图
ac_image = cv2.imread(str(ASSETS_PATH / "img" / "ac" / "ac.png"), cv2.IMREAD_UNCHANGED)
if not ac.on:
# 空调关闭状态,直接返回底图
pil_final = Image.fromarray(ac_image)
output = BytesIO()
pil_final.save(output, format="GIF")
return output
# 根据生成温度文本图像
text = f"{round(ac.temperature, 1)}°C"
text_image = text_to_transparent_image(
text,
font_size=60,
text_color=(0, 0, 0) # 黑色文字
)
# 获取长宽比
height, width = text_image.shape[:2]
aspect_ratio = width / height
# 定义3D变换的四个角点透视效果
# 顺序: [左上, 右上, 右下, 左下]
corners = np.array([
[123, 45], # 左上
[284, 101], # 右上
[290, 140], # 右下
[119, 100] # 左下
], dtype=np.float32)
# 对文本图像进行3D变换保持透明通道
transformed_text, transform_matrix = perspective_transform(text_image, ac_image, corners)
final_image_simple = blend_with_transparency(ac_image, transformed_text, (0, 0))
intensity = max(2, abs(int(ac.temperature) - 24) // 2)
frames = wiggle_transform(final_image_simple, intensity=intensity)
pil_frames = [Image.fromarray(frame) for frame in frames]
output = BytesIO()
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=50, disposal=2)
return output

View File

@ -0,0 +1,39 @@
import re
from nonebot import on_message
from nonebot_plugin_alconna import Reference, Reply, UniMsg
from nonebot.adapters import Event
matcher_fix = on_message()
pattern = (
r"^(?:(?:av|cv)\d+|BV[a-zA-Z0-9]{10})|"
r"(?:b23\.tv|bili(?:22|23|33|2233)\.cn|\.bilibili\.com|QQ小程序(?:&amp;#93;|&#93;|\])哔哩哔哩).{0,500}"
)
@matcher_fix.handle()
async def _(msg: UniMsg, event: Event):
to_search = msg.exclude(Reply, Reference).dump(json=True)
to_search2 = msg.exclude(Reply, Reference).extract_plain_text()
if not re.search(pattern, to_search) and not re.search(pattern, to_search2):
return
from nonebot_plugin_analysis_bilibili import handle_analysis
await handle_analysis(event)
# b_url: str
# b_page: str | None
# b_time: str | None
#
# from nonebot_plugin_analysis_bilibili.analysis_bilibili import extract as bilibili_extract
#
# b_url, b_page, b_time = bilibili_extract(to_search)
# if b_url is None:
# return
#
# await matcher_fix.send(await UniMessage().text(b_url).export())

View File

@ -0,0 +1,71 @@
import qrcode
# from pyzbar.pyzbar import decode
# from PIL import Image
import requests
from io import BytesIO
from nonebot_plugin_alconna import (Alconna, Args, Field, MultiVar, UniMessage,
on_alconna)
from nonebot_plugin_alconna.uniseg import UniMsg, At, Reply
from konabot.common.nb.wzq_conflict import no_wzqbot_rule
async def download_img(url):
resp = requests.get(url.replace("https://multimedia.nt.qq","http://multimedia.nt.qq")) # bim获取QQ的图片时避免SSLv3报错
img_bytes = BytesIO()
with open(img_bytes,"wb") as f:
f.write(resp.content)
return img_bytes
def genqr(data):
qr = qrcode.QRCode(version=1,error_correction=qrcode.constants.ERROR_CORRECT_L,box_size=8,border=4)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img_bytes = BytesIO()
img.save(img_bytes, format="PNG")
return img_bytes
"""
async def recqr(url):
im_path = "assets/img/qrcode/2.jpg"
data = await download_img(url)
img = Image.open(im_path)
decoded_objects = decode(img)
data = ""
for obj in decoded_objects:
data += obj.data.decode('utf-8')
return data
"""
gqrc = on_alconna(Alconna(
"genqr",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "请输入你要转换为二维码的文字!"
)],
# UniMessage[]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"生成二维码","genqrcode"}, rule=no_wzqbot_rule)
@gqrc.handle()
async def _(saying: list):
"""
img = await draw_pt("\n".join(saying))
img_bytes = BytesIO()
img.save(img_bytes, format="PNG")
await pt.send(await UniMessage().image(raw=img_bytes).export())
# print(saying)
# 二维码识别
if type(saying[0]) == 'image':
data = await recqr(saying[0].data['url'])
if data == "":
await gqrc.send("二维码图片解析失败!")
else:
await gqrc.send(recqr(saying[0].data['url']))
# 二维码生成
else:
"""
# genqr("\n".join(saying))
await gqrc.send(await UniMessage().image(raw=genqr("\n".join(saying))).export())

View File

@ -0,0 +1,217 @@
import random
from typing import Optional
import opencc
from nonebot import on_message
from nonebot.adapters import Event as BaseEvent
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
from nonebot_plugin_alconna import (
Alconna,
Args,
UniMessage,
UniMsg,
on_alconna,
)
convert_type = ["","","","","",""]
compiled_str = "|".join([f"{a}{mid}{b}" for mid in ["","",""] for a in convert_type for b in convert_type if a != b])
def hanzi_to_abbr(hanzi: str) -> str:
mapping = {
"": "s",
"": "s",
"": "t",
"": "t",
"": "hk",
"": "jp",
}
return mapping.get(hanzi, "")
def check_valid_convert_type(convert_type: str) -> bool:
avaliable_set = ["s2t","t2s","s2tw","tw2s","s2hk","hk2s","s2twp","tw2sp","t2tw","hk2t","t2hk","t2jp","jp2t","tw2t"]
if convert_type in avaliable_set:
return True
return False
def convert(source, src_abbr, dst_abbr):
convert_type_key = f"{src_abbr}2{dst_abbr}"
if not check_valid_convert_type(convert_type_key):
# 先转为繁体,再转为目标
converter = opencc.OpenCC(f"{src_abbr}2t.json")
source = converter.convert(source)
src_abbr = "t"
converter = opencc.OpenCC(f"{src_abbr}2{dst_abbr}.json")
converted = converter.convert(source)
return converted
evt = on_alconna(
Alconna(
f"re:({compiled_str})",
Args["source?", str],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=True,
)
@evt.handle()
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
if isinstance(event, DiscordMessageEvent):
content = event.get_message().extract_plain_text()
else:
content = event.get_message().extract_plain_text()
prefix = content.split()[0]
to_convert = ""
# 如果回复了消息,则转换回复的内容
if(source is None):
if event.reply:
to_convert = event.reply.message.extract_plain_text()
if not to_convert:
return
else:
return
else:
to_convert = source
parts = []
if "" in prefix:
parts = prefix.split("")
elif "" in prefix:
parts = prefix.split("")
elif "" in prefix:
parts = prefix.split("")
if len(parts) != 2:
notice = "转换格式错误,请使用“简转繁”、“繁转简”等格式。"
await evt.send(await UniMessage().text(notice).export())
return
src, dst = parts
src_abbr = hanzi_to_abbr(src)
dst_abbr = hanzi_to_abbr(dst)
if not src_abbr or not dst_abbr:
notice = "不支持的转换类型,请使用“简”、“繁”、“正”、“港”、“日”等。"
if src_abbr:
notice = convert(notice, "s", src_abbr)
await evt.send(await UniMessage().text(notice).export())
return
converted = convert(to_convert, src_abbr, dst_abbr)
converted_prefix = convert("转换结果", "s", dst_abbr)
await evt.send(await UniMessage().text(f"{converted_prefix}{converted}").export())
shuo = ["",""]
full_name_type = ["简体","簡體","繁體","繁体","正體","正体","港話","港话","日文"]
combined_list = [f"{a}{b}" for a in shuo for b in full_name_type]
compiled_str_2 = "|".join(combined_list)
evt = on_alconna(
Alconna(
f"re:({compiled_str_2})",
Args["source?", str]
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=True,
)
@evt.handle()
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
if isinstance(event, DiscordMessageEvent):
content = event.get_message().extract_plain_text()
else:
content = event.get_message().extract_plain_text()
prefix = content.split()[0]
to_convert = ""
# 如果回复了消息,则转换回复的内容
if(source is None):
if event.reply:
to_convert = event.reply.message.extract_plain_text()
if not to_convert:
return
else:
return
else:
to_convert = source
# 获取目标转换类型
dst = ""
match prefix:
case "说简体" | "說簡體" | "说簡體" | "說简体":
dst = ""
case "說繁體" | "说繁体" | "說繁体" | "说繁體":
dst = ""
case "說正體" | "说正体" | "說正体" | "说正體":
dst = ""
case "說港話" | "说港话" | "說港话" | "说港話":
dst = ""
case "說日文" | "说日文":
dst = ""
dst_abbr = hanzi_to_abbr(dst)
if not dst_abbr:
notice = "不支持的转换类型,请使用“简体”、“繁體”、“正體”、“港話”、“日文”等。"
await evt.send(await UniMessage().text(notice).export())
return
# 循环,将源语言一次次转换为目标语言
current_text = to_convert
for src_abbr in ["s","hk","jp","tw","t"]:
if src_abbr != dst_abbr:
current_text = convert(current_text, src_abbr, dst_abbr)
converted_prefix = convert("转换结果", "s", dst_abbr)
await evt.send(await UniMessage().text(f"{converted_prefix}{current_text}").export())
def random_char(char: str) -> str:
dst_abbr = random.choice(["s","t","hk","jp","tw"])
for src_abbr in ["s","hk","jp","tw","t"]:
if src_abbr != dst_abbr:
char = convert(char, src_abbr, dst_abbr)
return char
def random_string(text: str) -> str:
final_text = ""
for char in text:
final_text += random_char(char)
return final_text
random_match = ["混乱字形","混亂字形","乱数字形","亂數字形","ランダム字形"]
evt = on_alconna(
Alconna(
f"re:({'|'.join(random_match)})",
Args["source?", str]
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=True,
)
@evt.handle()
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
if isinstance(event, DiscordMessageEvent):
content = event.get_message().extract_plain_text()
else:
content = event.get_message().extract_plain_text()
prefix = content.split()[0]
to_convert = ""
# 如果回复了消息,则转换回复的内容
if(source is None):
if event.reply:
to_convert = event.reply.message.extract_plain_text()
if not to_convert:
return
else:
return
else:
to_convert = source
final_text = ""
final_text = random_string(to_convert)
converted_prefix = convert(random_string("转换结果"), "s", "s")
await evt.send(await UniMessage().text(f"{converted_prefix}{final_text}").export())

View File

@ -0,0 +1,684 @@
import asyncio as asynkio
import datetime
import json
import secrets
from enum import Enum
from pathlib import Path
from typing import Optional
from loguru import logger
from nonebot import on_message
from nonebot.adapters import Event as BaseEvent
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
from nonebot_plugin_alconna import (
Alconna,
Args,
UniMessage,
UniMsg,
on_alconna,
)
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import ASSETS_PATH
from konabot.common.llm import get_llm
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
DATA_FILE_PATH = (
DATA_DIR / "idiom_banned.json"
)
def load_banned_ids() -> list[str]:
if not DATA_FILE_PATH.exists():
return []
try:
return json.loads(DATA_FILE_PATH.read_text("utf-8"))
except Exception as e:
logger.warning(f"在解析成语接龙封禁文件时遇到问题:{e}")
return []
def is_idiom_game_banned(group_id: str) -> bool:
banned_ids = load_banned_ids()
return group_id in banned_ids
def add_banned_id(group_id: str):
banned_ids = load_banned_ids()
if group_id not in banned_ids:
banned_ids.append(group_id)
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4), "utf-8")
def remove_banned_id(group_id: str):
banned_ids = load_banned_ids()
if group_id in banned_ids:
banned_ids.remove(group_id)
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4), "utf-8")
class TryStartState(Enum):
STARTED = 0
ALREADY_PLAYING = 1
NO_REMAINING_TIMES = 2
class TryStopState(Enum):
STOPPED = 0
NOT_PLAYING = 1
class TryVerifyState(Enum):
VERIFIED = 0
VERIFIED_AND_REAL = 1
ALREADY_USED = 2
NOT_IDIOM = 3
WRONG_FIRST_CHAR = 4
BUT_NO_NEXT = 5
GAME_END = 6
class IdiomGameLLM:
@classmethod
async def verify_idiom_with_llm(cls, idiom: str) -> bool:
if len(idiom) != 4:
return False
llm = get_llm()
system_prompt = "请判断用户的输入是否为一个合理的成语或者这四个字在中文环境下是否说得通。如果是请回答「T」否则回答「F」。请注意即使这个词不是成语如果说得通也就是能念起来很通顺你也该输出「T」。请不要包含任何解释也不要包含任何标点符号。"
message = await llm.chat([{"role": "system", "content": system_prompt}, {"role": "user", "content": idiom}])
answer = message.content
logger.info(f"LLM 对成语 {idiom} 的判断结果是 {answer}")
if answer == "T":
await cls.storage_idiom(idiom)
return answer == "T"
@classmethod
async def storage_idiom(cls, idiom: str):
# 将 idiom 存入本地文件以备后续分析
with open(DATA_DIR / "idiom_llm_storage.txt", "a", encoding="utf-8") as f:
f.write(idiom + "\n")
IdiomGame.append_into_word_list(idiom)
class IdiomGame:
ALL_WORDS = [] # 所有四字词语
ALL_IDIOMS = [] # 所有成语
INSTANCE_LIST: dict[str, "IdiomGame"] = {} # 群号对应的游戏实例
IDIOM_FIRST_CHAR = {} # 所有成语包括词语的首字字典
AVALIABLE_IDIOM_FIRST_CHAR = {} # 真正有效的成语首字字典
__inited = False
def __init__(self, group_id: str):
# 初始化一局游戏
self.group_id = ""
self.now_playing = False
self.score_board = {}
self.last_idiom = ""
self.last_char = ""
self.remain_playing_times = 3
self.last_play_date = ""
self.all_buff_score = 0
self.lock = asynkio.Lock()
self.remain_rounds = 0 # 剩余回合数
self.already_idioms: dict[str, int] = {} # 已经使用过的成语和使用过的次数
self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链
IdiomGame.INSTANCE_LIST[group_id] = self
@classmethod
def append_into_word_list(cls, word: str):
'''
将一个新词加入到词语列表中
'''
if word not in cls.ALL_WORDS:
cls.ALL_WORDS.append(word)
if word[0] not in cls.IDIOM_FIRST_CHAR:
cls.IDIOM_FIRST_CHAR[word[0]] = []
cls.IDIOM_FIRST_CHAR[word[0]].append(word)
def be_able_to_play(self) -> bool:
if self.last_play_date != datetime.date.today():
self.last_play_date = datetime.date.today()
self.remain_playing_times = 3
if self.remain_playing_times > 0:
self.remain_playing_times -= 1
return True
return False
def choose_start_idiom(self) -> str:
"""
随机选择一个成语作为起始成语
"""
self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
self.last_char = self.last_idiom[-1]
if not self.is_nextable(self.last_char):
self.choose_start_idiom()
else:
self.add_history_idiom(self.last_idiom, new_chain=True)
return self.last_idiom
@classmethod
def try_start_game(cls, group_id: str, force: bool = False) -> TryStartState:
cls.init_lexicon()
if not cls.INSTANCE_LIST.get(group_id):
cls(group_id)
instance = cls.INSTANCE_LIST[group_id]
if instance.now_playing:
return TryStartState.ALREADY_PLAYING
if not instance.be_able_to_play() and not force:
return TryStartState.NO_REMAINING_TIMES
instance.now_playing = True
return TryStartState.STARTED
def start_game(self, rounds: int = 100):
self.now_playing = True
self.remain_rounds = rounds
self.choose_start_idiom()
@classmethod
def try_stop_game(cls, group_id: str) -> TryStopState:
if not cls.INSTANCE_LIST.get(group_id):
return TryStopState.NOT_PLAYING
instance = cls.INSTANCE_LIST[group_id]
if not instance.now_playing:
return TryStopState.NOT_PLAYING
instance.now_playing = False
return TryStopState.STOPPED
def clear_score_board(self):
self.score_board = {}
self.last_char = ""
self.all_buff_score = 0
self.already_idioms = {}
self.idiom_history = []
def get_score_board(self) -> dict:
return self.score_board
def get_all_buff_score(self) -> int:
return self.all_buff_score
async def skip_idiom(self, buff_score: int = -100) -> str:
"""
跳过当前成语,选择下一个成语
"""
async with self.lock:
self._skip_idiom_async()
self.add_buff_score(buff_score)
return self.last_idiom
def _skip_idiom_async(self) -> str:
self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
self.last_char = self.last_idiom[-1]
if not self.is_nextable(self.last_char):
self._skip_idiom_async()
else:
self.add_history_idiom(self.last_idiom, new_chain=True)
return self.last_idiom
async def try_verify_idiom(self, idiom: str, user_id: str) -> TryVerifyState:
"""
用户发送成语
"""
async with self.lock:
state = await self._verify_idiom(idiom, user_id)
return state
def is_nextable(self, last_char: str) -> bool:
"""
判断是否有成语可以接
"""
return last_char in IdiomGame.AVALIABLE_IDIOM_FIRST_CHAR
def add_already_idiom(self, idiom: str):
if idiom in self.already_idioms:
self.already_idioms[idiom] += 1
else:
self.already_idioms[idiom] = 1
def get_already_used_num(self, idiom: str) -> int:
if idiom in self.already_idioms:
return self.already_idioms[idiom]
return 0
def add_history_idiom(self, idiom: str, new_chain: bool = False):
if new_chain or len(self.idiom_history) == 0:
self.idiom_history.append([idiom])
else:
self.idiom_history[-1].append(idiom)
def display_history(self) -> list[str]:
result = []
for chain in self.idiom_history:
result.append(" -> ".join(chain))
return result
async def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
state = []
# 新成语的首字应与上一条成语的尾字相同
if idiom[0] != self.last_char:
state.append(TryVerifyState.WRONG_FIRST_CHAR)
return state
if idiom not in IdiomGame.ALL_IDIOMS and idiom not in IdiomGame.ALL_WORDS:
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
try:
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
self.add_score(user_id, -0.1)
state.append(TryVerifyState.NOT_IDIOM)
return state
except Exception as e:
logger.error(f"LLM 验证成语 {idiom} 时出现错误:{e}")
self.add_score(user_id, -0.1)
state.append(TryVerifyState.NOT_IDIOM)
return state
# 成语合法,更新状态
self.add_history_idiom(idiom)
score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半
if(score_k != 1):
state.append(TryVerifyState.ALREADY_USED)
self.add_already_idiom(idiom)
state.append(TryVerifyState.VERIFIED)
self.last_idiom = idiom
self.last_char = idiom[-1]
self.add_score(user_id, 1 * score_k) # 先加 1 分
if idiom in IdiomGame.ALL_IDIOMS:
state.append(TryVerifyState.VERIFIED_AND_REAL)
self.add_score(user_id, 4 * score_k) # 再加 4 分
self.remain_rounds -= 1
if self.remain_rounds <= 0:
self.now_playing = False
state.append(TryVerifyState.GAME_END)
if not self.is_nextable(self.last_char):
# 没有成语可以接了,自动跳过
self._skip_idiom_async()
self.add_buff_score(-100)
state.append(TryVerifyState.BUT_NO_NEXT)
return state
def get_user_score(self, user_id: str) -> float:
if user_id not in self.score_board:
return 0
# 避免浮点数精度问题导致过长
handled_score = round(self.score_board[user_id]["score"] + self.all_buff_score, 1)
return handled_score
def add_score(self, user_id: str, score: int):
if user_id not in self.score_board:
self.score_board[user_id] = {"name": user_id, "score": 0}
self.score_board[user_id]["score"] += score
def add_buff_score(self, score: int):
self.all_buff_score += score
def get_playing_state(self) -> bool:
return self.now_playing
def get_last_char(self) -> str:
return self.last_char
@classmethod
def random_idiom_starting_with(cls, first_char: str) -> Optional[str]:
cls.init_lexicon()
if first_char not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
return None
return secrets.choice(cls.AVALIABLE_IDIOM_FIRST_CHAR[first_char])
@classmethod
def init_lexicon(cls):
if cls.__inited:
return
cls.__inited = True
# 成语大表
with open(ASSETS_PATH / "lexicon" / "idiom.json", "r", encoding="utf-8") as f:
ALL_IDIOMS_INFOS = json.load(f)
# 词语大表
with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f:
jsonData = json.load(f)
cls.ALL_WORDS = [item["ci"] for item in jsonData]
logger.debug(f"Loaded {len(cls.ALL_WORDS)} words from ci.json")
logger.debug(f"Sample words: {cls.ALL_WORDS[:5]}")
COMMON_WORDS = []
# 读取 COMMON 词语大表
with open(ASSETS_PATH / "lexicon" / "common.txt", "r", encoding="utf-8") as f:
for line in f:
word = line.strip()
if len(word) == 4:
COMMON_WORDS.append(word)
logger.debug(f"Loaded {len(COMMON_WORDS)} common words from common.txt")
logger.debug(f"Sample common words: {COMMON_WORDS[:5]}")
# 读取 THUOCL 成语库
with open(
ASSETS_PATH / "lexicon" / "THUOCL" / "data" / "THUOCL_chengyu.txt",
"r",
encoding="utf-8",
) as f:
THUOCL_IDIOMS = [line.split(" ")[0].split("\t")[0].strip() for line in f]
logger.debug(f"Loaded {len(THUOCL_IDIOMS)} idioms from THUOCL_chengyu.txt")
logger.debug(f"Sample idioms: {THUOCL_IDIOMS[:5]}")
# 读取 THUOCL 剩下的所有 txt 文件,只保留四字词
THUOCL_WORDS = []
import os
for filename in os.listdir(ASSETS_PATH / "lexicon" / "THUOCL" / "data"):
if filename.endswith(".txt") and filename != "THUOCL_chengyu.txt":
with open(
ASSETS_PATH / "lexicon" / "THUOCL" / "data" / filename,
"r",
encoding="utf-8",
) as f:
for line in f:
word = line.lstrip().split(" ")[0].split("\t")[0].strip()
if len(word) == 4:
THUOCL_WORDS.append(word)
logger.debug(f"Loaded {len(THUOCL_WORDS)} words from THUOCL txt files")
logger.debug(f"Sample words: {THUOCL_WORDS[:5]}")
# 读取本地的 idiom_llm_storage.txt 文件,补充词语表
LOCAL_LLM_WORDS = []
if (DATA_DIR / "idiom_llm_storage.txt").exists():
with open(DATA_DIR / "idiom_llm_storage.txt", "r", encoding="utf-8") as f:
for line in f:
word = line.strip()
if len(word) == 4:
LOCAL_LLM_WORDS.append(word)
logger.debug(f"Loaded additional {len(LOCAL_LLM_WORDS)} words from idiom_llm_storage.txt")
# 只有成语的大表
cls.ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
cls.ALL_IDIOMS = list(set(cls.ALL_IDIOMS)) # 去重
# 其他四字词语表,仅表示可以有这个词
cls.ALL_WORDS = (
[word for word in cls.ALL_WORDS if len(word) == 4]
+ THUOCL_WORDS
+ COMMON_WORDS
+ LOCAL_LLM_WORDS
)
cls.ALL_WORDS = list(set(cls.ALL_WORDS)) # 去重
# 根据成语大表,划分出成语首字字典
for idiom in cls.ALL_IDIOMS + cls.ALL_WORDS:
if idiom[0] not in cls.IDIOM_FIRST_CHAR:
cls.IDIOM_FIRST_CHAR[idiom[0]] = []
cls.IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
# 根据真正的成语大表,划分出有效成语首字字典
for idiom in cls.ALL_IDIOMS:
if idiom[0] not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]] = []
cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
evt = on_alconna(
Alconna(
"我要玩成语接龙",
Args["rounds?", int],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=True,
)
@evt.handle()
async def play_game(
event: BaseEvent,
target: DepLongTaskTarget,
force=False,
rounds: Optional[int] = 100,
):
# group_id = str(event.get_session_id())
group_id = target.channel_id
if is_idiom_game_banned(group_id):
await evt.send(
await UniMessage().text("本群已被禁止使用成语接龙功能!").export()
)
return
rounds = rounds or 0
if rounds <= 0:
await evt.send(await UniMessage().text("干什么!你想玩负数局吗?").export())
return
state = IdiomGame.try_start_game(group_id, force)
if state == TryStartState.ALREADY_PLAYING:
await evt.send(
await UniMessage()
.text("当前已有成语接龙游戏在进行中,请稍后再试!")
.export()
)
return
if state == TryStartState.NO_REMAINING_TIMES:
await evt.send(await UniMessage().text("玩玩玩,就知道玩,快去睡觉!").export())
return
await evt.send(
await UniMessage()
.text(
"你小子,还真有意思!\n好,成语接龙游戏开始!我说一个成语,请大家接下去!"
)
.export()
)
instance = IdiomGame.INSTANCE_LIST[group_id]
instance.start_game(rounds)
# 发布成语
await evt.send(
await UniMessage()
.text(f"第一个成语:「{instance.last_idiom}」,请接!")
.export()
)
evt = on_alconna(
Alconna(
"老子就是要玩成语接龙!!!",
Args["rounds?", int],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=True,
)
@evt.handle()
async def force_play_game(
event: BaseEvent, target: DepLongTaskTarget, rounds: Optional[int] = 100
):
await play_game(event, target, force=True, rounds=rounds)
async def end_game(event: BaseEvent, group_id: str):
instance = IdiomGame.INSTANCE_LIST[group_id]
result_text = UniMessage().text("游戏结束!\n最终得分榜:\n")
score_board = instance.get_score_board()
if len(score_board) == 0:
result_text += "无人得分!\n"
else:
# 按分数排序,名字用 at 的方式
sorted_score = sorted(
score_board.items(), key=lambda x: x[1]["score"], reverse=True
)
for i, (user_id, info) in enumerate(sorted_score):
result_text += (
f"{i + 1}. "
+ UniMessage().at(user_id)
+ f": {round(info['score'] + instance.get_all_buff_score(), 1)}\n"
)
if len(instance.idiom_history) == 0:
result_text += "\n本局没有任何接龙记录。"
else:
result_text += "\n你们的接龙记录是:\n"
history_lines = instance.display_history()
for line in history_lines:
result_text += line + "\n"
await evt.send(await result_text.export())
instance.clear_score_board()
evt = on_alconna(
Alconna("不玩了"), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True
)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
# group_id = str(event.get_session_id())
group_id = target.channel_id
state = IdiomGame.try_stop_game(group_id)
if state == TryStopState.STOPPED:
# 发送好吧狗图片
# 打开好吧狗本地文件
with open(ASSETS_PATH / "img" / "dog" / "haoba_dog.jpg", "rb") as f:
img_data = f.read()
await evt.send(await UniMessage().image(raw=img_data).export())
await end_game(event, group_id)
else:
await evt.send(
await UniMessage().text("当前没有成语接龙游戏在进行中!").export()
)
# 跳过
evt = on_alconna(
Alconna("跳过成语"), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True
)
@evt.handle()
async def _(target: DepLongTaskTarget):
# group_id = str(event.get_session_id())
group_id = target.channel_id
instance = IdiomGame.INSTANCE_LIST.get(group_id)
if not instance or not instance.get_playing_state():
return
avaliable_idiom = IdiomGame.random_idiom_starting_with(instance.get_last_char())
# 发送哈哈狗图片
with open(ASSETS_PATH / "img" / "dog" / "haha_dog.jpg", "rb") as f:
img_data = f.read()
await evt.send(await UniMessage().image(raw=img_data).export())
await evt.send(await UniMessage().text(f"你们太菜了全部扣100分明明还可以接「{avaliable_idiom}」的!").export())
idiom = await instance.skip_idiom(-100)
await evt.send(
await UniMessage().text(f"重新开始,下一个成语是「{idiom}").export()
)
def get_user_info(event: BaseEvent):
if isinstance(event, DiscordMessageEvent):
user_id = str(event.author.id)
user_name = str(event.author.name)
else:
user_id = str(event.get_user_id())
user_name = str(event.get_user_id())
return user_id, user_name
# 直接读取消息
evt = on_message()
@evt.handle()
async def _(event: BaseEvent, msg: UniMsg, target: DepLongTaskTarget):
# group_id = str(event.get_session_id())
group_id = target.channel_id
instance = IdiomGame.INSTANCE_LIST.get(group_id)
if not instance or not instance.get_playing_state():
return
user_idiom = msg.extract_plain_text().strip()
user_id, user_name = get_user_info(event)
state = await instance.try_verify_idiom(user_idiom, user_id)
if TryVerifyState.WRONG_FIRST_CHAR in state:
return
if TryVerifyState.NOT_IDIOM in state:
await evt.send(
await UniMessage()
.at(user_id)
.text(" 接不上!这个不一样!你被扣了 0.1 分!")
.export()
)
return
already_used_num = instance.get_already_used_num(user_idiom)
if TryVerifyState.VERIFIED_AND_REAL in state:
score = 5 * (0.5 ** (already_used_num - 1))
if already_used_num > 1:
await evt.send(
await UniMessage()
.at(user_id)
.text(f" 接上了,这是个被重复用过的成语,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!")
.export()
)
else:
await evt.send(
await UniMessage()
.at(user_id)
.text(f" 接上了,这是个真实成语,喜提 5 分!你有 {instance.get_user_score(user_id)} 分!")
.export()
)
elif TryVerifyState.VERIFIED in state:
score = 1 * (0.5 ** (already_used_num - 1))
if already_used_num > 1:
await evt.send(
await UniMessage()
.at(user_id)
.text(f" 接上了,但重复了,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!")
.export()
)
else:
await evt.send(
await UniMessage()
.at(user_id)
.text(f" 接上了,喜提 1 分!你有 {instance.get_user_score(user_id)} 分!")
.export()
)
if TryVerifyState.GAME_END in state:
await evt.send(await UniMessage().text("全部回合结束!").export())
await end_game(event, group_id)
return
if TryVerifyState.BUT_NO_NEXT in state:
await evt.send(
await UniMessage()
.text("但是,这是条死路!你们全部都要扣 100 分!")
.export()
)
await evt.send(
await UniMessage().text(f"重新抽取成语「{instance.last_idiom}").export()
)
await evt.send(
await UniMessage()
.text(f"下一个成语请以「{instance.get_last_char()}」开头!")
.export()
)
evt = on_alconna(
Alconna("禁止成语接龙"),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=True,
)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
# group_id = str(event.get_session_id())
group_id = target.channel_id
add_banned_id(group_id)
await evt.send(await UniMessage().text("本群已被禁止使用成语接龙功能!").export())
evt = on_alconna(
Alconna("开启成语接龙"),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=True,
)
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
# group_id = str(event.get_session_id())
group_id = target.channel_id
remove_banned_id(group_id)
await evt.send(await UniMessage().text("本群已开启成语接龙功能!").export())

View File

@ -0,0 +1,3 @@
from pathlib import Path
ASSETS = Path(__file__).parent.parent / "assets"

View File

@ -1,10 +1,10 @@
import re
from io import BytesIO
import PIL.Image
from nonebot import on_message
from nonebot.adapters import Bot
from nonebot_plugin_alconna import (Alconna, Args, Image, Option, UniMessage,
on_alconna)
from nonebot_plugin_alconna import Alconna, Args, Image, Option, UniMessage, on_alconna
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.nb.extract_image import PIL_Image
@ -29,15 +29,17 @@ def parse_timestamp(tx: str) -> float | None:
return res
cmd_giftool = on_alconna(Alconna(
"giftool",
Args["img", Image | None],
Option("--ss", Args["start_point", str]),
Option("--frames:v", Args["frame_count", int]),
Option("-t", Args["length", str]),
Option("-to", Args["end_point", str]),
Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]),
))
cmd_giftool = on_alconna(
Alconna(
"giftool",
Args["img", Image | None],
Option("--ss", Args["start_point", str]),
Option("--frames:v", Args["frame_count", int]),
Option("-t", Args["length", str]),
Option("-to", Args["end_point", str]),
Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]),
)
)
@cmd_giftool.handle()
@ -71,87 +73,75 @@ async def _(
raise BotExceptionMessage("错误:出点时间小于入点")
if frame_count is not None and frame_count <= 0:
raise BotExceptionMessage("错误:帧数量应该大于 0")
if speed_factor <= 0:
raise BotExceptionMessage("错误:--speed 必须大于 0")
if speed_factor == 0:
raise BotExceptionMessage("错误:速度不能为 0")
is_rev = speed_factor < 0
speed_factor = abs(speed_factor)
if not getattr(image, "is_animated", False):
raise BotExceptionMessage("错误输入的不是动图GIF")
frames = []
durations = []
total_duration = 0.0
##
# 从这里开始,采样整个 GIF 图
frames: list[PIL.Image.Image] = []
durations: list[float] = []
try:
for i in range(getattr(image, "n_frames")):
image.seek(i)
frames.append(image.copy())
duration = image.info.get("duration", 100) # 单位:毫秒
duration = image.info.get("duration", 100) / 1000
durations.append(duration)
total_duration += duration / 1000.0 # 转为秒
except EOFError:
pass
if not frames:
raise BotExceptionMessage("错误:读取 GIF 帧失败")
# 采样结束
def time_to_frame_index(target_time: float) -> int:
if target_time <= 0:
return 0
cum = 0.0
for idx, dur in enumerate(durations):
cum += dur / 1000.0
if cum >= target_time:
return min(idx, len(frames) - 1)
return len(frames) - 1
start_frame = 0
end_frame = len(frames) - 1
if ss is not None:
start_frame = time_to_frame_index(ss)
if to is not None:
end_frame = time_to_frame_index(to)
if end_frame < start_frame:
end_frame = start_frame
elif t is not None:
end_time = (ss or 0.0) + t
end_frame = time_to_frame_index(end_time)
if end_frame < start_frame:
end_frame = start_frame
##
# 根据开始、结束时间或者帧数量来裁取 GIF 图
start_frame = max(0, start_frame)
end_frame = min(len(frames) - 1, end_frame)
selected_frames = frames[start_frame : end_frame + 1]
selected_durations = durations[start_frame : end_frame + 1]
begin_time = ss or 0
end_time = sum(durations)
end_time = min(begin_time + (t or end_time), to or end_time, end_time)
if frame_count is not None and frame_count > 0:
if frame_count >= len(selected_frames):
pass
else:
step = len(selected_frames) / frame_count
sampled_frames = []
sampled_durations = []
for i in range(frame_count):
idx = int(i * step)
sampled_frames.append(selected_frames[idx])
sampled_durations.append(
sum(selected_durations) // len(selected_durations)
)
selected_frames = sampled_frames
selected_durations = sampled_durations
accumulated = 0.0
status = 0
output_img = BytesIO()
sel_frames: list[PIL.Image.Image] = []
sel_durations: list[float] = []
adjusted_durations = [
dur / speed_factor for dur in selected_durations
]
for i in range(len(frames)):
frame = frames[i]
duration = durations[i]
if status == 0:
if accumulated + duration > begin_time:
status = 1
sel_frames.append(frame)
sel_durations.append(accumulated + duration - begin_time)
elif status == 1:
if accumulated + duration > end_time:
sel_frames.append(frame)
sel_durations.append(end_time - accumulated)
break
sel_frames.append(frame)
sel_durations.append(duration)
accumulated += duration
##
# 加速!
sel_durations = [dur / speed_factor * 1000 for dur in durations]
rframes = []
rdur = []
acc_mod_20 = 0
for i in range(len(selected_frames)):
fr = selected_frames[i]
du: float = adjusted_durations[i]
for i in range(len(sel_frames)):
fr = sel_frames[i]
du = round(sel_durations[i])
if du >= 20:
rframes.append(fr)
@ -167,10 +157,31 @@ async def _(
if acc_mod_20 >= 20:
acc_mod_20 = 0
if len(rframes) == 1 and len(selected_frames) > 1:
rframes.append(selected_frames[max(2, len(selected_frames) // 2)])
if len(rframes) == 1 and len(sel_frames) > 1:
rframes.append(sel_frames[max(2, len(sel_frames) // 2)])
rdur.append(20)
##
# 收尾:看看透明度这块
transparency_flag = False
for f in rframes:
if f.mode == "RGBA":
if any(pix < 255 for pix in f.getchannel("A").getdata()):
transparency_flag = True
break
elif f.mode == "P" and "transparency" in f.info:
transparency_flag = True
break
tf = {}
if transparency_flag:
tf["transparency"] = 0
if is_rev:
rframes = rframes[::-1]
rdur = rdur[::-1]
output_img = BytesIO()
if rframes:
rframes[0].save(
output_img,
@ -181,7 +192,7 @@ async def _(
loop=0,
optimize=False,
disposal=2,
transparency=0,
**tf,
)
else:
raise BotExceptionMessage("错误:没有可输出的帧")

View File

@ -0,0 +1,138 @@
import datetime
import re
from math import ceil
from loguru import logger
from nonebot import on_message
import nonebot
from nonebot.rule import to_me
from nonebot_plugin_alconna import (Alconna, Args, UniMessage, UniMsg,
on_alconna)
from nonebot_plugin_apscheduler import scheduler
from konabot.common.longtask import DepLongTaskTarget
from konabot.plugins.kona_ph.core.message import (get_daily_report,
get_daily_report_v2,
get_puzzle_description,
get_submission_message)
from konabot.plugins.kona_ph.core.storage import get_today_date
from konabot.plugins.kona_ph.manager import (PUZZLE_PAGE_SIZE,
create_admin_commands,
puzzle_manager)
from konabot.plugins.poster.poster_info import PosterInfo, register_poster_info
from konabot.plugins.poster.service import broadcast
create_admin_commands()
register_poster_info("每日谜题", info=PosterInfo(
aliases={"konaph", "kona_ph", "KonaPH", "此方谜题", "KONAPH"},
description="此方 BOT 每日谜题推送",
))
cmd_submit = on_message(rule=to_me())
@cmd_submit.handle()
async def _(msg: UniMsg, target: DepLongTaskTarget):
txt = msg.extract_plain_text().strip()
if match := re.match(r"^提交(?:答案|题解|[fF]lag)\s*(?P<submission>.+?)\s*$", txt):
submission: str = match.group("submission")
async with puzzle_manager() as manager:
result = manager.submit(target.target_id, submission)
if isinstance(result, str):
await target.send_message(result)
else:
await target.send_message(get_submission_message(
daily_puzzle_info=result.info,
submission=result.submission,
puzzle=result.puzzle,
))
cmd_query = on_alconna(Alconna(
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\?)?)|今日谜?题目?)"
), rule=to_me())
@cmd_query.handle()
async def _(target: DepLongTaskTarget):
async with puzzle_manager() as manager:
p = manager.get_today_puzzle()
if p is None:
return await target.send_message("今天无题,改日再来吧!")
await target.send_message(get_puzzle_description(p))
cmd_query_submission = on_alconna(Alconna(
"今日答题情况"
), rule=to_me())
@cmd_query_submission.handle()
async def _(target: DepLongTaskTarget):
gid = None
if re.match(r"^\d+$", target.channel_id):
gid = int(target.channel_id)
async with puzzle_manager() as manager:
await target.send_message(get_daily_report_v2(manager, gid))
cmd_history = on_alconna(Alconna(
"历史题目",
Args["page?", int],
Args["index_id?", str],
), rule=to_me())
@cmd_history.handle()
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
async with puzzle_manager() as manager:
today = get_today_date()
if index_id:
index_id = index_id.removeprefix("#")
if index_id not in manager.daily_puzzle:
return await target.send_message("没有这道题哦")
puzzle = manager.puzzle_data[manager.daily_puzzle[index_id].raw_id]
msg = get_puzzle_description(
puzzle,
with_answer=(index_id != manager.daily_puzzle_of_date.get(today, "")),
)
return await target.send_message(msg)
msg = UniMessage.text("====== 历史题目清单 ======\n\n")
puzzles = [
(manager.puzzle_data[manager.daily_puzzle[i].raw_id], d)
for d, i in manager.daily_puzzle_of_date.items()
]
puzzles = sorted(puzzles, key=lambda u: u[1], reverse=True)
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
if page <= 0 or page > count_pages:
return await target.send_message(UniMessage.text(
f"页数只有 1 ~ {count_pages} 啦!"
))
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
for p, d in puzzles:
info = manager.daily_puzzle[manager.daily_puzzle_of_date[d]]
msg = msg.text(
f"- [#{p.index_id}: {len(info.success_users)}/{len(info.tried_users)}]"
f" {p.title} ({d})"
)
msg = msg.text("\n")
msg = msg.text(f"\n==== 第 {page} 页,共 {count_pages} 页 ====")
await target.send_message(msg)
@scheduler.scheduled_job("cron", hour="8")
async def _():
async with puzzle_manager() as manager:
yesterday = get_today_date() - datetime.timedelta(days=1)
msg2 = get_daily_report(manager, yesterday)
if msg2 is not None:
await broadcast("每日谜题", msg2)
puzzle = manager.get_today_puzzle()
if puzzle is not None:
logger.info(f"找到了题目 {puzzle.raw_id},发送")
await broadcast("每日谜题", get_puzzle_description(puzzle))
else:
logger.info("自动任务:没有找到题目,跳过")
driver = nonebot.get_driver()

View File

@ -0,0 +1,29 @@
import nanoid
from konabot.common.path import ASSETS_PATH
from konabot.plugins.kona_ph.core.path import KONAPH_IMAGE_BASE
class PuzzleImageManager:
def read_puzzle_image(self, img_name: str) -> bytes:
fp = KONAPH_IMAGE_BASE / img_name
if fp.exists():
return fp.read_bytes()
return (ASSETS_PATH / "img" / "other" / "boom.jpg").read_bytes()
def upload_puzzle_image(self, data: bytes, suffix: str = ".png") -> str:
id = nanoid.generate(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz",
21,
)
img_name = f"{id}{suffix}"
(KONAPH_IMAGE_BASE / img_name).write_bytes(data)
return img_name
def remove_puzzle_image(self, img_name: str):
if img_name:
(KONAPH_IMAGE_BASE / img_name).unlink(True)
def get_image_manager() -> PuzzleImageManager:
return PuzzleImageManager()

View File

@ -0,0 +1,187 @@
"""
生成各种各样的 Message 的函数集合
"""
import datetime
import functools
import re
from typing import Any
from nonebot_plugin_alconna import UniMessage
from konabot.common.username import get_username
from konabot.plugins.kona_ph.core.image import get_image_manager
from konabot.plugins.kona_ph.core.storage import (DailyPuzzleInfo, Puzzle,
PuzzleManager,
PuzzleSubmission)
def get_puzzle_description(puzzle: Puzzle, with_answer: bool = False) -> UniMessage[Any]:
"""
获取一个谜题的描述
"""
img_manager = get_image_manager()
result = UniMessage.text(f"[KonaPH#{puzzle.index_id}] {puzzle.title}")
result = result.text(f"\n\n{puzzle.content}")
if puzzle.img_name:
result = result.text("\n\n").image(
raw=img_manager.read_puzzle_image(puzzle.img_name)
)
result = result.text(f"\n\n出题者:{get_username(puzzle.author_id)}")
if with_answer:
result = result.text(f"\n\n题目答案:{puzzle.flag}")
else:
result = result.text("\n\nAt 我或者私聊我「提交答案 答案」来提交你的解答")
return result
def get_submission_message(
puzzle: Puzzle,
submission: PuzzleSubmission,
daily_puzzle_info: DailyPuzzleInfo | None = None,
) -> str:
"""
获得提交答案的反馈信息
"""
if submission.success:
rank = -1
if daily_puzzle_info is not None:
rank = len(daily_puzzle_info.success_users)
return f"🎉 恭喜你答对了!你是今天第 {rank} 个解出来的!"
if submission.hint_id >= 0 and (
hint := puzzle.hints.get(submission.hint_id)
) is not None:
if hint.is_checkpoint:
hint_msg = "✨ 恭喜!这是本题的中间答案,加油!"
else:
hint_msg = "🤔 答错啦!请检查你的答案。"
return f"{hint_msg}\n\n提示:{hint.message}"
return "❌ 答错啦!请检查你的答案。"
def get_daily_report(
manager: PuzzleManager,
date: datetime.date,
) -> str | None:
"""
获得某日的提交的报告信息
"""
index_id = manager.daily_puzzle_of_date.get(date)
if index_id is None:
return None
info = manager.daily_puzzle[index_id]
puzzle = manager.puzzle_data[info.raw_id]
msg = f"[KonaPH#{puzzle.index_id}] 「{puzzle.title}」解答报告\n\n"
if len(info.success_users) == 0:
msg += "昨日,无人解出此题 😭😭\n\n"
else:
msg += f"昨日,共有 {len(info.success_users)} 人解出此题。\n\n"
msg += "前五名的解答者:\n\n"
us = [(u, d) for u, d in info.success_users.items()]
us = sorted(us, key=lambda t: t[1])
us = us[:5]
for u, _ in us:
m = manager.submissions[puzzle.raw_id][u][-1]
msg += f"- {get_username(u)}{m.time.strftime('%H:%M')}\n"
msg += "\n"
msg += f"出题人:{get_username(puzzle.author_id)}"
return msg
def get_daily_report_v2(manager: PuzzleManager, gid: int | None = None):
p = manager.get_today_puzzle()
if p is None:
return "今天无题"
msg = "==== 今日答题情况 ====\n\n"
subcount = len(functools.reduce(
lambda x, y: x + y,
manager.submissions.get(p.raw_id, {}).values(),
[],
))
info = manager.daily_puzzle[p.index_id]
msg += (
f"总体情况:答对 {len(info.success_users)} / "
f"参与 {len(info.tried_users)} / "
f"提交 {subcount}\n"
)
success_users = sorted(list(info.success_users.items()), key=lambda v: v[1])
for u, d in success_users:
uname = u
if re.match(r"^\d+$", u):
uname = get_username(int(u), gid)
t = d.strftime("%H:%M")
tries = len(manager.submissions[p.raw_id][u])
msg += f"\n- {uname} [🎉 {t} 完成 | {tries} 提交]"
for u in info.tried_users - set(info.success_users.keys()):
uname = u
if re.match(r"^\d+$", u):
uname = get_username(int(u), gid)
tries = len(manager.submissions[p.raw_id][u])
checkpoints_touched = len(set((
s.hint_id for s in manager.submissions[p.raw_id][u]
if (
s.hint_id >= 0
and s.hint_id in p.hints
and p.hints[s.hint_id].is_checkpoint
)
)))
checkpoint_message = ""
if checkpoints_touched > 0:
checkpoint_message = f" | 🚩 {checkpoints_touched} 记录点"
msg += f"\n- {uname} [💦 {tries} 提交{checkpoint_message}]"
return msg
def get_puzzle_info_message(manager: PuzzleManager, puzzle: Puzzle) -> UniMessage[Any]:
image_manager = get_image_manager()
status = "✅ 已准备,待发布" if puzzle.ready and not manager.is_puzzle_published(puzzle.raw_id) else \
(f"🟢 已发布: #{puzzle.index_id}" if manager.is_puzzle_published(puzzle.raw_id) else "⚙️ 未准备")
status_suffix = ""
if puzzle.raw_id == manager.puzzle_pinned:
status_suffix += " | 📌 已被管理员置顶"
msg = UniMessage.text(
f"--- 谜题信息 ---\n"
f"Raw ID: {puzzle.raw_id}\n"
f"出题者: {get_username(puzzle.author_id)} | {puzzle.author_id}\n"
f"创建时间: {puzzle.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"状态: {status}{status_suffix}\n\n"
f"标题: {puzzle.title}\n"
f"Flag: {puzzle.flag}\n\n"
f"{puzzle.content}"
)
if puzzle.img_name:
msg = msg.image(raw=image_manager.read_puzzle_image(puzzle.img_name))
msg = msg.text(f"\n---------\n使用 `konaph ready {puzzle.raw_id}` 完成编辑")
return msg
def get_puzzle_hint_list(puzzle: Puzzle) -> str:
msg = f"==== {puzzle.title} 提示与中间答案 ====\n"
if len(puzzle.hints) == 0:
msg += "\n你没有添加任何中间答案。"
return msg
for hint_id, hint in puzzle.hints.items():
n = {False: "[提示]", True: "[中间答案]"}[hint.is_checkpoint]
msg += f"\n{n}[{hint_id}] {hint.pattern}"
msg += f"\n {hint.message}"
return msg

View File

@ -0,0 +1,9 @@
from konabot.common.path import DATA_PATH
KONAPH_BASE = DATA_PATH / "KonaPH"
KONAPH_DATA_JSON = KONAPH_BASE / "data.json"
KONAPH_IMAGE_BASE = KONAPH_BASE / "imgs"
# 保证所有文件夹存在
KONAPH_BASE.mkdir(exist_ok=True)
KONAPH_IMAGE_BASE.mkdir(exist_ok=True)

View File

@ -0,0 +1,259 @@
import asyncio
import datetime
import random
import re
from contextlib import asynccontextmanager
import nanoid
from pydantic import BaseModel, Field, ValidationError
from konabot.plugins.kona_ph.core.path import KONAPH_DATA_JSON
class PuzzleHint(BaseModel):
pattern: str
message: str
is_checkpoint: bool
class PuzzleSubmission(BaseModel):
success: bool
flag: str
time: datetime.datetime
hint_id: int = -1
class Puzzle(BaseModel):
raw_id: str
"用于给出题者管理的 ID"
index_id: str
"展出的 ID以展出顺序为准"
title: str
content: str
img_name: str
author_id: str
flag: str
ready: bool = False
created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
hints: dict[int, PuzzleHint] = Field(default_factory=dict)
@property
def hint_id_max(self) -> int:
return max((0, *self.hints.keys()))
def check_submission(
self,
submission: str,
time: datetime.datetime | None = None,
) -> PuzzleSubmission:
if time is None:
time = datetime.datetime.now()
if submission == self.flag:
return PuzzleSubmission(
success=True,
flag=submission,
time=time,
)
for hint_id, hint in self.hints.items():
if hint.pattern.startswith('/') and hint.pattern.endswith('/'):
if re.match(hint.pattern.strip('/'), submission):
return PuzzleSubmission(
success=False,
flag=submission,
time=time,
hint_id=hint_id,
)
else:
if hint.pattern == submission:
return PuzzleSubmission(
success=False,
flag=submission,
time=time,
hint_id=hint_id,
)
return PuzzleSubmission(
success=False,
flag=submission,
time=time,
)
class DailyPuzzleInfo(BaseModel):
raw_id: str
time: datetime.date
tried_users: set[str] = set()
success_users: dict[str, datetime.datetime] = {}
class PuzzleSubmissionFeedback(BaseModel):
submission: PuzzleSubmission
puzzle: Puzzle
info: DailyPuzzleInfo | None = None
def get_today_date() -> datetime.date:
now = datetime.datetime.now()
if now.hour < 8:
now -= datetime.timedelta(days=1)
return now.date()
class PuzzleManager(BaseModel):
puzzle_data: dict[str, Puzzle] = {}
daily_puzzle: dict[str, DailyPuzzleInfo] = {}
daily_puzzle_of_date: dict[datetime.date, str] = {}
puzzle_pinned: str = ""
index_id_counter: int = 1
submissions: dict[str, dict[str, list[PuzzleSubmission]]] = {}
last_checked_date: datetime.date = Field(
default_factory=lambda: get_today_date() - datetime.timedelta(days=1)
)
@property
def last_publish_date(self):
return max(self.daily_puzzle_of_date.keys())
@property
def unpublished_puzzles(self):
return set((
p.raw_id for p in self.puzzle_data.values()
if not self.is_puzzle_published(p.raw_id) and p.ready
))
@property
def unready_puzzles(self):
return set((
p.raw_id for p in self.puzzle_data.values()
if not self.is_puzzle_published(p.raw_id) and not p.ready
))
@property
def published_puzzles(self):
return set((
p.raw_id for p in self.puzzle_data.values()
if self.is_puzzle_published(p.raw_id)
))
def is_puzzle_published(self, raw_id: str):
return raw_id in [i.raw_id for i in self.daily_puzzle.values()]
def publish_puzzle(self, raw_id: str):
assert raw_id in self.puzzle_data
today = get_today_date()
p = self.puzzle_data[raw_id]
p.index_id = str(self.index_id_counter)
p.ready = True
self.puzzle_pinned = ""
self.last_checked_date = today
self.daily_puzzle[p.index_id] = DailyPuzzleInfo(
raw_id=raw_id,
time=today,
)
self.daily_puzzle_of_date[today] = p.index_id
self.index_id_counter += 1
def admin_pin_puzzle(self, raw_id: str):
if raw_id in self.puzzle_data:
self.puzzle_pinned = raw_id
else:
self.puzzle_pinned = ""
def get_today_puzzle(self, strong: bool = False) -> Puzzle | None:
today = get_today_date()
if today in self.daily_puzzle_of_date:
index_id = self.daily_puzzle_of_date[today]
info = self.daily_puzzle[index_id]
return self.puzzle_data[info.raw_id]
if today == self.last_checked_date and not strong:
return
self.last_checked_date = today
if self.puzzle_pinned and self.puzzle_pinned in self.puzzle_data:
d = self.puzzle_pinned
self.publish_puzzle(d)
self.puzzle_pinned = ""
return self.puzzle_data[d]
elif len(self.unpublished_puzzles) > 0:
d = random.choice(list(self.unpublished_puzzles))
self.publish_puzzle(d)
return self.puzzle_data[d]
def get_today_info(self) -> DailyPuzzleInfo | None:
p = self.get_today_puzzle()
if p is None:
return
return self.daily_puzzle[p.index_id]
def submit(self, user: str, flag: str) -> PuzzleSubmissionFeedback | str:
p = self.get_today_puzzle()
d = self.get_today_info()
now = datetime.datetime.now()
if p is None or d is None:
return "今天没有题哦,改天再来吧!"
if user in d.success_users:
return "你今天已经答对过啦!不用重复提交哦!"
d.tried_users.add(user)
result = p.check_submission(flag, now)
self.submissions.setdefault(p.raw_id, {}).setdefault(user, []).append(result)
if result.success:
d.success_users[user] = now
return PuzzleSubmissionFeedback(
submission=result,
puzzle=p,
info=d,
)
def admin_create_puzzle(self, user: str):
p = Puzzle(
raw_id=nanoid.generate(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz",
12,
),
index_id="",
title="示例标题",
content="题目的内容填写内容",
img_name="",
author_id=user,
flag="konaph{this_is_a_flag}",
ready=False,
)
self.puzzle_data[p.raw_id] = p
return p
def get_puzzles_of_user(self, user: str):
return sorted([
p for p in self.puzzle_data.values()
if p.author_id == user
], key=lambda p: p.created_at, reverse=True)
lock = asyncio.Lock()
def read_data():
try:
data_raw = KONAPH_DATA_JSON.read_text("utf-8")
return PuzzleManager.model_validate_json(data_raw)
except (FileNotFoundError, ValidationError):
return PuzzleManager()
def write_data(data: PuzzleManager):
KONAPH_DATA_JSON.write_text(data.model_dump_json(), "utf-8")
@asynccontextmanager
async def puzzle_manager():
async with lock:
data = read_data()
yield data
write_data(data)

View File

@ -0,0 +1,464 @@
import datetime
from math import ceil
from nonebot import get_plugin_config
from nonebot_plugin_alconna import (Alconna, Args, Image, Option, Query,
Subcommand, SubcommandResult, UniMessage,
on_alconna)
from pydantic import BaseModel
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.nb.extract_image import download_image_bytes
from konabot.common.nb.qq_broadcast import qq_broadcast
from konabot.common.username import get_username
from konabot.plugins.kona_ph.core.image import get_image_manager
from konabot.plugins.kona_ph.core.message import (get_puzzle_description, get_puzzle_hint_list,
get_puzzle_info_message,
get_submission_message)
from konabot.plugins.kona_ph.core.storage import (Puzzle, PuzzleHint, PuzzleManager,
get_today_date,
puzzle_manager)
PUZZLE_PAGE_SIZE = 10
class PuzzleConfig(BaseModel):
plugin_puzzle_manager: list[str] = []
plugin_puzzle_admin: list[str] = []
plugin_puzzle_playgroup: list[str] = []
config = get_plugin_config(PuzzleConfig)
def is_puzzle_manager(target: DepLongTaskTarget):
return target.target_id in config.plugin_puzzle_manager or is_puzzle_admin(target)
def is_puzzle_admin(target: DepLongTaskTarget):
return target.target_id in config.plugin_puzzle_admin
def check_puzzle(manager: PuzzleManager, target: DepLongTaskTarget, raw_id: str) -> Puzzle:
if raw_id not in manager.puzzle_data:
raise BotExceptionMessage("没有这个谜题")
puzzle = manager.puzzle_data[raw_id]
if is_puzzle_admin(target):
return puzzle
if target.target_id != puzzle.author_id:
raise BotExceptionMessage("你没有权限查看或编辑这个谜题")
return puzzle
def create_admin_commands():
cmd_admin = on_alconna(
Alconna(
"konaph",
Subcommand("create", dest="create"),
Subcommand("ready", Args["raw_id", str], dest="ready"),
Subcommand("unready", Args["raw_id", str], dest="unready"),
Subcommand("info", Args["raw_id", str], dest="info"),
Subcommand("my", Args["page?", int], dest="my"),
Subcommand("all", Option("--ready", alias=["-r"]), Args["page?", int], dest="all"),
Subcommand("pin", Args["raw_id?", str], dest="pin"),
Subcommand("unpin", dest="unpin"),
Subcommand(
"modify",
Args["raw_id?", str],
Option("--title", Args["title", str], alias=["-t"]),
Option("--description", Args["description", str], alias=["-d"]),
Option("--image", Args["image?", Image], alias=["-i"]),
Option("--flag", Args["flag", str], alias=["-f"]),
Option("--remove-image"),
dest="modify",
),
Subcommand("publish", Args["raw_id?", str], dest="publish"),
Subcommand("preview", Args["raw_id", str], dest="preview"),
Subcommand("get-submits", Args["raw_id", str], dest="get-submits"),
Subcommand(
"test",
Args["raw_id", str],
Args["submission", str],
dest="test",
),
Subcommand(
"hint",
Subcommand(
"add",
Args["raw_id", str],
Args["pattern", str],
Args["message", str],
dest="add",
),
Subcommand(
"list",
Args["raw_id", str],
Args["page?", int],
dest="list",
),
Subcommand(
"modify",
Args["raw_id", str],
Args["hint_id", int],
Option("--pattern", Args["pattern", str], alias=["-p"]),
Option("--message", Args["message", str], alias=["-m"]),
Option("--checkpoint", Args["is_checkpoint", bool], alias=["-c"]),
dest="modify",
),
Subcommand(
"delete",
Args["raw_id", str],
Args["hint_id", int],
dest="delete",
),
dest="hint",
),
),
rule=is_puzzle_manager,
)
@cmd_admin.assign("$main")
async def _(target: DepLongTaskTarget):
msg = UniMessage.text("==== [KonaPH] 指令一览 ====\n\n")
msg = msg.text("konaph create - 创建一个新的谜题\n")
msg = msg.text("konaph ready <id> - 准备好一道谜题\n")
msg = msg.text("konaph unready <id> - 取消准备一道谜题\n")
msg = msg.text("konaph info <id> - 查看谜题\n")
msg = msg.text("konaph my <page?> - 查看我的谜题列表\n")
msg = msg.text("konaph modify - 查看如何修改谜题信息\n")
msg = msg.text("konaph preview <id> - 预览一个题目的效果,不会展示答案\n")
msg = msg.text("konaph get-submits <id> - 获得题目的提交记录\n")
msg = msg.text("konaph test <id> <answer> - 尝试提交一个答案,看回答的效果\n")
msg = msg.text("konaph hint - 查看如何编辑题目的中间答案\n")
if is_puzzle_admin(target):
msg = msg.text("konaph all [--ready] <page?> - 查看所有谜题\n")
msg = msg.text("konaph pin - 查看当前置顶谜题\n")
msg = msg.text("konaph pin <id> - 置顶一个谜题\n")
msg = msg.text("konaph unpin - 取消置顶所有谜题\n")
msg = msg.text("konaph publish <id?> - 强制发题")
await target.send_message(msg)
@cmd_admin.assign("create")
async def _(target: DepLongTaskTarget):
async with puzzle_manager() as manager:
puzzle = manager.admin_create_puzzle(target.target_id)
await target.send_message(UniMessage.text(
f"✨ 创建好啦!谜题 ID 为 {puzzle.raw_id}\n\n"
f"- 输入 `konaph info {puzzle.raw_id}` 获得谜题的信息\n"
f"- 输入 `konaph my` 查看你创建的谜题\n"
f"- 输入 `konaph modify` 查看更改谜题的方法"
))
@cmd_admin.assign("ready")
async def _(raw_id: str, target: DepLongTaskTarget):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
if p.ready:
return await target.send_message(UniMessage.text(
"题目早就准备好啦!"
))
p.ready = True
await target.send_message(UniMessage.text(
f"谜题「{p.title}」已经准备就绪!"
))
@cmd_admin.assign("unready")
async def _(raw_id: str, target: DepLongTaskTarget):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
if not p.ready:
return await target.send_message(UniMessage.text(
f"谜题「{p.title}」已经是未取消状态了!"
))
if manager.is_puzzle_published(p.raw_id):
return await target.send_message(UniMessage.text(
"已发布的谜题不能取消准备状态!"
))
p.ready = False
await target.send_message(UniMessage.text(
f"谜题「{p.title}」已经取消准备!"
))
@cmd_admin.assign("info")
async def _(raw_id: str, target: DepLongTaskTarget):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
await target.send_message(get_puzzle_info_message(manager, p))
@cmd_admin.assign("my")
async def _(target: DepLongTaskTarget, page: int = 1):
async with puzzle_manager() as manager:
puzzles = manager.get_puzzles_of_user(target.target_id)
if len(puzzles) == 0:
return await target.send_message(UniMessage.text(
"你没有谜题哦,使用 `konaph create` 创建一个吧!"
))
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
if page <= 0 or page > count_pages:
return await target.send_message(UniMessage.text(
f"页数只有 1 ~ {count_pages} 啦!"
))
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
message = UniMessage.text("==== 我的谜题 ====\n\n")
for p in puzzles:
message = message.text("- ")
if manager.puzzle_pinned == p.raw_id:
message = message.text("[📌]")
if manager.is_puzzle_published(p.raw_id):
message = message.text(f"[✨][#{p.index_id}] ")
elif p.ready:
message = message.text("[✅] ")
else:
message = message.text("[⚙️] ")
message = message.text(f"{p.title} ({p.raw_id})")
message = message.text("\n")
message = message.text(f"\n==== 第 {page} 页,共 {count_pages} 页 ====")
await target.send_message(message)
@cmd_admin.assign("all")
async def _(target: DepLongTaskTarget, ready: Query[bool] = Query("all.ready"), page: int = 1):
if not is_puzzle_admin(target):
return await target.send_message(UniMessage.text(
"你没有权限使用该指令"
))
async with puzzle_manager() as manager:
puzzles = [*manager.puzzle_data.values()]
if ready.available:
puzzles = [p for p in puzzles if p.ready]
puzzles = sorted(puzzles, key=lambda p: p.created_at, reverse=True)
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
if page <= 0 or page > count_pages:
return await target.send_message(UniMessage.text(
f"页数只有 1 ~ {count_pages} 啦!"
))
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
message = UniMessage.text("==== 所有谜题 ====\n\n")
for p in puzzles:
message = message.text("- ")
if p.raw_id == manager.puzzle_pinned:
message = message.text("[📌]")
if manager.is_puzzle_published(p.raw_id):
message = message.text(f"[✨][#{p.index_id}] ")
elif p.ready:
message = message.text("[✅] ")
else:
message = message.text("[⚙️] ")
message = message.text(f"{p.title} ({p.raw_id} by {p.author_id})")
message = message.text("\n")
message = message.text(f"\n==== 第 {page} 页,共 {count_pages} 页 ====")
await target.send_message(message)
@cmd_admin.assign("pin")
async def _(target: DepLongTaskTarget, raw_id: str = ""):
if not is_puzzle_admin(target):
return await target.send_message(UniMessage.text(
"你没有权限使用该指令"
))
async with puzzle_manager() as manager:
if raw_id == "":
if manager.puzzle_pinned:
return await target.send_message(UniMessage.text(
f"被 Pin 的谜题 ID = {manager.puzzle_pinned}"
))
return await target.send_message("没有置顶谜题")
if raw_id not in manager.unpublished_puzzles:
return await target.send_message(UniMessage.text(
"这个谜题已经发布了,或者还没准备好,或者不存在"
))
manager.admin_pin_puzzle(raw_id)
return await target.send_message(f"已置顶谜题 {raw_id}")
@cmd_admin.assign("unpin")
async def _(target: DepLongTaskTarget):
if not is_puzzle_admin(target):
return await target.send_message(UniMessage.text(
"你没有权限使用该指令"
))
async with puzzle_manager() as manager:
manager.admin_pin_puzzle("")
return await target.send_message("已取消所有置顶")
@cmd_admin.assign("modify")
async def _(
target: DepLongTaskTarget,
raw_id: str = "",
title: str | None = None,
description: str | None = None,
flag: str | None = None,
image: Image | None = None,
remove_image: Query[bool] = Query("modify.remove-image"),
):
if raw_id == "":
return await target.send_message(
"konaph modify <raw_id> - 修改一个谜题\n\n"
"支持的参数:\n"
" --title <str> 标题\n"
" --description <str> 题目详情描述(用直引号包裹以支持多行)\n"
" --flag <str> flag也就是题目的答案\n"
" --image <图片> 图片\n"
" --remove-image 删除图片"
)
image_manager = get_image_manager()
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
if title is not None:
p.title = title
if description is not None:
p.content = description
if flag is not None:
p.flag = flag.strip()
if flag.strip() != flag:
await target.send_message(
"⚠️ 注意:你输入的 Flag 含有开头或结尾的空格,已经帮你去除"
)
if image is not None and image.url is not None:
b = await download_image_bytes(image.url)
image_manager.remove_puzzle_image(p.img_name)
p.img_name = image_manager.upload_puzzle_image(b.unwrap())
elif remove_image.available:
image_manager.remove_puzzle_image(p.img_name)
info2 = get_puzzle_info_message(manager, p)
return await target.send_message("修改好啦!看看效果:\n\n" + info2)
@cmd_admin.assign("publish")
async def _(target: DepLongTaskTarget, raw_id: str | None = None):
if not is_puzzle_admin(target):
return await target.send_message(UniMessage.text(
"你没有权限使用该指令"
))
today = get_today_date()
async with puzzle_manager() as manager:
if today in manager.daily_puzzle_of_date:
return await target.send_message("今日已经有题了哦")
manager.last_checked_date = today - datetime.timedelta(days=-1)
if raw_id is not None:
manager.admin_pin_puzzle(raw_id)
p = manager.get_today_puzzle(strong=True)
if p is None:
return await target.send_message("上架失败了orz可能是没题了")
await qq_broadcast(config.plugin_puzzle_playgroup, get_puzzle_description(p))
return await target.send_message("Ok!")
@cmd_admin.assign("preview")
async def _(target: DepLongTaskTarget, raw_id: str):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
return await target.send_message(get_puzzle_description(p))
@cmd_admin.assign("get-submits")
async def _(target: DepLongTaskTarget, raw_id: str):
async with puzzle_manager() as manager:
puzzle = manager.puzzle_data.get(raw_id)
if puzzle is None:
return await target.send_message("没有这个谜题")
if not is_puzzle_admin(target) and target.target_id != puzzle.author_id:
return await target.send_message("你没有权限预览这个谜题")
msg = UniMessage.text(f"==== {puzzle.title} 提交记录 ====\n\n")
submits = manager.submissions.get(raw_id, {})
for uid, ls in submits.items():
s = ', '.join((i.flag for i in ls))
msg = msg.text(f"- {get_username(uid)}{s}\n")
return await target.send_message(msg)
@cmd_admin.assign("test")
async def _(target: DepLongTaskTarget, raw_id: str, submission: str):
"""
测试一道谜题的回答,并给出结果
"""
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
result = p.check_submission(submission)
msg = get_submission_message(p, result)
return await target.send_message("[测试提交] " + msg)
@cmd_admin.assign("subcommands.hint")
async def _(target: DepLongTaskTarget, subcommands: Query[SubcommandResult] = Query("subcommands.hint")):
if len(subcommands.result.subcommands) > 0:
return
return await target.send_message(
UniMessage.text("==== 提示/中间答案编辑器 ====\n\n")
.text("- konaph hint list <id>\n - 查看某道题的所有提示 / 中间答案\n")
.text("- konaph hint add <id> <pattern> <hint>\n - 添加一个提示 / 中间答案\n")
.text("- konaph hint modify <id> <hint_id>\n")
.text(" - --pattern <pattern>\n - 更改匹配规则\n")
.text(" - --message <message>\n - 更改提示文本\n")
.text(" - --checkpoint [True|False]\n - 更改是否为中间答案\n")
.text("- konaph hint delete <id> <hint_id>\n - 删除一个提示 / 中间答案\n")
.text("\n更多关于 pattern 和中间答案的信息,请见 man中间答案(7)")
)
@cmd_admin.assign("subcommands.hint.add")
async def _(
target: DepLongTaskTarget,
raw_id: str,
pattern: str,
message: str,
):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
p.hints[p.hint_id_max + 1] = PuzzleHint(
pattern=pattern,
message=message,
is_checkpoint=False,
)
await target.send_message("创建成功!\n\n" + get_puzzle_hint_list(p))
@cmd_admin.assign("subcommands.hint.list")
async def _(
target: DepLongTaskTarget,
raw_id: str,
):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
await target.send_message(get_puzzle_hint_list(p))
@cmd_admin.assign("subcommands.hint.modify")
async def _(
target: DepLongTaskTarget,
raw_id: str,
hint_id: int,
pattern: str | None = None,
message: str | None = None,
is_checkpoint: bool | None = None,
):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
if hint_id not in p.hints:
raise BotExceptionMessage(
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
)
hint = p.hints[hint_id]
if pattern is not None:
hint.pattern = pattern
if message is not None:
hint.message = message
if is_checkpoint is not None:
hint.is_checkpoint = is_checkpoint
await target.send_message("更改成功!\n\n" + get_puzzle_hint_list(p))
@cmd_admin.assign("subcommands.hint.delete")
async def _(
target: DepLongTaskTarget,
raw_id: str,
hint_id: int,
):
async with puzzle_manager() as manager:
p = check_puzzle(manager, target, raw_id)
if hint_id not in p.hints:
raise BotExceptionMessage(
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
)
del p.hints[hint_id]
await target.send_message("删除成功!\n\n" + get_puzzle_hint_list(p))
return cmd_admin

View File

@ -0,0 +1,40 @@
"""
肥肠危险注意:本文件仅用于开发环境测试 LLM 模块能否正常工作!
请不要在生产环境启用它!
"""
import nonebot
from nonebot_plugin_alconna import Alconna, Args, on_alconna
from pydantic import BaseModel
from konabot.common.llm import get_llm
from konabot.common.longtask import DepLongTaskTarget
class LLMTestConfig(BaseModel):
debug_enable_llm_test: bool = False
config = nonebot.get_plugin_config(LLMTestConfig)
if config.debug_enable_llm_test:
cmd = on_alconna(Alconna(
"debug-ask-llm",
Args["prompt", str],
))
@cmd.handle()
async def _(prompt: str, target: DepLongTaskTarget):
llm = get_llm()
msg = await llm.chat(
[
{"role": "user", "content": prompt}
],
timeout=None,
max_tokens=1024,
)
content = msg.content or ""
await target.send_message(content)

View File

@ -0,0 +1,47 @@
import asyncio
# import datetime
from loguru import logger
import nonebot
# from nonebot_plugin_alconna import UniMessage
from konabot.common.longtask import (
# DepLongTaskTarget,
# LongTask,
# create_longtask,
# handle_long_task,
init_longtask,
)
driver = nonebot.get_driver()
INIT_FLAG = {"flag": False}
@driver.on_bot_connect
async def _():
if INIT_FLAG["flag"]:
return
INIT_FLAG["flag"] = True
logger.info("有 Bot 连接,等待 5 秒后初始化 LongTask 模块")
await asyncio.sleep(5)
await init_longtask()
logger.info("LongTask 初始化完成")
# cmd1 = nonebot.on_command("test114")
#
#
# @handle_long_task("test_callback_001")
# async def _(lt: LongTask):
# await lt.target.send_message(UniMessage().text("Hello, world!"), at=True)
#
#
# @cmd1.handle()
# async def _(target: DepLongTaskTarget):
# await create_longtask(
# handler="test_callback_001",
# data={},
# target=target,
# deadline=datetime.datetime.now() + datetime.timedelta(seconds=20),
# )

View File

@ -1,15 +1,14 @@
from curses.ascii import isdigit
from pathlib import Path
import nonebot
import nonebot.adapters
import nonebot.adapters.discord
import nonebot.rule
from nonebot import on_command
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
from konabot.common.nb.is_admin import is_admin
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
from konabot.plugins.markdown.core import MarkDownCore
def search_man(section: int) -> dict[tuple[int, str], Path]:
base_path = {
@ -40,7 +39,10 @@ async def _(
doc: str | None,
event: nonebot.adapters.Event,
):
if doc is not None and section is None and all(isdigit(c) for c in doc):
if doc is not None and section is None and all(
ord('0') <= ord(c) <= ord('9')
for c in doc
):
section = int(doc)
doc = None
@ -53,7 +55,7 @@ async def _(
if doc is None:
# 检索模式
if section is None:
section_set = {1}
section_set = {1, 7}
else:
section_set = {section}
if 1 in section_set and is_admin(event):
@ -62,7 +64,7 @@ async def _(
for section in section_set:
mans += [f"{n}({s})" for s, n in search_man(section).keys()]
mans.sort()
await man.send(UniMessage().text(
(
"★此方 BOT 使用帮助★\n"
@ -75,7 +77,7 @@ async def _(
else:
# 查阅模式
if section is None:
section_set = {1}
section_set = {1, 7}
else:
section_set = {section}
if 1 in section_set and is_admin(event):
@ -92,9 +94,9 @@ async def _(
await man.send(UniMessage().text("你所检索的指令不存在"))
return
mans_msg = mans_fp.read_text('utf-8', 'replace')
if isinstance(event, nonebot.adapters.discord.event.MessageEvent):
mans_msg = f'```\n{mans_msg}\n```'
await man.send(UniMessage().text(mans_msg))
# await man.send(UniMessage().text(mans_msg))
img = await MarkDownCore.render_markdown(mans_msg)
await man.send(UniMessage.image(raw=img))
help_deprecated = on_command('help', rule=nonebot.rule.to_me())

View File

@ -0,0 +1,72 @@
from loguru import logger
import nonebot
from nonebot.adapters import Event as BaseEvent
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
from nonebot_plugin_alconna import (
UniMessage,
UniMsg
)
from konabot.plugins.markdown.core import MarkDownCore
def is_markdown_mentioned(msg: UniMsg) -> bool:
txt = msg.extract_plain_text()
if "markdown" not in txt[:8] and "md" not in txt[:2]:
return False
return True
evt = nonebot.on_message(rule=is_markdown_mentioned)
@evt.handle()
async def _(msg: UniMsg, event: BaseEvent):
if isinstance(event, DiscordMessageEvent):
content = msg.extract_plain_text()
else:
content = msg.extract_plain_text()
logger.debug(f"Received markdown command with content: {content}")
if "md" in content[:2]:
message = content.replace("md", "", 1).strip()
else:
message = content.replace("markdown", "", 1).strip()
# 如果回复了消息,则转换回复的内容
if(len(message) == 0):
if event.reply:
message = event.reply.message.extract_plain_text()
else:
return
logger.debug(f"Markdown content to render: {message}")
out = await MarkDownCore.render_markdown(message, theme="dark")
await evt.send(await UniMessage().image(raw=out).export())
def is_latex_mentioned(evt: BaseEvent, msg: UniMsg) -> bool:
txt = msg.extract_plain_text()
if "latex" not in txt[:5]:
return False
return True
evt = nonebot.on_message(rule=is_latex_mentioned)
@evt.handle()
async def _(msg: UniMsg, event: BaseEvent):
if isinstance(event, DiscordMessageEvent):
content = msg.extract_plain_text()
else:
content = msg.extract_plain_text()
logger.debug(f"Received markdown command with content: {content}")
message = content.replace("latex", "", 1).strip()
# 如果回复了消息,则转换回复的内容
if(len(message) == 0):
if event.reply:
message = event.reply.message.extract_plain_text()
else:
return
logger.debug(f"Latex content to render: {message}")
out = await MarkDownCore.render_latex(message, theme="dark")
await evt.send(await UniMessage().image(raw=out).export())

View File

@ -0,0 +1,57 @@
from loguru import logger
from playwright.async_api import ConsoleMessage, Page
from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer
class MarkDownCore:
@staticmethod
async def render_markdown(markdown_text: str, theme: str = "dark", params: dict = {}) -> bytes:
async def page_function(page: Page):
await page.emulate_media(color_scheme=theme)
await page.locator('textarea[name=content]').fill(markdown_text)
await page.locator('#button').click()
# 等待 checkState 函数加载完成
await page.wait_for_function("typeof checkState === 'function'", timeout=1000)
# 访问 checkState 函数,确保渲染完成
await page.wait_for_function("checkState() === true", timeout=1000)
out = await WebRenderer.render_with_persistent_page(
"markdown_renderer",
konaweb('markdown'),
target='#main',
other_function=page_function,
params=params
)
return out
@staticmethod
async def render_latex(text: str, theme: str = "dark") -> bytes:
params = {
"size": "2.5em",
}
async def page_function(page: Page):
await page.emulate_media(color_scheme=theme)
page.wait_for_selector('textarea[name=content]')
await page.locator('textarea[name=content]').fill(f"$$ {text} $$")
page.wait_for_selector('#button')
await page.locator('#button').click()
# 等待 checkState 函数加载完成
await page.wait_for_function("typeof checkState === 'function'", timeout=2000)
# 访问 checkState 函数,确保渲染完成
await page.wait_for_function("checkState() === true", timeout=10000)
out = await WebRenderer.render_with_persistent_page(
"latex_renderer",
konaweb('latex'),
target='#main',
other_function=page_function,
params=params
)
return out

View File

@ -1,26 +1,60 @@
from io import BytesIO
from typing import Iterable, cast
from loguru import logger
from nonebot import on_message
from nonebot_plugin_alconna import (Alconna, Args, Field, MultiVar, Text,
UniMessage, UniMsg, on_alconna)
from nonebot_plugin_alconna import (
Alconna,
Args,
Field,
Image,
MultiVar,
Option,
Text,
UniMessage,
UniMsg,
on_alconna,
)
from playwright.async_api import ConsoleMessage, Page
from konabot.common.nb.extract_image import extract_image_from_message
from konabot.plugins.memepack.drawing.display import draw_cao_display
from konabot.plugins.memepack.drawing.saying import (draw_cute_ten,
draw_geimao, draw_mnk,
draw_pt, draw_suan)
from konabot.common.nb.extract_image import PIL_Image, extract_image_from_message
from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer
from konabot.common.web_render.host_images import host_tempdir
from konabot.plugins.memepack.drawing.display import (
draw_cao_display,
draw_snaur_display,
draw_anan_display,
)
from konabot.plugins.memepack.drawing.saying import (
draw_cute_ten,
draw_geimao,
draw_kiosay,
draw_mnk,
draw_pt,
draw_suan,
)
from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark
from nonebot.adapters import Bot, Event
from returns.result import Success, Failure
geimao = on_alconna(Alconna(
"给猫说",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "你没有写给猫说了什么"
)]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"给猫哈"})
geimao = on_alconna(
Alconna(
"给猫说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写给猫说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases={"给猫哈"},
)
@geimao.handle()
async def _(saying: list[str]):
@ -31,12 +65,21 @@ async def _(saying: list[str]):
await geimao.send(await UniMessage().image(raw=img_bytes).export())
pt = on_alconna(Alconna(
"pt说",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "你没有写小帕说了什么"
)]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"小帕说"})
pt = on_alconna(
Alconna(
"pt说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写小帕说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases={"小帕说"},
)
@pt.handle()
async def _(saying: list[str]):
@ -47,12 +90,21 @@ async def _(saying: list[str]):
await pt.send(await UniMessage().image(raw=img_bytes).export())
mnk = on_alconna(Alconna(
"re:小?黑白子?说",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "你没有写黑白子说了什么"
)]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"mnk说"})
mnk = on_alconna(
Alconna(
"re:小?黑白子?说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写黑白子说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases={"mnk说"},
)
@mnk.handle()
async def _(saying: list[str]):
@ -63,12 +115,21 @@ async def _(saying: list[str]):
await mnk.send(await UniMessage().image(raw=img_bytes).export())
suan = on_alconna(Alconna(
"小蒜说",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "你没有写小蒜说了什么"
)]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases=set())
suan = on_alconna(
Alconna(
"小蒜说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写小蒜说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases=set(),
)
@suan.handle()
async def _(saying: list[str]):
@ -79,12 +140,21 @@ async def _(saying: list[str]):
await suan.send(await UniMessage().image(raw=img_bytes).export())
dsuan = on_alconna(Alconna(
"大蒜说",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "你没有写大蒜说了什么"
)]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases=set())
dsuan = on_alconna(
Alconna(
"大蒜说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写大蒜说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases=set(),
)
@dsuan.handle()
async def _(saying: list[str]):
@ -95,12 +165,21 @@ async def _(saying: list[str]):
await dsuan.send(await UniMessage().image(raw=img_bytes).export())
cutecat = on_alconna(Alconna(
"乖猫说",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "你没有写十猫说了什么"
)]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"十猫说"})
cutecat = on_alconna(
Alconna(
"乖猫说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写十猫说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases={"十猫说"},
)
@cutecat.handle()
async def _(saying: list[str]):
@ -113,13 +192,14 @@ async def _(saying: list[str]):
cao_display_cmd = on_message()
@cao_display_cmd.handle()
async def _(msg: UniMsg, evt: Event, bot: Bot):
flag = False
for text in cast(Iterable[Text], msg.get(Text)):
if text.text.strip() == "小槽展示":
flag = True
elif text.text.strip() == '':
elif text.text.strip() == "":
continue
else:
return
@ -134,8 +214,145 @@ async def _(msg: UniMsg, evt: Event, bot: Bot):
case Failure(err):
await cao_display_cmd.send(
await UniMessage()
.at(user_id=evt.get_user_id())
.text(' ')
.text(err)
.export()
.at(user_id=evt.get_user_id())
.text(" ")
.text(err)
.export()
)
snaur_display_cmd = on_alconna(
Alconna(
"卵总展示",
Option("--whiteness", Args["whiteness", float], alias=["-w"]),
Option("--black-level", Args["black_level", float], alias=["-b"]),
Option("--opacity", Args["opacity", float], alias=["-o"]),
Option("--saturation", Args["saturation", float], alias=["-s"]),
Args["image", Image | None],
)
)
@snaur_display_cmd.handle()
async def _(
img: PIL_Image,
whiteness: float = 0.0,
black_level: float = 0.2,
opacity: float = 0.8,
saturation: float = 0.85,
):
img_processed = await draw_snaur_display(
img,
whiteness,
black_level,
opacity,
saturation,
)
img_data = BytesIO()
img_processed.save(img_data, "PNG")
await snaur_display_cmd.send(await UniMessage().image(raw=img_data).export())
anan_display_cmd = on_message()
@anan_display_cmd.handle()
async def _(msg: UniMsg, evt: Event, bot: Bot):
flag = False
for text in cast(Iterable[Text], msg.get(Text)):
stripped = text.text.strip()
if stripped == "安安展示":
flag = True
elif stripped == "":
continue
else:
return
if not flag:
return
match await extract_image_from_message(evt.get_message(), evt, bot):
case Success(img):
img_handled = await draw_anan_display(img)
img_bytes = BytesIO()
img_handled.save(img_bytes, format="PNG")
await anan_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
case Failure(err):
await anan_display_cmd.send(
await UniMessage()
.at(user_id=evt.get_user_id())
.text(" ")
.text(err)
.export()
)
kiosay = on_alconna(
Alconna(
"西多说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写西多说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases=set(),
)
@kiosay.handle()
async def _(saying: list[str]):
img = await draw_kiosay("\n".join(saying))
img_bytes = BytesIO()
img.save(img_bytes, format="PNG")
await kiosay.send(await UniMessage().image(raw=img_bytes).export())
quote_cmd = on_alconna(Alconna(
"名人名言",
Args["quote", str],
Args["author", str],
Args["image?", Image | None],
), aliases={"quote"})
@quote_cmd.handle()
async def _(quote: str, author: str, img: PIL_Image):
async with host_tempdir() as tempdir:
img_path = tempdir.path / "image.png"
img_url = tempdir.url_of(img_path)
img.save(img_path)
async def page_function(page: Page):
async def on_console(msg: ConsoleMessage):
logger.debug(f"WEB CONSOLE {msg.text}")
page.on('console', on_console)
await page.locator('input[name=image]').fill(img_url)
await page.locator('input[name=quote]').fill(quote)
await page.locator('input[name=author]').fill(author)
await page.wait_for_timeout(500)
await page.wait_for_load_state('networkidle')
await page.wait_for_timeout(500)
out = await WebRenderer.render(
konaweb('makequote'),
target='#main',
other_function=page_function,
)
await quote_cmd.send(await UniMessage().image(raw=out).export())
doubao_cmd = on_alconna(Alconna(
"豆包水印",
Args["image?", Image | None],
))
@doubao_cmd.handle()
async def _(img: PIL_Image):
result = await draw_doubao_watermark(img)
result_bytes = BytesIO()
result.save(result_bytes, format="PNG")
await doubao_cmd.send(await UniMessage().image(raw=result_bytes).export())

View File

@ -4,10 +4,12 @@ from typing import Any, cast
import cv2
import numpy as np
import PIL.Image
import PIL.ImageChops
import PIL.ImageEnhance
from konabot.common.path import ASSETS_PATH
cao_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "caoimg1.png")
cao_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "caoimg1.png")
CAO_QUAD_POINTS = np.float32(cast(Any, [
[392, 540],
[577, 557],
@ -15,6 +17,25 @@ CAO_QUAD_POINTS = np.float32(cast(Any, [
[381, 687],
]))
snaur_image_base = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "snaur_1_base.png")
snaur_image_top = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "snaur_1_top.png")
SNAUR_RATIO = (1 / 2) ** .5
SNAUR_QUAD_POINTS = np.float32(cast(Any, [
[0, 466 ],
[673, 471 ],
[640, 1196],
[106, 1280],
]))
anan_image_base = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "anan_base.png")
anan_image_top = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "anan_top.png")
ANAN_QUAD_POINTS = np.float32([
[157, 585],
[793, 599],
[781, 908],
[160, 908]
])
def _draw_cao_display(image: PIL.Image.Image):
src = np.array(image.convert("RGB"))
h, w = src.shape[:2]
@ -43,3 +64,136 @@ def _draw_cao_display(image: PIL.Image.Image):
async def draw_cao_display(image: PIL.Image.Image):
return await asyncio.to_thread(_draw_cao_display, image)
def _draw_snaur_display(
image : PIL.Image.Image,
whiteness : float = 0.0 ,
black_level: float = 0.2 ,
opacity : float = 0.8 ,
saturation : float = 0.85 ,
):
src = np.array(image.convert("RGBA"))
_h, _w = src.shape[:2]
if _w / _h < SNAUR_RATIO:
_w_target = _w
_h_target = int(_w / SNAUR_RATIO)
else:
_w_target = int(_h * SNAUR_RATIO)
_h_target = _h
x_center = _w / 2
y_center = _h / 2
x1 = int(x_center - _w_target / 2)
x2 = int(x_center + _w_target / 2)
y1 = int(y_center - _h_target / 2)
y2 = int(y_center + _h_target / 2)
src = src[y1:y2, x1:x2, :]
h, w = src.shape[:2]
src_points = np.float32(cast(Any, [
[0, 0],
[w, 0],
[w, h],
[0, h],
]))
dst_points = SNAUR_QUAD_POINTS
M = cv2.getPerspectiveTransform(cast(Any, src_points), cast(Any, dst_points))
output_size = snaur_image_top.size
output_w, output_h = output_size
warped = cv2.warpPerspective(
src,
M,
(output_w, output_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0)
)
result = PIL.Image.fromarray(warped, 'RGBA')
r, g, b, a = result.split()
a = a.point(lambda p: int(p * opacity))
f2 = lambda p: int(
((p / 255) ** (2 ** whiteness)) * 255 * (1 - black_level)
+ 255 * black_level
)
r = r.point(f2)
g = g.point(f2)
b = b.point(f2)
result = PIL.Image.merge('RGBA', (r, g, b, a))
enhancer = PIL.ImageEnhance.Color(result)
result = enhancer.enhance(saturation)
result = PIL.ImageChops.multiply(result, snaur_image_base)
result = PIL.Image.alpha_composite(snaur_image_base, result)
result = PIL.Image.alpha_composite(result, snaur_image_top)
return result
async def draw_snaur_display(
image : PIL.Image.Image,
whiteness : float = 0.0 ,
black_level: float = 0.2 ,
opacity : float = 0.8 ,
saturation : float = 0.85 ,
) -> PIL.Image.Image:
return await asyncio.to_thread(
_draw_snaur_display, image, whiteness, black_level,
opacity, saturation,
)
def _draw_anan_display(image: PIL.Image.Image) -> PIL.Image.Image:
src = np.array(image.convert("RGBA"))
h, w = src.shape[:2]
src_points = np.float32([
[0, 0],
[w, 0],
[w, h],
[0, h]
])
dst_points = ANAN_QUAD_POINTS
M = cv2.getPerspectiveTransform(src_points, dst_points)
output_w, output_h = anan_image_top.size
src_rgb = cv2.cvtColor(src, cv2.COLOR_RGBA2RGB) if src.shape[2] == 4 else src
warped_rgb = cv2.warpPerspective(
src_rgb,
M,
(output_w, output_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0)
)
mask = np.zeros((h, w), dtype=np.uint8)
mask[:, :] = 255
warped_mask = cv2.warpPerspective(
mask,
M,
(output_w, output_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=0
)
warped_rgba = cv2.cvtColor(warped_rgb, cv2.COLOR_RGB2RGBA)
warped_rgba[:, :, 3] = warped_mask
warped_pil = PIL.Image.fromarray(warped_rgba, 'RGBA')
result = PIL.Image.alpha_composite(anan_image_base, warped_pil)
result = PIL.Image.alpha_composite(result, anan_image_top)
return result
async def draw_anan_display(image: PIL.Image.Image) -> PIL.Image.Image:
return await asyncio.to_thread(_draw_anan_display, image)

View File

@ -5,6 +5,7 @@ import imagetext_py
import PIL.Image
from konabot.common.path import ASSETS_PATH
from konabot.common.utils.to_async import make_async
from .base.fonts import HARMONYOS_SANS_SC_BLACK, HARMONYOS_SANS_SC_REGULAR, LXGWWENKAI_REGULAR
@ -14,6 +15,7 @@ mnk_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "mnksay.jpg").convert(
dasuan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "dss.png").convert("RGBA")
suan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "suanleba.png").convert("RGBA")
cute_ten_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "tententen.png").convert("RGBA")
kio_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "kiosay.jpg").convert("RGBA")
def _draw_geimao(saying: str):
@ -29,7 +31,7 @@ def _draw_geimao(saying: str):
draw_emojis=True,
)
return img
async def draw_geimao(saying: str):
return await asyncio.to_thread(_draw_geimao, saying)
@ -106,3 +108,18 @@ def _draw_cute_ten(saying: str):
async def draw_cute_ten(saying: str):
return await asyncio.to_thread(_draw_cute_ten, saying)
@make_async
def draw_kiosay(saying: str):
img = kio_image.copy()
with imagetext_py.Writer(img) as iw:
iw.draw_text_wrapped(
saying, 450, 540, 0.5, 0.5, 900, 96, LXGWWENKAI_REGULAR,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
1.0,
imagetext_py.TextAlign.Center,
draw_emojis=True,
)
return img

View File

@ -0,0 +1,20 @@
import PIL
import PIL.Image
from konabot.common.path import ASSETS_PATH
from konabot.common.utils.to_async import make_async
doubao_watermark = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "doubao.png").convert("RGBA")
@make_async
def draw_doubao_watermark(base: PIL.Image.Image) -> PIL.Image.Image:
base = base.copy().convert("RGBA")
w = base.size[0] / 768 * 140
h = base.size[0] / 768 * 40
x = base.size[0] / 768 * 160
y = base.size[0] / 768 * 60
w, h, x, y = map(int, (w, h, x, y))
base.alpha_composite(doubao_watermark.resize((w, h)), (base.size[0] - x, base.size[1] - y))
return base

View File

@ -0,0 +1,44 @@
import nonebot
from nonebot.adapters.onebot.v11.bot import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot_plugin_alconna import UniMsg, UniMessage
from pydantic import BaseModel
class NoLuowenConfig(BaseModel):
plugin_noluowen_qqid: int = -1
plugin_noluowen_enable_group: list[int] = []
config = nonebot.get_plugin_config(NoLuowenConfig)
async def is_luowen_mentioned(evt: GroupMessageEvent, msg: UniMsg) -> bool:
if config.plugin_noluowen_qqid <= 0:
return False
if evt.user_id == config.plugin_noluowen_qqid:
return False
if evt.group_id not in config.plugin_noluowen_enable_group:
return False
txt = msg.extract_plain_text()
if "洛温" not in txt:
return False
if "罗文" in txt:
return False
if "阿特金森" in txt:
return False
return True
evt_luowen_mentioned = nonebot.on_message(rule=is_luowen_mentioned)
@evt_luowen_mentioned.handle()
async def _(evt: GroupMessageEvent, bot: Bot):
msg = (
UniMessage()
.reply(str(evt.message_id))
.at(str(config.plugin_noluowen_qqid))
.text(" 好像有人念错了你的 ID")
)
await evt_luowen_mentioned.send(await msg.export(bot=bot))

View File

@ -0,0 +1,168 @@
import json, time
from nonebot.rule import Rule
from nonebot_plugin_alconna import Alconna, Args, Field, MultiVar, on_alconna
from nonebot.adapters.onebot.v11 import Event
from konabot.common.nb.wzq_conflict import no_wzqbot_rule
from konabot.common.path import ASSETS_PATH, DATA_PATH
POLL_TEMPLATE_FILE = ASSETS_PATH / "json" / "poll.json"
POLL_DATA_FILE = DATA_PATH / "poll.json"
if not POLL_DATA_FILE.exists():
POLL_DATA_FILE.write_bytes(POLL_TEMPLATE_FILE.read_bytes())
poll_list = json.loads(POLL_DATA_FILE.read_text("utf-8"))['poll']
async def createpoll(title,qqid,options):
polllength = len(poll_list)
pollid = str(polllength)
poll_create = int(time.time())
poll_expiry = poll_create + 24*3600
polljson = {"title":title,"qq":qqid,"create":poll_create,"expiry":poll_expiry,"options":options,"polldata":{}}
poll_list[pollid] = polljson
writeback()
return pollid
def getpolldata(pollid_or_title):
# 初始化“被指定的投票项目”
thepoll = {}
polnum = -1
# 判断是ID还是标题
if str.isdigit(pollid_or_title):
if pollid_or_title in poll_list:
thepoll = poll_list[pollid_or_title]
polnum = pollid_or_title
else:
return [{},-1]
else:
for i in poll_list:
if poll_list[i]["title"] == pollid_or_title:
thepoll = poll_list[i]
polnum = i
break
if polnum == -1:
return [{},-1]
return [thepoll,polnum]
def writeback():
# file = open(poll_json_path,"w",encoding="utf-8")
# json.dump({'poll':poll_list},file,ensure_ascii=False,sort_keys=True)
POLL_DATA_FILE.write_text(json.dumps({
'poll': poll_list,
}, ensure_ascii=False, sort_keys=True), "utf-8")
async def pollvote(polnum,optionnum,qqnum):
optiond = poll_list[polnum]["polldata"]
if optionnum not in optiond:
poll_list[polnum]["polldata"][optionnum] = []
poll_list[polnum]["polldata"][optionnum].append(qqnum)
writeback()
return
poll = on_alconna(Alconna(
"poll",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "参数错误。用法:发起投票 <投票标题> <选项1> <选项2> ..."
)],
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"发起投票","createpoll"}, rule=no_wzqbot_rule)
@poll.handle()
async def _(saying: list, event: Event):
if (len(saying) < 3):
await poll.send("请提供至少两个投票选项!")
elif (len(saying) < 17):
title = saying[0]
saying.remove(title)
options = {}
for i in saying:
options[saying.index(i)] = i
qqid = event.get_user_id()
result = await createpoll(title,qqid,options)
await poll.send("已创建投票。回复 查看投票 "+str(result)+" 查看该投票。")
else:
await poll.send("投票选项太多了请减少到15个选项以内。")
viewpoll = on_alconna(Alconna(
"viewpoll",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "请指定投票ID或标题。用法查看投票 <投票ID或标题>"
)],
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"查看投票"}, rule=no_wzqbot_rule)
@viewpoll.handle()
async def _(saying: list):
# 参数投票ID或者标题
# pollid_or_title = params[0]
polldata = getpolldata(saying[0])
# 被指定的投票项目
thepoll = polldata[0]
polnum = polldata[1]
if polnum == -1:
await viewpoll.send("该投票不存在!")
else:
# 检查投票是否已结束
pollended = 0
if time.time() > thepoll["expiry"]:
pollended = 1
# 回复内容
reply = "投票:"+thepoll["title"]+" [ID: "+str(polnum)+"]"
# 如果投票已结束
if pollended:
for i in thepoll["options"]:
reply += "\n"
# 检查该选项是否有人投票
if i in thepoll["polldata"]:
reply += "["+str(len(thepoll["polldata"][i]))+" 票]"
else:
reply += "[0 票]"
reply += " "+thepoll["options"][i]
reply += "\n\n此投票已结束。"
else:
for i in thepoll["options"]:
reply += "\n"
reply += "- "+thepoll["options"][i]
# reply += "\n\n小提示向bot私聊发送 /viewpoll "+str(polnum)+" 可查看已投票数哦!"
reply += "\n\n发送 投票 "+str(polnum)+" <选项文本> 即可参与投票!"
await viewpoll.send(reply)
vote = on_alconna(Alconna(
"vote",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "参数错误。用法:投票 <投票ID/标题> <选项文本>"
)],
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"投票","参与投票"}, rule=no_wzqbot_rule)
@vote.handle()
async def _(saying: list, event: Event):
if (len(saying) < 2):
await vote.send("请指定投给哪一项!")
else:
polldata = getpolldata(saying[0])
# 被指定的投票项目
thepoll = polldata[0]
polnum = polldata[1]
if polnum == -1:
await viewpoll.finish("没有找到这个投票!")
# thepolldata = thepoll["polldata"]
# 查找对应的投票项
optionnum = -1
for i in thepoll["options"]:
if saying[1] == thepoll["options"][i]:
optionnum = i
break
if optionnum == -1:
reply = "此投票里面没有这一项!可用的选项有:"
for i in thepoll["options"]:
reply += "\n"
reply += "- "+thepoll["options"][i]
await viewpoll.send(reply)
# 检查是否符合投票条件该qq号是否已参与过投票、投票是否过期
elif time.time() > thepoll["expiry"]:
await viewpoll.send("此投票已经结束!请发送 查看投票 "+polnum+" 查看结果。")
elif str(event.get_user_id()) in str(thepoll["polldata"]):
await viewpoll.send("你已参与过此投票!请在投票结束后发送 查看投票 "+polnum+" 查看结果。")
# 写入项目
else:
await pollvote(polnum,optionnum,event.get_user_id())
await viewpoll.send("投票成功!你投给了 "+saying[1])

View File

@ -0,0 +1,85 @@
import nonebot
from nonebot_plugin_alconna import Alconna, Args, on_alconna
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.pager import PagerQuery
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
from konabot.plugins.poster.service import dep_poster_service
cmd_subscribe = on_alconna(Alconna(
"订阅",
Args["channel", str],
))
@cmd_subscribe.handle()
async def _(target: DepLongTaskTarget, channel: str):
async with dep_poster_service() as service:
result = await service.subscribe(channel, target)
if result:
await target.send_message(f"已订阅「{channel}")
else:
await target.send_message(f"已经订阅过「{channel}」了")
cmd_list = on_alconna(Alconna(
"re:(?:查询|我的|获取)订阅(列表)?",
Args["page?", int],
))
def better_channel_message(channel_id: str) -> str:
if channel_id not in POSTER_INFO_DATA:
return channel_id
data = POSTER_INFO_DATA[channel_id]
return f"{channel_id}{data.description}"
@cmd_list.handle()
async def _(target: DepLongTaskTarget, page: int = 1):
async with dep_poster_service() as service:
result = await service.get_channels(target, PagerQuery(
page_index=page,
page_size=10,
))
await target.send_message(result.to_unimessage(title="订阅列表", formatter=better_channel_message))
cmd_list_available = on_alconna(Alconna(
"re:(查询)?可用订阅(列表)?",
Args["page?", int],
))
@cmd_list_available.handle()
async def _(target: DepLongTaskTarget, page: int = 1):
result = PagerQuery(
page_index=page,
page_size=10,
).apply(sorted(POSTER_INFO_DATA.keys()))
await target.send_message(result.to_unimessage(title="可用订阅列表", formatter=better_channel_message))
cmd_unsubscribe = on_alconna(Alconna(
"取消订阅",
Args["channel", str],
))
@cmd_unsubscribe.handle()
async def _(target: DepLongTaskTarget, channel: str):
async with dep_poster_service() as service:
result = await service.subscribe(channel, target)
if result:
await target.send_message(f"已取消订阅「{channel}")
else:
await target.send_message(f"这里没有订阅过「{channel}")
driver = nonebot.get_driver()
@driver.on_startup
async def _():
async with dep_poster_service() as service:
await service.fix_data()

View File

@ -0,0 +1,15 @@
from dataclasses import dataclass, field
@dataclass
class PosterInfo:
aliases: set[str] = field(default_factory=set)
description: str = field(default='')
POSTER_INFO_DATA: dict[str, PosterInfo] = {}
def register_poster_info(channel: str, info: PosterInfo):
POSTER_INFO_DATA[channel] = info

View File

@ -0,0 +1,112 @@
import asyncio
from contextlib import asynccontextmanager
from typing import Annotated
from nonebot.params import Depends
from pydantic import BaseModel, ValidationError
from konabot.common.longtask import LongTaskTarget
from konabot.common.pager import PagerQuery, PagerResult
from konabot.common.path import DATA_PATH
from konabot.plugins.poster.repository import IPosterRepo
class ChannelData(BaseModel):
targets: list[LongTaskTarget] = []
class PosterData(BaseModel):
channels: dict[str, ChannelData] = {}
def is_the_same_target(target1: LongTaskTarget, target2: LongTaskTarget) -> bool:
if (target1.is_private_chat and not target2.is_private_chat):
return False
if (target2.is_private_chat and not target1.is_private_chat):
return False
if target1.platform != target2.platform:
return False
# 如果是群聊,则要求 channel_id 相同
if not target1.is_private_chat:
return target1.channel_id == target2.channel_id
return target1.target_id == target2.target_id
class LocalPosterRepo(IPosterRepo):
def __init__(self, data: PosterData) -> None:
self.data = data
super().__init__()
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
if channel not in self.data.channels:
self.data.channels[channel] = ChannelData()
return self.data.channels[channel].targets
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
targets = await self.get_channel_targets(channel)
for t in targets:
if is_the_same_target(t, target):
return False
targets.append(target)
return True
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
targets = await self.get_channel_targets(channel)
len0 = len(targets)
self.data.channels[channel].targets = [
t for t in targets if not is_the_same_target(t, target)
]
len1 = len(self.data.channels[channel].targets)
return len0 != len1
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
channels: list[str] = []
for channel_id, channel in self.data.channels.items():
for t in channel.targets:
if is_the_same_target(target, t):
channels.append(channel_id)
break
channels = sorted(channels)
return pager.apply(channels)
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
channel_from = await self.get_channel_targets(from_channel)
channel_to = await self.get_channel_targets(to_channel)
for t1 in channel_from:
flag = True
for t2 in channel_to:
if is_the_same_target(t1, t2):
flag = False
break
if flag:
channel_to.append(t1)
del self.data.channels[from_channel]
LOCAL_POSTER_DATA_LOCK = asyncio.Lock()
LOCAL_POSTER_DATA_PATH = DATA_PATH / "module_poster_data.json"
@asynccontextmanager
async def local_poster_data():
async with LOCAL_POSTER_DATA_LOCK:
if not LOCAL_POSTER_DATA_PATH.exists():
data = PosterData()
else:
try:
data = PosterData.model_validate_json(LOCAL_POSTER_DATA_PATH.read_text())
except ValidationError:
data = PosterData()
yield data
LOCAL_POSTER_DATA_PATH.write_text(data.model_dump_json())
@asynccontextmanager
async def local_poster():
async with local_poster_data() as data:
yield LocalPosterRepo(data)
DepLocalPosterRepo = Annotated[LocalPosterRepo, Depends(local_poster)]

View File

@ -0,0 +1,37 @@
from abc import ABC, abstractmethod
from konabot.common.longtask import LongTaskTarget
from konabot.common.pager import PagerQuery, PagerResult
class IPosterRepo(ABC):
@abstractmethod
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
"""
获取广播通道的所有广播对象
"""
@abstractmethod
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
"""
向广播通道添加一个广播目标。若目标已存在,则返回 False
"""
@abstractmethod
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
"""
移除一个广播通道的目标。若目标不存在,则返回 False
"""
@abstractmethod
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
"""
获得一个目标已经订阅了的广播通道
"""
@abstractmethod
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
"""
合并两个 Channel 为一个,并移除另一个
"""

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