Compare commits

..

311 Commits

Author SHA1 Message Date
c2161635a8 Merge pull request 'fix: use Query to properly handle --pingpong flag' (#65) from pi-agent/konabot:fix/giftool-pingpong-bool into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #65
2026-04-02 20:47:34 +08:00
f21b7067df fix: use Query to properly handle --pingpong flag
Use nonebot_plugin_alconna Query to correctly handle the --pingpong
boolean flag. Previously the flag wasn't working because the
parameter wasn't being properly injected.

Changes:
- Import Query from nonebot_plugin_alconna
- Change Option to not have args (just Option('--pingpong'))
- Use Query[bool] type annotation with default Query('pingpong')
- Check pingpong.available to determine if flag was set
2026-04-02 20:34:23 +08:00
f7212d6f67 Merge pull request 'feat: add --pingpong flag to giftool' (#64) from pi-agent/konabot:feat/giftool-pingpong into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #64
2026-04-02 20:17:25 +08:00
b87c58485c feat: add --pingpong flag to giftool
Add pingpong mode to giftool command. When --pingpong flag is used,
the generated GIF will play forward then backward, creating a
back-and-forth looping effect.

Features:
- Add --pingpong option to giftool command
- Support combining with --speed for adjusted playback speed
- Update documentation with new option

Examples:
- giftool [图片] --pingpong
- giftool [图片] --pingpong --speed 2.0
2026-04-02 20:06:15 +08:00
51c0bf4229 Merge pull request 'fix: support empty string literals in textfx' (#63) from pi-agent/konabot:fix/textfx-empty-string into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #63
2026-03-18 19:41:34 +08:00
5b1c6d446c fix: remove redundant whitespace check; echo no-arg outputs empty line 2026-03-18 19:40:02 +08:00
717b7a95e8 fix: echo should not read stdin (Unix semantics) 2026-03-18 19:30:55 +08:00
9bac2b8cdf fix: support empty string literals in textfx
- Fix tokenizer to emit empty string token when closing quote on empty buffer
- Add force parameter to flush_word() to handle empty quoted strings
- Add test case for echo "" and echo ''
2026-03-18 19:23:42 +08:00
bfb8ebab29 Merge pull request 'feat: evolve textfx into a mini shell' (#62) from pi-agent/konabot:feat/textfx-minishell into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #62
2026-03-18 19:14:48 +08:00
805e60a9ff fix: address code review feedback
- Add exception handling in run_pipeline to catch unexpected errors
- Remove dead code in THTest (self.name check that never executes)
- Add timeout and concurrency limit tests to test_textfx_runtime_limits.py
2026-03-18 18:15:49 +08:00
1331f8f893 feat: evolve textfx into a mini shell 2026-03-18 18:13:35 +08:00
00f42dbdf1 Merge pull request '语法糖' (#61) from feature/sugar into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #61
2026-03-18 17:39:36 +08:00
d37c4870d8 Merge branch 'master' into feature/sugar 2026-03-18 17:38:59 +08:00
23b9f101b3 语法糖 2026-03-18 17:29:42 +08:00
8c1651ad3d 忘记 await 相关权限了,导致永远判 True
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-18 16:29:36 +08:00
ff60642c62 Merge pull request 'feat: add TRPG roll command' (#59) from pi-agent/konabot:feat/trpg-roll into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #59
Reviewed-by: 钟晓帕 <Passthem183@gmail.com>
2026-03-14 02:19:15 +08:00
69b5908445 refactor: narrow trpg roll message matching 2026-03-14 02:17:20 +08:00
a542ed1fd9 feat: add TRPG roll command 2026-03-14 02:02:41 +08:00
e86a385448 Merge pull request 'fix: parse fx resize y scale argument' (#58) from pi-agent/konabot:fix/fx-resize-arg-parsing into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #58
Reviewed-by: 钟晓帕 <Passthem183@gmail.com>
2026-03-14 01:27:40 +08:00
d4bb36a074 fix: parse fx resize y scale argument 2026-03-14 01:26:16 +08:00
1a2a3c0468 Merge pull request 'fix: correct fx resize behavior' (#57) from pi-agent/konabot:fix/fx-resize-behavior into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #57
Reviewed-by: 钟晓帕 <Passthem183@gmail.com>
2026-03-14 01:11:44 +08:00
67502cb932 fix: correct fx resize behavior 2026-03-14 01:07:24 +08:00
f9a312b80a Merge pull request 'feat: add JPEG damage filter to fx' (#56) from pi-agent/konabot:feat/fx-jpeg-damage into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #56
2026-03-14 01:01:38 +08:00
1980f8a895 feat: add jpeg damage filter to fx 2026-03-14 00:52:34 +08:00
d273ed4b1a 放宽 wolfx api 限制
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-12 20:33:03 +08:00
265e9cc583 改为使用中国地震台网的正式报
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-10 21:58:42 +08:00
8f5061ba41 wolfx api
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-10 11:39:41 +08:00
b3c3c77f3c 添加 Ignore 2026-03-10 11:16:23 +08:00
6a84ce2cd8 提供订阅模块的文档
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-09 14:56:31 +08:00
392c699b33 移动 poster 模块到 common
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-09 14:40:27 +08:00
72e21cd9aa 添加多字符喵对一些符号的响应 2026-03-09 13:46:56 +08:00
f3389ff2b9 添加服务器管理相关,以及 cronjob
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-08 03:34:14 +08:00
e59d3c2e4b 哎哟喂这个文件怎么没交
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-08 00:40:11 +08:00
31d19b7ec0 我没辙了直接把测试打包进去吧
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 18:41:59 +08:00
c2f677911d 添加一些权限目标
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-07 18:36:51 +08:00
f5b81319f8 konaph 接入权限系统
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-07 18:15:28 +08:00
870e2383d8 为 Drone 提供单元测试目录 2026-03-07 18:15:16 +08:00
7e8fa45f36 Merge pull request '权限系统' (#55) from feature/permsystem into master
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #55
2026-03-07 17:55:27 +08:00
abb864ec70 修复单元测试问题 2026-03-07 17:51:49 +08:00
b38dde1b70 修正若干拼写错误,增强相关逻辑 2026-03-07 17:50:35 +08:00
8f40572a38 修复拼写错误并完成文档 2026-03-07 17:43:37 +08:00
230705f689 完成权限系统 2026-03-07 17:35:59 +08:00
e605527900 补充 README 2026-03-07 16:25:15 +08:00
9064b31fe9 添加显示 coverage 的工具 2026-03-07 16:21:49 +08:00
27e53c7acd 提高代码覆盖率并提供显示代码覆盖率的工具 2026-03-07 16:17:14 +08:00
ca1db103b5 通过了单元测试嗯 2026-03-07 15:53:13 +08:00
7f1035ff43 创建获取权限的基础方法 2026-03-07 15:19:49 +08:00
5e0e39bfc3 创建基本的表结构 2026-03-07 13:52:16 +08:00
88861f4264 修复坏枪从来没有运行过的单元测试,为项目引入单元测试框架(终于。。) 2026-03-07 13:16:24 +08:00
a1c9f9bccb 添加给 AI 阅读的 AGENTS.md
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 12:06:37 +08:00
f6601f807a 添加 krg 表情差分
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-02 13:50:09 +08:00
f7cea196ec krgsay
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-28 15:31:12 +08:00
d4826e9e8b 配置两个 AI 功能都使用默认模型 2026-02-28 13:50:52 +08:00
33934ef7b5 让回复 celeste 允许不用带有前缀 2026-02-28 13:49:28 +08:00
f9f8ae4e67 调整 celeste 过度反应的 bug 2026-02-28 12:42:23 +08:00
94db34037b Merge pull request 'Enhancement: 为 man 和 textfx 指令添加图片渲染和文本 fallback' (#54) from enhancement/man-and-textfx into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #54
2026-02-25 16:26:13 +08:00
df409a13a9 把 timeout 调长一点 2026-02-25 16:24:19 +08:00
34175e8c17 添加错误捕获范围,调整日志注入参数方式 2026-02-25 16:20:44 +08:00
c66576e12b Merge pull request 'Feature: 创建启动通知' (#53) from feature/startup-notification into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #53
2026-02-25 16:13:01 +08:00
91769f93ae 添加渲染错误信息为图片 2026-02-25 16:11:23 +08:00
27841b8422 添加 man 指令的渲染 Fallback 2026-02-25 16:11:11 +08:00
48282ceb6c 添加启动通知 2026-02-25 15:08:23 +08:00
00c0202720 Merge pull request 'Feature: 支持响应更多类型的喵' (#52) from feature/nya-more into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #52
2026-02-25 13:57:36 +08:00
3ddf81e7de 修复变量名遮蔽问题 2026-02-25 13:52:25 +08:00
ba15841836 修复缺少「喵」字匹配的问题 2026-02-25 13:49:15 +08:00
014e9c9a71 创建更多喵的响应 2026-02-25 13:40:45 +08:00
32cabc9452 Merge pull request '添加节气报告功能' (#51) from feature/24-solar-terms-notification into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #51
2026-02-21 23:54:24 +08:00
19e83dea01 考虑循环边界条件的风险 2026-02-21 23:51:03 +08:00
9210f85300 让节气静态内置而不是实时 LLM 生成 2026-02-21 23:45:01 +08:00
74052594c3 添加节气查询指令 2026-02-21 23:36:42 +08:00
31ad8dac3e 添加节气报告功能 2026-02-21 22:43:09 +08:00
c46b88060b 宾几人功能调整;bilibili fetch 调整
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 17:57:00 +08:00
02018cd11d 添加宾几人功能
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-18 16:43:35 +08:00
d4cde42bdc Vibe Coding: textfx 若干 issue 更新
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-16 19:36:24 +08:00
58ff8f02da 更新 Celeste Classic 功能
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 19:06:24 +08:00
b32ddcaf38 调整 PPI
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-18 12:45:58 +08:00
1eb7e62cfe 添加 Typst 支持
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-18 12:25:17 +08:00
c44e29a907 添加 AI
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 23:12:28 +08:00
24457ff7cd 添加文档
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 22:21:04 +08:00
0d36bea3ca morse
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:49:33 +08:00
bf8504d432 忘记加 sort 了
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:44:10 +08:00
16a55ae69a 添加好多好多 crypto
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:39:45 +08:00
3adbd38d65 fix
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-09 23:42:34 +08:00
420630e35c 添加文本处理功能
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-09 23:39:13 +08:00
36a564547c 不回复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-31 15:21:18 +08:00
eb8bf16346 能不能给此方bot加上这个 by 蜡笔
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-31 15:02:08 +08:00
67884f7133 甲骨文跳过错误修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-30 22:25:50 +08:00
f18d94670e 二简字
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-29 22:11:27 +08:00
6e86a6987f 错误提示
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 21:15:22 +08:00
9c9496efbd new
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 21:13:21 +08:00
770d7567fb 甲骨文
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 16:13:55 +08:00
7026337a43 甲骨文 2025-12-16 15:40:18 +08:00
ef617e1c85 删除「黑白」的文档
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 18:52:51 +08:00
bd71a8d75f 黑白子说要 at 才能用太麻烦了
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 18:51:27 +08:00
605407549b 阴影透明度、颜色读取修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 20:35:13 +08:00
5e01e086f2 形状描边修复 2025-12-13 20:22:13 +08:00
1f887aeaf6 设置遮罩
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 18:37:15 +08:00
5de4b72a6b 大量滤镜转为RGBA以避免问题
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 18:34:15 +08:00
1861cd4f1a 阴影修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 18:25:43 +08:00
9148073095 完善形状描边,新增文本图层、空白图层生成
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 22:45:33 +08:00
ef3404b096 添加 shapely 库
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 19:34:13 +08:00
14feae943e 设置通道
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-10 19:29:30 +08:00
1d763dfc3c 设置通道 2025-12-10 19:28:34 +08:00
a829f035b3 new fx
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 17:22:12 +08:00
9904653cc6 new fx 2025-12-10 17:17:52 +08:00
de04fcbec1 Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-09 20:33:20 +08:00
70e3565e44 文档 2025-12-09 20:32:45 +08:00
6b10c99c7a 完整版 2025-12-09 20:21:36 +08:00
54fae88914 待完善 2025-12-09 00:02:26 +08:00
cdfb822f42 把所有代办都换成待办
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-07 23:11:21 +08:00
73aad89f57 避开访问百度的过程
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 22:23:11 +08:00
e1b5f9cfc9 添加对多行的匹配
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 22:16:57 +08:00
35f411fb3a 提醒UI的GIF实现与参数传递
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 16:20:09 +08:00
eed21e6223 new
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 22:38:50 +08:00
bf5c10b7a7 notice test
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 22:23:44 +08:00
274ca0fa9a 初步尝试UI化 2025-12-03 22:00:44 +08:00
c72cdd6a6b 新滤镜,新修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 17:20:46 +08:00
16b0451133 删掉黑白
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 13:32:11 +08:00
cb34813c4b Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot 2025-12-03 13:22:09 +08:00
2de3be271e 最新最热模糊
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 13:10:16 +08:00
f7d2168dac 最新最热 2025-12-03 12:25:39 +08:00
40be5ce335 fx 完善
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 14:45:07 +08:00
8e6131473d fximage 2025-12-02 12:17:11 +08:00
26e10be4ec 修复相关文档 2025-11-28 17:10:18 +08:00
78bda5fc0a 添加云盾
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-28 16:59:58 +08:00
97658a6c56 补充文档
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-28 16:54:29 +08:00
3fedc685a9 没有人需要的提取首字母功能 2025-11-28 16:51:16 +08:00
d1a3e44c45 调整日志等级和内容
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-26 13:09:37 +08:00
f637778173 完成排行榜部分
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-26 13:02:26 +08:00
145bfedf67 Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-25 14:29:46 +08:00
61b9d733a5 添加阿里绿网云盾 API 2025-11-25 14:29:26 +08:00
ae59c20e2f 添加对 pyrightconfig 的 ignore,方便使用其他 IDE 的人使用 config 文件指定虚拟环境位置等
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-24 18:36:06 +08:00
0b7d21aeb0 新增一条示例,以便处理几百年的(
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-22 01:22:42 +08:00
d6ede3e6cd 标准化时间的解析
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-21 16:56:00 +08:00
07ace8e6e9 就是加个 Y 的事情(
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-21 16:19:19 +08:00
6f08c22b5b LLM 胜利了!!!!!!
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-21 16:13:38 +08:00
3e5c1941c8 重构 ptimeparse 模块
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-11-21 06:03:28 +08:00
f6e7dfcd93 空调最高峰,空调数据库挂载再优化
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-19 16:24:24 +08:00
1233677eea 将成语接龙还原为内存存储,空调优化为部分内存存储且具有过期期限,避免频繁数据库查询
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-19 11:04:13 +08:00
00bdb90e3c Merge pull request '为此方 Bot 接入数据库' (#49) from database into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #49
2025-11-19 00:52:01 +08:00
988965451b 坏坏 AI 怎么把 diff 文件交上去了 2025-11-19 00:47:24 +08:00
f6fadb7226 Qwen 说让我再改点东西所以改了,强化了数据库相关的事情 2025-11-19 00:44:44 +08:00
0d540eea4c 我拿 AI 改坏枪代码! 2025-11-18 23:55:31 +08:00
f21da657db database 接入 2025-11-18 19:36:05 +08:00
a8a7b62f76 修复 playwright 在不同源的版本不同导致的问题
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-18 02:22:35 +08:00
789500842c wocao1
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-15 20:31:38 +08:00
2f22f11d57 调整 Gif 图渲染策略
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-15 20:16:42 +08:00
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
e72bc283f8 调整 giftool
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-12 15:18:07 +08:00
c9d58e7498 修改文档
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-12 13:45:34 +08:00
627a48da1c 添加安全限制 2025-10-12 13:40:40 +08:00
87be1916ee 添加 Shadertool(谁需要??????)
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-12 13:36:54 +08:00
0ca901e7b1 添加 giftool
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-12 12:47:52 +08:00
d096f43d38 添加 giftool 2025-10-12 12:40:33 +08:00
38ae3d1c74 补充黑白的 man 2025-10-12 12:04:19 +08:00
a0483d1d5c 修复断言逻辑
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-12 11:52:41 +08:00
ae83b66908 添加图像黑白
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-12 11:50:15 +08:00
6abeb05a18 去除未使用的函数 2025-10-12 11:02:51 +08:00
9b0a0368fa 修改 YTPGIF 的功能问题
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-12 10:55:44 +08:00
4eac493de4 更改令人费解的 requirements.txt 方案
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-09 20:25:34 +08:00
b4e400b626 调整 ytpgif 使用共用方法读取图片
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-09 19:56:16 +08:00
c35ee57976 优化时间读取逻辑 2025-10-09 19:55:53 +08:00
8edb999050 添加巨大多东西
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-07 16:07:48 +08:00
109a81923f 添加 man 指令 2025-10-07 15:54:16 +08:00
91687fb8c3 Merge pull request 'feature-更多更多说' (#22) from feature-更多更多说 into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #22
2025-10-03 17:40:52 +08:00
f889381cce 排序 import 2025-10-03 17:39:49 +08:00
1256055c9d 补充依赖 2025-10-03 17:38:02 +08:00
40f35a474e 搞小槽的说话 2025-10-03 17:37:43 +08:00
6b01acfa8c 十猫 2025-10-03 14:23:01 +08:00
09c9d44798 Merge pull request 'Feature: 好多好多的说' (#21) from feature-新说 into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #21
2025-10-03 13:52:31 +08:00
0c4206f461 好多好多的说 2025-10-03 13:49:36 +08:00
9fb8fd90dc 修复类型注解
Some checks are pending
continuous-integration/drone/push Build is running
2025-10-02 12:06:20 +08:00
8c4fa2b5e4 Merge pull request 'fix: 透明底正常生成;静动图分离完成' (#18) from tnot/konabot:fix--修复部分Bug into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #18
2025-10-01 19:24:23 +08:00
fb2c3f1ce2 fix: 透明底正常生成;静动图分离完成 2025-10-01 11:54:54 +08:00
265415e727 Merge pull request 'feat: ytpgif' (#16) from tnot/konabot:feat--ytpgif into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #16
2025-09-30 22:38:15 +08:00
06555b2225 feat: ytpgif 2025-09-30 22:24:16 +08:00
244 changed files with 734016 additions and 4150 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
@ -26,7 +30,7 @@ steps:
volumes:
- name: docker-socket
path: /var/run/docker.sock
- name: 在容器中测试插件加载
- name: 在容器中进行若干测试
image: docker:dind
privileged: true
volumes:
@ -34,6 +38,19 @@ 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
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python scripts/test_playwright.py
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python -m pytest --cov=./konabot/ --cov-report term-missing:skip-covered
- 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 +67,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 +87,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

View File

@ -1,4 +1,4 @@
ENVIRONMENT=dev
PORT=21333
DATABASE_PATH="./data/database.db"
ENABLE_CONSOLE=true

24
.gitignore vendored
View File

@ -1,4 +1,26 @@
# 基本的数据文件,以及环境用文件
/.env
/data
/pyrightconfig.json
/pyrightconfig.toml
/uv.lock
__pycache__
# 缓存文件
__pycache__
/.ruff_cache
/.pytest_cache
/.mypy_cache
/.black_cache
# 可能会偶然生成的 diff 文件
/*.diff
# 代码覆盖报告
/.coverage
/.coverage.db
/htmlcov
# 对手动创建虚拟环境的人
/.venv
/venv
*.egg-info

6
.gitmodules vendored Normal file
View File

@ -0,0 +1,6 @@
[submodule "assets/lexicon/THUOCL"]
path = assets/lexicon/THUOCL
url = https://github.com/thunlp/THUOCL.git
[submodule "assets/oracle"]
path = assets/oracle
url = https://gitea.service.jazzwhom.top/mttu-developers/oracle-source.git

6
.sqls.yml Normal file
View File

@ -0,0 +1,6 @@
lowercaseKeywords: false
connections:
- driver: sqlite
dataSourceName: "./data/database.db"
- driver: sqlite
dataSourceName: "./data/perm.sqlite3"

24
.vscode/launch.json vendored
View File

@ -1,24 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "运行 Bot 并调试(自动重载)",
"type": "debugpy",
"request": "launch",
"module": "watchfiles",
"args": [
"bot.main"
],
"console": "integratedTerminal",
"justMyCode": true,
"env": {
"PYTHONPATH": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}",
"presentation": {
"hidden": false,
"group": "bot"
}
}
]
}

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

@ -0,0 +1,5 @@
{
"python.REPL.enableREPLSmartSend": false,
"python-envs.defaultEnvManager": "ms-python.python:poetry",
"python-envs.defaultPackageManager": "ms-python.python:poetry"
}

30
.vscode/tasks.json vendored
View File

@ -1,30 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Poetry: Export requirements.txt (Production)",
"type": "shell",
"command": "poetry export -f requirements.txt --output requirements.txt --without-hashes",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": [],
"detail": "导出生产环境依赖到 requirements.txt"
},
{
"label": "Bot: Run with Auto-reload",
"type": "shell",
"command": "poetry run watchfiles bot.main",
"group": "build",
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": [],
"detail": "运行 bot 并启用自动重载功能"
}
]
}

188
AGENTS.md Normal file
View File

@ -0,0 +1,188 @@
# AGENTS.md
本文件面向两类协作者:
- 手写代码的人类朋友
- 会在此仓库中协助开发的 AI Agents
这个项目以手写为主,欢迎协作,但请先理解这里的约束和结构,再开始改动。
## 项目定位
- 这是一个娱乐性质的、私域使用的 QQ Bot 项目。
- 虽然主要用于熟人环境,但依然要按“不信任输入”的标准写代码。
- 不要因为使用场景偏内部,就默认消息内容、安全边界、调用参数一定可靠。
## 基本原则
### 1. 默认不信任用户输入
所有来自聊天消息、命令参数、平台事件等的输入,都应视为不可信。
开发时至少注意以下几点:
- 不假设输入类型正确,先校验再使用。
- 不假设输入长度合理,注意超长文本、大量参数、异常嵌套结构。
- 不假设输入内容安全避免直接拼接到文件路径、SQL、shell 参数、HTML 或模板中。
- 不假设用户一定按预期使用命令,错误输入要能优雅失败。
- 对任何外部请求、文件读写、渲染、执行型逻辑,都要先考虑滥用风险。
### 2. 优先保持现有风格
- 这是一个以人工维护为主的项目,改动应尽量贴近现有写法。
- 除非有明确收益,不要为了“看起来更现代”而大规模重构。
- 新增能力时,优先复用已有通用模块,而不是重复造轮子。
### 3. 小步修改,影响清晰
- 尽量做局部、明确、可解释的改动。
- 修改插件时,避免顺手改动无关插件。
- 如果要调整公共模块,先确认是否会影响大量插件行为。
## 仓库结构
### `konabot/`
核心代码目录。
#### `konabot/common/`
通用模块目录。
- 放置可复用的基础能力、工具模块、公共逻辑。
- 如果某段逻辑可能被多个插件共享,应优先考虑放到这里。
- 修改这里的代码时,要额外关注兼容性,因为它可能被很多插件依赖。
#### `konabot/docs/`
Bot 内部文档系统使用的文档目录。
- 这是给用户看的文档来源。
- 文档会通过 `man` 指令被触发和展示。
- 虽然文档文件通常使用 `.txt` 后缀,但内容可以按 markdown 风格书写。
- `.md` 后缀文件会被忽略,因此 `.md` 更适合只留给仓库维护者阅读的附加说明。
- 文档文件名就是用户查询时使用的指令名,应保持简洁、稳定、易理解。
补充说明:
- `konabot/docs/user/` 是直接面向用户检索的文档。
- `konabot/docs/lib/` 更偏向维护者参考。
- `konabot/docs/concepts/` 用于记录概念。
- `konabot/docs/sys/` 用于特定范围可见的系统文档。
#### `konabot/plugins/`
插件目录。
- 插件数量很多,是本项目最主要的功能承载位置。
- 插件可以是单文件,也可以是文件夹形式。
- 新增插件或修改插件时,请先观察相邻插件的组织方式,再决定采用单文件还是目录结构。
- 如果逻辑已经明显超出单文件可维护范围,应拆成目录插件,不要把一个文件堆得过大。
## 根目录文档
### `docs/`
仓库根目录下的 `docs/` 主要用于记录一些可以通用的模块说明和开发文档。
- 这里的内容主要面向开发和维护。
- 适合放公共模块说明、集成说明、配置说明、开发笔记。
- 不要把面向 `man` 指令直接展示给用户的文档放到这里;那类内容应放在 `konabot/docs/` 下。
## 对 AI Agents 的具体要求
如果你是 AI Agent请遵守以下约定
### 修改前
- 先阅读将要修改的文件以及相关上下文,不要只凭文件名猜用途。
- 先判断目标逻辑属于公共模块、用户文档,还是某个具体插件。
- 如果需求可以在局部完成,就不要扩大改动范围。
### 修改时
- 优先延续现有命名、目录结构和编码风格。
- 不要因为“顺手”而批量格式化整个项目。
- 不要擅自重命名大量文件、移动目录、替换现有架构。
- 涉及用户输入、路径、网络、数据库、渲染时,主动补上必要的校验与防御。
- 如果要新增 `konabot/common/` 或其他会被多处依赖的模块,优先考虑 NoneBot2 框架下的依赖注入方式,而不是把全局状态或硬编码依赖散落到调用方。
- 写文档时,区分清楚是给 `man` 系统看的,还是给仓库维护者看的。
### 修改后
- 检查改动是否误伤其他插件或公共模块。
- 如果新增了用户可见功能,考虑是否需要补充 `konabot/docs/` 下对应文档。
- 如果新增或调整了通用能力,考虑是否需要补充根目录 `docs/` 下的说明。
## 插件开发建议
- 单个插件内部优先保持自洽,不要把特定业务逻辑过早抽成公共模块。
- 当多个插件开始重复同类逻辑时,再考虑上移到 `konabot/common/`
- 插件应尽量对异常输入有稳定反馈,而不是直接抛出难理解的错误。
- 如果插件会访问外部服务,要考虑超时、失败降级和返回内容校验。
### 最基本的用户交互书写建议
- 先用清晰、可收敛的规则匹配消息,再进入处理逻辑,不要一上来就在 handler 里兜底解析所有输入。
- 在 handler 里尽早提取纯文本、拆分命令和参数,并对缺失参数、非法参数、异常格式给出稳定反馈。
- 如果用户输入只允许有限枚举值,先定义允许集合,再进行归一化和校验。
- 输出优先保持简单直接;能一句话说明问题时,不要返回难懂的异常堆栈或过度技术化提示。
- 涉及渲染、网络请求、图片生成等较重操作时,先确认输入合理,再执行昂贵逻辑。
- 如果插件只是做单一交互,优先保持 handler 简短,把渲染、请求、转换等逻辑拆成独立函数。
- 倾向于使用 `UniMessage` / `UniMsg` 这一套消息抽象来组织收发消息,而不是把平台细节和文本拼接散落在各处。
- 倾向于显式构造返回消息并发送,而不是大量依赖 NoneBot2 原生的 `.finish()` 作为主要输出路径,除非该场景确实更简单清晰。
### 关于公共能力的依赖方式
- 新建通用能力时,优先设计成可注入、可替换、可测试的接口。
- 如果一个模块未来可能被多个插件依赖,优先考虑 NoneBot2 的依赖注入,而不是让调用方手动维护重复的初始化流程。
- 除非确有必要,不要让插件直接依赖隐藏的全局副作用。
- 如果使用单例、缓存或全局管理器,要明确其生命周期、并发行为以及关闭时机。
## 运行环境与部署限制
这个项目默认会跑在 Docker 环境里,修改功能时请先意识到运行环境不是一台“什么都有”的开发机。
### 容器环境
- 运行时基础镜像是 `python:3.13-slim`,不是完整桌面 Linux很多系统库默认不存在。
- 项目运行依赖 Playwright Chromium、字体库、图形相关库以及部分额外二进制工具。
- 构建阶段和运行阶段是分离的;不要假设在 builder 里装过的系统包runtime 里也一定可用。
- 额外制品目前通过多阶段构建放进镜像,例如 `typst`
### Docker 相关要求
- 如果你新增的 Python 依赖背后还需要 Linux 动态库、字体、图形库、编译工具或其他系统包,必须同步检查并在 `Dockerfile` 中补齐。
- 不要只让本地虚拟环境能跑;要默认以容器可运行作为完成标准之一。
- 如果新功能依赖系统命令、共享库、浏览器能力或字体,请在提交说明里明确写出原因。
- `.dockerignore` 当前会排除 `/.env``/.git``/data` 等内容;不要依赖这些文件被复制进镜像。
- 关于额外制品的管理,优先先阅读根目录文档 `docs/artifact.md`;适合统一管理的二进制或外部资源,倾向于复用 `konabot/common/artifact.py`,而不是在各插件里各自处理下载和校验。
### 本地运行
- 本地开发可参考 `justfile`,当前主要入口是 `just watch`
- 如果你的改动影响启动方式、依赖准备方式或运行命令,记得同步更新对应文档或脚本。
## 分支与协作流程
- 本项目托管在个人 Gitea 实例:`https://gitea.service.jazzwhom.top/mttu-developers/konabot`
- 如果需要创建 Pull Request优先倾向使用 `tea` CLI`https://gitea.com/gitea/tea`
- Pull Request 创建后,当前主要会有自动机器人做初步评审,项目维护者会手动查看;不要催促立即合并,也不要默认会马上进主分支。
- 如果当前是在仓库本体上直接开发、而不是在 fork 上工作,尽量提醒用户不要直接在主分支持续改动,优先使用功能分支。
- 除非用户明确要求,否则不要擅自把改动直接合并到主分支。
## 文档编写建议
### 面向 `man` 的文档
- 放在 `konabot/docs/` 对应子目录。
- 文件名直接对应用户查询名称。
- 建议内容简洁,优先说明“做什么、怎么用、示例、注意事项”。
- 使用 `.txt` 后缀;内容可以写成接近 markdown 的可读格式。
### 面向开发者的文档
- 放在仓库根目录 `docs/`
- 主要描述公共模块、配置方法、设计说明、维护经验。
- 可以使用 `.md`

View File

@ -1,13 +1,67 @@
FROM python:3.13-slim
FROM alpine:latest AS artifacts
RUN apk add --no-cache curl xz
WORKDIR /tmp
RUN mkdir -p /artifacts
RUN curl -L -o typst.tar.xz "https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz" \
&& tar -xJf typst.tar.xz \
&& mv typst-x86_64-unknown-linux-musl/typst /artifacts
RUN chmod -R +x /artifacts/
FROM python:3.13-slim AS base
ENV VIRTUAL_ENV=/app/.venv \
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 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/*
FROM base AS builder
# 安装构建依赖
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
COPY requirements.txt ./
RUN pip install -r requirements.txt --no-deps
COPY pyproject.toml poetry.lock ./
RUN uv sync --no-install-project
FROM base AS runtime
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --from=artifacts /artifacts/ /usr/local/bin/
WORKDIR /app
RUN python -m playwright install chromium
COPY bot.py pyproject.toml .env.prod .env.test ./
COPY assets ./assets
COPY scripts ./scripts
COPY konabot ./konabot
COPY tests ./tests
ENV PYTHONPATH=/app

View File

@ -1,4 +1,4 @@
# 此方 bot
# konabot
在 MTTU 内部使用的 bot 一只。
@ -63,12 +63,24 @@ code .
配置 `ENABLE_CONSOLE=false`
#### 配置并支持 LLM大语言模型
详见[LLM 配置文档](/docs/LLM.md)。
#### 配置 konabot-web 以支持更高级的图片渲染
详见[konabot-web 配置文档](/docs/konabot-web.md)
#### 数据库配置
本项目使用SQLite作为数据库默认数据库文件位于`./data/database.db`。可以通过设置`DATABASE_PATH`环境变量来指定其他位置。
### 运行
你可以在 VSCode 的「运行与调试」窗口,启动 `运行 Bot 并调试(自动重载)` 任务来启动 Bot也可以使用命令行手动启动 Bot
使用命令行手动启动 Bot
```bash
poetry run watchfiles bot.main
poetry run just watch
```
如果你不希望自动重载,只是想运行 Bot可以直接运行
@ -83,3 +95,22 @@ poetry run python bot.py
- [事件响应器](https://nonebot.dev/docs/tutorial/matcher)
- [事件处理](https://nonebot.dev/docs/tutorial/handler)
- [Alconna 插件](https://nonebot.dev/docs/best-practice/alconna/)
## 代码测试
本项目使用 pytest 进行自动化测试,你可以把你的测试代码放在 `./tests` 目录下。
使用命令行执行测试:
```bash
poetry run just test
```
使用命令行,在浏览器查看测试覆盖率报告:
```bash
poetry run just coverage
# 此时会打开一个 :8000 端口的 Web 服务器
# 你可以在 http://localhost:8000 查看覆盖率报告
# 在控制台使用 Ctrl+C 关闭这个 Web 服务器
```

Binary file not shown.

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/caoimg1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
assets/img/meme/dss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
assets/img/meme/mnksay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 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

9856
assets/old_font/symtable.csv Normal file

File diff suppressed because it is too large Load Diff

1
assets/oracle Submodule

Submodule assets/oracle added at 9f3c08c5d2

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>

35
bot.py
View File

@ -7,6 +7,13 @@ 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.appcontext import run_afterinit_functions
from konabot.common.log import init_logger
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.path import LOG_PATH
from konabot.common.database import get_global_db_manager
dotenv.load_dotenv()
env = os.environ.get("ENVIRONMENT", "prod")
env_enable_console = os.environ.get("ENABLE_CONSOLE", "none")
@ -14,12 +21,27 @@ 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()
if (env != "prod" and env != "test" and env_enable_console.upper() != "FALSE") or (env_enable_console.upper() == "TRUE"):
if (env != "prod" and env != "test" and env_enable_console.upper() != "FALSE") or (
env_enable_console.upper() == "TRUE"
):
driver.register_adapter(ConsoleAdapter)
if env_enable_qq.upper() == "TRUE":
@ -33,8 +55,19 @@ def main():
# nonebot.load_builtin_plugin("echo")
nonebot.load_plugins("konabot/plugins")
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
run_afterinit_functions()
# 注册关闭钩子
@driver.on_shutdown
async def _():
# 关闭全局数据库管理器
db_manager = get_global_db_manager()
await db_manager.close_all_connections()
nonebot.run()
if __name__ == "__main__":
main()

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 越狱等情况。

26
docs/artifact.md Normal file
View File

@ -0,0 +1,26 @@
# artifact 模块说明
`konabot/common/artifact.py` 用于管理项目运行过程中依赖的额外制品,尤其是二进制文件、外部工具和按平台区分的运行时资源。
## 适用场景
- 某个插件或公共模块依赖额外下载的可执行文件或二进制资源。
- 依赖需要按操作系统或架构区分。
- 希望在启动时统一检测、按需下载并校验哈希。
如果额外制品适合在镜像构建阶段直接打包进 Docker 镜像,也可以在 `Dockerfile` 中通过多阶段构建处理;但对于需要在运行环境按平台管理、懒下载或统一校验的资源,优先考虑复用 `artifact.py`
## 推荐做法
- 新增额外制品时,先判断它更适合放进镜像构建阶段,还是更适合交给 `artifact.py` 管理。
- 如果该资源会被多个插件或环境复用,倾向于统一通过 `ArtifactDepends``register_artifacts(...)` 管理。
- 为下载资源提供稳定来源,并填写 `sha256` 校验值,不要只校验“能不能下载下来”。
- 使用 `required_os``required_arch` 限制平台,避免无意义下载。
- 需要代理时,确认其行为与当前 NoneBot2 配置兼容。
## 注意事项
- 不要把是否存在额外制品的判断散落到多个插件里各自实现。
- 不要跳过哈希校验,除非该资源确实无法提供稳定校验值,并且有明确理由。
- 如果一个新能力除了额外制品,还依赖 Linux 动态库、字体、浏览器或系统命令,仍然需要同步检查并更新 `Dockerfile`
- 如果镜像构建和运行阶段都依赖该制品,要分别确认 builder 和 runtime 的可用性。

223
docs/database.md Normal file
View File

@ -0,0 +1,223 @@
# 数据库系统使用文档
本文档详细介绍了本项目中使用的异步数据库系统,包括其架构设计、使用方法和最佳实践。
## 系统概述
本项目的数据库系统基于 `aiosqlite` 库构建,提供了异步的 SQLite 数据库访问接口。系统主要特性包括:
1. **异步操作**:完全支持异步/await模式适配NoneBot2框架
2. **连接池**:内置连接池机制,提高数据库访问性能
3. **参数化查询**支持安全的参数化查询防止SQL注入
4. **SQL文件支持**可以直接执行SQL文件中的脚本
5. **类型支持**:支持 `pathlib.Path``str` 类型的路径参数
## 核心类和方法
### DatabaseManager 类
`DatabaseManager` 是数据库操作的核心类,提供了以下主要方法:
#### 初始化
```python
from konabot.common.database import DatabaseManager
from pathlib import Path
# 使用默认数据库路径
db = DatabaseManager()
# 指定了义数据库路径
db = DatabaseManager("./data/myapp.db")
db = DatabaseManager(Path("./data/myapp.db"))
```
#### 查询操作
```python
# 执行查询语句并返回结果
results = await db.query("SELECT * FROM users WHERE age > ?", (18,))
# 从SQL文件执行查询
results = await db.query_by_sql_file("./sql/get_users.sql", (18,))
```
#### 执行操作
```python
# 执行非查询语句
await db.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("张三", "zhangsan@example.com"))
# 执行SQL脚本不带参数
await db.execute_script("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
INSERT INTO users (name, email) VALUES ('测试用户', 'test@example.com');
""")
# 从SQL文件执行非查询语句
await db.execute_by_sql_file("./sql/create_tables.sql")
# 带参数执行SQL文件
await db.execute_by_sql_file("./sql/insert_user.sql", ("张三", "zhangsan@example.com"))
# 执行多条语句(每条语句使用相同参数)
await db.execute_many("INSERT INTO users (name, email) VALUES (?, ?)", [
("张三", "zhangsan@example.com"),
("李四", "lisi@example.com"),
("王五", "wangwu@example.com")
])
# 从SQL文件执行多条语句每条语句使用相同参数
await db.execute_many_values_by_sql_file("./sql/batch_insert.sql", [
("张三", "zhangsan@example.com"),
("李四", "lisi@example.com")
])
```
## SQL文件处理机制
### 单语句SQL文件
```sql
-- insert_user.sql
INSERT INTO users (name, email) VALUES (?, ?);
```
```python
# 使用方式
await db.execute_by_sql_file("./sql/insert_user.sql", ("张三", "zhangsan@example.com"))
```
### 多语句SQL文件
```sql
-- setup.sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER,
age INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
```python
# 使用方式
await db.execute_by_sql_file("./sql/setup.sql")
```
### 多语句带不同参数的SQL文件
```sql
-- batch_operations.sql
INSERT INTO users (name, email) VALUES (?, ?);
INSERT INTO profiles (user_id, age) VALUES (?, ?);
```
```python
# 使用方式
await db.execute_by_sql_file("./sql/batch_operations.sql", [
("张三", "zhangsan@example.com"), # 第一条语句的参数
(1, 25) # 第二条语句的参数
])
```
## 最佳实践
### 1. 数据库表设计
```sql
-- 推荐的表设计实践
CREATE TABLE IF NOT EXISTS example_table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 2. SQL文件组织
建议按照功能模块组织SQL文件
```
plugin/
├── sql/
│ ├── create_tables.sql
│ ├── insert_data.sql
│ ├── update_data.sql
│ └── query_data.sql
└── __init__.py
```
### 3. 错误处理
```python
try:
results = await db.query("SELECT * FROM users WHERE id = ?", (user_id,))
except Exception as e:
logger.error(f"数据库查询失败: {e}")
# 处理错误情况
```
### 4. 连接管理
```python
# 在应用启动时初始化
db_manager = DatabaseManager()
# 在应用关闭时清理连接
async def shutdown():
await db_manager.close_all_connections()
```
## 高级特性
### 连接池配置
```python
class DatabaseManager:
def __init__(self, db_path: Optional[Union[str, Path]] = None):
# 连接池大小配置
self._pool_size = 5 # 可根据需要调整
```
### 事务支持
```python
# 通过execute方法的自动提交机制支持事务
await db.execute("BEGIN TRANSACTION")
try:
await db.execute("INSERT INTO users (name) VALUES (?)", ("张三",))
await db.execute("INSERT INTO profiles (user_id, age) VALUES (?, ?)", (1, 25))
await db.execute("COMMIT")
except Exception:
await db.execute("ROLLBACK")
raise
```
## 注意事项
1. **异步环境**:所有数据库操作都必须在异步环境中执行
2. **参数安全**始终使用参数化查询避免SQL注入
3. **资源管理**:确保在应用关闭时调用 `close_all_connections()`
4. **SQL解析**:使用 `sqlparse` 库准确解析SQL语句正确处理包含分号的字符串和注释
5. **错误处理**:适当处理数据库操作可能抛出的异常
## 常见问题
### Q: 如何处理数据库约束错误?
A: 确保SQL语句中的字段名正确引用特别是保留字需要使用双引号包围
```sql
CREATE TABLE air_conditioner (
id VARCHAR(128) PRIMARY KEY,
"on" BOOLEAN NOT NULL, -- 使用双引号包围保留字
temperature REAL NOT NULL
);
```
### Q: 如何处理多个语句和参数的匹配?
A: 当SQL文件包含多个语句时参数应该是参数列表每个语句对应一个参数元组
```python
await db.execute_by_sql_file("./sql/batch.sql", [
("参数1", "参数2"), # 第一个语句的参数
("参数3", "参数4") # 第二个语句的参数
])
```
通过遵循这些指南和最佳实践,您可以充分利用本项目的异步数据库系统,构建高性能、安全的数据库应用。

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 为端口号。

244
docs/permsys.md Normal file
View File

@ -0,0 +1,244 @@
# 权限系统 `konabot.common.permsys`
本文档面向维护者,说明 `konabot/common/permsys` 模块的职责、数据模型、权限解析规则,以及在插件中接入的推荐方式。
## 模块目标
`permsys` 提供了一套简单的、可继承的权限系统,用于回答两个问题:
1. 某个事件对应的主体是谁。
2. 该主体是否拥有某项权限。
它适合处理 bot 内部的功能开关、管理权限、平台级授权等场景。
当前模块由以下几部分组成:
- `konabot/common/permsys/__init__.py`
- 暴露 `PermManager``DepPermManager``require_permission`
- 负责数据库初始化、启动迁移、超级管理员默认授权
- 提供 `register_default_allow_permission()` 用于注册“启动时默认放行”的权限键
- `konabot/common/permsys/entity.py`
- 定义 `PermEntity`
- 将事件转换为可查询的实体链
- `konabot/common/permsys/repo.py`
- 封装 SQLite 读写
- `konabot/common/permsys/migrates/`
- 存放迁移 SQL
- `konabot/common/permsys/sql/`
- 存放查询与更新 SQL
## 核心概念
### 1. `PermEntity`
`PermEntity` 是权限系统中的最小主体标识:
```python
PermEntity(platform: str, entity_type: str, external_id: str)
```
示例:
- `PermEntity("sys", "global", "global")`
- `PermEntity("ob11", "group", "123456")`
- `PermEntity("ob11", "user", "987654")`
其中:
- `platform` 表示来源平台,如 `sys``ob11``discord`
- `entity_type` 表示主体类型,如 `global``group``user`
- `external_id` 表示平台侧的外部标识
### 2. 实体链
权限判断不是只看单个实体,而是看一条“实体链”。
`get_entity_chain_of_entity()` 为例,传入一个具体实体时,返回的链为:
```python
[
PermEntity(platform, entity_type, external_id),
PermEntity(platform, "global", "global"),
PermEntity("sys", "global", "global"),
]
```
这意味着权限会优先读取更具体的主体,再回退到平台全局,最后回退到系统全局。
`get_entity_chain(event)` 则会根据事件类型自动构造链。例如:
- OneBot V11 群消息:用户 -> 群 -> 平台全局 -> 系统全局
- OneBot V11 私聊:用户 -> 平台全局 -> 系统全局
- Discord 频道消息:用户/频道/服务器 -> 平台全局 -> 系统全局
- Console控制台用户/频道 -> 平台全局 -> 系统全局
注意:当前 `entity.py` 中的具体链顺序与字段命名应以实现为准;修改这里时要评估现有权限继承是否会被破坏。
### 3. 权限键
权限键使用点分结构,例如:
- `admin`
- `plugin.weather`
- `plugin.weather.use`
检查时会自动做前缀回退。以 `plugin.weather.use` 为例,查询顺序是:
1. `plugin.weather.use`
2. `plugin.weather`
3. `plugin`
4. `*`
因此,`*` 可以看作兜底总权限。
## 权限解析规则
`PermManager.check_has_permission_info()` 的逻辑可以概括为:
1. 先把输入转换成实体链。
2. 对权限键做逐级回退,同时追加 `*`
3. 在数据库中批量查出链上所有实体、所有候选键的显式记录。
4. 按“实体越具体越优先、权限键越具体越优先”的顺序,返回第一条命中的记录。
若没有任何显式记录:
- `check_has_permission_info()` 返回 `None`
- `check_has_permission()` 返回 `False`
这表示本系统默认是“未授权即拒绝”。
## 数据存储
模块使用 SQLite默认数据库文件位于
- `data/perm.sqlite3`
启动时会执行迁移:
- `create_startup()` 在 NoneBot 启动事件中调用 `execute_migration()`
权限值支持三态:
- `True`:显式允许
- `False`:显式拒绝
- `None`:删除/清空该层的显式设置,让判断重新回退到继承链
`repo.py` 中的 `update_perm_info()` 会将这个三态直接写入数据库。
## 超级管理员注入
在启动阶段,`create_startup()` 会读取 `konabot.common.nb.is_admin.cfg.admin_qq_account`,并为这些 QQ 账号写入:
```python
PermEntity("ob11", "user", str(account)), "*", True
```
也就是说,配置中的超级管理员会直接拥有全部权限。
此外,模块也支持插件在导入阶段通过 `register_default_allow_permission("some.key")` 注册默认放行的权限键;这些键会在启动时被写入到:
```python
PermEntity("sys", "global", "global"), "some.key", True
```
这适合“默认所有人可用,但仍希望后续能被权限系统单独关闭”的功能。
这属于启动时自动灌入的保底策略,不依赖手工授权命令。
## 在插件中使用
### 1. 直接做权限检查
```python
from konabot.common.permsys import DepPermManager
async def handler(pm: DepPermManager, event):
ok = await pm.check_has_permission(event, "plugin.example.use")
if not ok:
return
```
适合需要在处理流程中动态决定权限键的场景。
### 2. 挂到 Rule 上做准入控制
```python
from nonebot_plugin_alconna import Alconna, on_alconna
from konabot.common.permsys import require_permission
cmd = on_alconna(
Alconna("example"),
rule=require_permission("plugin.example.use"),
)
```
适合命令入口明确、未通过时直接拦截的场景。
### 3. 更新权限
```python
from konabot.common.permsys import DepPermManager
from konabot.common.permsys.entity import PermEntity
await pm.update_permission(
PermEntity("ob11", "group", "123456"),
"plugin.example.use",
True,
)
```
建议只在专门的管理插件中开放写权限,避免普通功能插件到处分散改表。
## `perm_manage` 插件与本模块的关系
`konabot/plugins/perm_manage/__init__.py` 是本模块当前的管理入口,提供:
- `konaperm list`:列出实体链上已有的显式权限记录
- `konaperm get`:查看某个权限最终命中的记录
- `konaperm set`:写入 allow/deny/null
这个插件本身使用 `require_permission("admin")` 保护,因此只有拥有 `admin` 权限的主体才能管理权限。
## 接入建议
### 权限键命名
建议使用稳定、可扩展的分层键名:
- 推荐:`plugin.xxx``plugin.xxx.action`
- 不推荐:含糊的单词或临时字符串
这样才能利用前缀回退机制做批量授权。
### 输入安全
虽然这个项目偏内部使用,但权限键、实体类型、外部 ID 仍然应视为不可信输入:
- 不要把聊天输入直接拼到 SQL 中
- 不要让任意用户可随意构造高权限写入
- 对可写命令至少做权限保护和必要校验
### 改动兼容性
以下改动都可能影响全局权限行为,修改前应充分评估:
- 更改实体链顺序
- 更改默认兜底键 `*` 的语义
- 更改 `None` 的处理方式
- 更改启动时超级管理员注入逻辑
## 调试建议
- 先用 `konaperm get ...` 确认某个权限最终命中了哪一层
- 再用 `konaperm list ...` 查看该实体链上有哪些显式记录
- 若表现异常,检查是否是更上层实体或更宽泛权限键提前命中
## 相关文件
- `konabot/common/permsys/__init__.py`
- `konabot/common/permsys/entity.py`
- `konabot/common/permsys/repo.py`
- `konabot/plugins/perm_manage/__init__.py`

37
docs/subscribe.md Normal file
View File

@ -0,0 +1,37 @@
# subscribe 模块
一套统一的接口,让用户可以订阅一些延迟或者定时消息。
```python
import asyncio
from pathlib import Path
from konabot.common.subscribe import register_poster_info, broadcast, PosterInfo
from nonebot_plugin_alconna import UniMessage
# 注册了服务信息,用户可以用「查询可用订阅」指令了解可用的订阅清单。
# 用户可以使用「订阅 某某服务通知」或者「订阅 某某服务」来订阅消息。
# 如果用户在群聊发起订阅,则会在 QQ 群订阅,不然会在私聊订阅
register_poster_info("某某服务通知", PosterInfo(
aliases={"某某服务"},
description="告诉你关于某某的最新资讯等信息",
))
async def main():
while True:
# 这里的服务 channel 名字必须填写该服务的名字,不可以是 alias
# 这会给所有订阅了该通道的用户发送「向大家发送纯文本通知」
await broadcast("某某服务通知", "向大家发送纯文本通知")
# 也可以发送 UniMessage 对象,可以构造包含图片的通知等
data = Path('image.png').read_bytes()
await broadcast(
"某某服务通知",
UniMessage.text("很遗憾告诉大家,我们倒闭了:").image(raw=data),
)
await asyncio.sleep(114.514)
```
该模块的代码请查阅 `/konabot/common/subscribe/` 下的文件。

9
justfile Normal file
View File

@ -0,0 +1,9 @@
watch:
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
test:
poetry run pytest --cov-report term-missing:skip-covered
coverage:
poetry run pytest --cov-report html
python -m http.server -d htmlcov

View File

@ -0,0 +1,92 @@
import asyncio
import json
from alibabacloud_green20220302.client import Client as AlibabaGreenClient
from alibabacloud_green20220302.models import TextModerationPlusRequest
from alibabacloud_tea_openapi.models import Config as AlibabaTeaConfig
from loguru import logger
from pydantic import BaseModel
import nonebot
class AlibabaGreenPluginConfig(BaseModel):
module_aligreen_enable: bool = False
module_aligreen_access_key_id: str = ""
module_aligreen_access_key_secret: str = ""
module_aligreen_region_id: str = "cn-shenzhen"
module_aligreen_endpoint: str = "green-cip.cn-shenzhen.aliyuncs.com"
module_aligreen_service: str = "llm_query_moderation"
class AlibabaGreen:
_client: AlibabaGreenClient | None = None
_config: AlibabaGreenPluginConfig | None = None
@staticmethod
def get_client() -> AlibabaGreenClient:
assert AlibabaGreen._client is not None
return AlibabaGreen._client
@staticmethod
def get_config() -> AlibabaGreenPluginConfig:
assert AlibabaGreen._config is not None
return AlibabaGreen._config
@staticmethod
def init():
config = nonebot.get_plugin_config(AlibabaGreenPluginConfig)
AlibabaGreen._config = config
if not config.module_aligreen_enable:
logger.info("该环境未启用阿里内容审查,跳过初始化")
return
AlibabaGreen._client = AlibabaGreenClient(AlibabaTeaConfig(
access_key_id=config.module_aligreen_access_key_id,
access_key_secret=config.module_aligreen_access_key_secret,
connect_timeout=10000,
read_timeout=3000,
region_id=config.module_aligreen_region_id,
endpoint=config.module_aligreen_endpoint,
))
@staticmethod
def _detect_sync(content: str) -> bool:
if len(content) == 0:
return True
if not AlibabaGreen.get_config().module_aligreen_enable:
logger.debug("该环境未启用阿里内容审查,直接跳过")
return True
client = AlibabaGreen.get_client()
try:
response = client.text_moderation_plus(TextModerationPlusRequest(
service=AlibabaGreen.get_config().module_aligreen_service,
service_parameters=json.dumps({
"content": content,
}),
))
if response.status_code == 200:
result = response.body
logger.info(f"检测违规内容 API 调用成功:{result}")
risk_level: str = result.data.risk_level or "none"
if risk_level == "high":
return False
return True
logger.error(f"检测违规内容 API 调用失败:{response}")
return True
except Exception as e:
logger.error("检测违规内容 API 调用失败")
logger.exception(e)
return True
@staticmethod
async def detect(content: str) -> bool:
return await asyncio.to_thread(AlibabaGreen._detect_sync, content)
driver = nonebot.get_driver()
@driver.on_startup
async def _():
AlibabaGreen.init()

View File

@ -0,0 +1,281 @@
"""
Wolfx 防灾免费 API
"""
import asyncio
import json
from typing import Literal, TypeVar, cast
import aiohttp
from aiosignal import Signal
from loguru import logger
from pydantic import BaseModel, RootModel
import pydantic
from konabot.common.appcontext import after_init
class ScEewReport(BaseModel):
"""
四川地震局报文
"""
ID: str
"EEW 发报 ID"
EventID: str
"EEW 发报事件 ID"
ReportTime: str
"EEW 发报时间(UTC+8)"
ReportNum: int
"EEW 发报数"
OriginTime: str
"发震时间(UTC+8)"
HypoCenter: str
"震源地"
Latitude: float
"震源地纬度"
Longitude: float
"震源地经度"
Magnitude: float
"震级"
Depth: float | None
"震源深度"
MaxIntensity: float
"最大烈度"
class CencEewReport(BaseModel):
"""
中国地震台网报文
"""
ID: str
"EEW 发报 ID"
EventID: str
"EEW 发报事件 ID"
ReportTime: str
"EEW 发报时间(UTC+8)"
ReportNum: int
"EEW 发报数"
OriginTime: str
"发震时间(UTC+8)"
HypoCenter: str
"震源地"
Latitude: float
"震源地纬度"
Longitude: float
"震源地经度"
Magnitude: float
"震级"
Depth: float | None
"震源深度"
MaxIntensity: float
"最大烈度"
class CencEqReport(BaseModel):
type: str
"报告类型"
EventID: str
"事件 ID"
time: str
"UTC+8 格式的地震发生时间"
location: str
"地震发生位置"
magnitude: str
"震级"
depth: str
"地震深度"
latitude: str
"纬度"
longtitude: str
"经度"
intensity: str
"烈度"
class CencEqlist(RootModel):
root: dict[str, CencEqReport]
class WolfxWebSocket:
def __init__(self, url: str) -> None:
self.url = url
self.signal: Signal[bytes] = Signal(self)
self._running = False
self._task: asyncio.Task | None = None
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
@property
def session(self) -> aiohttp.ClientSession: # pragma: no cover
assert self._session is not None
return self._session
async def start(self): # pragma: no cover
if self._running:
return
self._running = True
self._session = aiohttp.ClientSession()
self._task = asyncio.create_task(self._run())
self.signal.freeze()
async def stop(self): # pragma: no cover
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
if self._session:
await self._session.close()
async def _run(self): # pragma: no cover
retry_delay = 1
while self._running:
try:
async with self.session.ws_connect(self.url) as ws:
self._ws = ws
logger.info(f"Wolfx API 服务连接上了 {self.url} 的 WebSocket")
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await self.handle(cast(str, msg.data).encode())
elif msg.type == aiohttp.WSMsgType.BINARY:
await self.handle(cast(bytes, msg.data))
elif msg.type == aiohttp.WSMsgType.CLOSED:
break
elif msg.type == aiohttp.WSMsgType.ERROR:
break
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning("连接 WebSocket 时发生错误")
logger.exception(e)
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Wolfx API 发生未知错误")
logger.exception(e)
self._ws = None
if self._running:
logger.info(f"Wolfx API 准备断线重连 {self.url}")
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 60)
async def handle(self, data: bytes):
try:
obj = json.loads(data)
except json.JSONDecodeError as e:
logger.warning("解析 Wolfs API 时出错")
logger.exception(e)
return
if obj.get("type") == "heartbeat" or obj.get("type") == "pong":
logger.debug(f"Wolfx API 收到了来自 {self.url} 的心跳: {obj}")
else:
await self.signal.send(data)
T = TypeVar("T", bound=BaseModel)
class WolfxAPIService:
sc_eew: Signal[ScEewReport]
"四川地震局地震速报"
cenc_eew: Signal[CencEewReport]
"中国地震台网地震速报"
cenc_eqlist: Signal[CencEqReport]
"中国地震台网地震信息发布"
def __init__(self) -> None:
self.sc_eew = Signal(self)
self._sc_eew_ws = WolfxWebSocket("wss://ws-api.wolfx.jp/sc_eew")
WolfxAPIService.bind(self.sc_eew, self._sc_eew_ws, ScEewReport)
self.cenc_eew = Signal(self)
self._cenc_eew_ws = WolfxWebSocket("wss://ws-api.wolfx.jp/cenc_eew")
WolfxAPIService.bind(self.cenc_eew, self._cenc_eew_ws, CencEewReport)
self.cenc_eqlist = Signal(self)
self._cenc_eqlist_ws = WolfxWebSocket("wss://ws-api.wolfx.jp/cenc_eqlist")
WolfxAPIService.bind(self.cenc_eqlist, self._cenc_eqlist_ws, CencEqReport)
@staticmethod
def bind(signal: Signal[T], ws: WolfxWebSocket, t: type[T]):
@ws.signal.append
async def _(data: bytes):
try:
obj = t.model_validate_json(data)
logger.info(f"接收到来自 Wolfx API 的信息:{data}")
await signal.send(obj)
except pydantic.ValidationError as e:
logger.warning(f"解析 Wolfx API 时出错 URL={ws.url}")
logger.error(e)
async def start(self): # pragma: no cover
self.cenc_eew.freeze()
self.sc_eew.freeze()
self.cenc_eqlist.freeze()
async with asyncio.TaskGroup() as task_group:
if len(self.cenc_eew) > 0:
task_group.create_task(self._cenc_eew_ws.start())
if len(self.sc_eew) > 0:
task_group.create_task(self._sc_eew_ws.start())
if len(self.cenc_eqlist) > 0:
task_group.create_task(self._cenc_eqlist_ws.start())
async def stop(self): # pragma: no cover
async with asyncio.TaskGroup() as task_group:
task_group.create_task(self._cenc_eew_ws.stop())
task_group.create_task(self._sc_eew_ws.stop())
task_group.create_task(self._cenc_eqlist_ws.stop())
wolfx_api = WolfxAPIService()
@after_init
def init(): # pragma: no cover
import nonebot
driver = nonebot.get_driver()
@driver.on_startup
async def _():
await wolfx_api.start()
@driver.on_shutdown
async def _():
await wolfx_api.stop()

View File

@ -0,0 +1,15 @@
from typing import Any, Callable
AFTER_INIT_FUNCTION = Callable[[], Any]
_after_init_functions: list[AFTER_INIT_FUNCTION] = []
def after_init(func: AFTER_INIT_FUNCTION):
_after_init_functions.append(func)
def run_afterinit_functions(): # pragma: no cover
for f in _after_init_functions:
f()

112
konabot/common/artifact.py Normal file
View File

@ -0,0 +1,112 @@
import asyncio
import aiohttp
import hashlib
import platform
from dataclasses import dataclass
from pathlib import Path
import nonebot
from loguru import logger
from nonebot.adapters.discord.config import Config as DiscordConfig
from pydantic import BaseModel
@dataclass
class ArtifactDepends:
url: str
sha256: str
target: Path
required_os: str | None = None
"示例值Windows, Linux, Darwin"
required_arch: str | None = None
"示例值AMD64, x86_64, arm64"
use_proxy: bool = True
"网络问题,赫赫;使用的是 Discord 模块配置的 proxy"
def is_corresponding_platform(self) -> bool:
if self.required_os is not None:
if self.required_os.lower() != platform.system().lower():
return False
if self.required_arch is not None:
if self.required_arch.lower() != platform.machine().lower():
return False
return True
class Config(BaseModel):
prefetch_artifact: bool = False
"是否提前下载好二进制依赖"
artifact_list = []
driver = nonebot.get_driver()
config = nonebot.get_plugin_config(Config)
@driver.on_startup
async def _():
if config.prefetch_artifact:
logger.info("启动检测中:正在检测需求的二进制是否下载")
semaphore = asyncio.Semaphore(10)
async def _task(artifact: ArtifactDepends):
async with semaphore:
await ensure_artifact(artifact)
tasks: set[asyncio.Task] = set()
for a in artifact_list:
tasks.add(asyncio.Task(_task(a)))
await asyncio.gather(*tasks, return_exceptions=False)
logger.info("检测好了")
async def download_artifact(artifact: ArtifactDepends):
proxy = None
if artifact.use_proxy:
discord_config = nonebot.get_plugin_config(DiscordConfig)
proxy = discord_config.discord_proxy
if proxy is not None:
logger.info(f"正在使用 Proxy 下载 TARGET={artifact.target} PROXY={proxy}")
else:
logger.info(f"正在下载 TARGET={artifact.target}")
async with aiohttp.ClientSession(proxy=proxy) as client:
result = await client.get(artifact.url)
if result.status != 200:
logger.warning(f"已经下载了二进制,但是注意服务器没有返回 200 URL={artifact.url} TARGET={artifact.target} CODE={result.status}")
data = await result.read()
artifact.target.write_bytes(data)
if not platform.system().lower() == 'windows':
artifact.target.chmod(0o755)
logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}")
m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower():
logger.warning(f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}")
async def ensure_artifact(artifact: ArtifactDepends):
if not artifact.is_corresponding_platform():
return
if not artifact.target.exists():
logger.info(f"二进制依赖 {artifact.target} 不存在")
if not artifact.target.parent.exists():
artifact.target.parent.mkdir(parents=True, exist_ok=True)
await download_artifact(artifact)
else:
m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower():
logger.info(f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载")
artifact.target.unlink()
await download_artifact(artifact)
def register_artifacts(*artifacts: ArtifactDepends):
artifact_list.extend(artifacts)

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,231 @@
from contextlib import asynccontextmanager
import os
import asyncio
import sqlparse
from pathlib import Path
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
import aiosqlite
if TYPE_CHECKING:
from . import DatabaseManager
# 全局数据库管理器实例
_global_db_manager: Optional["DatabaseManager"] = None
def get_global_db_manager() -> "DatabaseManager":
"""获取全局数据库管理器实例"""
global _global_db_manager
if _global_db_manager is None:
from . import DatabaseManager
_global_db_manager = DatabaseManager()
return _global_db_manager
def close_global_db_manager() -> None:
"""关闭全局数据库管理器实例"""
global _global_db_manager
if _global_db_manager is not None:
# 注意这个函数应该在async环境中调用close_all_connections
_global_db_manager = None
class DatabaseManager:
"""异步数据库管理器"""
def __init__(self, db_path: Optional[Union[str, Path]] = None, pool_size: int = 5):
"""
初始化数据库管理器
Args:
db_path: 数据库文件路径支持str和Path类型
pool_size: 连接池大小
"""
if db_path is None:
self.db_path = os.environ.get("DATABASE_PATH", "./data/database.db")
else:
self.db_path = str(db_path) if isinstance(db_path, Path) else db_path
# 连接池
self._connection_pool = []
self._pool_size = pool_size
self._lock = asyncio.Lock()
self._in_use = set() # 跟踪正在使用的连接
async def _get_connection(self) -> aiosqlite.Connection:
"""从连接池获取连接"""
async with self._lock:
# 尝试从池中获取现有连接
while self._connection_pool:
conn = self._connection_pool.pop()
# 检查连接是否仍然有效
try:
await conn.execute("SELECT 1")
self._in_use.add(conn)
return conn
except:
# 连接已失效,关闭它
try:
await conn.close()
except:
pass
# 如果连接池为空,创建新连接
conn = await aiosqlite.connect(self.db_path)
await conn.execute("PRAGMA foreign_keys = ON")
self._in_use.add(conn)
return conn
async def _return_connection(self, conn: aiosqlite.Connection) -> None:
"""将连接返回到连接池"""
async with self._lock:
self._in_use.discard(conn)
if len(self._connection_pool) < self._pool_size:
self._connection_pool.append(conn)
else:
# 池已满,直接关闭连接
try:
await conn.close()
except:
pass
@asynccontextmanager
async def get_conn(self):
conn = await self._get_connection()
yield conn
await self._return_connection(conn)
async def query(
self, query: str, params: Optional[tuple] = None
) -> List[Dict[str, Any]]:
"""执行查询语句并返回结果"""
conn = await self._get_connection()
try:
cursor = await conn.execute(query, params or ())
columns = [description[0] for description in cursor.description]
rows = await cursor.fetchall()
results = [dict(zip(columns, row)) for row in rows]
await cursor.close()
return results
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库查询失败: {str(e)}") from e
finally:
await self._return_connection(conn)
async def query_by_sql_file(
self, file_path: Union[str, Path], params: Optional[tuple] = None
) -> List[Dict[str, Any]]:
"""从 SQL 文件中读取查询语句并执行"""
path = str(file_path) if isinstance(file_path, Path) else file_path
with open(path, "r", encoding="utf-8") as f:
query = f.read()
return await self.query(query, params)
async def execute(self, command: str, params: Optional[tuple] = None) -> None:
"""执行非查询语句"""
conn = await self._get_connection()
try:
await conn.execute(command, params or ())
await conn.commit()
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库执行失败: {str(e)}") from e
finally:
await self._return_connection(conn)
async def execute_script(self, script: str) -> None:
"""执行SQL脚本"""
conn = await self._get_connection()
try:
await conn.executescript(script)
await conn.commit()
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库脚本执行失败: {str(e)}") from e
finally:
await self._return_connection(conn)
def _parse_sql_statements(self, script: str) -> List[str]:
"""解析SQL脚本分割成独立的语句"""
# 使用sqlparse库更准确地分割SQL语句
parsed = sqlparse.split(script)
statements = []
for statement in parsed:
statement = statement.strip()
if statement:
statements.append(statement)
return statements
async def execute_by_sql_file(
self,
file_path: Union[str, Path],
params: Optional[Union[tuple, List[tuple]]] = None,
) -> None:
"""从 SQL 文件中读取非查询语句并执行"""
path = str(file_path) if isinstance(file_path, Path) else file_path
with open(path, "r", encoding="utf-8") as f:
script = f.read()
# 如果有参数且是元组使用execute执行整个脚本
if params is not None and isinstance(params, tuple):
await self.execute(script, params)
# 如果有参数且是列表,分别执行每个语句
elif params is not None and isinstance(params, list):
# 使用sqlparse准确分割SQL语句
statements = self._parse_sql_statements(script)
if len(statements) != len(params):
raise ValueError(
f"语句数量({len(statements)})与参数组数量({len(params)})不匹配"
)
for statement, stmt_params in zip(statements, params):
if statement:
await self.execute(statement, stmt_params)
# 如果无参数使用executescript
else:
await self.execute_script(script)
async def execute_many(self, command: str, seq_of_params: List[tuple]) -> None:
"""执行多条非查询语句"""
conn = await self._get_connection()
try:
await conn.executemany(command, seq_of_params)
await conn.commit()
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库批量执行失败: {str(e)}") from e
finally:
await self._return_connection(conn)
async def execute_many_values_by_sql_file(
self, file_path: Union[str, Path], seq_of_params: List[tuple]
) -> None:
"""从 SQL 文件中读取一条语句,但是被不同值同时执行"""
path = str(file_path) if isinstance(file_path, Path) else file_path
with open(path, "r", encoding="utf-8") as f:
command = f.read()
await self.execute_many(command, seq_of_params)
async def close_all_connections(self) -> None:
"""关闭所有连接"""
async with self._lock:
# 关闭池中的连接
for conn in self._connection_pool:
try:
await conn.close()
except:
pass
self._connection_pool.clear()
# 关闭正在使用的连接
for conn in self._in_use.copy():
try:
await conn.close()
except:
pass
self._in_use.clear()

View File

@ -0,0 +1,67 @@
from typing import Any, cast
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] | list[dict[str, Any]],
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=cast(Any, 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:
if llm_config.default_llm in llm_config.llms:
logger.warning(f"[LLM] 需求的 LLM 不存在,回退到默认模型 REQUIRED={llm_model}")
return llm_config.llms[llm_config.default_llm]
raise NotImplementedError("[LLM] 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)]

9
konabot/common/nb/exc.py Normal file
View File

@ -0,0 +1,9 @@
from nonebot_plugin_alconna import UniMessage
class BotExceptionMessage(Exception):
def __init__(self, msg: UniMessage | str) -> None:
super().__init__()
if isinstance(msg, str):
msg = UniMessage().text(msg)
self.msg = msg

View File

@ -0,0 +1,227 @@
from io import BytesIO
from pathlib import Path
from typing import Annotated
import httpx
import PIL.Image
from loguru import logger
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
discordConfig = nonebot.get_plugin_config(DiscordConfig)
class ExtractImageConfig(BaseModel):
module_extract_image_no_download: bool = False
"""
要不要算了,不下载了,直接爆炸算了,
适用于一些比较奇怪的网络环境,无法从协议端下载文件
"""
module_extract_image_target: str = './assets/img/other/boom.jpg'
"""
使用哪个图片呢
"""
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(Path(module_config.module_extract_image_target).read_bytes())
logger.debug(f"开始从 {url} 下载图片")
async with httpx.AsyncClient(proxy=proxy) as c:
try:
response = await c.get(url)
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
return Failure(f"HTTPX 模块下载图片时出错:{e}")
except httpx.ConnectTimeout:
return Failure("下载图片失败了网络超时了qwq")
if response.status_code != 200:
return Failure("无法下载图片,可能存在网络问题需要排查")
return Success(response.content)
def bytes_to_pil(raw_data: bytes | BytesIO) -> Result[PIL.Image.Image, str]:
try:
if not isinstance(raw_data, BytesIO):
img_pil = PIL.Image.open(BytesIO(raw_data))
else:
img_pil = PIL.Image.open(raw_data)
img_pil.verify()
if not isinstance(raw_data, BytesIO):
img = PIL.Image.open(BytesIO(raw_data))
else:
raw_data.seek(0)
img = PIL.Image.open(raw_data)
return Success(img)
except UnidentifiedImageError:
return Failure("图像无法读取可能是格式不支持orz")
except IOError:
return Failure("图像无法读取可能是网络存在问题orz")
async def unimsg_img_to_bytes(image: Image) -> Result[bytes, str]:
if image.url is not None:
raw_result = await download_image_bytes(image.url)
elif image.raw is not None:
if isinstance(image.raw, bytes):
raw_result = Success(image.raw)
else:
raw_result = Success(image.raw.getvalue())
else:
return Failure("由于一些内部问题下载图片失败了orz")
return raw_result
async def unimsg_img_to_pil(image: Image) -> Result[PIL.Image.Image, str]:
return (await unimsg_img_to_bytes(image)).bind(bytes_to_pil)
async def extract_image_from_qq_message(
msg: OnebotV11Message,
evt: OnebotV11MessageEvent,
bot: OnebotV11Bot,
allow_reply: bool = True,
) -> Result[bytes, str]:
if allow_reply and (reply := evt.reply) is not None:
return await extract_image_from_qq_message(
reply.message,
evt,
bot,
False,
)
for seg in msg:
if seg.type == "reply" and allow_reply:
msgid = seg.data.get("id")
if msgid is None:
return Failure("消息可能太久远,无法读取到消息原文")
try:
msg2 = await bot.get_msg(message_id=msgid)
except Exception as e:
logger.warning(f"获取消息内容时出错:{e}")
return Failure("消息可能太久远,无法读取到消息原文")
msg2_data = msg2.get("message")
if msg2_data is None:
return Failure("消息可能太久远,无法读取到消息原文")
logger.debug("发现消息引用,递归一层")
return await extract_image_from_qq_message(
msg=OnebotV11Message(msg2_data),
evt=evt,
bot=bot,
allow_reply=False,
)
if seg.type == "image":
url = seg.data.get("url")
if url is None:
return Failure("无法下载图片,可能有一些网络问题")
return await download_image_bytes(url)
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
async def extract_image_data_from_message(
msg: Message,
evt: Event,
bot: Bot,
allow_reply: bool = True,
) -> Result[bytes, str]:
if (
isinstance(bot, OnebotV11Bot)
and isinstance(msg, OnebotV11Message)
and isinstance(evt, OnebotV11MessageEvent)
):
# 看起来 UniMessage 在这方面能力似乎不足,因此用 QQ 的
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)
for seg in UniMessage.of(msg, bot):
logger.info(seg)
if isinstance(seg, Image):
return await unimsg_img_to_bytes(seg)
elif isinstance(seg, Reply) and allow_reply:
msg2 = seg.msg
logger.debug(f"深入搜索引用的消息:{msg2}")
if msg2 is None or isinstance(msg2, str):
continue
return await extract_image_data_from_message(msg2, evt, bot, False)
elif isinstance(seg, RefNode) and allow_reply:
if isinstance(bot, DiscordBot):
return Failure("暂时不支持在 Discord 中通过引用的方式获取图片")
else:
return Failure("暂时不支持在这里中通过引用的方式获取图片")
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
async def _ext_img_data(
evt: Event,
bot: Bot,
matcher: Matcher,
) -> bytes | None:
match await extract_image_data_from_message(evt.get_message(), evt, bot):
case Success(img):
return img
case Failure(err):
# raise BotExceptionMessage(err)
await matcher.send(await UniMessage().text(err).export())
return None
assert False
async def _ext_img(
evt: Event,
bot: Bot,
matcher: Matcher,
) -> PIL.Image.Image | None:
r = await _ext_img_data(evt, bot, matcher)
if r:
match bytes_to_pil(r):
case Success(img):
return img
case Failure(msg):
await matcher.send(await UniMessage.text(msg).export())
return None
async def _try_ext_img(
evt: Event,
bot: Bot,
matcher: Matcher,
) -> bytes | None:
match await extract_image_data_from_message(evt.get_message(), evt, bot):
case Success(img):
return img
case Failure(err):
# raise BotExceptionMessage(err)
# await matcher.send(await UniMessage().text(err).export())
return None
assert False
DepImageBytes = Annotated[bytes, nonebot.params.Depends(_ext_img_data)]
DepPILImage = Annotated[PIL.Image.Image, nonebot.params.Depends(_ext_img)]
DepImageBytesOrNone = Annotated[bytes | None, nonebot.params.Depends(_try_ext_img)]

View File

@ -0,0 +1,34 @@
from nonebot import get_plugin_config
import nonebot
import nonebot.adapters
import nonebot.adapters.console
import nonebot.adapters.discord
import nonebot.adapters.onebot
from pydantic import BaseModel
class IsAdminConfig(BaseModel):
admin_qq_group: list[int] = []
admin_qq_account: list[int] = []
admin_discord_channel: list[int] = []
admin_discord_account: list[int] = []
cfg = get_plugin_config(IsAdminConfig)
def is_admin(event: nonebot.adapters.Event):
if isinstance(event, nonebot.adapters.onebot.v11.MessageEvent):
if event.user_id in cfg.admin_qq_account:
return True
if isinstance(event, nonebot.adapters.onebot.v11.GroupMessageEvent):
if event.group_id in cfg.admin_qq_group:
return True
if isinstance(event, nonebot.adapters.discord.event.MessageEvent):
if event.channel_id in cfg.admin_discord_channel:
return True
if event.user_id in cfg.admin_discord_account:
return True
if isinstance(event, nonebot.adapters.console.event.Event):
return True
return False

View File

@ -0,0 +1,16 @@
import re
from nonebot_plugin_alconna import Text, UniMsg
def match_keyword(*patterns: str | re.Pattern):
async def _matcher(msg: UniMsg):
text = msg.get(Text).extract_plain_text().strip()
for pattern in patterns:
if isinstance(pattern, str) and text == pattern:
return True
if isinstance(pattern, re.Pattern) and re.match(pattern, text):
return True
return False
return _matcher

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,13 @@
from io import BytesIO
import PIL
import PIL.Image
from nonebot.adapters import Bot
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import UniMessage
async def reply_image(matcher: type[Matcher], bot: Bot, img: PIL.Image.Image):
data = BytesIO()
img.save(data, "PNG")
await matcher.send(await UniMessage().image(raw=data).export(bot))

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

@ -2,3 +2,27 @@ from pathlib import Path
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"
TMP_PATH = DATA_PATH / "tmp"
LOG_PATH = DATA_PATH / "logs"
CONFIG_PATH = DATA_PATH / "config"
BINARY_PATH = DATA_PATH / "bin"
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)
TMP_PATH.mkdir(exist_ok=True)
BINARY_PATH.mkdir(exist_ok=True)

View File

@ -0,0 +1,119 @@
from typing import Annotated
import nonebot
from nonebot.adapters import Event
from nonebot.params import Depends
from nonebot.rule import Rule
from konabot.common.appcontext import after_init
from konabot.common.database import DatabaseManager
from konabot.common.pager import PagerQuery
from konabot.common.path import DATA_PATH
from konabot.common.permsys.entity import PermEntity, get_entity_chain
from konabot.common.permsys.migrates import execute_migration
from konabot.common.permsys.repo import PermRepo
db = DatabaseManager(DATA_PATH / "perm.sqlite3")
_default_allow_permissions: set[str] = set()
_EntityLike = Event | PermEntity | list[PermEntity]
async def _to_entity_chain(el: _EntityLike):
if isinstance(el, Event):
return await get_entity_chain(el) # pragma: no cover
if isinstance(el, PermEntity):
return [el]
return el
class PermManager:
def __init__(self, db: DatabaseManager) -> None:
self.db = db
async def check_has_permission_info(self, entities: _EntityLike, key: str):
entities = await _to_entity_chain(entities)
key = key.removesuffix("*").removesuffix(".")
key_split = key.split(".")
key_split = [s for s in key_split if len(s) > 0]
keys = [".".join(key_split[: i + 1]) for i in range(len(key_split))][::-1] + [
"*"
]
async with self.db.get_conn() as conn:
repo = PermRepo(conn)
data = await repo.get_perm_info_batch(entities, keys)
for entity in entities:
for k in keys:
p = data.get((entity, k))
if p is not None:
return (entity, k, p)
return None
async def check_has_permission(self, entities: _EntityLike, key: str) -> bool:
res = await self.check_has_permission_info(entities, key)
if res is None:
return False
return res[2]
async def update_permission(self, entity: PermEntity, key: str, perm: bool | None):
async with self.db.get_conn() as conn:
repo = PermRepo(conn)
await repo.update_perm_info(entity, key, perm)
async def list_permission(self, entities: _EntityLike, query: PagerQuery):
entities = await _to_entity_chain(entities)
async with self.db.get_conn() as conn:
repo = PermRepo(conn)
return await repo.list_perm_info_batch(entities, query)
def perm_manager(_db: DatabaseManager | None = None) -> PermManager: # pragma: no cover
if _db is None:
_db = db
return PermManager(_db)
@after_init
def create_startup(): # pragma: no cover
from konabot.common.nb.is_admin import cfg
driver = nonebot.get_driver()
@driver.on_startup
async def _():
async with db.get_conn() as conn:
await execute_migration(conn)
pm = perm_manager(db)
for account in cfg.admin_qq_account:
# ^ 这里的是超级管理员!!用环境变量定义的。
# 咕嘿嘿嘿!!!夺取全部权限!!!
await pm.update_permission(
PermEntity("ob11", "user", str(account)), "*", True
)
for key in _default_allow_permissions:
await pm.update_permission(
PermEntity("sys", "global", "global"), key, True
)
@driver.on_shutdown
async def _():
try:
await db.close_all_connections()
except Exception:
pass
DepPermManager = Annotated[PermManager, Depends(perm_manager)]
def register_default_allow_permission(key: str):
_default_allow_permissions.add(key)
def require_permission(perm: str) -> Rule: # pragma: no cover
async def check_permission(event: Event, pm: DepPermManager) -> bool:
return await pm.check_has_permission(event, perm)
return Rule(check_permission)

View File

@ -0,0 +1,69 @@
from dataclasses import dataclass
from nonebot.internal.adapter import Event
from nonebot.adapters.onebot.v11 import Event as OB11Event
from nonebot.adapters.onebot.v11.event import GroupMessageEvent as OB11GroupEvent
from nonebot.adapters.onebot.v11.event import PrivateMessageEvent as OB11PrivateEvent
from nonebot.adapters.discord.event import Event as DiscordEvent
from nonebot.adapters.discord.event import GuildMessageCreateEvent as DiscordGMEvent
from nonebot.adapters.discord.event import DirectMessageCreateEvent as DiscordDMEvent
from nonebot.adapters.minecraft.event import MessageEvent as MinecraftMessageEvent
from nonebot.adapters.console.event import MessageEvent as ConsoleEvent
@dataclass(frozen=True)
class PermEntity:
platform: str
entity_type: str
external_id: str
def get_entity_chain_of_entity(entity: PermEntity) -> list[PermEntity]:
return [
PermEntity("sys", "global", "global"),
PermEntity(entity.platform, "global", "global"),
entity,
][::-1]
async def get_entity_chain(event: Event) -> list[PermEntity]: # pragma: no cover
entities = [PermEntity("sys", "global", "global")]
if isinstance(event, OB11Event):
entities.append(PermEntity("ob11", "global", "global"))
if isinstance(event, OB11GroupEvent):
entities.append(PermEntity("ob11", "group", str(event.group_id)))
entities.append(PermEntity("ob11", "user", str(event.user_id)))
if isinstance(event, OB11PrivateEvent):
entities.append(PermEntity("ob11", "user", str(event.user_id)))
if isinstance(event, DiscordEvent):
entities.append(PermEntity("discord", "global", "global"))
if isinstance(event, DiscordGMEvent):
entities.append(PermEntity("discord", "guild", str(event.guild_id)))
entities.append(PermEntity("discord", "channel", str(event.channel_id)))
entities.append(PermEntity("discord", "user", str(event.user_id)))
if isinstance(event, DiscordDMEvent):
entities.append(PermEntity("discord", "channel", str(event.channel_id)))
entities.append(PermEntity("discord", "user", str(event.user_id)))
if isinstance(event, MinecraftMessageEvent):
entities.append(PermEntity("minecraft", "global", "global"))
entities.append(PermEntity("minecraft", "server", event.server_name))
player_uuid = event.player.uuid
if player_uuid is not None:
entities.append(PermEntity("minecraft", "player", player_uuid.hex))
if isinstance(event, ConsoleEvent):
entities.append(PermEntity("console", "global", "global"))
entities.append(PermEntity("console", "channel", event.channel.id))
entities.append(PermEntity("console", "user", event.user.id))
return entities[::-1]

View File

@ -0,0 +1,81 @@
from dataclasses import dataclass
from pathlib import Path
import aiosqlite
from loguru import logger
from konabot.common.database import DatabaseManager
from konabot.common.path import DATA_PATH
PATH_THISFOLDER = Path(__file__).parent
SQL_CHECK_EXISTS = (PATH_THISFOLDER / "./check_migrate_version_exists.sql").read_text()
SQL_CREATE_TABLE = (PATH_THISFOLDER / "./create_migrate_version_table.sql").read_text()
SQL_GET_MIGRATE_VERSION = (PATH_THISFOLDER / "get_migrate_version.sql").read_text()
SQL_UPDATE_VERSION = (PATH_THISFOLDER / "./update_migrate_version.sql").read_text()
db = DatabaseManager(DATA_PATH / "perm.sqlite3")
@dataclass
class Migration:
upgrade: str | Path
downgrade: str | Path
def get_upgrade_script(self) -> str:
if isinstance(self.upgrade, Path):
return self.upgrade.read_text()
return self.upgrade
def get_downgrade_script(self) -> str:
if isinstance(self.downgrade, Path):
return self.downgrade.read_text()
return self.downgrade
migrations = [
Migration(
PATH_THISFOLDER / "./mu1_create_permsys_table.sql",
PATH_THISFOLDER / "./md1_remove_permsys_table.sql",
),
]
TARGET_VERSION = len(migrations)
async def get_current_version(conn: aiosqlite.Connection) -> int:
cursor = await conn.execute(SQL_CHECK_EXISTS)
count = await cursor.fetchone()
assert count is not None
if count[0] < 1:
logger.info("权限系统数据表不存在,现在创建表")
await conn.executescript(SQL_CREATE_TABLE)
await conn.commit()
return 0
cursor = await conn.execute(SQL_GET_MIGRATE_VERSION)
row = await cursor.fetchone()
if row is None:
return 0
return row[0]
async def execute_migration(
conn: aiosqlite.Connection,
version: int = TARGET_VERSION,
migrations: list[Migration] = migrations,
):
now_version = await get_current_version(conn)
while now_version < version:
migration = migrations[now_version]
await conn.executescript(migration.get_upgrade_script())
now_version += 1
await conn.execute(SQL_UPDATE_VERSION, (now_version,))
await conn.commit()
while now_version > version:
migration = migrations[now_version - 1]
await conn.executescript(migration.get_downgrade_script())
now_version -= 1
await conn.execute(SQL_UPDATE_VERSION, (now_version,))
await conn.commit()

View File

@ -0,0 +1,7 @@
SELECT
COUNT(*)
FROM
sqlite_master
WHERE
type = 'table'
AND name = 'migrate_version'

View File

@ -0,0 +1,3 @@
CREATE TABLE migrate_version(version INT PRIMARY KEY);
INSERT INTO migrate_version(version)
VALUES(0);

View File

@ -0,0 +1,4 @@
SELECT
version
FROM
migrate_version;

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS perm_entity;
DROP TABLE IF EXISTS perm_info;

View File

@ -0,0 +1,30 @@
CREATE TABLE perm_entity(
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
entity_type TEXT NOT NULL,
external_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_perm_entity_lookup
ON perm_entity(platform, entity_type, external_id);
CREATE TABLE perm_info(
entity_id INTEGER NOT NULL,
config_key TEXT NOT NULL,
value BOOLEAN,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 联合主键
PRIMARY KEY (entity_id, config_key)
);
CREATE TRIGGER perm_entity_update AFTER UPDATE
ON perm_entity BEGIN
UPDATE perm_entity SET updated_at=CURRENT_TIMESTAMP WHERE id=old.id;
END;
CREATE TRIGGER perm_info_update AFTER UPDATE
ON perm_info BEGIN
UPDATE perm_info SET updated_at=CURRENT_TIMESTAMP WHERE entity_id=old.entity_id;
END;

View File

@ -0,0 +1,2 @@
UPDATE migrate_version
SET version = ?;

View File

@ -0,0 +1,242 @@
from dataclasses import dataclass
import math
from pathlib import Path
import aiosqlite
from konabot.common.pager import PagerQuery, PagerResult
from .entity import PermEntity
def s(p: str):
"""读取 SQL 文件内容。
Args:
p: SQL 文件名(相对于当前文件所在目录的 sql/ 子目录)。
Returns:
SQL 文件的内容字符串。
"""
return (Path(__file__).parent / "./sql/" / p).read_text()
@dataclass
class PermRepo:
"""权限实体存储库,负责与数据库交互管理权限实体。
Attributes:
conn: aiosqlite 数据库连接对象。
"""
conn: aiosqlite.Connection
async def create_entity(self, entity: PermEntity) -> int:
"""创建新的权限实体并返回其 ID。
Args:
entity: 要创建的权限实体对象。
Returns:
新创建实体的数据库 ID。
Raises:
AssertionError: 如果创建后无法获取实体 ID。
"""
await self.conn.execute(
s("create_entity.sql"),
(entity.platform, entity.entity_type, entity.external_id),
)
await self.conn.commit()
eid = await self._get_entity_id_or_none(entity)
assert eid is not None
return eid
async def _get_entity_id_or_none(self, entity: PermEntity) -> int | None:
"""查询实体 ID如果不存在则返回 None。
Args:
entity: 要查询的权限实体对象。
Returns:
实体 ID如果不存在则返回 None。
"""
res = await self.conn.execute(
s("get_entity_id.sql"),
(entity.platform, entity.entity_type, entity.external_id),
)
row = await res.fetchone()
if row is None:
return None
return row[0]
async def get_entity_id(self, entity: PermEntity) -> int:
"""获取实体 ID如果不存在则自动创建。
Args:
entity: 权限实体对象。
Returns:
实体的数据库 ID。
"""
eid = await self._get_entity_id_or_none(entity)
if eid is None:
return await self.create_entity(entity)
return eid
async def get_perm_info(self, entity: PermEntity, config_key: str) -> bool | None:
"""获取实体的权限配置信息。
Args:
entity: 权限实体对象。
config_key: 配置项的键名。
Returns:
配置值True/False如果不存在则返回 None。
"""
eid = await self.get_entity_id(entity)
res = await self.conn.execute(
s("get_perm_info.sql"),
(eid, config_key),
)
row = await res.fetchone()
if row is None:
return None
return bool(row[0])
async def update_perm_info(
self, entity: PermEntity, config_key: str, value: bool | None
):
"""更新实体的权限配置信息。
Args:
entity: 权限实体对象。
config_key: 配置项的键名。
value: 要设置的配置值True/False/None
"""
eid = await self.get_entity_id(entity)
await self.conn.execute(s("update_perm_info.sql"), (eid, config_key, value))
await self.conn.commit()
async def get_entity_id_batch(
self, entities: list[PermEntity]
) -> dict[PermEntity, int]:
"""批量获取 Entity 的 entity_id
Args:
entities: PermEntity 列表
Returns:
字典,键为 PermEntity值为对应的 ID
"""
# for entity in entities:
# await self.conn.execute(
# s("create_entity.sql"),
# (entity.platform, entity.entity_type, entity.external_id),
# )
await self.conn.executemany(
s("create_entity.sql"),
[(e.platform, e.entity_type, e.external_id) for e in entities],
)
await self.conn.commit()
val_placeholders = ", ".join(["(?, ?, ?)"] * len(entities))
params = []
for e in entities:
params.extend([e.platform, e.entity_type, e.external_id])
cursor = await self.conn.execute(
f"""
SELECT id, platform, entity_type, external_id
FROM perm_entity
WHERE (platform, entity_type, external_id) IN (VALUES {val_placeholders});
""",
params,
)
rows = await cursor.fetchall()
return {PermEntity(row[1], row[2], row[3]): row[0] for row in rows}
async def get_perm_info_batch(
self, entities: list[PermEntity], config_keys: list[str]
) -> dict[tuple[PermEntity, str], bool]:
"""批量获取权限信息
Args:
entities: PermEntity 列表
config_keys: 查询的键列表
Returns:
字典,键是 PermEntity 和 config_key 的元组,值是布尔,过滤掉所有空值
"""
entity_ids = {
v: k for k, v in (await self.get_entity_id_batch(entities)).items()
}
placeholders1 = ", ".join("?" * len(entity_ids))
placeholders2 = ", ".join("?" * len(config_keys))
sql = f"""
SELECT entity_id, config_key, value
FROM perm_info
WHERE entity_id IN ({placeholders1})
AND config_key IN ({placeholders2})
AND value IS NOT NULL;
"""
params = tuple(entity_ids.keys()) + tuple(config_keys)
cursor = await self.conn.execute(sql, params)
rows = await cursor.fetchall()
return {(entity_ids[row[0]], row[1]): bool(row[2]) for row in rows}
async def list_perm_info_batch(
self, entities: list[PermEntity], pager: PagerQuery
) -> PagerResult[tuple[PermEntity, str, bool]]:
"""批量获取某个实体的权限信息
Args:
entities: PermEntity 列表
pager: PagerQuery 对象,即分页要求
Returns:
字典,键是 PermEntity值是权限条目和布尔的元组过滤掉所有空值
"""
entity_to_id = await self.get_entity_id_batch(entities)
id_to_entity = {v: k for k, v in entity_to_id.items()}
ordered_ids = [entity_to_id[e] for e in entities if e in entity_to_id]
placeholders = ", ".join("?" * len(ordered_ids))
order_by_cases = " ".join([f"WHEN ? THEN {i}" for i in range(len(ordered_ids))])
pagecount_sql = f"SELECT COUNT(*) FROM perm_info WHERE entity_id IN ({placeholders}) AND value IS NOT NULL;"
count_cursor = await self.conn.execute(pagecount_sql, tuple(ordered_ids))
total_count = (await count_cursor.fetchone() or (0,))[0]
sql = f"""
SELECT entity_id, config_key, value
FROM perm_info
WHERE entity_id IN ({placeholders})
AND value IS NOT NULL
ORDER BY
(CASE entity_id {order_by_cases} END) ASC,
config_key ASC
LIMIT ?
OFFSET ?;
"""
params = (
tuple(ordered_ids)
+ tuple(ordered_ids)
+ (
pager.page_size,
(pager.page_index - 1) * pager.page_size,
)
)
cursor = await self.conn.execute(sql, params)
rows = await cursor.fetchall()
# return {entity_ids[row[0]]: (row[1], bool(row[2])) for row in rows}
return PagerResult(
data=[(id_to_entity[row[0]], row[1], row[2]) for row in rows],
success=True,
message="",
page_count=math.ceil(total_count / pager.page_size),
query=pager,
)

View File

@ -0,0 +1,11 @@
INSERT
OR IGNORE INTO perm_entity(
platform,
entity_type,
external_id
)
VALUES(
?,
?,
?
);

View File

@ -0,0 +1,8 @@
SELECT
id
FROM
perm_entity
WHERE
perm_entity.platform = ?
AND perm_entity.entity_type = ?
AND perm_entity.external_id = ?;

View File

@ -0,0 +1,7 @@
SELECT
VALUE
FROM
perm_info
WHERE
entity_id = ?
AND config_key = ?;

View File

@ -0,0 +1,4 @@
INSERT INTO perm_info (entity_id, config_key, value)
VALUES (?, ?, ?)
ON CONFLICT(entity_id, config_key)
DO UPDATE SET value=excluded.value;

View File

@ -0,0 +1,3 @@
# 已废弃
坏枪用简单的 LLM + 提示词工程,完成了这 200 块的 `qwen3-coder-plus` 都搞不定的 nb 功能

View File

@ -0,0 +1,58 @@
"""
Professional time parsing module for Chinese and English time expressions.
This module provides a robust parser for natural language time expressions,
supporting both Chinese and English formats with proper whitespace handling.
"""
import datetime
from typing import Optional
from .expression import TimeExpression
def parse(text: str, now: Optional[datetime.datetime] = None) -> datetime.datetime:
"""
Parse a time expression and return a datetime object.
Args:
text: The time expression to parse
now: The reference time (defaults to current time)
Returns:
A datetime object representing the parsed time
Raises:
TokenUnhandledException: If the input cannot be parsed
"""
return TimeExpression.parse(text, now)
class Parser:
"""
Parser for time expressions with backward compatibility.
Maintains the original interface:
>>> parser = Parser()
>>> result = parser.parse("10分钟后")
"""
def __init__(self, now: Optional[datetime.datetime] = None):
self.now = now or datetime.datetime.now()
def parse(self, text: str) -> datetime.datetime:
"""
Parse a time expression and return a datetime object.
This maintains backward compatibility with the original interface.
Args:
text: The time expression to parse
Returns:
A datetime object representing the parsed time
Raises:
TokenUnhandledException: If the input cannot be parsed
"""
return TimeExpression.parse(text, self.now)

View File

@ -0,0 +1,133 @@
"""
Chinese number parser for the time expression parser.
"""
import re
from typing import Tuple
class ChineseNumberParser:
"""Parser for Chinese numbers."""
def __init__(self):
self.digits = {"": 0, "": 1, "": 2, "": 3, "": 4,
"": 5, "": 6, "": 7, "": 8, "": 9}
self.units = {"": 10, "": 100, "": 1000, "": 10000, "亿": 100000000}
def digest(self, text: str) -> Tuple[str, int]:
"""
Parse a Chinese number from the beginning of text and return the rest and the parsed number.
Args:
text: Text that may start with a Chinese number
Returns:
Tuple of (remaining_text, parsed_number)
"""
if not text:
return text, 0
# Handle "两" at start
if text.startswith(""):
# Check if "两" is followed by a time unit
# Look ahead to see if we have a valid pattern like "两小时", "两分钟", etc.
if len(text) >= 2:
# Check for time units that start with the second character
time_units = ["小时", "分钟", ""]
for unit in time_units:
if text[1:].startswith(unit):
# Return the text starting from the time unit, not after it
# The parser will handle the time unit in the next step
return text[1:], 2
# Check for single character time units
next_char = text[1]
if next_char in "时分秒":
return text[1:], 2
# Check for Chinese number units
if next_char in "十百千万亿":
# This will be handled by the normal parsing below
pass
# If "两" is at the end of string, treat it as standalone
elif len(text) == 1:
return "", 2
# Also accept "两" followed by whitespace and then time units
elif next_char.isspace():
# Check if after whitespace we have time units
rest_after_space = text[2:].lstrip()
for unit in time_units:
if rest_after_space.startswith(unit):
# Return the text starting from the time unit
space_len = len(text[2:]) - len(rest_after_space)
return text[2+space_len:], 2
# Check single character time units after whitespace
if rest_after_space and rest_after_space[0] in "时分秒":
return text[2:], 2
else:
# Just "两" by itself
return "", 2
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:]
return rest, self.parse(num_str)
def parse(self, text: str) -> int:
"""
Parse a Chinese number string and return its integer value.
Args:
text: Chinese number string
Returns:
Integer value of the Chinese number
"""
if not text:
return 0
if text == "":
return 0
if text == "":
return 2
# Handle special case for "十"
if text == "":
return 10
# Handle numbers with "亿"
if "亿" in text:
parts = text.split("亿", 1)
a, b = parts[0], parts[1]
return self.parse(a) * 100000000 + self.parse(b)
# Handle numbers with "万"
if "" in text:
parts = text.split("", 1)
a, b = parts[0], parts[1]
return self.parse(a) * 10000 + self.parse(b)
# Handle remaining numbers
result = 0
temp = 0
for char in text:
if char == "":
continue
elif char == "":
temp = 2
elif char in self.digits:
temp = self.digits[char]
elif char in self.units:
unit = self.units[char]
if unit == 10 and temp == 0:
# Special case for numbers like "十三"
temp = 1
result += temp * unit
temp = 0
result += temp
return result

View File

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

View File

@ -0,0 +1,63 @@
"""
Main time expression parser class that integrates all components.
"""
import datetime
from typing import Optional
from .lexer import Lexer
from .parser import Parser
from .semantic import SemanticAnalyzer
from .ptime_ast import TimeExpressionNode
from .err import TokenUnhandledException
class TimeExpression:
"""Main class for parsing time expressions."""
def __init__(self, text: str, now: Optional[datetime.datetime] = None):
self.text = text.strip()
self.now = now or datetime.datetime.now()
if not self.text:
raise TokenUnhandledException("Empty input")
# Initialize components
self.lexer = Lexer(self.text, self.now)
self.parser = Parser(self.text, self.now)
self.semantic_analyzer = SemanticAnalyzer(self.now)
# Parse the expression
self.ast = self._parse()
def _parse(self) -> TimeExpressionNode:
"""Parse the time expression and return the AST."""
try:
return self.parser.parse()
except Exception as e:
raise TokenUnhandledException(f"Failed to parse '{self.text}': {str(e)}")
def evaluate(self) -> datetime.datetime:
"""Evaluate the time expression and return the datetime."""
try:
return self.semantic_analyzer.evaluate(self.ast)
except Exception as e:
raise TokenUnhandledException(f"Failed to evaluate '{self.text}': {str(e)}")
@classmethod
def parse(cls, text: str, now: Optional[datetime.datetime] = None) -> datetime.datetime:
"""
Parse a time expression and return a datetime object.
Args:
text: The time expression to parse
now: The reference time (defaults to current time)
Returns:
A datetime object representing the parsed time
Raises:
TokenUnhandledException: If the input cannot be parsed
"""
expression = cls(text, now)
return expression.evaluate()

View File

@ -0,0 +1,225 @@
"""
Lexical analyzer for time expressions.
"""
import re
from typing import Iterator, Optional
import datetime
from .ptime_token import Token, TokenType
from .chinese_number import ChineseNumberParser
class Lexer:
"""Lexical analyzer for time expressions."""
def __init__(self, text: str, now: Optional[datetime.datetime] = None):
self.text = text
self.pos = 0
self.current_char = self.text[self.pos] if self.text else None
self.now = now or datetime.datetime.now()
self.chinese_parser = ChineseNumberParser()
# Define token patterns
self.token_patterns = [
# Whitespace
(r'^\s+', TokenType.WHITESPACE),
# Time separators
(r'^:', TokenType.TIME_SEPARATOR),
(r'^点', TokenType.TIME_SEPARATOR),
(r'^时', TokenType.TIME_SEPARATOR),
(r'^分', TokenType.TIME_SEPARATOR),
(r'^秒', TokenType.TIME_SEPARATOR),
# Special time markers
(r'^半', TokenType.HALF),
(r'^一刻', TokenType.QUARTER),
(r'^整', TokenType.ZHENG),
(r'^钟', TokenType.ZHONG),
# Period indicators (must come before relative time patterns to avoid conflicts)
(r'^(上午|早晨|早上|清晨|早(?!\d))', TokenType.PERIOD_AM),
(r'^(中午|下午|晚上|晚(?!\d)|凌晨|午夜)', TokenType.PERIOD_PM),
# Week scope (more specific patterns first)
(r'^本周', TokenType.WEEK_SCOPE_CURRENT),
(r'^上周', TokenType.WEEK_SCOPE_LAST),
(r'^下周', TokenType.WEEK_SCOPE_NEXT),
# Relative directions
(r'^(后|以后|之后)', TokenType.RELATIVE_DIRECTION_FORWARD),
(r'^(前|以前|之前)', TokenType.RELATIVE_DIRECTION_BACKWARD),
# Extended relative time
(r'^明年', TokenType.RELATIVE_NEXT),
(r'^去年', TokenType.RELATIVE_LAST),
(r'^今年', TokenType.RELATIVE_THIS),
(r'^下(?![午年月周])', TokenType.RELATIVE_NEXT),
(r'^(上|去)(?![午年月周])', TokenType.RELATIVE_LAST),
(r'^这', TokenType.RELATIVE_THIS),
(r'^本(?![周月年])', TokenType.RELATIVE_THIS), # Match "本" but not "本周", "本月", "本年"
# Week scope (fallback for standalone terms)
(r'^本', TokenType.WEEK_SCOPE_CURRENT),
(r'^上', TokenType.WEEK_SCOPE_LAST),
(r'^下(?![午年月周])', TokenType.WEEK_SCOPE_NEXT),
# Week days (order matters - longer patterns first)
(r'^周一', TokenType.WEEKDAY_MONDAY),
(r'^周二', TokenType.WEEKDAY_TUESDAY),
(r'^周三', TokenType.WEEKDAY_WEDNESDAY),
(r'^周四', TokenType.WEEKDAY_THURSDAY),
(r'^周五', TokenType.WEEKDAY_FRIDAY),
(r'^周六', TokenType.WEEKDAY_SATURDAY),
(r'^周日', TokenType.WEEKDAY_SUNDAY),
# Single character weekdays should be matched after numbers
# (r'^一', TokenType.WEEKDAY_MONDAY),
# (r'^二', TokenType.WEEKDAY_TUESDAY),
# (r'^三', TokenType.WEEKDAY_WEDNESDAY),
# (r'^四', TokenType.WEEKDAY_THURSDAY),
# (r'^五', TokenType.WEEKDAY_FRIDAY),
# (r'^六', TokenType.WEEKDAY_SATURDAY),
# (r'^日', TokenType.WEEKDAY_SUNDAY),
# Student-friendly time expressions
(r'^早(?=\d)', TokenType.EARLY_MORNING),
(r'^晚(?=\d)', TokenType.LATE_NIGHT),
# Relative today variants
(r'^今晚上', TokenType.RELATIVE_TODAY),
(r'^今晚', TokenType.RELATIVE_TODAY),
(r'^今早', TokenType.RELATIVE_TODAY),
(r'^今天早上', TokenType.RELATIVE_TODAY),
(r'^今天早晨', TokenType.RELATIVE_TODAY),
(r'^今天上午', TokenType.RELATIVE_TODAY),
(r'^今天下午', TokenType.RELATIVE_TODAY),
(r'^今天晚上', TokenType.RELATIVE_TODAY),
(r'^今天', TokenType.RELATIVE_TODAY),
# Relative days
(r'^明天', TokenType.RELATIVE_TOMORROW),
(r'^后天', TokenType.RELATIVE_DAY_AFTER_TOMORROW),
(r'^大后天', TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW),
(r'^昨天', TokenType.RELATIVE_YESTERDAY),
(r'^前天', TokenType.RELATIVE_DAY_BEFORE_YESTERDAY),
(r'^大前天', TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY),
# Digits
(r'^\d+', TokenType.INTEGER),
# Time units (must come after date separators to avoid conflicts)
(r'^年(?![月日号])', TokenType.YEAR),
(r'^月(?![日号])', TokenType.MONTH),
(r'^[日号](?![月年])', TokenType.DAY),
(r'^天', TokenType.DAY),
(r'^周', TokenType.WEEK),
(r'^小时', TokenType.HOUR),
(r'^分钟', TokenType.MINUTE),
(r'^秒', TokenType.SECOND),
# Date separators (fallback patterns)
(r'^年', TokenType.DATE_SEPARATOR),
(r'^月', TokenType.DATE_SEPARATOR),
(r'^[日号]', TokenType.DATE_SEPARATOR),
(r'^[-/]', TokenType.DATE_SEPARATOR),
]
def advance(self):
"""Advance the position pointer and set the current character."""
self.pos += 1
if self.pos >= len(self.text):
self.current_char = None
else:
self.current_char = self.text[self.pos]
def skip_whitespace(self):
"""Skip whitespace characters."""
while self.current_char is not None and self.current_char.isspace():
self.advance()
def integer(self) -> int:
"""Parse an integer from the input."""
result = ''
while self.current_char is not None and self.current_char.isdigit():
result += self.current_char
self.advance()
return int(result)
def chinese_number(self) -> int:
"""Parse a Chinese number from the input."""
# Find the longest prefix that can be parsed as a Chinese number
for i in range(len(self.text) - self.pos, 0, -1):
prefix = self.text[self.pos:self.pos + i]
try:
# Use digest to get both the remaining text and the parsed value
remaining, value = self.chinese_parser.digest(prefix)
# Check if we actually consumed part of the prefix
consumed_length = len(prefix) - len(remaining)
if consumed_length > 0:
# Advance position by the length of the consumed text
for _ in range(consumed_length):
self.advance()
return value
except ValueError:
continue
# If no Chinese number found, just return 0
return 0
def get_next_token(self) -> Token:
"""Lexical analyzer that breaks the sentence into tokens."""
while self.current_char is not None:
# Skip whitespace
if self.current_char.isspace():
self.skip_whitespace()
continue
# Try to match each pattern
text_remaining = self.text[self.pos:]
for pattern, token_type in self.token_patterns:
match = re.match(pattern, text_remaining)
if match:
value = match.group(0)
position = self.pos
# Advance position
for _ in range(len(value)):
self.advance()
# Special handling for some tokens
if token_type == TokenType.INTEGER:
value = int(value)
elif token_type == TokenType.RELATIVE_TODAY and value in [
"今早上", "今天早上", "今天早晨", "今天上午"
]:
token_type = TokenType.PERIOD_AM
elif token_type == TokenType.RELATIVE_TODAY and value in [
"今晚上", "今天下午", "今天晚上"
]:
token_type = TokenType.PERIOD_PM
return Token(token_type, value, position)
# Try to parse Chinese numbers
chinese_start_pos = self.pos
try:
chinese_value = self.chinese_number()
if chinese_value > 0:
# We successfully parsed a Chinese number
return Token(TokenType.CHINESE_NUMBER, chinese_value, chinese_start_pos)
except ValueError:
pass
# If no pattern matches, skip the character and continue
self.advance()
# End of file
return Token(TokenType.EOF, None, self.pos)
def tokenize(self) -> Iterator[Token]:
"""Generate all tokens from the input."""
while True:
token = self.get_next_token()
yield token
if token.type == TokenType.EOF:
break

View File

@ -0,0 +1,846 @@
"""
Parser for time expressions that builds an Abstract Syntax Tree (AST).
"""
from typing import Iterator, Optional, List
import datetime
from .ptime_token import Token, TokenType
from .ptime_ast import (
ASTNode, NumberNode, DateNode, TimeNode,
RelativeDateNode, RelativeTimeNode, WeekdayNode, TimeExpressionNode
)
from .lexer import Lexer
class ParserError(Exception):
"""Exception raised for parser errors."""
pass
class Parser:
"""Parser for time expressions that builds an AST."""
def __init__(self, text: str, now: Optional[datetime.datetime] = None):
self.lexer = Lexer(text, now)
self.tokens: List[Token] = list(self.lexer.tokenize())
self.pos = 0
self.now = now or datetime.datetime.now()
@property
def current_token(self) -> Token:
"""Get the current token."""
if self.pos < len(self.tokens):
return self.tokens[self.pos]
return Token(TokenType.EOF, None, len(self.tokens))
def eat(self, token_type: TokenType) -> Token:
"""Consume a token of the expected type."""
if self.current_token.type == token_type:
token = self.current_token
self.pos += 1
return token
else:
raise ParserError(
f"Expected token {token_type}, got {self.current_token.type} "
f"at position {self.current_token.position}"
)
def peek(self, offset: int = 1) -> Token:
"""Look ahead at the next token without consuming it."""
next_pos = self.pos + offset
if next_pos < len(self.tokens):
return self.tokens[next_pos]
return Token(TokenType.EOF, None, len(self.tokens))
def parse_number(self) -> NumberNode:
"""Parse a number (integer or Chinese number)."""
token = self.current_token
if token.type == TokenType.INTEGER:
self.eat(TokenType.INTEGER)
return NumberNode(value=token.value)
elif token.type == TokenType.CHINESE_NUMBER:
self.eat(TokenType.CHINESE_NUMBER)
return NumberNode(value=token.value)
else:
raise ParserError(
f"Expected number, got {token.type} at position {token.position}"
)
def parse_date(self) -> DateNode:
"""Parse a date specification."""
year_node = None
month_node = None
day_node = None
# Try YYYY-MM-DD or YYYY/MM/DD format
if (self.current_token.type == TokenType.INTEGER and
self.peek().type == TokenType.DATE_SEPARATOR and
self.peek().value in ['-', '/'] and
self.peek(2).type == TokenType.INTEGER and
self.peek(3).type == TokenType.DATE_SEPARATOR and
self.peek(3).value in ['-', '/'] and
self.peek(4).type == TokenType.INTEGER):
year_token = self.current_token
self.eat(TokenType.INTEGER)
separator1 = self.eat(TokenType.DATE_SEPARATOR).value
month_token = self.current_token
self.eat(TokenType.INTEGER)
separator2 = self.eat(TokenType.DATE_SEPARATOR).value
day_token = self.current_token
self.eat(TokenType.INTEGER)
year_node = NumberNode(value=year_token.value)
month_node = NumberNode(value=month_token.value)
day_node = NumberNode(value=day_token.value)
return DateNode(year=year_node, month=month_node, day=day_node)
# Try YYYY年MM月DD[日号] format
if (self.current_token.type == TokenType.INTEGER and
self.peek().type in [TokenType.DATE_SEPARATOR, TokenType.YEAR] and
self.peek(2).type == TokenType.INTEGER and
self.peek(3).type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
self.peek(4).type == TokenType.INTEGER):
year_token = self.current_token
self.eat(TokenType.INTEGER)
self.eat(self.current_token.type) # 年 (could be DATE_SEPARATOR or YEAR)
month_token = self.current_token
self.eat(TokenType.INTEGER)
self.eat(self.current_token.type) # 月 (could be DATE_SEPARATOR or MONTH)
day_token = self.current_token
self.eat(TokenType.INTEGER)
# Optional 日 or 号
if self.current_token.type in [TokenType.DATE_SEPARATOR, TokenType.DAY]:
self.eat(self.current_token.type)
year_node = NumberNode(value=year_token.value)
month_node = NumberNode(value=month_token.value)
day_node = NumberNode(value=day_token.value)
return DateNode(year=year_node, month=month_node, day=day_node)
# Try MM月DD[日号] format (without year)
if (self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.peek().type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
self.peek().value == '' and
self.peek(2).type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
month_token = self.current_token
self.eat(month_token.type)
self.eat(self.current_token.type) # 月 (could be DATE_SEPARATOR or MONTH)
day_token = self.current_token
self.eat(day_token.type)
# Optional 日 or 号
if self.current_token.type in [TokenType.DATE_SEPARATOR, TokenType.DAY]:
self.eat(self.current_token.type)
month_node = NumberNode(value=month_token.value)
day_node = NumberNode(value=day_token.value)
return DateNode(year=None, month=month_node, day=day_node)
# Try Chinese MM月DD[日号] format
if (self.current_token.type == TokenType.CHINESE_NUMBER and
self.peek().type == TokenType.DATE_SEPARATOR and
self.peek().value == '' and
self.peek(2).type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
month_token = self.current_token
self.eat(TokenType.CHINESE_NUMBER)
self.eat(TokenType.DATE_SEPARATOR) # 月
day_token = self.current_token
self.eat(day_token.type)
# Optional 日 or 号
if self.current_token.type == TokenType.DATE_SEPARATOR:
self.eat(TokenType.DATE_SEPARATOR)
month_node = NumberNode(value=month_token.value)
day_node = NumberNode(value=day_token.value)
return DateNode(year=None, month=month_node, day=day_node)
raise ParserError(
f"Unable to parse date at position {self.current_token.position}"
)
def parse_time(self) -> TimeNode:
"""Parse a time specification."""
hour_node = None
minute_node = None
second_node = None
is_24hour = False
period = None
# Try HH:MM format
if (self.current_token.type == TokenType.INTEGER and
self.peek().type == TokenType.TIME_SEPARATOR and
self.peek().value == ':'):
hour_token = self.current_token
self.eat(TokenType.INTEGER)
self.eat(TokenType.TIME_SEPARATOR) # :
minute_token = self.current_token
self.eat(TokenType.INTEGER)
hour_node = NumberNode(value=hour_token.value)
minute_node = NumberNode(value=minute_token.value)
is_24hour = True # HH:MM is always interpreted as 24-hour
# Optional :SS
if (self.current_token.type == TokenType.TIME_SEPARATOR and
self.peek().type == TokenType.INTEGER):
self.eat(TokenType.TIME_SEPARATOR) # :
second_token = self.current_token
self.eat(TokenType.INTEGER)
second_node = NumberNode(value=second_token.value)
return TimeNode(
hour=hour_node,
minute=minute_node,
second=second_node,
is_24hour=is_24hour,
period=period
)
# Try Chinese time format (X点X分)
# First check for period indicators
period = None
if self.current_token.type in [TokenType.PERIOD_AM, TokenType.PERIOD_PM]:
if self.current_token.type == TokenType.PERIOD_AM:
period = "AM"
else:
period = "PM"
self.eat(self.current_token.type)
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER, TokenType.EARLY_MORNING, TokenType.LATE_NIGHT]:
if self.current_token.type == TokenType.EARLY_MORNING:
self.eat(TokenType.EARLY_MORNING)
is_24hour = True
period = "AM"
# Expect a number next
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
hour_token = self.current_token
self.eat(hour_token.type)
hour_node = NumberNode(value=hour_token.value)
# "早八" should be interpreted as 08:00
# If hour is greater than 12, treat as 24-hour
if hour_node.value > 12:
is_24hour = True
period = None
else:
raise ParserError(
f"Expected number after '', got {self.current_token.type} "
f"at position {self.current_token.position}"
)
elif self.current_token.type == TokenType.LATE_NIGHT:
self.eat(TokenType.LATE_NIGHT)
is_24hour = True
period = "PM"
# Expect a number next
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
hour_token = self.current_token
self.eat(hour_token.type)
hour_node = NumberNode(value=hour_token.value)
# "晚十" should be interpreted as 22:00
# Adjust hour to 24-hour format
if hour_node.value <= 12:
hour_node.value += 12
is_24hour = True
period = None
else:
raise ParserError(
f"Expected number after '', got {self.current_token.type} "
f"at position {self.current_token.position}"
)
else:
# Regular time parsing
hour_token = self.current_token
self.eat(hour_token.type)
# Check for 点 or 时
if self.current_token.type == TokenType.TIME_SEPARATOR:
separator = self.current_token.value
self.eat(TokenType.TIME_SEPARATOR)
if separator == '':
is_24hour = False
elif separator == '':
is_24hour = True
hour_node = NumberNode(value=hour_token.value)
# Optional minutes
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
minute_token = self.current_token
self.eat(minute_token.type)
# Optional 分
if self.current_token.type == TokenType.TIME_SEPARATOR and \
self.current_token.value == '':
self.eat(TokenType.TIME_SEPARATOR)
minute_node = NumberNode(value=minute_token.value)
# Handle special markers
if self.current_token.type == TokenType.HALF:
self.eat(TokenType.HALF)
minute_node = NumberNode(value=30)
elif self.current_token.type == TokenType.QUARTER:
self.eat(TokenType.QUARTER)
minute_node = NumberNode(value=15)
elif self.current_token.type == TokenType.ZHENG:
self.eat(TokenType.ZHENG)
if minute_node is None:
minute_node = NumberNode(value=0)
# Optional 钟
if self.current_token.type == TokenType.ZHONG:
self.eat(TokenType.ZHONG)
else:
# If no separator, treat as hour-only time (like "三点")
hour_node = NumberNode(value=hour_token.value)
is_24hour = False
return TimeNode(
hour=hour_node,
minute=minute_node,
second=second_node,
is_24hour=is_24hour,
period=period
)
raise ParserError(
f"Unable to parse time at position {self.current_token.position}"
)
def parse_relative_date(self) -> RelativeDateNode:
"""Parse a relative date specification."""
years = 0
months = 0
weeks = 0
days = 0
# Handle today variants
if self.current_token.type == TokenType.RELATIVE_TODAY:
self.eat(TokenType.RELATIVE_TODAY)
days = 0
elif self.current_token.type == TokenType.RELATIVE_TOMORROW:
self.eat(TokenType.RELATIVE_TOMORROW)
days = 1
elif self.current_token.type == TokenType.RELATIVE_DAY_AFTER_TOMORROW:
self.eat(TokenType.RELATIVE_DAY_AFTER_TOMORROW)
days = 2
elif self.current_token.type == TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW:
self.eat(TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW)
days = 3
elif self.current_token.type == TokenType.RELATIVE_YESTERDAY:
self.eat(TokenType.RELATIVE_YESTERDAY)
days = -1
elif self.current_token.type == TokenType.RELATIVE_DAY_BEFORE_YESTERDAY:
self.eat(TokenType.RELATIVE_DAY_BEFORE_YESTERDAY)
days = -2
elif self.current_token.type == TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY:
self.eat(TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY)
days = -3
else:
# Check if this looks like an absolute date pattern before processing
# Look ahead to see if this matches absolute date patterns
is_likely_absolute_date = False
# Check for MM月DD[日号] patterns (like "6月20日")
if (self.pos + 2 < len(self.tokens) and
self.tokens[self.pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.tokens[self.pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
self.tokens[self.pos + 1].value == '' and
self.tokens[self.pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
is_likely_absolute_date = True
if is_likely_absolute_date:
# This looks like an absolute date, skip relative date parsing
raise ParserError("Looks like absolute date format")
# Try to parse extended relative time expressions
# Handle patterns like "明年", "去年", "下个月", "上个月", etc.
original_pos = self.pos
try:
# Check for "今年", "明年", "去年"
if self.current_token.type == TokenType.RELATIVE_THIS and self.peek().type == TokenType.YEAR:
self.eat(TokenType.RELATIVE_THIS)
self.eat(TokenType.YEAR)
years = 0 # Current year
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.peek().type == TokenType.YEAR:
self.eat(TokenType.RELATIVE_NEXT)
self.eat(TokenType.YEAR)
years = 1 # Next year
elif self.current_token.type == TokenType.RELATIVE_LAST and self.peek().type == TokenType.YEAR:
self.eat(TokenType.RELATIVE_LAST)
self.eat(TokenType.YEAR)
years = -1 # Last year
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.current_token.value == "明年":
self.eat(TokenType.RELATIVE_NEXT)
years = 1 # Next year
# Check if there's a month after "明年"
if (self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.peek().type == TokenType.MONTH):
# Parse the month
month_node = self.parse_number()
self.eat(TokenType.MONTH) # Eat the "月" token
# Store the month in the months field as a special marker
# We'll handle this in semantic analysis
months = month_node.value - 100 # Use negative offset to indicate absolute month
elif self.current_token.type == TokenType.RELATIVE_LAST and self.current_token.value == "去年":
self.eat(TokenType.RELATIVE_LAST)
years = -1 # Last year
elif self.current_token.type == TokenType.RELATIVE_THIS and self.current_token.value == "今年":
self.eat(TokenType.RELATIVE_THIS)
years = 0 # Current year
# Check for "这个月", "下个月", "上个月"
elif self.current_token.type == TokenType.RELATIVE_THIS and self.peek().type == TokenType.MONTH:
self.eat(TokenType.RELATIVE_THIS)
self.eat(TokenType.MONTH)
months = 0 # Current month
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.peek().type == TokenType.MONTH:
self.eat(TokenType.RELATIVE_NEXT)
self.eat(TokenType.MONTH)
months = 1 # Next month
# Handle patterns like "下个月五号"
if (self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.peek().type == TokenType.DAY):
# Parse the day
day_node = self.parse_number()
self.eat(TokenType.DAY) # Eat the "号" token
# Instead of adding days to the current date, we should set a specific day in the target month
# We'll handle this in semantic analysis by setting a flag or special value
days = 0 # Reset days - we'll handle the day differently
# Use a special marker to indicate we want a specific day in the target month
# For now, we'll just store the target day in the weeks field as a temporary solution
weeks = day_node.value # This is a hack - we'll fix this in semantic analysis
elif self.current_token.type == TokenType.RELATIVE_LAST and self.peek().type == TokenType.MONTH:
self.eat(TokenType.RELATIVE_LAST)
self.eat(TokenType.MONTH)
months = -1 # Last month
# Check for "下周", "上周"
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.peek().type == TokenType.WEEK:
self.eat(TokenType.RELATIVE_NEXT)
self.eat(TokenType.WEEK)
weeks = 1 # Next week
elif self.current_token.type == TokenType.RELATIVE_LAST and self.peek().type == TokenType.WEEK:
self.eat(TokenType.RELATIVE_LAST)
self.eat(TokenType.WEEK)
weeks = -1 # Last week
# Handle more complex patterns like "X年后", "X个月后", etc.
elif self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
# Check if this is likely an absolute date format (e.g., "2025年11月21日")
# If the next token after the number is a date separator or date unit,
# and the number looks like a year (4 digits) or the pattern continues,
# it might be an absolute date. In that case, skip relative date parsing.
# Look ahead to see if this matches absolute date patterns
lookahead_pos = self.pos
is_likely_absolute_date = False
# Check for YYYY-MM-DD or YYYY/MM/DD patterns
if (lookahead_pos + 4 < len(self.tokens) and
self.tokens[lookahead_pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.tokens[lookahead_pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.YEAR] and
self.tokens[lookahead_pos + 1].value in ['-', '/', ''] and
self.tokens[lookahead_pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.tokens[lookahead_pos + 3].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
self.tokens[lookahead_pos + 3].value in ['-', '/', '']):
is_likely_absolute_date = True
# Check for YYYY年MM月DD patterns
if (lookahead_pos + 4 < len(self.tokens) and
self.tokens[lookahead_pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.tokens[lookahead_pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.YEAR] and
self.tokens[lookahead_pos + 1].value == '' and
self.tokens[lookahead_pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.tokens[lookahead_pos + 3].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
self.tokens[lookahead_pos + 3].value == ''):
is_likely_absolute_date = True
# Check for MM月DD[日号] patterns (like "6月20日")
if (self.pos + 2 < len(self.tokens) and
self.tokens[self.pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
self.tokens[self.pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
self.tokens[self.pos + 1].value == '' and
self.tokens[self.pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
is_likely_absolute_date = True
if is_likely_absolute_date:
# This looks like an absolute date, skip relative date parsing
raise ParserError("Looks like absolute date format")
print(f"DEBUG: Parsing complex relative date pattern")
# Parse the number
number_node = self.parse_number()
number_value = number_node.value
print(f"DEBUG: Parsed number: {number_value}")
# Check the unit
if self.current_token.type == TokenType.YEAR:
self.eat(TokenType.YEAR)
years = number_value
print(f"DEBUG: Set years to {years}")
elif self.current_token.type == TokenType.MONTH:
self.eat(TokenType.MONTH)
months = number_value
print(f"DEBUG: Set months to {months}")
elif self.current_token.type == TokenType.WEEK:
self.eat(TokenType.WEEK)
weeks = number_value
print(f"DEBUG: Set weeks to {weeks}")
elif self.current_token.type == TokenType.DAY:
self.eat(TokenType.DAY)
days = number_value
print(f"DEBUG: Set days to {days}")
else:
print(f"DEBUG: Unexpected token type: {self.current_token.type}")
raise ParserError(
f"Expected time unit, got {self.current_token.type} "
f"at position {self.current_token.position}"
)
# Check direction (前/后)
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
print(f"DEBUG: Forward direction, values are already positive")
# Values are already positive
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
print(f"DEBUG: Backward direction, negating values")
years = -years
months = -months
weeks = -weeks
days = -days
except ParserError:
# Reset position if parsing failed
self.pos = original_pos
raise ParserError(
f"Expected relative date, got {self.current_token.type} "
f"at position {self.current_token.position}"
)
return RelativeDateNode(years=years, months=months, weeks=weeks, days=days)
def parse_weekday(self) -> WeekdayNode:
"""Parse a weekday specification."""
# Parse week scope (本, 上, 下)
scope = "current"
if self.current_token.type == TokenType.WEEK_SCOPE_CURRENT:
self.eat(TokenType.WEEK_SCOPE_CURRENT)
scope = "current"
elif self.current_token.type == TokenType.WEEK_SCOPE_LAST:
self.eat(TokenType.WEEK_SCOPE_LAST)
scope = "last"
elif self.current_token.type == TokenType.WEEK_SCOPE_NEXT:
self.eat(TokenType.WEEK_SCOPE_NEXT)
scope = "next"
# Parse weekday
weekday_map = {
TokenType.WEEKDAY_MONDAY: 0,
TokenType.WEEKDAY_TUESDAY: 1,
TokenType.WEEKDAY_WEDNESDAY: 2,
TokenType.WEEKDAY_THURSDAY: 3,
TokenType.WEEKDAY_FRIDAY: 4,
TokenType.WEEKDAY_SATURDAY: 5,
TokenType.WEEKDAY_SUNDAY: 6,
# Handle Chinese numbers (1=Monday, 2=Tuesday, etc.)
TokenType.CHINESE_NUMBER: lambda x: x - 1 if 1 <= x <= 7 else None,
}
if self.current_token.type in weekday_map:
if self.current_token.type == TokenType.CHINESE_NUMBER:
# Handle numeric weekday (1=Monday, 2=Tuesday, etc.)
weekday_num = self.current_token.value
if 1 <= weekday_num <= 7:
weekday = weekday_num - 1 # Convert to 0-based index
self.eat(TokenType.CHINESE_NUMBER)
return WeekdayNode(weekday=weekday, scope=scope)
else:
raise ParserError(
f"Invalid weekday number: {weekday_num} "
f"at position {self.current_token.position}"
)
else:
weekday = weekday_map[self.current_token.type]
self.eat(self.current_token.type)
return WeekdayNode(weekday=weekday, scope=scope)
raise ParserError(
f"Expected weekday, got {self.current_token.type} "
f"at position {self.current_token.position}"
)
def parse_relative_time(self) -> RelativeTimeNode:
"""Parse a relative time specification."""
hours = 0.0
minutes = 0.0
seconds = 0.0
def parse_relative_time(self) -> RelativeTimeNode:
"""Parse a relative time specification."""
hours = 0.0
minutes = 0.0
seconds = 0.0
# Parse sequences of relative time expressions
while self.current_token.type in [
TokenType.INTEGER, TokenType.CHINESE_NUMBER,
TokenType.HALF, TokenType.QUARTER
] or (self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD or
self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD):
# Handle 半小时
if (self.current_token.type == TokenType.HALF):
self.eat(TokenType.HALF)
# Optional 个
if (self.current_token.type == TokenType.INTEGER and
self.current_token.value == ""):
self.eat(TokenType.INTEGER)
# Optional 小时
if self.current_token.type == TokenType.HOUR:
self.eat(TokenType.HOUR)
hours += 0.5
# Check for direction
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
hours = -hours
continue
# Handle 一刻钟 (15 minutes)
if self.current_token.type == TokenType.QUARTER:
self.eat(TokenType.QUARTER)
# Optional 钟
if self.current_token.type == TokenType.ZHONG:
self.eat(TokenType.ZHONG)
minutes += 15
# Check for direction
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
minutes = -minutes
continue
# Parse number if we have one
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
number_node = self.parse_number()
number_value = number_node.value
# Determine unit and direction
unit = None
direction = 1 # Forward by default
# Check for unit
if self.current_token.type == TokenType.HOUR:
self.eat(TokenType.HOUR)
# Optional 个
if (self.current_token.type == TokenType.INTEGER and
self.current_token.value == ""):
self.eat(TokenType.INTEGER)
unit = "hour"
elif self.current_token.type == TokenType.MINUTE:
self.eat(TokenType.MINUTE)
unit = "minute"
elif self.current_token.type == TokenType.SECOND:
self.eat(TokenType.SECOND)
unit = "second"
elif self.current_token.type == TokenType.TIME_SEPARATOR:
# Handle "X点", "X分", "X秒" format
sep_value = self.current_token.value
self.eat(TokenType.TIME_SEPARATOR)
if sep_value == "":
unit = "hour"
# Optional 钟
if self.current_token.type == TokenType.ZHONG:
self.eat(TokenType.ZHONG)
# If we have "X点" without a direction, this is likely an absolute time
# Check if there's a direction after
if not (self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD or
self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD):
# This is probably an absolute time, not relative time
# Push back the number and break
break
elif sep_value == "":
unit = "minute"
# Optional 钟
if self.current_token.type == TokenType.ZHONG:
self.eat(TokenType.ZHONG)
elif sep_value == "":
unit = "second"
else:
# If no unit specified, but we have a number followed by a direction,
# assume it's hours
if (self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD or
self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD):
unit = "hour"
else:
# If no unit and no direction, this might not be a relative time expression
# Push the number back and break
# We can't easily push back, so let's break
break
# Check for direction (后/前)
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
direction = 1
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
direction = -1
# Apply the value based on unit
if unit == "hour":
hours += number_value * direction
elif unit == "minute":
minutes += number_value * direction
elif unit == "second":
seconds += number_value * direction
continue
# If we still haven't handled the current token, break
break
return RelativeTimeNode(hours=hours, minutes=minutes, seconds=seconds)
def parse_time_expression(self) -> TimeExpressionNode:
"""Parse a complete time expression."""
date_node = None
time_node = None
relative_date_node = None
relative_time_node = None
weekday_node = None
# Parse different parts of the expression
while self.current_token.type != TokenType.EOF:
# Try to parse date first (absolute dates should take precedence)
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
if date_node is None:
original_pos = self.pos
try:
date_node = self.parse_date()
continue
except ParserError:
# Reset position if parsing failed
self.pos = original_pos
pass
# Try to parse relative date
if self.current_token.type in [
TokenType.RELATIVE_TODAY, TokenType.RELATIVE_TOMORROW,
TokenType.RELATIVE_DAY_AFTER_TOMORROW, TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW,
TokenType.RELATIVE_YESTERDAY, TokenType.RELATIVE_DAY_BEFORE_YESTERDAY,
TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY,
TokenType.INTEGER, TokenType.CHINESE_NUMBER, # For patterns like "X年后", "X个月后", etc.
TokenType.RELATIVE_NEXT, TokenType.RELATIVE_LAST, TokenType.RELATIVE_THIS
]:
if relative_date_node is None:
original_pos = self.pos
try:
relative_date_node = self.parse_relative_date()
continue
except ParserError:
# Reset position if parsing failed
self.pos = original_pos
pass
# Try to parse relative time first (since it can have numbers)
if self.current_token.type in [
TokenType.INTEGER, TokenType.CHINESE_NUMBER,
TokenType.HALF, TokenType.QUARTER,
TokenType.RELATIVE_DIRECTION_FORWARD, TokenType.RELATIVE_DIRECTION_BACKWARD
]:
if relative_time_node is None:
original_pos = self.pos
try:
relative_time_node = self.parse_relative_time()
# Only continue if we actually parsed some relative time
if relative_time_node.hours != 0 or relative_time_node.minutes != 0 or relative_time_node.seconds != 0:
continue
else:
# If we didn't parse any relative time, reset position
self.pos = original_pos
except ParserError:
# Reset position if parsing failed
self.pos = original_pos
pass
# Try to parse time
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER, TokenType.TIME_SEPARATOR, TokenType.PERIOD_AM, TokenType.PERIOD_PM]:
if time_node is None:
original_pos = self.pos
try:
time_node = self.parse_time()
continue
except ParserError:
# Reset position if parsing failed
self.pos = original_pos
pass
# Try to parse time
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER, TokenType.TIME_SEPARATOR, TokenType.PERIOD_AM, TokenType.PERIOD_PM]:
if time_node is None:
original_pos = self.pos
try:
time_node = self.parse_time()
continue
except ParserError:
# Reset position if parsing failed
self.pos = original_pos
pass
# Try to parse weekday
if self.current_token.type in [
TokenType.WEEK_SCOPE_CURRENT, TokenType.WEEK_SCOPE_LAST, TokenType.WEEK_SCOPE_NEXT,
TokenType.WEEKDAY_MONDAY, TokenType.WEEKDAY_TUESDAY, TokenType.WEEKDAY_WEDNESDAY,
TokenType.WEEKDAY_THURSDAY, TokenType.WEEKDAY_FRIDAY, TokenType.WEEKDAY_SATURDAY,
TokenType.WEEKDAY_SUNDAY
]:
if weekday_node is None:
original_pos = self.pos
try:
weekday_node = self.parse_weekday()
continue
except ParserError:
# Reset position if parsing failed
self.pos = original_pos
pass
# If we get here and couldn't parse anything, skip the token
self.pos += 1
return TimeExpressionNode(
date=date_node,
time=time_node,
relative_date=relative_date_node,
relative_time=relative_time_node,
weekday=weekday_node
)
def parse(self) -> TimeExpressionNode:
"""Parse the complete time expression and return the AST."""
return self.parse_time_expression()

View File

@ -0,0 +1,71 @@
"""
Abstract Syntax Tree (AST) nodes for the time expression parser.
"""
from abc import ABC
from typing import Optional
from dataclasses import dataclass
@dataclass
class ASTNode(ABC):
"""Base class for all AST nodes."""
pass
@dataclass
class NumberNode(ASTNode):
"""Represents a numeric value."""
value: int
@dataclass
class DateNode(ASTNode):
"""Represents a date specification."""
year: Optional[ASTNode]
month: Optional[ASTNode]
day: Optional[ASTNode]
@dataclass
class TimeNode(ASTNode):
"""Represents a time specification."""
hour: Optional[ASTNode]
minute: Optional[ASTNode]
second: Optional[ASTNode]
is_24hour: bool = False
period: Optional[str] = None # AM or PM
@dataclass
class RelativeDateNode(ASTNode):
"""Represents a relative date specification."""
years: int = 0
months: int = 0
weeks: int = 0
days: int = 0
@dataclass
class RelativeTimeNode(ASTNode):
"""Represents a relative time specification."""
hours: float = 0.0
minutes: float = 0.0
seconds: float = 0.0
@dataclass
class WeekdayNode(ASTNode):
"""Represents a weekday specification."""
weekday: int # 0=Monday, 6=Sunday
scope: str # current, last, next
@dataclass
class TimeExpressionNode(ASTNode):
"""Represents a complete time expression."""
date: Optional[DateNode] = None
time: Optional[TimeNode] = None
relative_date: Optional[RelativeDateNode] = None
relative_time: Optional[RelativeTimeNode] = None
weekday: Optional[WeekdayNode] = None

View File

@ -0,0 +1,95 @@
"""
Token definitions for the time parser.
"""
from enum import Enum
from typing import Union
from dataclasses import dataclass
class TokenType(Enum):
"""Types of tokens recognized by the lexer."""
# Numbers
INTEGER = "INTEGER"
CHINESE_NUMBER = "CHINESE_NUMBER"
# Time units
YEAR = "YEAR"
MONTH = "MONTH"
DAY = "DAY"
WEEK = "WEEK"
HOUR = "HOUR"
MINUTE = "MINUTE"
SECOND = "SECOND"
# Date separators
DATE_SEPARATOR = "DATE_SEPARATOR" # -, /, 年, 月, 日, 号
# Time separators
TIME_SEPARATOR = "TIME_SEPARATOR" # :, 点, 时, 分, 秒
# Period indicators
PERIOD_AM = "PERIOD_AM" # 上午, 早上, 早晨, etc.
PERIOD_PM = "PERIOD_PM" # 下午, 晚上, 中午, etc.
# Relative time
RELATIVE_TODAY = "RELATIVE_TODAY" # 今天, 今晚, 今早, etc.
RELATIVE_TOMORROW = "RELATIVE_TOMORROW" # 明天
RELATIVE_DAY_AFTER_TOMORROW = "RELATIVE_DAY_AFTER_TOMORROW" # 后天
RELATIVE_THREE_DAYS_AFTER_TOMORROW = "RELATIVE_THREE_DAYS_AFTER_TOMORROW" # 大后天
RELATIVE_YESTERDAY = "RELATIVE_YESTERDAY" # 昨天
RELATIVE_DAY_BEFORE_YESTERDAY = "RELATIVE_DAY_BEFORE_YESTERDAY" # 前天
RELATIVE_THREE_DAYS_BEFORE_YESTERDAY = "RELATIVE_THREE_DAYS_BEFORE_YESTERDAY" # 大前天
RELATIVE_DIRECTION_FORWARD = "RELATIVE_DIRECTION_FORWARD" # 后, 以后, 之后
RELATIVE_DIRECTION_BACKWARD = "RELATIVE_DIRECTION_BACKWARD" # 前, 以前, 之前
# Extended relative time
RELATIVE_NEXT = "RELATIVE_NEXT" # 下
RELATIVE_LAST = "RELATIVE_LAST" # 上, 去
RELATIVE_THIS = "RELATIVE_THIS" # 这, 本
# Week days
WEEKDAY_MONDAY = "WEEKDAY_MONDAY"
WEEKDAY_TUESDAY = "WEEKDAY_TUESDAY"
WEEKDAY_WEDNESDAY = "WEEKDAY_WEDNESDAY"
WEEKDAY_THURSDAY = "WEEKDAY_THURSDAY"
WEEKDAY_FRIDAY = "WEEKDAY_FRIDAY"
WEEKDAY_SATURDAY = "WEEKDAY_SATURDAY"
WEEKDAY_SUNDAY = "WEEKDAY_SUNDAY"
# Week scope
WEEK_SCOPE_CURRENT = "WEEK_SCOPE_CURRENT" # 本
WEEK_SCOPE_LAST = "WEEK_SCOPE_LAST" # 上
WEEK_SCOPE_NEXT = "WEEK_SCOPE_NEXT" # 下
# Special time markers
HALF = "HALF" # 半
QUARTER = "QUARTER" # 一刻
ZHENG = "ZHENG" # 整
ZHONG = "ZHONG" # 钟
# Student-friendly time expressions
EARLY_MORNING = "EARLY_MORNING" # 早X
LATE_NIGHT = "LATE_NIGHT" # 晚X
# Whitespace
WHITESPACE = "WHITESPACE"
# End of input
EOF = "EOF"
@dataclass
class Token:
"""Represents a single token from the lexer."""
type: TokenType
value: Union[str, int]
position: int
def __str__(self):
return f"Token({self.type.value}, {repr(self.value)}, {self.position})"
def __repr__(self):
return self.__str__()

View File

@ -0,0 +1,369 @@
"""
Semantic analyzer for time expressions that evaluates the AST and produces datetime objects.
"""
import datetime
import calendar
from typing import Optional
from .ptime_ast import (
TimeExpressionNode, DateNode, TimeNode,
RelativeDateNode, RelativeTimeNode, WeekdayNode, NumberNode
)
from .err import TokenUnhandledException
class SemanticAnalyzer:
"""Semantic analyzer that evaluates time expression ASTs."""
def __init__(self, now: Optional[datetime.datetime] = None):
self.now = now or datetime.datetime.now()
def evaluate_number(self, node: NumberNode) -> int:
"""Evaluate a number node."""
return node.value
def evaluate_date(self, node: DateNode) -> datetime.date:
"""Evaluate a date node."""
year = self.now.year
month = 1
day = 1
if node.year is not None:
year = self.evaluate_number(node.year)
if node.month is not None:
month = self.evaluate_number(node.month)
if node.day is not None:
day = self.evaluate_number(node.day)
return datetime.date(year, month, day)
def evaluate_time(self, node: TimeNode) -> datetime.time:
"""Evaluate a time node."""
hour = 0
minute = 0
second = 0
if node.hour is not None:
hour = self.evaluate_number(node.hour)
if node.minute is not None:
minute = self.evaluate_number(node.minute)
if node.second is not None:
second = self.evaluate_number(node.second)
# Handle 24-hour vs 12-hour format
if not node.is_24hour and node.period is not None:
if node.period == "AM":
if hour == 12:
hour = 0
elif node.period == "PM":
if hour != 12 and hour <= 12:
hour += 12
# Validate time values
if not (0 <= hour <= 23):
raise TokenUnhandledException(f"Invalid hour: {hour}")
if not (0 <= minute <= 59):
raise TokenUnhandledException(f"Invalid minute: {minute}")
if not (0 <= second <= 59):
raise TokenUnhandledException(f"Invalid second: {second}")
return datetime.time(hour, minute, second)
def evaluate_relative_date(self, node: RelativeDateNode) -> datetime.timedelta:
"""Evaluate a relative date node."""
# Start with current time
result = self.now
# Special case: If weeks contains a target day (hacky way to pass target day info)
# This is for patterns like "下个月五号"
if node.weeks > 0 and node.weeks <= 31: # Valid day range
target_day = node.weeks
# Calculate the target month
if node.months != 0:
# Handle month arithmetic carefully
total_months = result.month + node.months - 1
new_year = result.year + total_months // 12
new_month = total_months % 12 + 1
# Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
max_day_in_target_month = calendar.monthrange(new_year, new_month)[1]
target_day = min(target_day, max_day_in_target_month)
try:
result = result.replace(year=new_year, month=new_month, day=target_day)
except ValueError:
# Handle edge cases
result = result.replace(year=new_year, month=new_month, day=max_day_in_target_month)
# Return the difference between the new date and the original date
return result - self.now
# Apply years
if node.years != 0:
# Handle year arithmetic carefully due to leap years
new_year = result.year + node.years
try:
result = result.replace(year=new_year)
except ValueError:
# Handle leap year edge case (Feb 29 -> Feb 28)
result = result.replace(year=new_year, month=2, day=28)
# Apply months
if node.months != 0:
# Check if this is a special marker for absolute month (negative offset)
if node.months < 0:
# This is an absolute month specification (e.g., from "明年五月")
absolute_month = node.months + 100
if 1 <= absolute_month <= 12:
result = result.replace(year=result.year, month=absolute_month, day=result.day)
else:
# Handle month arithmetic carefully
total_months = result.month + node.months - 1
new_year = result.year + total_months // 12
new_month = total_months % 12 + 1
# Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
new_day = min(result.day, calendar.monthrange(new_year, new_month)[1])
result = result.replace(year=new_year, month=new_month, day=new_day)
# Apply weeks and days
if node.weeks != 0 or node.days != 0:
delta_days = node.weeks * 7 + node.days
result = result + datetime.timedelta(days=delta_days)
return result - self.now
def evaluate_relative_time(self, node: RelativeTimeNode) -> datetime.timedelta:
"""Evaluate a relative time node."""
# Convert all values to seconds for precise calculation
total_seconds = (
node.hours * 3600 +
node.minutes * 60 +
node.seconds
)
return datetime.timedelta(seconds=total_seconds)
def evaluate_weekday(self, node: WeekdayNode) -> datetime.timedelta:
"""Evaluate a weekday node."""
current_weekday = self.now.weekday() # 0=Monday, 6=Sunday
target_weekday = node.weekday
if node.scope == "current":
delta = target_weekday - current_weekday
elif node.scope == "last":
delta = target_weekday - current_weekday - 7
elif node.scope == "next":
delta = target_weekday - current_weekday + 7
else:
delta = target_weekday - current_weekday
return datetime.timedelta(days=delta)
def infer_smart_time(self, hour: int, minute: int = 0, second: int = 0, base_time: Optional[datetime.datetime] = None) -> datetime.datetime:
"""
Smart time inference based on current time.
For example:
- If now is 14:30 and user says "3点", interpret as 15:00
- If now is 14:30 and user says "1点", interpret as next day 01:00
- If now is 8:00 and user says "3点", interpret as 15:00
- If now is 8:00 and user says "9点", interpret as 09:00
"""
# Use base_time if provided, otherwise use self.now
now = base_time if base_time is not None else self.now
# Handle 24-hour format directly (13-23)
if 13 <= hour <= 23:
candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
if candidate <= now:
candidate += datetime.timedelta(days=1)
return candidate
# Handle 12 (noon/midnight)
if hour == 12:
# For 12 specifically, we need to be more careful
# Try noon first
noon_candidate = now.replace(hour=12, minute=minute, second=second, microsecond=0)
midnight_candidate = now.replace(hour=0, minute=minute, second=second, microsecond=0)
# Special case: If it's afternoon or evening, "十二点" likely means next day midnight
if now.hour >= 12:
result = midnight_candidate + datetime.timedelta(days=1)
return result
# If noon is in the future and closer than midnight, use it
if noon_candidate > now and (midnight_candidate <= now or noon_candidate < midnight_candidate):
return noon_candidate
# If midnight is in the future, use it
elif midnight_candidate > now:
return midnight_candidate
# Both are in the past, use the closer one
elif noon_candidate > midnight_candidate:
return noon_candidate
# Otherwise use midnight next day
else:
result = midnight_candidate + datetime.timedelta(days=1)
return result
# Handle 1-11 (12-hour format)
if 1 <= hour <= 11:
# Calculate 12-hour format candidates
pm_hour = hour + 12
pm_candidate = now.replace(hour=pm_hour, minute=minute, second=second, microsecond=0)
am_candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
# Special case: If it's afternoon (12:00-18:00) and the hour is 1-6,
# user might mean either PM today or AM tomorrow.
# But if PM is in the future, that's more likely what they mean.
if 12 <= now.hour <= 18 and 1 <= hour <= 6:
if pm_candidate > now:
return pm_candidate
else:
# PM is in the past, so use AM tomorrow
result = am_candidate + datetime.timedelta(days=1)
return result
# Special case: If it's late evening (after 22:00) and user specifies early morning hours (1-5),
# user likely means next day early morning
if now.hour >= 22 and 1 <= hour <= 5:
result = am_candidate + datetime.timedelta(days=1)
return result
# Special case: In the morning (0-12:00)
if now.hour < 12:
# In the morning, for hours 1-11, generally prefer AM interpretation
# unless it's a very early hour that's much earlier than current time
# Only push to next day for very early hours (1-2) that are significantly earlier
if hour <= 2 and hour < now.hour and now.hour - hour >= 6:
# Very early morning hour that's significantly earlier, use next day
result = am_candidate + datetime.timedelta(days=1)
return result
else:
# For morning, generally prefer AM if it's in the future
if am_candidate > now:
return am_candidate
# If PM is in the future, use it
elif pm_candidate > now:
return pm_candidate
# Both are in the past, prefer AM if it's closer
elif am_candidate > pm_candidate:
return am_candidate
# Otherwise use PM next day
else:
result = pm_candidate + datetime.timedelta(days=1)
return result
else:
# General case: choose the one that's in the future and closer
if pm_candidate > now and (am_candidate <= now or pm_candidate < am_candidate):
return pm_candidate
elif am_candidate > now:
return am_candidate
# Both are in the past, use the closer one
elif pm_candidate > am_candidate:
return pm_candidate
# Otherwise use AM next day
else:
result = am_candidate + datetime.timedelta(days=1)
return result
# Handle 0 (midnight)
if hour == 0:
candidate = now.replace(hour=0, minute=minute, second=second, microsecond=0)
if candidate <= now:
candidate += datetime.timedelta(days=1)
return candidate
# Default case (should not happen with valid input)
candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
if candidate <= now:
candidate += datetime.timedelta(days=1)
return candidate
def evaluate(self, node: TimeExpressionNode) -> datetime.datetime:
"""Evaluate a complete time expression node."""
result = self.now
# Apply relative date (should set time to 00:00:00 for dates)
if node.relative_date is not None:
delta = self.evaluate_relative_date(node.relative_date)
result = result + delta
# For relative dates like "今天", "明天", set time to 00:00:00
# But only for cases where we're dealing with days, not years/months
if (node.date is None and node.time is None and node.weekday is None and
node.relative_date.years == 0 and node.relative_date.months == 0):
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
# Apply weekday
if node.weekday is not None:
delta = self.evaluate_weekday(node.weekday)
result = result + delta
# For weekdays, set time to 00:00:00
if node.date is None and node.time is None:
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
# Apply relative time
if node.relative_time is not None:
delta = self.evaluate_relative_time(node.relative_time)
result = result + delta
# Apply absolute date
if node.date is not None:
date = self.evaluate_date(node.date)
result = result.replace(year=date.year, month=date.month, day=date.day)
# For absolute dates without time, set time to 00:00:00
if node.time is None:
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
# Apply time
if node.time is not None:
time = self.evaluate_time(node.time)
# Handle explicit period or student-friendly expressions
if node.time.is_24hour or node.time.period is not None:
# Handle explicit period
if not node.time.is_24hour and node.time.period is not None:
hour = time.hour
minute = time.minute
second = time.second
if node.time.period == "AM":
if hour == 12:
hour = 0
elif node.time.period == "PM":
# Special case: "晚上十二点" should be interpreted as next day 00:00
if hour == 12 and minute == 0 and second == 0:
# Move to next day at 00:00:00
result = result.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
# Skip the general replacement since we've already handled it
skip_general_replacement = True
else:
# For other PM times, convert to 24-hour format
if hour != 12 and hour <= 12:
hour += 12
# Validate hour
if not (0 <= hour <= 23):
raise TokenUnhandledException(f"Invalid hour: {hour}")
# Only do general replacement if we haven't handled it specially
if not locals().get('skip_general_replacement', False):
result = result.replace(hour=hour, minute=minute, second=second, microsecond=0)
else:
# Already in 24-hour format
result = result.replace(hour=time.hour, minute=time.minute, second=time.second, microsecond=0)
else:
# Use smart time inference for regular times
# But if we have an explicit date, treat the time as 24-hour format
if node.date is not None or node.relative_date is not None:
# For explicit dates, treat time as 24-hour format
result = result.replace(hour=time.hour, minute=time.minute or 0, second=time.second or 0, microsecond=0)
else:
# Use smart time inference for regular times
smart_time = self.infer_smart_time(time.hour, time.minute, time.second, base_time=result)
result = smart_time
return result

View File

@ -0,0 +1,34 @@
from typing import Any
from loguru import logger
from nonebot_plugin_alconna import UniMessage
import playwright.async_api
from playwright.async_api import Page
from konabot.common.web_render import WebRenderer, konaweb
async def render_error_message(message: str) -> UniMessage[Any]:
"""
渲染文本消息为错误信息图片。
如果无法访达 Web 端则返回纯文本给用户。
"""
async def page_function(page: Page):
await page.wait_for_function("typeof setContent === 'function'", timeout=3000)
await page.evaluate(
"""(message) => {return setContent(message);}""",
message,
)
try:
img_data = await WebRenderer.render(
url=konaweb("error_report"),
target="#main",
other_function=page_function,
)
return UniMessage.image(raw=img_data)
except (playwright.async_api.Error, ConnectionError) as e:
logger.warning("渲染报错信息图片时出错了,回退到文本 ERR={}", e)
return UniMessage.text(message)

View File

@ -0,0 +1,11 @@
"""
Subscribe 模块,用于向一些订阅的频道广播消息
"""
from .service import broadcast as broadcast
from .service import dep_poster_service as dep_poster_service
from .service import DepPosterService as DepPosterService
from .service import PosterService as PosterService
from .subscribe_info import PosterInfo as PosterInfo
from .subscribe_info import POSTER_INFO_DATA as POSTER_INFO_DATA
from .subscribe_info import register_poster_info as register_poster_info

View File

@ -0,0 +1,116 @@
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 .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 为一个,并移除另一个
"""

View File

@ -0,0 +1,63 @@
from contextlib import asynccontextmanager
from typing import Annotated, Any
from nonebot.params import Depends
from nonebot_plugin_alconna import UniMessage
from konabot.common.longtask import LongTaskTarget
from konabot.common.pager import PagerQuery, PagerResult
from .subscribe_info import POSTER_INFO_DATA
from .repo_local_data import local_poster
from .repository import IPosterRepo
class PosterService:
def __init__(self, repo: IPosterRepo) -> None:
self.repo = repo
def parse_channel_id(self, channel: str):
for cid, cinfo in POSTER_INFO_DATA.items():
if channel in cinfo.aliases:
return cid
return channel
async def subscribe(self, channel: str, target: LongTaskTarget) -> bool:
channel = self.parse_channel_id(channel)
return await self.repo.add_channel_target(channel, target)
async def unsubscribe(self, channel: str, target: LongTaskTarget) -> bool:
channel = self.parse_channel_id(channel)
return await self.repo.remove_channel_target(channel, target)
async def broadcast(
self, channel: str, message: UniMessage[Any] | str
) -> list[LongTaskTarget]:
channel = self.parse_channel_id(channel)
targets = await self.repo.get_channel_targets(channel)
for target in targets:
# 因为是订阅消息,就不要 At 对方了
await target.send_message(message, at=False)
return targets
async def get_channels(
self, target: LongTaskTarget, pager: PagerQuery
) -> PagerResult[str]:
return await self.repo.get_subscribed_channels(target, pager)
async def fix_data(self):
for cid, cinfo in POSTER_INFO_DATA.items():
for alias in cinfo.aliases:
await self.repo.merge_channel(alias, cid)
@asynccontextmanager
async def dep_poster_service():
async with local_poster() as repo:
yield PosterService(repo)
async def broadcast(channel: str, message: UniMessage[Any] | str):
async with dep_poster_service() as service:
return await service.broadcast(channel, message)
DepPosterService = Annotated[PosterService, Depends(dep_poster_service)]

View File

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

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