Compare commits
63 Commits
00c0202720
...
enhancemen
| Author | SHA1 | Date | |
|---|---|---|---|
| 157236d9a6 | |||
|
8725f28caf
|
|||
| 51c0bf4229 | |||
| 5b1c6d446c | |||
| 717b7a95e8 | |||
| 9bac2b8cdf | |||
| bfb8ebab29 | |||
| 805e60a9ff | |||
| 1331f8f893 | |||
|
fef9041a97
|
|||
| 00f42dbdf1 | |||
| d37c4870d8 | |||
| 23b9f101b3 | |||
|
6a2fe11753
|
|||
|
97e87c7ec3
|
|||
|
8c1651ad3d
|
|||
| ff60642c62 | |||
| 69b5908445 | |||
| a542ed1fd9 | |||
| e86a385448 | |||
| d4bb36a074 | |||
| 1a2a3c0468 | |||
| 67502cb932 | |||
| f9a312b80a | |||
| 1980f8a895 | |||
|
d273ed4b1a
|
|||
|
265e9cc583
|
|||
|
8f5061ba41
|
|||
|
b3c3c77f3c
|
|||
|
6a84ce2cd8
|
|||
|
392c699b33
|
|||
|
72e21cd9aa
|
|||
|
f3389ff2b9
|
|||
|
e59d3c2e4b
|
|||
|
31d19b7ec0
|
|||
|
c2f677911d
|
|||
|
f5b81319f8
|
|||
|
870e2383d8
|
|||
| 7e8fa45f36 | |||
|
abb864ec70
|
|||
|
b38dde1b70
|
|||
|
8f40572a38
|
|||
|
230705f689
|
|||
|
e605527900
|
|||
|
9064b31fe9
|
|||
|
27e53c7acd
|
|||
|
ca1db103b5
|
|||
|
7f1035ff43
|
|||
|
5e0e39bfc3
|
|||
|
88861f4264
|
|||
|
a1c9f9bccb
|
|||
|
f6601f807a
|
|||
|
f7cea196ec
|
|||
|
d4826e9e8b
|
|||
|
33934ef7b5
|
|||
|
f9f8ae4e67
|
|||
| 94db34037b | |||
|
df409a13a9
|
|||
|
34175e8c17
|
|||
| c66576e12b | |||
|
91769f93ae
|
|||
|
27841b8422
|
|||
|
48282ceb6c
|
14
.drone.yml
14
.drone.yml
@ -13,7 +13,7 @@ steps:
|
|||||||
- name: submodules
|
- name: submodules
|
||||||
image: alpine/git
|
image: alpine/git
|
||||||
commands:
|
commands:
|
||||||
- git submodule update --init --recursive
|
- git submodule update --init --recursive
|
||||||
- name: 构建 Docker 镜像
|
- name: 构建 Docker 镜像
|
||||||
image: plugins/docker:latest
|
image: plugins/docker:latest
|
||||||
privileged: true
|
privileged: true
|
||||||
@ -30,7 +30,7 @@ steps:
|
|||||||
volumes:
|
volumes:
|
||||||
- name: docker-socket
|
- name: docker-socket
|
||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
- name: 在容器中测试插件加载
|
- name: 在容器中进行若干测试
|
||||||
image: docker:dind
|
image: docker:dind
|
||||||
privileged: true
|
privileged: true
|
||||||
volumes:
|
volumes:
|
||||||
@ -38,14 +38,8 @@ steps:
|
|||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
commands:
|
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_plugin_load.py
|
||||||
- name: 在容器中测试 Playwright 工作正常
|
|
||||||
image: docker:dind
|
|
||||||
privileged: true
|
|
||||||
volumes:
|
|
||||||
- name: docker-socket
|
|
||||||
path: /var/run/docker.sock
|
|
||||||
commands:
|
|
||||||
- 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 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
|
- name: 发送构建结果到 ntfy
|
||||||
image: parrazam/drone-ntfy
|
image: parrazam/drone-ntfy
|
||||||
when:
|
when:
|
||||||
@ -76,7 +70,7 @@ steps:
|
|||||||
- name: submodules
|
- name: submodules
|
||||||
image: alpine/git
|
image: alpine/git
|
||||||
commands:
|
commands:
|
||||||
- git submodule update --init --recursive
|
- git submodule update --init --recursive
|
||||||
- name: 构建并推送 Release Docker 镜像
|
- name: 构建并推送 Release Docker 镜像
|
||||||
image: plugins/docker:latest
|
image: plugins/docker:latest
|
||||||
privileged: true
|
privileged: true
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@ -3,9 +3,24 @@
|
|||||||
/data
|
/data
|
||||||
/pyrightconfig.json
|
/pyrightconfig.json
|
||||||
/pyrightconfig.toml
|
/pyrightconfig.toml
|
||||||
|
/uv.lock
|
||||||
|
|
||||||
# 缓存文件
|
# 缓存文件
|
||||||
__pycache__
|
__pycache__
|
||||||
|
/.ruff_cache
|
||||||
|
/.pytest_cache
|
||||||
|
/.mypy_cache
|
||||||
|
/.black_cache
|
||||||
|
|
||||||
# 可能会偶然生成的 diff 文件
|
# 可能会偶然生成的 diff 文件
|
||||||
/*.diff
|
/*.diff
|
||||||
|
|
||||||
|
# 代码覆盖报告
|
||||||
|
/.coverage
|
||||||
|
/.coverage.db
|
||||||
|
/htmlcov
|
||||||
|
|
||||||
|
# 对手动创建虚拟环境的人
|
||||||
|
/.venv
|
||||||
|
/venv
|
||||||
|
*.egg-info
|
||||||
|
|||||||
6
.sqls.yml
Normal file
6
.sqls.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
lowercaseKeywords: false
|
||||||
|
connections:
|
||||||
|
- driver: sqlite
|
||||||
|
dataSourceName: "./data/database.db"
|
||||||
|
- driver: sqlite
|
||||||
|
dataSourceName: "./data/perm.sqlite3"
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"python.REPL.enableREPLSmartSend": false
|
"python.REPL.enableREPLSmartSend": false,
|
||||||
|
"python-envs.defaultEnvManager": "ms-python.python:poetry",
|
||||||
|
"python-envs.defaultPackageManager": "ms-python.python:poetry"
|
||||||
}
|
}
|
||||||
188
AGENTS.md
Normal file
188
AGENTS.md
Normal 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`。
|
||||||
|
|
||||||
15
Dockerfile
15
Dockerfile
@ -1,16 +1,3 @@
|
|||||||
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
|
FROM python:3.13-slim AS base
|
||||||
|
|
||||||
ENV VIRTUAL_ENV=/app/.venv \
|
ENV VIRTUAL_ENV=/app/.venv \
|
||||||
@ -51,7 +38,6 @@ RUN uv sync --no-install-project
|
|||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
|
|
||||||
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||||
COPY --from=artifacts /artifacts/ /usr/local/bin/
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@ -61,6 +47,7 @@ COPY bot.py pyproject.toml .env.prod .env.test ./
|
|||||||
COPY assets ./assets
|
COPY assets ./assets
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
COPY konabot ./konabot
|
COPY konabot ./konabot
|
||||||
|
COPY tests ./tests
|
||||||
|
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
|||||||
187
QWEN.md
187
QWEN.md
@ -1,187 +0,0 @@
|
|||||||
# Konabot Project Context
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Konabot is a multi-platform chatbot built using the NoneBot2 framework, primarily used within MTTU (likely an organization or community). The bot supports multiple adapters including Discord, QQ (via Onebot), Minecraft, and Console interfaces.
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- Multi-platform support (Discord, QQ, Minecraft, Console)
|
|
||||||
- Rich plugin ecosystem with over 20 built-in plugins
|
|
||||||
- Asynchronous database system with connection pooling (SQLite-based)
|
|
||||||
- Advanced image processing capabilities
|
|
||||||
- Integration with external services like Bilibili analysis
|
|
||||||
- Support for Large Language Models (LLM)
|
|
||||||
- Web rendering capabilities for advanced image generation
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **Framework**: NoneBot2
|
|
||||||
- **Language**: Python 3.12+
|
|
||||||
- **Dependency Management**: Poetry
|
|
||||||
- **Database**: SQLite with aiosqlite for async operations
|
|
||||||
- **Build System**: Just (task runner)
|
|
||||||
- **Containerization**: Docker
|
|
||||||
- **CI/CD**: Drone CI
|
|
||||||
- **Testing**: Pytest
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
konabot/
|
|
||||||
├── bot.py # Main entry point
|
|
||||||
├── pyproject.toml # Project dependencies and metadata
|
|
||||||
├── justfile # Task definitions
|
|
||||||
├── Dockerfile # Container build definition
|
|
||||||
├── .drone.yml # CI/CD pipeline configuration
|
|
||||||
├── konabot/ # Main source code
|
|
||||||
│ ├── common/ # Shared utilities and modules
|
|
||||||
│ │ ├── database/ # Async database manager with connection pooling
|
|
||||||
│ │ ├── llm/ # Large Language Model integration
|
|
||||||
│ │ ├── web_render/ # Web-based image rendering
|
|
||||||
│ │ └── ... # Other utilities
|
|
||||||
│ ├── plugins/ # Plugin modules (core functionality)
|
|
||||||
│ │ ├── air_conditioner/
|
|
||||||
│ │ ├── bilibili_fetch/
|
|
||||||
│ │ ├── gen_qrcode/
|
|
||||||
│ │ ├── hanzi/
|
|
||||||
│ │ ├── idiomgame/
|
|
||||||
│ │ ├── image_process/
|
|
||||||
│ │ ├── roll_dice/
|
|
||||||
│ │ ├── weather/
|
|
||||||
│ │ └── ... (20+ plugins)
|
|
||||||
│ └── test/
|
|
||||||
├── tests/ # Test suite
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
├── docs/ # Documentation
|
|
||||||
├── assets/ # Static assets
|
|
||||||
└── data/ # Runtime data storage
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Environment Setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Python 3.12+
|
|
||||||
- Git
|
|
||||||
- Poetry (installed via pipx)
|
|
||||||
|
|
||||||
### Installation Steps
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone https://gitea.service.jazzwhom.top/Passthem/konabot.git
|
|
||||||
cd konabot
|
|
||||||
```
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
poetry install
|
|
||||||
```
|
|
||||||
3. Configure environment:
|
|
||||||
- Copy `.env.example` to `.env`
|
|
||||||
- Modify settings as needed for your platform adapters
|
|
||||||
|
|
||||||
### Platform Adapters Configuration
|
|
||||||
- **Discord**: Set `ENABLE_DISCORD=true` and configure bot token
|
|
||||||
- **QQ (Onebot)**: Set `ENABLE_QQ=true` and configure connection
|
|
||||||
- **Console**: Enabled by default, disable with `ENABLE_CONSOLE=false`
|
|
||||||
- **Minecraft**: Set `ENABLE_MINECRAFT=true`
|
|
||||||
|
|
||||||
## Building and Running
|
|
||||||
|
|
||||||
### Development
|
|
||||||
- Auto-reload development mode:
|
|
||||||
```bash
|
|
||||||
poetry run just watch
|
|
||||||
```
|
|
||||||
- Manual start:
|
|
||||||
```bash
|
|
||||||
poetry run python bot.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
- Docker container build and run:
|
|
||||||
```bash
|
|
||||||
docker build -t konabot .
|
|
||||||
docker run konabot
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the test suite with:
|
|
||||||
```bash
|
|
||||||
poetry run pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests are located in the `tests/` directory and focus primarily on core functionality like the database manager.
|
|
||||||
|
|
||||||
## Database System
|
|
||||||
|
|
||||||
The project implements a custom asynchronous database manager (`konabot/common/database/__init__.py`) with these features:
|
|
||||||
- Connection pooling for performance
|
|
||||||
- Parameterized queries for security
|
|
||||||
- SQL file execution support
|
|
||||||
- Support for both string and Path objects for file paths
|
|
||||||
- Automatic resource management
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```python
|
|
||||||
from konabot.common.database import DatabaseManager
|
|
||||||
|
|
||||||
db = DatabaseManager()
|
|
||||||
results = await db.query("SELECT * FROM users WHERE age > ?", (18,))
|
|
||||||
await db.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("John", "john@example.com"))
|
|
||||||
```
|
|
||||||
|
|
||||||
## Plugin Architecture
|
|
||||||
|
|
||||||
Plugins are organized in `konabot/plugins/` and follow the NoneBot2 plugin structure. Each plugin typically consists of:
|
|
||||||
- `__init__.py`: Main plugin logic using Alconna command parser
|
|
||||||
- Supporting modules for specific functionality
|
|
||||||
|
|
||||||
Popular plugins include:
|
|
||||||
- `roll_dice`: Dice rolling with image generation
|
|
||||||
- `weather`: Weather radar image fetching
|
|
||||||
- `bilibili_fetch`: Bilibili video analysis
|
|
||||||
- `image_process`: Image manipulation tools
|
|
||||||
- `markdown`: Markdown rendering
|
|
||||||
|
|
||||||
## CI/CD Pipeline
|
|
||||||
|
|
||||||
Drone CI is configured with two pipelines:
|
|
||||||
1. **Nightly builds**: Triggered on pushes to master branch
|
|
||||||
2. **Release builds**: Triggered on git tags
|
|
||||||
|
|
||||||
Both pipelines:
|
|
||||||
- Build Docker images
|
|
||||||
- Test plugin loading
|
|
||||||
- Verify Playwright functionality
|
|
||||||
- Send notifications via ntfy
|
|
||||||
|
|
||||||
## Development Conventions
|
|
||||||
|
|
||||||
- Use Poetry for dependency management
|
|
||||||
- Follow NoneBot2 plugin development patterns
|
|
||||||
- Write async code for database operations
|
|
||||||
- Use Alconna for command parsing
|
|
||||||
- Organize SQL queries in separate files when complex
|
|
||||||
- Write tests for core functionality
|
|
||||||
- Document features in the `docs/` directory
|
|
||||||
|
|
||||||
## Common Development Tasks
|
|
||||||
|
|
||||||
1. **Add a new plugin**:
|
|
||||||
- Create a new directory in `konabot/plugins/`
|
|
||||||
- Implement functionality in `__init__.py`
|
|
||||||
- Use Alconna for command definition
|
|
||||||
|
|
||||||
2. **Database operations**:
|
|
||||||
- Use the `DatabaseManager` class
|
|
||||||
- Always parameterize queries
|
|
||||||
- Store complex SQL in separate `.sql` files
|
|
||||||
|
|
||||||
3. **Image processing**:
|
|
||||||
- Leverage existing utilities in `image_process` plugin
|
|
||||||
- Use Pillow and Skia-Python for advanced graphics
|
|
||||||
|
|
||||||
4. **Testing**:
|
|
||||||
- Add tests to the `tests/` directory
|
|
||||||
- Use pytest with async support
|
|
||||||
- Mock external services when needed
|
|
||||||
19
README.md
19
README.md
@ -96,6 +96,21 @@ poetry run python bot.py
|
|||||||
- [事件处理](https://nonebot.dev/docs/tutorial/handler)
|
- [事件处理](https://nonebot.dev/docs/tutorial/handler)
|
||||||
- [Alconna 插件](https://nonebot.dev/docs/best-practice/alconna/)
|
- [Alconna 插件](https://nonebot.dev/docs/best-practice/alconna/)
|
||||||
|
|
||||||
## 数据库模块
|
## 代码测试
|
||||||
|
|
||||||
本项目的数据库模块已更新为异步实现,使用连接池来提高性能,并支持现代的`pathlib.Path`参数类型。详细使用方法请参考[数据库使用文档](/docs/database.md)。
|
本项目使用 pytest 进行自动化测试,你可以把你的测试代码放在 `./tests` 目录下。
|
||||||
|
|
||||||
|
使用命令行执行测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run just test
|
||||||
|
```
|
||||||
|
|
||||||
|
使用命令行,在浏览器查看测试覆盖率报告:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run just coverage
|
||||||
|
# 此时会打开一个 :8000 端口的 Web 服务器
|
||||||
|
# 你可以在 http://localhost:8000 查看覆盖率报告
|
||||||
|
# 在控制台使用 Ctrl+C 关闭这个 Web 服务器
|
||||||
|
```
|
||||||
|
|||||||
26
bot.py
26
bot.py
@ -7,6 +7,7 @@ from nonebot.adapters.discord import Adapter as DiscordAdapter
|
|||||||
from nonebot.adapters.minecraft import Adapter as MinecraftAdapter
|
from nonebot.adapters.minecraft import Adapter as MinecraftAdapter
|
||||||
from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter
|
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.log import init_logger
|
||||||
from konabot.common.nb.exc import BotExceptionMessage
|
from konabot.common.nb.exc import BotExceptionMessage
|
||||||
from konabot.common.path import LOG_PATH
|
from konabot.common.path import LOG_PATH
|
||||||
@ -22,19 +23,25 @@ env_enable_minecraft = os.environ.get("ENABLE_MINECRAFT", "none")
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if env.upper() == 'DEBUG' or env.upper() == 'DEV':
|
if env.upper() == "DEBUG" or env.upper() == "DEV":
|
||||||
console_log_level = 'DEBUG'
|
console_log_level = "DEBUG"
|
||||||
else:
|
else:
|
||||||
console_log_level = 'INFO'
|
console_log_level = "INFO"
|
||||||
init_logger(LOG_PATH, [
|
init_logger(
|
||||||
BotExceptionMessage,
|
LOG_PATH,
|
||||||
], console_log_level=console_log_level)
|
[
|
||||||
|
BotExceptionMessage,
|
||||||
|
],
|
||||||
|
console_log_level=console_log_level,
|
||||||
|
)
|
||||||
|
|
||||||
nonebot.init()
|
nonebot.init()
|
||||||
|
|
||||||
driver = nonebot.get_driver()
|
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)
|
driver.register_adapter(ConsoleAdapter)
|
||||||
|
|
||||||
if env_enable_qq.upper() == "TRUE":
|
if env_enable_qq.upper() == "TRUE":
|
||||||
@ -50,14 +57,17 @@ def main():
|
|||||||
nonebot.load_plugins("konabot/plugins")
|
nonebot.load_plugins("konabot/plugins")
|
||||||
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
|
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
|
||||||
|
|
||||||
|
run_afterinit_functions()
|
||||||
|
|
||||||
# 注册关闭钩子
|
# 注册关闭钩子
|
||||||
@driver.on_shutdown
|
@driver.on_shutdown
|
||||||
async def shutdown_handler():
|
async def _():
|
||||||
# 关闭全局数据库管理器
|
# 关闭全局数据库管理器
|
||||||
db_manager = get_global_db_manager()
|
db_manager = get_global_db_manager()
|
||||||
await db_manager.close_all_connections()
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
nonebot.run()
|
nonebot.run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
26
docs/artifact.md
Normal file
26
docs/artifact.md
Normal 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 的可用性。
|
||||||
244
docs/permsys.md
Normal file
244
docs/permsys.md
Normal 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
37
docs/subscribe.md
Normal 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/` 下的文件。
|
||||||
5
justfile
5
justfile
@ -1,4 +1,9 @@
|
|||||||
watch:
|
watch:
|
||||||
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
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
|
||||||
|
|||||||
281
konabot/common/apis/wolfx.py
Normal file
281
konabot/common/apis/wolfx.py
Normal 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()
|
||||||
15
konabot/common/appcontext.py
Normal file
15
konabot/common/appcontext.py
Normal 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()
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import hashlib
|
import hashlib
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import nonebot
|
import nonebot
|
||||||
@ -14,6 +15,8 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ArtifactDepends:
|
class ArtifactDepends:
|
||||||
|
_Callback = Callable[[bool], Awaitable[Any]]
|
||||||
|
|
||||||
url: str
|
url: str
|
||||||
sha256: str
|
sha256: str
|
||||||
target: Path
|
target: Path
|
||||||
@ -27,6 +30,9 @@ class ArtifactDepends:
|
|||||||
use_proxy: bool = True
|
use_proxy: bool = True
|
||||||
"网络问题,赫赫;使用的是 Discord 模块配置的 proxy"
|
"网络问题,赫赫;使用的是 Discord 模块配置的 proxy"
|
||||||
|
|
||||||
|
callbacks: list[_Callback] = field(default_factory=list)
|
||||||
|
"在任务完成以后,应该做的事情"
|
||||||
|
|
||||||
def is_corresponding_platform(self) -> bool:
|
def is_corresponding_platform(self) -> bool:
|
||||||
if self.required_os is not None:
|
if self.required_os is not None:
|
||||||
if self.required_os.lower() != platform.system().lower():
|
if self.required_os.lower() != platform.system().lower():
|
||||||
@ -36,26 +42,43 @@ class ArtifactDepends:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def on_finished(self, task: _Callback) -> _Callback:
|
||||||
|
self.callbacks.append(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
async def _finished(self, downloaded: bool) -> list[Any | BaseException]:
|
||||||
|
tasks = set()
|
||||||
|
for f in self.callbacks:
|
||||||
|
tasks.add(f(downloaded))
|
||||||
|
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
prefetch_artifact: bool = False
|
prefetch_artifact: bool = False
|
||||||
"是否提前下载好二进制依赖"
|
"是否提前下载好二进制依赖"
|
||||||
|
|
||||||
|
|
||||||
artifact_list = []
|
artifact_list: list[ArtifactDepends] = []
|
||||||
|
|
||||||
|
|
||||||
driver = nonebot.get_driver()
|
driver = nonebot.get_driver()
|
||||||
config = nonebot.get_plugin_config(Config)
|
config = nonebot.get_plugin_config(Config)
|
||||||
|
|
||||||
|
|
||||||
@driver.on_startup
|
@driver.on_startup
|
||||||
async def _():
|
async def _():
|
||||||
if config.prefetch_artifact:
|
if config.prefetch_artifact:
|
||||||
logger.info("启动检测中:正在检测需求的二进制是否下载")
|
logger.info("启动检测中:正在检测需求的二进制是否下载")
|
||||||
semaphore = asyncio.Semaphore(10)
|
semaphore = asyncio.Semaphore(10)
|
||||||
|
|
||||||
async def _task(artifact: ArtifactDepends):
|
async def _task(artifact: ArtifactDepends):
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
await ensure_artifact(artifact)
|
downloaded = await ensure_artifact(artifact)
|
||||||
|
result = await artifact._finished(downloaded)
|
||||||
|
for r in result:
|
||||||
|
if isinstance(r, BaseException):
|
||||||
|
logger.warning("完成了二进制文件的下载,但是有未捕捉的错误")
|
||||||
|
logger.exception(r)
|
||||||
|
|
||||||
tasks: set[asyncio.Task] = set()
|
tasks: set[asyncio.Task] = set()
|
||||||
for a in artifact_list:
|
for a in artifact_list:
|
||||||
@ -78,35 +101,43 @@ async def download_artifact(artifact: ArtifactDepends):
|
|||||||
async with aiohttp.ClientSession(proxy=proxy) as client:
|
async with aiohttp.ClientSession(proxy=proxy) as client:
|
||||||
result = await client.get(artifact.url)
|
result = await client.get(artifact.url)
|
||||||
if result.status != 200:
|
if result.status != 200:
|
||||||
logger.warning(f"已经下载了二进制,但是注意服务器没有返回 200! URL={artifact.url} TARGET={artifact.target} CODE={result.status}")
|
logger.warning(
|
||||||
|
f"已经下载了二进制,但是注意服务器没有返回 200! URL={artifact.url} TARGET={artifact.target} CODE={result.status}"
|
||||||
|
)
|
||||||
data = await result.read()
|
data = await result.read()
|
||||||
artifact.target.write_bytes(data)
|
artifact.target.write_bytes(data)
|
||||||
if not platform.system().lower() == 'windows':
|
if not platform.system().lower() == "windows":
|
||||||
artifact.target.chmod(0o755)
|
artifact.target.chmod(0o755)
|
||||||
|
|
||||||
logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}")
|
logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}")
|
||||||
m = hashlib.sha256(artifact.target.read_bytes())
|
m = hashlib.sha256(artifact.target.read_bytes())
|
||||||
if m.hexdigest().lower() != artifact.sha256.lower():
|
if m.hexdigest().lower() != artifact.sha256.lower():
|
||||||
logger.warning(f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}")
|
logger.warning(
|
||||||
|
f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def ensure_artifact(artifact: ArtifactDepends):
|
async def ensure_artifact(artifact: ArtifactDepends) -> bool:
|
||||||
if not artifact.is_corresponding_platform():
|
if not artifact.is_corresponding_platform():
|
||||||
return
|
return False
|
||||||
|
|
||||||
if not artifact.target.exists():
|
if not artifact.target.exists():
|
||||||
logger.info(f"二进制依赖 {artifact.target} 不存在")
|
logger.info(f"二进制依赖 {artifact.target} 不存在")
|
||||||
if not artifact.target.parent.exists():
|
if not artifact.target.parent.exists():
|
||||||
artifact.target.parent.mkdir(parents=True, exist_ok=True)
|
artifact.target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
await download_artifact(artifact)
|
await download_artifact(artifact)
|
||||||
|
return True
|
||||||
else:
|
else:
|
||||||
m = hashlib.sha256(artifact.target.read_bytes())
|
m = hashlib.sha256(artifact.target.read_bytes())
|
||||||
if m.hexdigest().lower() != artifact.sha256.lower():
|
if m.hexdigest().lower() != artifact.sha256.lower():
|
||||||
logger.info(f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载")
|
logger.info(
|
||||||
|
f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载"
|
||||||
|
)
|
||||||
artifact.target.unlink()
|
artifact.target.unlink()
|
||||||
await download_artifact(artifact)
|
await download_artifact(artifact)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def register_artifacts(*artifacts: ArtifactDepends):
|
def register_artifacts(*artifacts: ArtifactDepends):
|
||||||
artifact_list.extend(artifacts)
|
artifact_list.extend(artifacts)
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlparse
|
import sqlparse
|
||||||
@ -10,16 +11,19 @@ if TYPE_CHECKING:
|
|||||||
from . import DatabaseManager
|
from . import DatabaseManager
|
||||||
|
|
||||||
# 全局数据库管理器实例
|
# 全局数据库管理器实例
|
||||||
_global_db_manager: Optional['DatabaseManager'] = None
|
_global_db_manager: Optional["DatabaseManager"] = None
|
||||||
|
|
||||||
def get_global_db_manager() -> 'DatabaseManager':
|
|
||||||
|
def get_global_db_manager() -> "DatabaseManager":
|
||||||
"""获取全局数据库管理器实例"""
|
"""获取全局数据库管理器实例"""
|
||||||
global _global_db_manager
|
global _global_db_manager
|
||||||
if _global_db_manager is None:
|
if _global_db_manager is None:
|
||||||
from . import DatabaseManager
|
from . import DatabaseManager
|
||||||
|
|
||||||
_global_db_manager = DatabaseManager()
|
_global_db_manager = DatabaseManager()
|
||||||
return _global_db_manager
|
return _global_db_manager
|
||||||
|
|
||||||
|
|
||||||
def close_global_db_manager() -> None:
|
def close_global_db_manager() -> None:
|
||||||
"""关闭全局数据库管理器实例"""
|
"""关闭全局数据库管理器实例"""
|
||||||
global _global_db_manager
|
global _global_db_manager
|
||||||
@ -87,6 +91,12 @@ class DatabaseManager:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_conn(self):
|
||||||
|
conn = await self._get_connection()
|
||||||
|
yield conn
|
||||||
|
await self._return_connection(conn)
|
||||||
|
|
||||||
async def query(
|
async def query(
|
||||||
self, query: str, params: Optional[tuple] = None
|
self, query: str, params: Optional[tuple] = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
@ -143,22 +153,24 @@ class DatabaseManager:
|
|||||||
# 使用sqlparse库更准确地分割SQL语句
|
# 使用sqlparse库更准确地分割SQL语句
|
||||||
parsed = sqlparse.split(script)
|
parsed = sqlparse.split(script)
|
||||||
statements = []
|
statements = []
|
||||||
|
|
||||||
for statement in parsed:
|
for statement in parsed:
|
||||||
statement = statement.strip()
|
statement = statement.strip()
|
||||||
if statement:
|
if statement:
|
||||||
statements.append(statement)
|
statements.append(statement)
|
||||||
|
|
||||||
return statements
|
return statements
|
||||||
|
|
||||||
async def execute_by_sql_file(
|
async def execute_by_sql_file(
|
||||||
self, file_path: Union[str, Path], params: Optional[Union[tuple, List[tuple]]] = None
|
self,
|
||||||
|
file_path: Union[str, Path],
|
||||||
|
params: Optional[Union[tuple, List[tuple]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""从 SQL 文件中读取非查询语句并执行"""
|
"""从 SQL 文件中读取非查询语句并执行"""
|
||||||
path = str(file_path) if isinstance(file_path, Path) else file_path
|
path = str(file_path) if isinstance(file_path, Path) else file_path
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
script = f.read()
|
script = f.read()
|
||||||
|
|
||||||
# 如果有参数且是元组,使用execute执行整个脚本
|
# 如果有参数且是元组,使用execute执行整个脚本
|
||||||
if params is not None and isinstance(params, tuple):
|
if params is not None and isinstance(params, tuple):
|
||||||
await self.execute(script, params)
|
await self.execute(script, params)
|
||||||
@ -167,8 +179,10 @@ class DatabaseManager:
|
|||||||
# 使用sqlparse准确分割SQL语句
|
# 使用sqlparse准确分割SQL语句
|
||||||
statements = self._parse_sql_statements(script)
|
statements = self._parse_sql_statements(script)
|
||||||
if len(statements) != len(params):
|
if len(statements) != len(params):
|
||||||
raise ValueError(f"语句数量({len(statements)})与参数组数量({len(params)})不匹配")
|
raise ValueError(
|
||||||
|
f"语句数量({len(statements)})与参数组数量({len(params)})不匹配"
|
||||||
|
)
|
||||||
|
|
||||||
for statement, stmt_params in zip(statements, params):
|
for statement, stmt_params in zip(statements, params):
|
||||||
if statement:
|
if statement:
|
||||||
await self.execute(statement, stmt_params)
|
await self.execute(statement, stmt_params)
|
||||||
@ -215,4 +229,3 @@ class DatabaseManager:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
self._in_use.clear()
|
self._in_use.clear()
|
||||||
|
|
||||||
|
|||||||
119
konabot/common/permsys/__init__.py
Normal file
119
konabot/common/permsys/__init__.py
Normal 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)
|
||||||
69
konabot/common/permsys/entity.py
Normal file
69
konabot/common/permsys/entity.py
Normal 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]
|
||||||
81
konabot/common/permsys/migrates/__init__.py
Normal file
81
konabot/common/permsys/migrates/__init__.py
Normal 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()
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
SELECT
|
||||||
|
COUNT(*)
|
||||||
|
FROM
|
||||||
|
sqlite_master
|
||||||
|
WHERE
|
||||||
|
type = 'table'
|
||||||
|
AND name = 'migrate_version'
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
CREATE TABLE migrate_version(version INT PRIMARY KEY);
|
||||||
|
INSERT INTO migrate_version(version)
|
||||||
|
VALUES(0);
|
||||||
4
konabot/common/permsys/migrates/get_migrate_version.sql
Normal file
4
konabot/common/permsys/migrates/get_migrate_version.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
SELECT
|
||||||
|
version
|
||||||
|
FROM
|
||||||
|
migrate_version;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS perm_entity;
|
||||||
|
DROP TABLE IF EXISTS perm_info;
|
||||||
30
konabot/common/permsys/migrates/mu1_create_permsys_table.sql
Normal file
30
konabot/common/permsys/migrates/mu1_create_permsys_table.sql
Normal 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;
|
||||||
|
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
UPDATE migrate_version
|
||||||
|
SET version = ?;
|
||||||
242
konabot/common/permsys/repo.py
Normal file
242
konabot/common/permsys/repo.py
Normal 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,
|
||||||
|
)
|
||||||
11
konabot/common/permsys/sql/create_entity.sql
Normal file
11
konabot/common/permsys/sql/create_entity.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
INSERT
|
||||||
|
OR IGNORE INTO perm_entity(
|
||||||
|
platform,
|
||||||
|
entity_type,
|
||||||
|
external_id
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?
|
||||||
|
);
|
||||||
8
konabot/common/permsys/sql/get_entity_id.sql
Normal file
8
konabot/common/permsys/sql/get_entity_id.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
perm_entity
|
||||||
|
WHERE
|
||||||
|
perm_entity.platform = ?
|
||||||
|
AND perm_entity.entity_type = ?
|
||||||
|
AND perm_entity.external_id = ?;
|
||||||
7
konabot/common/permsys/sql/get_perm_info.sql
Normal file
7
konabot/common/permsys/sql/get_perm_info.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
SELECT
|
||||||
|
VALUE
|
||||||
|
FROM
|
||||||
|
perm_info
|
||||||
|
WHERE
|
||||||
|
entity_id = ?
|
||||||
|
AND config_key = ?;
|
||||||
4
konabot/common/permsys/sql/update_perm_info.sql
Normal file
4
konabot/common/permsys/sql/update_perm_info.sql
Normal 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;
|
||||||
34
konabot/common/render_error_message.py
Normal file
34
konabot/common/render_error_message.py
Normal 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)
|
||||||
|
|
||||||
11
konabot/common/subscribe/__init__.py
Normal file
11
konabot/common/subscribe/__init__.py
Normal 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
|
||||||
@ -6,7 +6,8 @@ from pydantic import BaseModel, ValidationError
|
|||||||
from konabot.common.longtask import LongTaskTarget
|
from konabot.common.longtask import LongTaskTarget
|
||||||
from konabot.common.pager import PagerQuery, PagerResult
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
from konabot.common.path import DATA_PATH
|
from konabot.common.path import DATA_PATH
|
||||||
from konabot.plugins.poster.repository import IPosterRepo
|
|
||||||
|
from .repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
class ChannelData(BaseModel):
|
class ChannelData(BaseModel):
|
||||||
@ -18,9 +19,9 @@ class PosterData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def is_the_same_target(target1: LongTaskTarget, target2: LongTaskTarget) -> bool:
|
def is_the_same_target(target1: LongTaskTarget, target2: LongTaskTarget) -> bool:
|
||||||
if (target1.is_private_chat and not target2.is_private_chat):
|
if target1.is_private_chat and not target2.is_private_chat:
|
||||||
return False
|
return False
|
||||||
if (target2.is_private_chat and not target1.is_private_chat):
|
if target2.is_private_chat and not target1.is_private_chat:
|
||||||
return False
|
return False
|
||||||
if target1.platform != target2.platform:
|
if target1.platform != target2.platform:
|
||||||
return False
|
return False
|
||||||
@ -58,7 +59,9 @@ class LocalPosterRepo(IPosterRepo):
|
|||||||
len1 = len(self.data.channels[channel].targets)
|
len1 = len(self.data.channels[channel].targets)
|
||||||
return len0 != len1
|
return len0 != len1
|
||||||
|
|
||||||
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
async def get_subscribed_channels(
|
||||||
|
self, target: LongTaskTarget, pager: PagerQuery
|
||||||
|
) -> PagerResult[str]:
|
||||||
channels: list[str] = []
|
channels: list[str] = []
|
||||||
for channel_id, channel in self.data.channels.items():
|
for channel_id, channel in self.data.channels.items():
|
||||||
for t in channel.targets:
|
for t in channel.targets:
|
||||||
@ -95,7 +98,9 @@ async def local_poster_data():
|
|||||||
data = PosterData()
|
data = PosterData()
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
data = PosterData.model_validate_json(LOCAL_POSTER_DATA_PATH.read_text())
|
data = PosterData.model_validate_json(
|
||||||
|
LOCAL_POSTER_DATA_PATH.read_text()
|
||||||
|
)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
data = PosterData()
|
data = PosterData()
|
||||||
yield data
|
yield data
|
||||||
@ -109,4 +114,3 @@ async def local_poster():
|
|||||||
|
|
||||||
|
|
||||||
DepLocalPosterRepo = Annotated[LocalPosterRepo, Depends(local_poster)]
|
DepLocalPosterRepo = Annotated[LocalPosterRepo, Depends(local_poster)]
|
||||||
|
|
||||||
@ -4,9 +4,10 @@ from nonebot.params import Depends
|
|||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
from konabot.common.longtask import LongTaskTarget
|
from konabot.common.longtask import LongTaskTarget
|
||||||
from konabot.common.pager import PagerQuery, PagerResult
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
|
||||||
from konabot.plugins.poster.repo_local_data import local_poster
|
from .subscribe_info import POSTER_INFO_DATA
|
||||||
from konabot.plugins.poster.repository import IPosterRepo
|
from .repo_local_data import local_poster
|
||||||
|
from .repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
class PosterService:
|
class PosterService:
|
||||||
@ -27,7 +28,9 @@ class PosterService:
|
|||||||
channel = self.parse_channel_id(channel)
|
channel = self.parse_channel_id(channel)
|
||||||
return await self.repo.remove_channel_target(channel, target)
|
return await self.repo.remove_channel_target(channel, target)
|
||||||
|
|
||||||
async def broadcast(self, channel: str, message: UniMessage[Any] | str) -> list[LongTaskTarget]:
|
async def broadcast(
|
||||||
|
self, channel: str, message: UniMessage[Any] | str
|
||||||
|
) -> list[LongTaskTarget]:
|
||||||
channel = self.parse_channel_id(channel)
|
channel = self.parse_channel_id(channel)
|
||||||
targets = await self.repo.get_channel_targets(channel)
|
targets = await self.repo.get_channel_targets(channel)
|
||||||
for target in targets:
|
for target in targets:
|
||||||
@ -35,7 +38,9 @@ class PosterService:
|
|||||||
await target.send_message(message, at=False)
|
await target.send_message(message, at=False)
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
async def get_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
async def get_channels(
|
||||||
|
self, target: LongTaskTarget, pager: PagerQuery
|
||||||
|
) -> PagerResult[str]:
|
||||||
return await self.repo.get_subscribed_channels(target, pager)
|
return await self.repo.get_subscribed_channels(target, pager)
|
||||||
|
|
||||||
async def fix_data(self):
|
async def fix_data(self):
|
||||||
@ -56,4 +61,3 @@ async def broadcast(channel: str, message: UniMessage[Any] | str):
|
|||||||
|
|
||||||
|
|
||||||
DepPosterService = Annotated[PosterService, Depends(dep_poster_service)]
|
DepPosterService = Annotated[PosterService, Depends(dep_poster_service)]
|
||||||
|
|
||||||
@ -4,7 +4,7 @@ from dataclasses import dataclass, field
|
|||||||
@dataclass
|
@dataclass
|
||||||
class PosterInfo:
|
class PosterInfo:
|
||||||
aliases: set[str] = field(default_factory=set)
|
aliases: set[str] = field(default_factory=set)
|
||||||
description: str = field(default='')
|
description: str = field(default="")
|
||||||
|
|
||||||
|
|
||||||
POSTER_INFO_DATA: dict[str, PosterInfo] = {}
|
POSTER_INFO_DATA: dict[str, PosterInfo] = {}
|
||||||
@ -12,4 +12,3 @@ POSTER_INFO_DATA: dict[str, PosterInfo] = {}
|
|||||||
|
|
||||||
def register_poster_info(channel: str, info: PosterInfo):
|
def register_poster_info(channel: str, info: PosterInfo):
|
||||||
POSTER_INFO_DATA[channel] = info
|
POSTER_INFO_DATA[channel] = info
|
||||||
|
|
||||||
212
konabot/docs/sys/konaperm.txt
Normal file
212
konabot/docs/sys/konaperm.txt
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# 指令介绍
|
||||||
|
|
||||||
|
`konaperm` - 用于查看和修改 Bot 内部权限系统记录的管理员指令
|
||||||
|
|
||||||
|
## 权限要求
|
||||||
|
|
||||||
|
只有拥有 `admin` 权限的主体才能使用本指令。
|
||||||
|
|
||||||
|
## 格式
|
||||||
|
|
||||||
|
```text
|
||||||
|
konaperm list <platform> <entity_type> <external_id> [page]
|
||||||
|
konaperm get <platform> <entity_type> <external_id> <perm>
|
||||||
|
konaperm set <platform> <entity_type> <external_id> <perm> <val>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 子命令说明
|
||||||
|
|
||||||
|
### `list`
|
||||||
|
|
||||||
|
列出指定对象及其继承链上的显式权限记录,按分页输出。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- `platform` 平台名,如 `ob11`、`discord`、`sys`
|
||||||
|
- `entity_type` 对象类型,如 `user`、`group`、`global`
|
||||||
|
- `external_id` 平台侧对象 ID;全局对象通常写 `global`
|
||||||
|
- `page` 页码,可省略,默认 `1`
|
||||||
|
|
||||||
|
### `get`
|
||||||
|
|
||||||
|
查询某个对象对指定权限的最终判断结果,并说明它是从哪一层继承来的。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- `platform`
|
||||||
|
- `entity_type`
|
||||||
|
- `external_id`
|
||||||
|
- `perm` 权限键,如 `admin`、`plugin.xxx.use`
|
||||||
|
|
||||||
|
### `set`
|
||||||
|
|
||||||
|
为指定对象写入显式权限。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- `platform`
|
||||||
|
- `entity_type`
|
||||||
|
- `external_id`
|
||||||
|
- `perm` 权限键
|
||||||
|
- `val` 设置值
|
||||||
|
|
||||||
|
`val` 支持以下写法:
|
||||||
|
|
||||||
|
- 允许:`y` `yes` `allow` `true` `t`
|
||||||
|
- 拒绝:`n` `no` `deny` `false` `f`
|
||||||
|
- 清除:`null` `none`
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- 允许 表示显式授予该权限
|
||||||
|
- 拒绝 表示显式禁止该权限
|
||||||
|
- 清除 表示删除该层的显式设置,重新回退到继承链判断
|
||||||
|
|
||||||
|
## 对象格式
|
||||||
|
|
||||||
|
本指令操作的对象由三段组成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<platform>.<entity_type>.<external_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- `ob11.user.123456789`
|
||||||
|
- `ob11.group.987654321`
|
||||||
|
- `sys.global.global`
|
||||||
|
|
||||||
|
## 当前支持的 `PermEntity` 值
|
||||||
|
|
||||||
|
以下内容按当前实现整理,便于手工查询和设置权限。
|
||||||
|
|
||||||
|
### `sys`
|
||||||
|
|
||||||
|
- `sys.global.global`
|
||||||
|
|
||||||
|
这是系统总兜底对象。
|
||||||
|
|
||||||
|
### `ob11`
|
||||||
|
|
||||||
|
- `ob11.global.global`
|
||||||
|
- `ob11.group.<group_id>`
|
||||||
|
- `ob11.user.<user_id>`
|
||||||
|
|
||||||
|
常见场景:
|
||||||
|
|
||||||
|
- 给整个 OneBot V11 平台统一授权:`ob11.global.global`
|
||||||
|
- 给某个 QQ 群授权:`ob11.group.群号`
|
||||||
|
- 给某个 QQ 用户授权:`ob11.user.QQ号`
|
||||||
|
|
||||||
|
### `discord`
|
||||||
|
|
||||||
|
- `discord.global.global`
|
||||||
|
- `discord.guild.<guild_id>`
|
||||||
|
- `discord.channel.<channel_id>`
|
||||||
|
- `discord.user.<user_id>`
|
||||||
|
|
||||||
|
常见场景:
|
||||||
|
|
||||||
|
- 给整个 Discord 平台统一授权:`discord.global.global`
|
||||||
|
- 给某个服务器授权:`discord.guild.服务器ID`
|
||||||
|
- 给某个频道授权:`discord.channel.频道ID`
|
||||||
|
- 给某个用户授权:`discord.user.用户ID`
|
||||||
|
|
||||||
|
### `minecraft`
|
||||||
|
|
||||||
|
- `minecraft.global.global`
|
||||||
|
- `minecraft.server.<server_name>`
|
||||||
|
- `minecraft.player.<player_uuid_hex>`
|
||||||
|
|
||||||
|
常见场景:
|
||||||
|
|
||||||
|
- 给整个 Minecraft 平台统一授权:`minecraft.global.global`
|
||||||
|
- 给某个服务器授权:`minecraft.server.服务器名`
|
||||||
|
- 给某个玩家授权:`minecraft.player.玩家UUID的hex`
|
||||||
|
|
||||||
|
### `console`
|
||||||
|
|
||||||
|
- `console.global.global`
|
||||||
|
- `console.channel.<channel_id>`
|
||||||
|
- `console.user.<user_id>`
|
||||||
|
|
||||||
|
### 快速参考
|
||||||
|
|
||||||
|
```text
|
||||||
|
sys.global.global
|
||||||
|
|
||||||
|
ob11.global.global
|
||||||
|
ob11.group.<group_id>
|
||||||
|
ob11.user.<user_id>
|
||||||
|
|
||||||
|
discord.global.global
|
||||||
|
discord.guild.<guild_id>
|
||||||
|
discord.channel.<channel_id>
|
||||||
|
discord.user.<user_id>
|
||||||
|
|
||||||
|
minecraft.global.global
|
||||||
|
minecraft.server.<server_name>
|
||||||
|
minecraft.player.<player_uuid_hex>
|
||||||
|
|
||||||
|
console.global.global
|
||||||
|
console.channel.<channel_id>
|
||||||
|
console.user.<user_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 权限继承
|
||||||
|
|
||||||
|
权限不是只看当前对象,还会按继承链回退。
|
||||||
|
|
||||||
|
例如对 `ob11.user.123456` 查询时,通常会从更具体的对象一路回退到:
|
||||||
|
|
||||||
|
1. 当前用户
|
||||||
|
2. 平台全局对象
|
||||||
|
3. 系统全局对象
|
||||||
|
|
||||||
|
权限键本身也支持逐级回退。比如查询 `plugin.demo.use` 时,可能依次命中:
|
||||||
|
|
||||||
|
1. `plugin.demo.use`
|
||||||
|
2. `plugin.demo`
|
||||||
|
3. `plugin`
|
||||||
|
4. `*`
|
||||||
|
|
||||||
|
所以 `get` 返回的结果可能来自更宽泛的权限键,或更上层的继承对象。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
konaperm list ob11 user 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
查看 `ob11.user.123456` 及其继承链上的权限记录第一页。
|
||||||
|
|
||||||
|
```text
|
||||||
|
konaperm get ob11 user 123456 admin
|
||||||
|
```
|
||||||
|
|
||||||
|
查看该用户最终是否拥有 `admin` 权限,以及命中来源。
|
||||||
|
|
||||||
|
```text
|
||||||
|
konaperm set ob11 user 123456 admin allow
|
||||||
|
```
|
||||||
|
|
||||||
|
显式授予该用户 `admin` 权限。
|
||||||
|
|
||||||
|
```text
|
||||||
|
konaperm set ob11 user 123456 admin deny
|
||||||
|
```
|
||||||
|
|
||||||
|
显式拒绝该用户 `admin` 权限。
|
||||||
|
|
||||||
|
```text
|
||||||
|
konaperm set ob11 user 123456 admin none
|
||||||
|
```
|
||||||
|
|
||||||
|
删除该用户这一层对 `admin` 的显式设置,恢复继承判断。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 这是系统级管理指令,误操作可能直接影响其他插件的权限控制。
|
||||||
|
- `list` 只列出显式记录;没有显示出来不代表最终一定无权限,可能是从上层继承。
|
||||||
|
- `get` 显示的是最终命中的结果,比 `list` 更适合排查“为什么有/没有某个权限”。
|
||||||
|
- 对 `admin` 或 `*` 这类高影响权限做修改前,建议先确认对象是否写对。
|
||||||
38
konabot/docs/user/celeste.txt
Normal file
38
konabot/docs/user/celeste.txt
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Celeste
|
||||||
|
|
||||||
|
爬山小游戏,移植自 Ccleste,是 Celeste Classic(即 PICO-8)版。
|
||||||
|
|
||||||
|
使用 `wasdxc` 和数字进行操作。
|
||||||
|
|
||||||
|
## 操作说明
|
||||||
|
|
||||||
|
`wsad` 是上下左右摇杆方向,或者是方向键。`c` 是跳跃键,`x` 是冲刺键。
|
||||||
|
|
||||||
|
使用空格分隔每一个操作,每个操作持续一帧。如果后面跟着数字,则持续那么多帧。
|
||||||
|
|
||||||
|
### 例子 1
|
||||||
|
|
||||||
|
```
|
||||||
|
xc 180
|
||||||
|
```
|
||||||
|
|
||||||
|
按下 xc 一帧,然后空置 180 帧。
|
||||||
|
|
||||||
|
### 例子 2
|
||||||
|
|
||||||
|
```
|
||||||
|
d10 cd d10 xdw d20
|
||||||
|
```
|
||||||
|
|
||||||
|
向右走 10 帧,向右跳一帧,再继续按下右 10 帧,按下向右上冲刺一帧,再按下右 20 帧。
|
||||||
|
|
||||||
|
## 指令使用说明
|
||||||
|
|
||||||
|
直接说 `celeste` 会开启一个新的游戏。但是,你需要在后面跟有操作,才能够渲染 gif 图出来。
|
||||||
|
|
||||||
|
一个常见的开始操作是直接发送 `celeste xc 130`,即按下 xc 两个按键触发 PICO 版的开始游戏,然后等待 130 秒动画播放完毕。
|
||||||
|
|
||||||
|
对于一个已经存在而且时间不是非常久远的 gif 图,只要是由 bot 自己发送出来的,就可以在它的基础上继续游戏。回复这条消息,可以继续游戏。
|
||||||
|
|
||||||
|
一种很常见的技巧是回复一个已经存在的 gif 图 `celeste 1`,此时会空操作一帧并且渲染画面。你可以用这种方法查看一个 gif 图的游戏目前的状态。
|
||||||
|
|
||||||
@ -76,6 +76,8 @@ fx [滤镜名称] <参数1> <参数2> ...
|
|||||||
* ```fx 设置遮罩```
|
* ```fx 设置遮罩```
|
||||||
* ```fx 色键 <目标颜色="rgb(255,0,0)"> <容差=60>```
|
* ```fx 色键 <目标颜色="rgb(255,0,0)"> <容差=60>```
|
||||||
* ```fx 晃动 <最大偏移量=5> <运动模糊=False>```
|
* ```fx 晃动 <最大偏移量=5> <运动模糊=False>```
|
||||||
|
* ```fx JPEG损坏 <质量=10>```
|
||||||
|
* 质量范围建议为 1~95,数值越低,压缩痕迹越重、效果越搞笑。
|
||||||
* ```fx 动图 <帧率=10>```
|
* ```fx 动图 <帧率=10>```
|
||||||
|
|
||||||
### 多图像处理器
|
### 多图像处理器
|
||||||
|
|||||||
53
konabot/docs/user/roll.txt
Normal file
53
konabot/docs/user/roll.txt
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
**roll** - 面向跑团的文本骰子指令
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
`roll 表达式`
|
||||||
|
|
||||||
|
支持常见骰子写法:
|
||||||
|
|
||||||
|
- `roll 3d6`
|
||||||
|
- `roll d20+5`
|
||||||
|
- `roll 2d8+1d4+3`
|
||||||
|
- `roll d%`
|
||||||
|
- `roll 4dF`
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- `NdM` 表示掷 N 个 M 面骰,例如 `3d6`
|
||||||
|
- `d20` 等价于 `1d20`
|
||||||
|
- `d%` 表示百分骰,范围 1 到 100
|
||||||
|
- `dF` 表示 Fate/Fudge 骰,单骰结果为 -1、0、+1
|
||||||
|
- 支持用 `+`、`-` 连接多个项,也支持常数修正
|
||||||
|
|
||||||
|
## 返回格式
|
||||||
|
|
||||||
|
会返回总结果,以及每一项的明细。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- `roll 3d6`
|
||||||
|
可能返回:
|
||||||
|
- `3d6 = 11`
|
||||||
|
- `+3d6=[2, 4, 5]`
|
||||||
|
|
||||||
|
- `roll d20+5`
|
||||||
|
可能返回:
|
||||||
|
- `d20+5 = 19`
|
||||||
|
- `+1d20=[14] +5=5`
|
||||||
|
|
||||||
|
## 限制
|
||||||
|
|
||||||
|
为防止刷屏和滥用,当前实现会限制:
|
||||||
|
|
||||||
|
- 单项最多 100 个骰子
|
||||||
|
- 单个骰子最多 1000 面
|
||||||
|
- 一次表达式最多 20 项
|
||||||
|
- 一次表达式最多实际掷 200 个骰子
|
||||||
|
- 结果过长时会直接拒绝
|
||||||
|
|
||||||
|
## 权限
|
||||||
|
|
||||||
|
需要 `trpg.roll` 权限。
|
||||||
|
|
||||||
|
默认启动时会给系统全局授予允许,因此通常所有人都能用;如有需要可再用权限系统单独关闭。
|
||||||
@ -31,7 +31,16 @@
|
|||||||
|
|
||||||
- 用 `|` 连接多个操作,前一个的输出自动作为后一个的输入。
|
- 用 `|` 连接多个操作,前一个的输出自动作为后一个的输入。
|
||||||
- 用 `;` 分隔多条独立指令,它们各自产生输出,最终合并显示。
|
- 用 `;` 分隔多条独立指令,它们各自产生输出,最终合并显示。
|
||||||
- 用 `>` 或 `>>` 把结果保存起来(见下文),被重定向的指令不会产生输出。
|
- 用 `&&` / `||` 做最小 shell 风格条件执行:
|
||||||
|
- `cmd1 && cmd2`:仅当 `cmd1` 成功时执行 `cmd2`
|
||||||
|
- `cmd1 || cmd2`:仅当 `cmd1` 失败时执行 `cmd2`
|
||||||
|
- 用 `!` 对一条 pipeline 的成功/失败取反。
|
||||||
|
- 支持最小 bash-like `if ... then ... else ... fi` 语句。
|
||||||
|
- 支持最小 bash-like `while ... do ... done` 循环。
|
||||||
|
- 可使用内建真假命令:`true` / `false`。
|
||||||
|
- 为避免滥用与卡死:
|
||||||
|
- 同一用户同时只能运行 **一个** textfx 脚本
|
||||||
|
- 单个脚本最长执行时间为 **60 秒**
|
||||||
|
|
||||||
**例子**:把"HELLO"先反转,再转成摩斯电码:(转换为摩斯电码功能暂未实现)
|
**例子**:把"HELLO"先反转,再转成摩斯电码:(转换为摩斯电码功能暂未实现)
|
||||||
```
|
```
|
||||||
@ -39,6 +48,36 @@ textfx reverse HELLO | morse en
|
|||||||
```
|
```
|
||||||
→ 输出:`--- .-.. .-.. . ....`
|
→ 输出:`--- .-.. .-.. . ....`
|
||||||
|
|
||||||
|
**例子**:失败后兜底执行:
|
||||||
|
```
|
||||||
|
textfx test a = b || echo 不相等
|
||||||
|
```
|
||||||
|
→ 输出:`不相等`
|
||||||
|
|
||||||
|
**例子**:成功后继续执行:
|
||||||
|
```
|
||||||
|
textfx [ 2 -gt 1 ] && echo 条件成立
|
||||||
|
```
|
||||||
|
→ 输出:`条件成立`
|
||||||
|
|
||||||
|
**例子**:真正的 if 语句:
|
||||||
|
```
|
||||||
|
textfx if test a = b; then echo yes; else echo no; fi
|
||||||
|
```
|
||||||
|
→ 输出:`no`
|
||||||
|
|
||||||
|
**例子**:对条件取反:
|
||||||
|
```
|
||||||
|
textfx ! test a = b && echo 条件不成立
|
||||||
|
```
|
||||||
|
→ 输出:`条件不成立`
|
||||||
|
|
||||||
|
**例子**:while 循环:
|
||||||
|
```
|
||||||
|
textfx while false; do echo 不会执行; done
|
||||||
|
```
|
||||||
|
→ 输出为空
|
||||||
|
|
||||||
**例子**:多条指令各自输出:
|
**例子**:多条指令各自输出:
|
||||||
```
|
```
|
||||||
textfx echo 你好; echo 世界
|
textfx echo 你好; echo 世界
|
||||||
@ -132,6 +171,51 @@ Base64 编码或解码。
|
|||||||
|
|
||||||
> 缓存仅在当前对话中有效,重启后清空。
|
> 缓存仅在当前对话中有效,重启后清空。
|
||||||
|
|
||||||
|
### true / false / test / [
|
||||||
|
最小 shell 风格条件命令。通常配合 `if`、`&&`、`||`、`!` 使用。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- `true`:总是成功
|
||||||
|
- `false`:总是失败
|
||||||
|
- 字符串非空:`test foo`
|
||||||
|
- `-n` / `-z`:`test -n foo`、`test -z ""`
|
||||||
|
- 字符串比较:`test a = a`、`test a != b`
|
||||||
|
- 整数比较:`test 2 -gt 1`、`test 3 -le 5`
|
||||||
|
- 方括号别名:`[ 2 -gt 1 ]`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- `/textfx true && echo 一定执行`
|
||||||
|
- `/textfx false || echo 兜底执行`
|
||||||
|
- `/textfx test hello && echo 有内容`
|
||||||
|
- `/textfx test a = b || echo 不相等`
|
||||||
|
- `/textfx [ 3 -ge 2 ] && echo yes`
|
||||||
|
|
||||||
|
### if / then / else / fi
|
||||||
|
支持最小 bash-like 条件语句。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- `/textfx if test a = a; then echo yes; else echo no; fi`
|
||||||
|
- `/textfx if [ 2 -gt 1 ]; then echo 成立; fi`
|
||||||
|
- `/textfx if test a = a; then if test b = c; then echo x; else echo y; fi; fi`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `if` 后面跟一个条件链,可配合 `test`、`[`、`!`、`&&`、`||`
|
||||||
|
- `then` 和 `else` 后面都可以写多条以 `;` 分隔的 textfx 语句
|
||||||
|
- `else` 可省略
|
||||||
|
|
||||||
|
### while / do / done
|
||||||
|
支持最小 bash-like 循环语句。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- `/textfx while false; do echo 不会执行; done`
|
||||||
|
- `/textfx while ! false; do false; done`
|
||||||
|
- `/textfx while ! false; do if true; then false; fi; done`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `while` 后面跟一个条件链,返回成功就继续循环
|
||||||
|
- `do` 后面可写多条以 `;` 分隔的 textfx 语句
|
||||||
|
- 为避免 bot 死循环,内置最大循环次数限制;超限会报错
|
||||||
|
|
||||||
### replace(或 替换、sed)
|
### replace(或 替换、sed)
|
||||||
替换文字(支持正则表达式)。
|
替换文字(支持正则表达式)。
|
||||||
示例(普通):`/textfx replace 世界 宇宙 你好世界` → `你好宇宙`
|
示例(普通):`/textfx replace 世界 宇宙 你好世界` → `你好宇宙`
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from nonebot import get_plugin_config, on_message
|
from nonebot import get_plugin_config, on_message
|
||||||
|
from nonebot.rule import Rule
|
||||||
from nonebot_plugin_alconna import Reference, Reply, UniMsg
|
from nonebot_plugin_alconna import Reference, Reply, UniMsg
|
||||||
|
|
||||||
from nonebot.adapters import Event
|
from nonebot.adapters import Event
|
||||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent as OB11GroupEvent
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from konabot.common.permsys import require_permission
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
bilifetch_enabled_groups: list[int] = []
|
bilifetch_enabled_groups: list[int] = []
|
||||||
@ -19,11 +21,7 @@ pattern = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule(msg: UniMsg, evt: Event) -> bool:
|
def _rule(msg: UniMsg) -> bool:
|
||||||
if isinstance(evt, OB11GroupEvent):
|
|
||||||
if evt.group_id not in config.bilifetch_enabled_groups:
|
|
||||||
return False
|
|
||||||
|
|
||||||
to_search = msg.exclude(Reply, Reference).dump(json=True)
|
to_search = msg.exclude(Reply, Reference).dump(json=True)
|
||||||
to_search2 = msg.exclude(Reply, Reference).extract_plain_text()
|
to_search2 = msg.exclude(Reply, Reference).extract_plain_text()
|
||||||
if not re.search(pattern, to_search) and not re.search(pattern, to_search2):
|
if not re.search(pattern, to_search) and not re.search(pattern, to_search2):
|
||||||
@ -31,11 +29,11 @@ def _rule(msg: UniMsg, evt: Event) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
matcher_fix = on_message(rule=_rule)
|
matcher_fix = on_message(rule=Rule(_rule) & require_permission("bilifetch"))
|
||||||
|
|
||||||
|
|
||||||
@matcher_fix.handle()
|
@matcher_fix.handle()
|
||||||
async def _(event: Event):
|
async def _(event: Event):
|
||||||
from nonebot_plugin_analysis_bilibili import handle_analysis
|
from nonebot_plugin_analysis_bilibili import handle_analysis
|
||||||
|
|
||||||
await handle_analysis(event)
|
await handle_analysis(event)
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import on_command
|
from nonebot import on_message
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nonebot.adapters import Event, Bot
|
from nonebot.adapters import Event, Bot
|
||||||
@ -41,11 +42,12 @@ class CelesteStatus(BaseModel):
|
|||||||
celeste_status = DataManager(CelesteStatus, DATA_PATH / "celeste-status.json")
|
celeste_status = DataManager(CelesteStatus, DATA_PATH / "celeste-status.json")
|
||||||
|
|
||||||
|
|
||||||
cmd = on_command(cmd="celeste", aliases={"蔚蓝", "爬山", "鳌太线"})
|
# ↓ 这里的 Type Hinting 是为了能 fit 进去 set[str | tuple[str, ...]]
|
||||||
|
aliases: set[Any] = {"celeste", "蔚蓝", "爬山", "鳌太线"}
|
||||||
|
ALLOW_CHARS = "wasdxc0123456789 \t\n\r"
|
||||||
|
|
||||||
|
|
||||||
@cmd.handle()
|
async def get_prev(evt: Event, bot: Bot) -> str | None:
|
||||||
async def _(msg: UniMsg, evt: Event, bot: Bot):
|
|
||||||
prev = None
|
prev = None
|
||||||
if isinstance(evt, OB11MessageEvent):
|
if isinstance(evt, OB11MessageEvent):
|
||||||
if evt.reply is not None:
|
if evt.reply is not None:
|
||||||
@ -55,17 +57,37 @@ async def _(msg: UniMsg, evt: Event, bot: Bot):
|
|||||||
if seg.type == 'reply':
|
if seg.type == 'reply':
|
||||||
msgid = seg.get('id')
|
msgid = seg.get('id')
|
||||||
prev = f"QQ:{bot.self_id}:" + str(msgid)
|
prev = f"QQ:{bot.self_id}:" + str(msgid)
|
||||||
|
if prev is not None:
|
||||||
|
async with celeste_status.get_data() as data:
|
||||||
|
prev = data.records.get(prev)
|
||||||
|
return prev
|
||||||
|
|
||||||
actions = msg.extract_plain_text().strip().removeprefix("celeste")
|
|
||||||
for alias in {"蔚蓝", "爬山", "鳌太线"}:
|
async def match_celeste(evt: Event, bot: Bot, msg: UniMsg) -> bool:
|
||||||
|
prev = await get_prev(evt, bot)
|
||||||
|
text = msg.extract_plain_text().strip()
|
||||||
|
if any(text.startswith(a) for a in aliases):
|
||||||
|
return True
|
||||||
|
if prev is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# cmd = on_command(cmd="celeste", aliases=aliases)
|
||||||
|
cmd = on_message(rule=match_celeste)
|
||||||
|
|
||||||
|
|
||||||
|
@cmd.handle()
|
||||||
|
async def _(msg: UniMsg, evt: Event, bot: Bot):
|
||||||
|
prev = await get_prev(evt, bot)
|
||||||
|
actions = msg.extract_plain_text().strip()
|
||||||
|
for alias in aliases:
|
||||||
actions = actions.removeprefix(alias)
|
actions = actions.removeprefix(alias)
|
||||||
actions = actions.strip()
|
actions = actions.strip()
|
||||||
if len(actions) == 0:
|
if len(actions) == 0:
|
||||||
return
|
return
|
||||||
|
if any((c not in ALLOW_CHARS) for c in actions):
|
||||||
if prev is not None:
|
return
|
||||||
async with celeste_status.get_data() as data:
|
|
||||||
prev = data.records.get(prev)
|
|
||||||
|
|
||||||
await ensure_artifact(arti_ccleste_wrap_linux)
|
await ensure_artifact(arti_ccleste_wrap_linux)
|
||||||
await ensure_artifact(arti_ccleste_wrap_windows)
|
await ensure_artifact(arti_ccleste_wrap_windows)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import random
|
import random
|
||||||
|
from io import BytesIO
|
||||||
from PIL import Image, ImageFilter, ImageDraw, ImageStat, ImageFont
|
from PIL import Image, ImageFilter, ImageDraw, ImageStat, ImageFont
|
||||||
from PIL import ImageEnhance
|
from PIL import ImageEnhance
|
||||||
from PIL import ImageChops
|
from PIL import ImageChops
|
||||||
@ -167,26 +168,53 @@ class ImageFilterImplement:
|
|||||||
|
|
||||||
return Image.fromarray(result, 'RGBA')
|
return Image.fromarray(result, 'RGBA')
|
||||||
|
|
||||||
|
# JPEG 损坏感压缩
|
||||||
|
@staticmethod
|
||||||
|
def apply_jpeg_damage(image: Image.Image, quality: int = 10) -> Image.Image:
|
||||||
|
quality = max(1, min(95, int(quality)))
|
||||||
|
|
||||||
|
alpha = None
|
||||||
|
if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info):
|
||||||
|
rgba_image = image.convert('RGBA')
|
||||||
|
alpha = rgba_image.getchannel('A')
|
||||||
|
rgb_image = Image.new('RGB', rgba_image.size, (255, 255, 255))
|
||||||
|
rgb_image.paste(rgba_image, mask=alpha)
|
||||||
|
else:
|
||||||
|
rgb_image = image.convert('RGB')
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
rgb_image.save(output, format='JPEG', quality=quality, optimize=False)
|
||||||
|
output.seek(0)
|
||||||
|
damaged = Image.open(output).convert('RGB')
|
||||||
|
|
||||||
|
if alpha is not None:
|
||||||
|
return Image.merge('RGBA', (*damaged.split(), alpha))
|
||||||
|
return damaged.convert('RGBA')
|
||||||
|
|
||||||
# 缩放
|
# 缩放
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def apply_resize(image: Image.Image, scale: float = 1.5, scale_y = None) -> Image.Image:
|
def apply_resize(image: Image.Image, scale: float = 1.5, scale_y: float = None) -> Image.Image:
|
||||||
# scale 可以为负
|
scale_x = float(scale)
|
||||||
# 如果 scale 为负,则代表翻转
|
scale_y_value = float(scale_y) if scale_y is not None else None
|
||||||
if scale_y is not None:
|
|
||||||
if float(scale_y) < 0:
|
if scale_y_value is not None:
|
||||||
|
if scale_y_value < 0:
|
||||||
image = ImageOps.flip(image)
|
image = ImageOps.flip(image)
|
||||||
scale_y = abs(float(scale_y))
|
scale_y_value = abs(scale_y_value)
|
||||||
if scale < 0:
|
if scale_x < 0:
|
||||||
image = ImageOps.mirror(image)
|
image = ImageOps.mirror(image)
|
||||||
scale = abs(scale)
|
scale_x = abs(scale_x)
|
||||||
new_size = (int(image.width * scale), int(image.height * float(scale_y)))
|
target_scale_y = scale_y_value
|
||||||
return image.resize(new_size, Image.Resampling.LANCZOS)
|
else:
|
||||||
if scale < 0:
|
if scale_x < 0:
|
||||||
image = ImageOps.mirror(image)
|
image = ImageOps.mirror(image)
|
||||||
image = ImageOps.flip(image)
|
image = ImageOps.flip(image)
|
||||||
scale = abs(scale)
|
scale_x = abs(scale_x)
|
||||||
new_size = (int(image.width * scale), int(image.height * scale))
|
target_scale_y = scale_x
|
||||||
return image.resize(new_size, Image.Resampling.LANCZOS)
|
|
||||||
|
new_width = max(1, round(image.width * scale_x))
|
||||||
|
new_height = max(1, round(image.height * target_scale_y))
|
||||||
|
return image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# 波纹
|
# 波纹
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -50,6 +50,7 @@ class ImageFilterManager:
|
|||||||
"描边": ImageFilterImplement.apply_stroke,
|
"描边": ImageFilterImplement.apply_stroke,
|
||||||
"形状描边": ImageFilterImplement.apply_shape_stroke,
|
"形状描边": ImageFilterImplement.apply_shape_stroke,
|
||||||
"半调": ImageFilterImplement.apply_halftone,
|
"半调": ImageFilterImplement.apply_halftone,
|
||||||
|
"JPEG损坏": ImageFilterImplement.apply_jpeg_damage,
|
||||||
"设置通道": ImageFilterImplement.apply_set_channel,
|
"设置通道": ImageFilterImplement.apply_set_channel,
|
||||||
"设置遮罩": ImageFilterImplement.apply_set_mask,
|
"设置遮罩": ImageFilterImplement.apply_set_mask,
|
||||||
# 图像处理
|
# 图像处理
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
|
import asyncio
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import on_command
|
from nonebot import on_command
|
||||||
import nonebot
|
import nonebot
|
||||||
@ -10,6 +11,7 @@ from nonebot.adapters.onebot.v11.message import Message as OB11Message
|
|||||||
|
|
||||||
from konabot.common.apis.ali_content_safety import AlibabaGreen
|
from konabot.common.apis.ali_content_safety import AlibabaGreen
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
|
from konabot.common.render_error_message import render_error_message
|
||||||
from konabot.plugins.handle_text.base import (
|
from konabot.plugins.handle_text.base import (
|
||||||
PipelineRunner,
|
PipelineRunner,
|
||||||
TextHandlerEnvironment,
|
TextHandlerEnvironment,
|
||||||
@ -30,8 +32,11 @@ from konabot.plugins.handle_text.handlers.random_handlers import THShuffle, THSo
|
|||||||
from konabot.plugins.handle_text.handlers.unix_handlers import (
|
from konabot.plugins.handle_text.handlers.unix_handlers import (
|
||||||
THCat,
|
THCat,
|
||||||
THEcho,
|
THEcho,
|
||||||
|
THFalse,
|
||||||
THReplace,
|
THReplace,
|
||||||
THRm,
|
THRm,
|
||||||
|
THTest,
|
||||||
|
THTrue,
|
||||||
)
|
)
|
||||||
from konabot.plugins.handle_text.handlers.whitespace_handlers import (
|
from konabot.plugins.handle_text.handlers.whitespace_handlers import (
|
||||||
THLines,
|
THLines,
|
||||||
@ -42,11 +47,37 @@ from konabot.plugins.handle_text.handlers.whitespace_handlers import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEXTFX_MAX_RUNTIME_SECONDS = 60
|
||||||
|
_textfx_running_users: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_textfx_user_key(evt: Event) -> str:
|
||||||
|
user_id = getattr(evt, "user_id", None)
|
||||||
|
self_id = getattr(evt, "self_id", None)
|
||||||
|
group_id = getattr(evt, "group_id", None)
|
||||||
|
if user_id is not None:
|
||||||
|
if group_id is not None:
|
||||||
|
return f"{self_id}:{group_id}:{user_id}"
|
||||||
|
return f"{self_id}:private:{user_id}"
|
||||||
|
session_id = getattr(evt, "get_session_id", None)
|
||||||
|
if callable(session_id):
|
||||||
|
try:
|
||||||
|
return f"session:{evt.get_session_id()}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return f"event:{evt.__class__.__name__}:{id(evt)}"
|
||||||
|
|
||||||
|
|
||||||
cmd = on_command(cmd="textfx", aliases={"处理文字", "处理文本"})
|
cmd = on_command(cmd="textfx", aliases={"处理文字", "处理文本"})
|
||||||
|
|
||||||
|
|
||||||
@cmd.handle()
|
@cmd.handle()
|
||||||
async def _(msg: UniMsg, evt: Event, bot: Bot, target: DepLongTaskTarget):
|
async def _(msg: UniMsg, evt: Event, bot: Bot, target: DepLongTaskTarget):
|
||||||
|
user_key = _get_textfx_user_key(evt)
|
||||||
|
if user_key in _textfx_running_users:
|
||||||
|
await target.send_message("你当前已有一个 textfx 脚本正在运行,请等待它结束后再试。")
|
||||||
|
return
|
||||||
|
|
||||||
istream = ""
|
istream = ""
|
||||||
if isinstance(evt, OB11MessageEvent):
|
if isinstance(evt, OB11MessageEvent):
|
||||||
if evt.reply is not None:
|
if evt.reply is not None:
|
||||||
@ -69,16 +100,30 @@ async def _(msg: UniMsg, evt: Event, bot: Bot, target: DepLongTaskTarget):
|
|||||||
await target.send_message(res)
|
await target.send_message(res)
|
||||||
return
|
return
|
||||||
|
|
||||||
env = TextHandlerEnvironment(is_trusted=False)
|
env = TextHandlerEnvironment(is_trusted=False, event=evt)
|
||||||
results = await runner.run_pipeline(res, istream or None, env)
|
|
||||||
|
_textfx_running_users.add(user_key)
|
||||||
|
try:
|
||||||
|
results = await asyncio.wait_for(
|
||||||
|
runner.run_pipeline(res, istream or None, env),
|
||||||
|
timeout=TEXTFX_MAX_RUNTIME_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
rendered = await render_error_message(
|
||||||
|
f"处理指令时出现问题:脚本执行超时(超过 {TEXTFX_MAX_RUNTIME_SECONDS} 秒)"
|
||||||
|
)
|
||||||
|
await target.send_message(rendered)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
_textfx_running_users.discard(user_key)
|
||||||
|
|
||||||
# 检查是否有错误
|
|
||||||
for r in results:
|
for r in results:
|
||||||
if r.code != 0:
|
if r.code != 0:
|
||||||
await target.send_message(f"处理指令时出现问题:{r.ostream}")
|
message = f"处理指令时出现问题:{r.ostream}"
|
||||||
|
rendered = await render_error_message(message)
|
||||||
|
await target.send_message(rendered)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 收集所有组的文本输出和附件
|
|
||||||
ostreams = [r.ostream for r in results if r.ostream is not None]
|
ostreams = [r.ostream for r in results if r.ostream is not None]
|
||||||
attachments = [r.attachment for r in results if r.attachment is not None]
|
attachments = [r.attachment for r in results if r.attachment is not None]
|
||||||
|
|
||||||
@ -105,6 +150,9 @@ async def _():
|
|||||||
THCat(),
|
THCat(),
|
||||||
THEcho(),
|
THEcho(),
|
||||||
THRm(),
|
THRm(),
|
||||||
|
THTrue(),
|
||||||
|
THFalse(),
|
||||||
|
THTest(),
|
||||||
THShuffle(),
|
THShuffle(),
|
||||||
THReplace(),
|
THReplace(),
|
||||||
THBase64(),
|
THBase64(),
|
||||||
|
|||||||
@ -1,17 +1,20 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from string import whitespace
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
|
||||||
|
|
||||||
|
MAX_WHILE_ITERATIONS = 100
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TextHandlerEnvironment:
|
class TextHandlerEnvironment:
|
||||||
is_trusted: bool
|
is_trusted: bool
|
||||||
|
event: Event | None = None
|
||||||
buffers: dict[str, str] = field(default_factory=dict)
|
buffers: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@ -51,29 +54,63 @@ class TextHandlerSync(TextHandler):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PipelineCommand:
|
class Redirect:
|
||||||
handler: TextHandler
|
target: str
|
||||||
args: list[str]
|
append: bool = False
|
||||||
# 新增:重定向目标(buffer key)
|
|
||||||
redirect_target: str | None = None
|
|
||||||
# 新增:是否为追加模式 (>>)
|
|
||||||
redirect_append: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Pipeline:
|
class CommandNode:
|
||||||
command_groups: list[list[PipelineCommand]] = field(default_factory=list)
|
name: str
|
||||||
"一个列表的列表,每一组之间的指令之间使用管道符连接,而不同组之间不会有数据流"
|
handler: TextHandler
|
||||||
|
args: list[str]
|
||||||
|
redirects: list[Redirect] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PipelineParseStatus(Enum):
|
@dataclass
|
||||||
normal = 0
|
class PipelineNode:
|
||||||
in_string = 1
|
commands: list[CommandNode] = field(default_factory=list)
|
||||||
in_string_to_escape = 2
|
negate: bool = False
|
||||||
off_string = 3
|
|
||||||
|
|
||||||
|
|
||||||
whitespaces = whitespace + " "
|
@dataclass
|
||||||
|
class ConditionalPipeline:
|
||||||
|
op: str | None
|
||||||
|
pipeline: PipelineNode
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandGroup:
|
||||||
|
chains: list[ConditionalPipeline] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IfNode:
|
||||||
|
condition: CommandGroup
|
||||||
|
then_body: "Script"
|
||||||
|
else_body: "Script | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WhileNode:
|
||||||
|
condition: CommandGroup
|
||||||
|
body: "Script"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Script:
|
||||||
|
statements: list[CommandGroup | IfNode | WhileNode] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenKind(Enum):
|
||||||
|
WORD = "word"
|
||||||
|
OP = "op"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Token:
|
||||||
|
kind: TokenKind
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
class PipelineRunner:
|
class PipelineRunner:
|
||||||
@ -91,254 +128,456 @@ class PipelineRunner:
|
|||||||
def register(self, handler: TextHandler):
|
def register(self, handler: TextHandler):
|
||||||
self.handlers.append(handler)
|
self.handlers.append(handler)
|
||||||
|
|
||||||
def parse_pipeline(self, script: str) -> Pipeline | str:
|
def _resolve_handler(self, cmd_name: str) -> TextHandler | str:
|
||||||
pipeline = Pipeline()
|
matched = [
|
||||||
|
h for h in self.handlers if cmd_name == h.name or cmd_name in h.keywords
|
||||||
# 当前正在构建的上下文
|
]
|
||||||
current_group: list[PipelineCommand] = []
|
if not matched:
|
||||||
current_command_args: list[str] = []
|
return f"不存在名为 {cmd_name} 的函数"
|
||||||
|
if len(matched) > 1:
|
||||||
# 字符串解析状态
|
logger.warning(
|
||||||
status = PipelineParseStatus.normal
|
f"指令能对应超过一个文本处理器 CMD={cmd_name} handlers={self.handlers}"
|
||||||
current_string = ""
|
|
||||||
current_string_raw = ""
|
|
||||||
status_in_string_pair = ""
|
|
||||||
has_token = False # 是否正在构建一个 token(区分空字符串和无 token)
|
|
||||||
|
|
||||||
# 重定向解析状态
|
|
||||||
is_parsing_redirect_filename = False
|
|
||||||
current_redirect_target: str | None = None
|
|
||||||
current_redirect_append = False
|
|
||||||
|
|
||||||
# 辅助函数:将当前解析到的字符串 flush 到 参数列表 或 重定向目标
|
|
||||||
def _flush_token():
|
|
||||||
nonlocal \
|
|
||||||
current_string, \
|
|
||||||
current_string_raw, \
|
|
||||||
is_parsing_redirect_filename, \
|
|
||||||
current_redirect_target, \
|
|
||||||
has_token
|
|
||||||
if not has_token:
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_parsing_redirect_filename:
|
|
||||||
current_redirect_target = current_string
|
|
||||||
is_parsing_redirect_filename = False # 重定向文件名只取一个 token
|
|
||||||
else:
|
|
||||||
current_command_args.append(current_string)
|
|
||||||
|
|
||||||
current_string = ""
|
|
||||||
current_string_raw = ""
|
|
||||||
has_token = False
|
|
||||||
|
|
||||||
# 辅助函数:将当前指令 flush 到当前组
|
|
||||||
def _flush_command() -> str | None:
|
|
||||||
nonlocal \
|
|
||||||
current_command_args, \
|
|
||||||
current_redirect_target, \
|
|
||||||
current_redirect_append
|
|
||||||
if not current_command_args:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cmd_name = current_command_args[0]
|
|
||||||
args = current_command_args[1:]
|
|
||||||
|
|
||||||
matched = [
|
|
||||||
h for h in self.handlers if cmd_name in h.keywords or cmd_name == h.name
|
|
||||||
]
|
|
||||||
if not matched:
|
|
||||||
return f"不存在名为 {cmd_name} 的函数"
|
|
||||||
if len(matched) > 1:
|
|
||||||
logger.warning(
|
|
||||||
f"指令能对应超过一个文本处理器 CMD={cmd_name} handlers={self.handlers}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = PipelineCommand(
|
|
||||||
handler=matched[0],
|
|
||||||
args=args,
|
|
||||||
redirect_target=current_redirect_target,
|
|
||||||
redirect_append=current_redirect_append,
|
|
||||||
)
|
)
|
||||||
current_group.append(cmd)
|
return matched[0]
|
||||||
|
|
||||||
# 重置指令级状态
|
def tokenize(self, script: str) -> list[Token] | str:
|
||||||
current_command_args = []
|
tokens: list[Token] = []
|
||||||
current_redirect_target = None
|
buf = ""
|
||||||
current_redirect_append = False
|
quote: str | None = None
|
||||||
return None
|
escape = False
|
||||||
|
|
||||||
# 使用索引遍历以支持 look-ahead (处理 >>)
|
|
||||||
i = 0
|
i = 0
|
||||||
length = len(script)
|
operators = {"|", ";", ">", "&&", "||", ">>", "!"}
|
||||||
|
escape_map = {
|
||||||
|
"n": "\n",
|
||||||
|
"r": "\r",
|
||||||
|
"t": "\t",
|
||||||
|
"0": "\0",
|
||||||
|
"a": "\a",
|
||||||
|
"b": "\b",
|
||||||
|
"f": "\f",
|
||||||
|
"v": "\v",
|
||||||
|
"\\": "\\",
|
||||||
|
'"': '"',
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
|
||||||
while i < length:
|
def flush_word(force: bool = False):
|
||||||
|
nonlocal buf
|
||||||
|
if buf or force:
|
||||||
|
tokens.append(Token(TokenKind.WORD, buf))
|
||||||
|
buf = ""
|
||||||
|
|
||||||
|
while i < len(script):
|
||||||
c = script[i]
|
c = script[i]
|
||||||
|
|
||||||
match status:
|
if quote is not None:
|
||||||
case PipelineParseStatus.normal:
|
if escape:
|
||||||
if c in whitespaces:
|
buf += escape_map.get(c, c)
|
||||||
_flush_token()
|
escape = False
|
||||||
|
elif c == "\\":
|
||||||
|
escape = True
|
||||||
|
elif c == quote:
|
||||||
|
quote = None
|
||||||
|
flush_word(force=True) # 引号闭合时强制 flush,即使是空字符串
|
||||||
|
else:
|
||||||
|
buf += c
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
elif c in "'\"":
|
if c in "'\"":
|
||||||
status_in_string_pair = c
|
quote = c
|
||||||
status = PipelineParseStatus.in_string
|
i += 1
|
||||||
current_string_raw = ""
|
continue
|
||||||
has_token = True
|
|
||||||
|
|
||||||
elif c == "|":
|
if c.isspace():
|
||||||
_flush_token()
|
flush_word()
|
||||||
if err := _flush_command():
|
i += 1
|
||||||
return err
|
continue
|
||||||
# 管道符不结束 group,继续在 current_group 添加
|
|
||||||
|
|
||||||
elif c == ";":
|
two = script[i : i + 2]
|
||||||
_flush_token()
|
if two in operators:
|
||||||
if err := _flush_command():
|
flush_word()
|
||||||
return err
|
tokens.append(Token(TokenKind.OP, two))
|
||||||
# 分号结束 group
|
i += 2
|
||||||
if current_group:
|
continue
|
||||||
pipeline.command_groups.append(current_group)
|
|
||||||
current_group = []
|
|
||||||
|
|
||||||
elif c == ">":
|
if c in {"|", ";", ">", "!"}:
|
||||||
_flush_token() # 先结束之前的参数
|
flush_word()
|
||||||
# 检查是否是 append 模式 (>>)
|
tokens.append(Token(TokenKind.OP, c))
|
||||||
if i + 1 < length and script[i + 1] == ">":
|
i += 1
|
||||||
current_redirect_append = True
|
continue
|
||||||
i += 1 # 跳过下一个 >
|
|
||||||
else:
|
|
||||||
current_redirect_append = False
|
|
||||||
|
|
||||||
# 标记下一个 token 为文件名
|
if c == "\\":
|
||||||
is_parsing_redirect_filename = True
|
if i + 1 < len(script):
|
||||||
|
i += 1
|
||||||
else:
|
buf += escape_map.get(script[i], script[i])
|
||||||
current_string += c
|
else:
|
||||||
has_token = True
|
buf += c
|
||||||
|
i += 1
|
||||||
case PipelineParseStatus.in_string:
|
continue
|
||||||
current_string_raw += c
|
|
||||||
if c == status_in_string_pair:
|
|
||||||
status = PipelineParseStatus.off_string
|
|
||||||
elif c == "\\":
|
|
||||||
status = PipelineParseStatus.in_string_to_escape
|
|
||||||
else:
|
|
||||||
current_string += c
|
|
||||||
|
|
||||||
case PipelineParseStatus.in_string_to_escape:
|
|
||||||
escape_map = {
|
|
||||||
"n": "\n",
|
|
||||||
"r": "\r",
|
|
||||||
"t": "\t",
|
|
||||||
"0": "\0",
|
|
||||||
"a": "\a",
|
|
||||||
"b": "\b",
|
|
||||||
"f": "\f",
|
|
||||||
"v": "\v",
|
|
||||||
"\\": "\\",
|
|
||||||
}
|
|
||||||
current_string += escape_map.get(c, c)
|
|
||||||
status = PipelineParseStatus.in_string
|
|
||||||
|
|
||||||
case PipelineParseStatus.off_string:
|
|
||||||
if c in whitespaces:
|
|
||||||
_flush_token()
|
|
||||||
status = PipelineParseStatus.normal
|
|
||||||
elif c == "|":
|
|
||||||
_flush_token()
|
|
||||||
if err := _flush_command():
|
|
||||||
return err
|
|
||||||
status = PipelineParseStatus.normal
|
|
||||||
elif c == ";":
|
|
||||||
_flush_token()
|
|
||||||
if err := _flush_command():
|
|
||||||
return err
|
|
||||||
if current_group:
|
|
||||||
pipeline.command_groups.append(current_group)
|
|
||||||
current_group = []
|
|
||||||
status = PipelineParseStatus.normal
|
|
||||||
elif c == ">":
|
|
||||||
_flush_token()
|
|
||||||
status = PipelineParseStatus.normal
|
|
||||||
# 回退索引,让下一次循环进入 normal 状态的 > 处理逻辑
|
|
||||||
i -= 1
|
|
||||||
else:
|
|
||||||
# 紧接着的字符继续作为当前字符串的一部分 (如 "abc"d)
|
|
||||||
current_string += c
|
|
||||||
current_string_raw = ""
|
|
||||||
status = PipelineParseStatus.normal
|
|
||||||
|
|
||||||
|
buf += c
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
# 循环结束后的收尾
|
if quote is not None:
|
||||||
_flush_token()
|
return "存在未闭合的引号"
|
||||||
if err := _flush_command():
|
if escape:
|
||||||
return err
|
buf += "\\"
|
||||||
|
|
||||||
if current_group:
|
flush_word()
|
||||||
pipeline.command_groups.append(current_group)
|
return tokens
|
||||||
|
|
||||||
return pipeline
|
def parse_pipeline(self, script: str) -> Script | str:
|
||||||
|
tokens = self.tokenize(script)
|
||||||
|
if isinstance(tokens, str):
|
||||||
|
return tokens
|
||||||
|
if not tokens:
|
||||||
|
return Script()
|
||||||
|
|
||||||
|
pos = 0
|
||||||
|
|
||||||
|
def peek(offset: int = 0) -> Token | None:
|
||||||
|
idx = pos + offset
|
||||||
|
return tokens[idx] if idx < len(tokens) else None
|
||||||
|
|
||||||
|
def consume() -> Token:
|
||||||
|
nonlocal pos
|
||||||
|
tok = tokens[pos]
|
||||||
|
pos += 1
|
||||||
|
return tok
|
||||||
|
|
||||||
|
def consume_if_op(value: str) -> bool:
|
||||||
|
tok = peek()
|
||||||
|
if tok is not None and tok.kind == TokenKind.OP and tok.value == value:
|
||||||
|
consume()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def consume_if_word(value: str) -> bool:
|
||||||
|
tok = peek()
|
||||||
|
if tok is not None and tok.kind == TokenKind.WORD and tok.value == value:
|
||||||
|
consume()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def expect_word(msg: str) -> Token | str:
|
||||||
|
tok = peek()
|
||||||
|
if tok is None or tok.kind != TokenKind.WORD:
|
||||||
|
return msg
|
||||||
|
return consume()
|
||||||
|
|
||||||
|
def parse_command() -> CommandNode | str:
|
||||||
|
first = expect_word("缺少指令名")
|
||||||
|
if isinstance(first, str):
|
||||||
|
return first
|
||||||
|
|
||||||
|
handler = self._resolve_handler(first.value)
|
||||||
|
if isinstance(handler, str):
|
||||||
|
return handler
|
||||||
|
|
||||||
|
args: list[str] = []
|
||||||
|
redirects: list[Redirect] = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
tok = peek()
|
||||||
|
if tok is None:
|
||||||
|
break
|
||||||
|
if tok.kind == TokenKind.OP and tok.value in {"|", ";", "&&", "||"}:
|
||||||
|
break
|
||||||
|
if tok.kind == TokenKind.OP and tok.value in {">", ">>"}:
|
||||||
|
op_tok = consume()
|
||||||
|
target = expect_word("重定向操作符后面需要缓存名")
|
||||||
|
if isinstance(target, str):
|
||||||
|
return target
|
||||||
|
redirects.append(
|
||||||
|
Redirect(target=target.value, append=op_tok.value == ">>")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if tok.kind != TokenKind.WORD:
|
||||||
|
return f"无法解析的 token: {tok.value}"
|
||||||
|
args.append(consume().value)
|
||||||
|
|
||||||
|
return CommandNode(
|
||||||
|
name=first.value,
|
||||||
|
handler=handler,
|
||||||
|
args=args,
|
||||||
|
redirects=redirects,
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_pipe() -> PipelineNode | str:
|
||||||
|
negate = False
|
||||||
|
while consume_if_op("!"):
|
||||||
|
negate = not negate
|
||||||
|
|
||||||
|
pipeline = PipelineNode(negate=negate)
|
||||||
|
command = parse_command()
|
||||||
|
if isinstance(command, str):
|
||||||
|
return command
|
||||||
|
pipeline.commands.append(command)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
tok = peek()
|
||||||
|
if tok is None or tok.kind != TokenKind.OP or tok.value != "|":
|
||||||
|
break
|
||||||
|
consume()
|
||||||
|
next_command = parse_command()
|
||||||
|
if isinstance(next_command, str):
|
||||||
|
return next_command
|
||||||
|
pipeline.commands.append(next_command)
|
||||||
|
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
def parse_chain() -> CommandGroup | str:
|
||||||
|
group = CommandGroup()
|
||||||
|
first_pipeline = parse_pipe()
|
||||||
|
if isinstance(first_pipeline, str):
|
||||||
|
return first_pipeline
|
||||||
|
group.chains.append(ConditionalPipeline(op=None, pipeline=first_pipeline))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
tok = peek()
|
||||||
|
if tok is None or tok.kind != TokenKind.OP or tok.value not in {"&&", "||"}:
|
||||||
|
break
|
||||||
|
op = consume().value
|
||||||
|
next_pipeline = parse_pipe()
|
||||||
|
if isinstance(next_pipeline, str):
|
||||||
|
return next_pipeline
|
||||||
|
group.chains.append(ConditionalPipeline(op=op, pipeline=next_pipeline))
|
||||||
|
|
||||||
|
return group
|
||||||
|
|
||||||
|
def parse_if() -> IfNode | str:
|
||||||
|
if not consume_if_word("if"):
|
||||||
|
return "缺少 if"
|
||||||
|
|
||||||
|
condition = parse_chain()
|
||||||
|
if isinstance(condition, str):
|
||||||
|
return condition
|
||||||
|
|
||||||
|
consume_if_op(";")
|
||||||
|
if not consume_if_word("then"):
|
||||||
|
return "if 语句缺少 then"
|
||||||
|
|
||||||
|
then_body = parse_script(stop_words={"else", "fi"})
|
||||||
|
if isinstance(then_body, str):
|
||||||
|
return then_body
|
||||||
|
|
||||||
|
else_body: Script | None = None
|
||||||
|
if consume_if_word("else"):
|
||||||
|
else_body = parse_script(stop_words={"fi"})
|
||||||
|
if isinstance(else_body, str):
|
||||||
|
return else_body
|
||||||
|
|
||||||
|
if not consume_if_word("fi"):
|
||||||
|
return "if 语句缺少 fi"
|
||||||
|
|
||||||
|
return IfNode(condition=condition, then_body=then_body, else_body=else_body)
|
||||||
|
|
||||||
|
def parse_while() -> WhileNode | str:
|
||||||
|
if not consume_if_word("while"):
|
||||||
|
return "缺少 while"
|
||||||
|
|
||||||
|
condition = parse_chain()
|
||||||
|
if isinstance(condition, str):
|
||||||
|
return condition
|
||||||
|
|
||||||
|
consume_if_op(";")
|
||||||
|
if not consume_if_word("do"):
|
||||||
|
return "while 语句缺少 do"
|
||||||
|
|
||||||
|
body = parse_script(stop_words={"done"})
|
||||||
|
if isinstance(body, str):
|
||||||
|
return body
|
||||||
|
|
||||||
|
if not consume_if_word("done"):
|
||||||
|
return "while 语句缺少 done"
|
||||||
|
|
||||||
|
return WhileNode(condition=condition, body=body)
|
||||||
|
|
||||||
|
def parse_statement() -> CommandGroup | IfNode | WhileNode | str:
|
||||||
|
tok = peek()
|
||||||
|
if tok is not None and tok.kind == TokenKind.WORD:
|
||||||
|
if tok.value == "if":
|
||||||
|
return parse_if()
|
||||||
|
if tok.value == "while":
|
||||||
|
return parse_while()
|
||||||
|
return parse_chain()
|
||||||
|
|
||||||
|
def parse_script(stop_words: set[str] | None = None) -> Script | str:
|
||||||
|
parsed = Script()
|
||||||
|
nonlocal pos
|
||||||
|
|
||||||
|
while pos < len(tokens):
|
||||||
|
tok = peek()
|
||||||
|
if tok is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if stop_words and tok.kind == TokenKind.WORD and tok.value in stop_words:
|
||||||
|
break
|
||||||
|
|
||||||
|
if tok.kind == TokenKind.OP and tok.value == ";":
|
||||||
|
consume()
|
||||||
|
continue
|
||||||
|
|
||||||
|
statement = parse_statement()
|
||||||
|
if isinstance(statement, str):
|
||||||
|
return statement
|
||||||
|
parsed.statements.append(statement)
|
||||||
|
|
||||||
|
tok = peek()
|
||||||
|
if tok is not None and tok.kind == TokenKind.OP and tok.value == ";":
|
||||||
|
consume()
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
parsed = parse_script()
|
||||||
|
if isinstance(parsed, str):
|
||||||
|
return parsed
|
||||||
|
if pos != len(tokens):
|
||||||
|
tok = tokens[pos]
|
||||||
|
return f"无法解析的 token: {tok.value}"
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
async def _execute_command(
|
||||||
|
self,
|
||||||
|
command: CommandNode,
|
||||||
|
istream: str | None,
|
||||||
|
env: TextHandlerEnvironment,
|
||||||
|
) -> TextHandleResult:
|
||||||
|
logger.debug(
|
||||||
|
f"Executing: {command.name} args={command.args} redirects={command.redirects}"
|
||||||
|
)
|
||||||
|
result = await command.handler.handle(env, istream, command.args)
|
||||||
|
|
||||||
|
if result.code != 0:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if command.redirects:
|
||||||
|
content = result.ostream or ""
|
||||||
|
for redirect in command.redirects:
|
||||||
|
if redirect.append:
|
||||||
|
old_content = env.buffers.get(redirect.target, "")
|
||||||
|
env.buffers[redirect.target] = old_content + content
|
||||||
|
else:
|
||||||
|
env.buffers[redirect.target] = content
|
||||||
|
return TextHandleResult(code=0, ostream=None, attachment=result.attachment)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _execute_pipeline(
|
||||||
|
self,
|
||||||
|
pipeline: PipelineNode,
|
||||||
|
istream: str | None,
|
||||||
|
env: TextHandlerEnvironment,
|
||||||
|
) -> TextHandleResult:
|
||||||
|
current_stream = istream
|
||||||
|
last_result = TextHandleResult(code=0, ostream=None)
|
||||||
|
|
||||||
|
for command in pipeline.commands:
|
||||||
|
try:
|
||||||
|
last_result = await self._execute_command(command, current_stream, env)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Pipeline execution failed at {command.name}")
|
||||||
|
logger.exception(e)
|
||||||
|
return TextHandleResult(code=-1, ostream="处理流水线时出现 python 错误")
|
||||||
|
|
||||||
|
if last_result.code != 0:
|
||||||
|
if pipeline.negate:
|
||||||
|
return TextHandleResult(code=0, ostream=None)
|
||||||
|
return last_result
|
||||||
|
current_stream = last_result.ostream
|
||||||
|
|
||||||
|
if pipeline.negate:
|
||||||
|
return TextHandleResult(code=1, ostream=None)
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
async def _execute_group(
|
||||||
|
self,
|
||||||
|
group: CommandGroup,
|
||||||
|
istream: str | None,
|
||||||
|
env: TextHandlerEnvironment,
|
||||||
|
) -> TextHandleResult:
|
||||||
|
last_result = TextHandleResult(code=0, ostream=None)
|
||||||
|
|
||||||
|
for chain in group.chains:
|
||||||
|
should_run = True
|
||||||
|
if chain.op == "&&":
|
||||||
|
should_run = last_result.code == 0
|
||||||
|
elif chain.op == "||":
|
||||||
|
should_run = last_result.code != 0
|
||||||
|
|
||||||
|
if should_run:
|
||||||
|
last_result = await self._execute_pipeline(chain.pipeline, istream, env)
|
||||||
|
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
async def _execute_if(
|
||||||
|
self,
|
||||||
|
if_node: IfNode,
|
||||||
|
istream: str | None,
|
||||||
|
env: TextHandlerEnvironment,
|
||||||
|
) -> TextHandleResult:
|
||||||
|
condition_result = await self._execute_group(if_node.condition, istream, env)
|
||||||
|
if condition_result.code == 0:
|
||||||
|
results = await self.run_pipeline(if_node.then_body, istream, env)
|
||||||
|
else:
|
||||||
|
results = (
|
||||||
|
await self.run_pipeline(if_node.else_body, istream, env)
|
||||||
|
if if_node.else_body is not None
|
||||||
|
else [TextHandleResult(code=0, ostream=None)]
|
||||||
|
)
|
||||||
|
return results[-1] if results else TextHandleResult(code=0, ostream=None)
|
||||||
|
|
||||||
|
async def _execute_while(
|
||||||
|
self,
|
||||||
|
while_node: WhileNode,
|
||||||
|
istream: str | None,
|
||||||
|
env: TextHandlerEnvironment,
|
||||||
|
) -> TextHandleResult:
|
||||||
|
last_result = TextHandleResult(code=0, ostream=None)
|
||||||
|
|
||||||
|
for _ in range(MAX_WHILE_ITERATIONS):
|
||||||
|
condition_result = await self._execute_group(while_node.condition, istream, env)
|
||||||
|
if condition_result.code != 0:
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
body_results = await self.run_pipeline(while_node.body, istream, env)
|
||||||
|
if body_results:
|
||||||
|
last_result = body_results[-1]
|
||||||
|
if last_result.code != 0:
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
return TextHandleResult(
|
||||||
|
code=2,
|
||||||
|
ostream=f"while 循环超过最大迭代次数限制({MAX_WHILE_ITERATIONS})",
|
||||||
|
)
|
||||||
|
|
||||||
async def run_pipeline(
|
async def run_pipeline(
|
||||||
self,
|
self,
|
||||||
pipeline: Pipeline,
|
pipeline: Script,
|
||||||
istream: str | None,
|
istream: str | None,
|
||||||
env: TextHandlerEnvironment | None = None,
|
env: TextHandlerEnvironment | None = None,
|
||||||
) -> list[TextHandleResult]:
|
) -> list[TextHandleResult]:
|
||||||
if env is None:
|
if env is None:
|
||||||
env = TextHandlerEnvironment(is_trusted=False, buffers={})
|
env = TextHandlerEnvironment(is_trusted=False, event=None, buffers={})
|
||||||
|
|
||||||
results: list[TextHandleResult] = []
|
results: list[TextHandleResult] = []
|
||||||
|
|
||||||
# 遍历执行指令组 (分号分隔),每个组独立产生输出
|
for statement in pipeline.statements:
|
||||||
for group in pipeline.command_groups:
|
try:
|
||||||
current_stream = istream
|
if isinstance(statement, IfNode):
|
||||||
group_result = TextHandleResult(code=0, ostream=None)
|
results.append(await self._execute_if(statement, istream, env))
|
||||||
|
elif isinstance(statement, WhileNode):
|
||||||
# 遍历组内指令 (管道分隔)
|
results.append(await self._execute_while(statement, istream, env))
|
||||||
for cmd in group:
|
else:
|
||||||
try:
|
results.append(await self._execute_group(statement, istream, env))
|
||||||
logger.debug(
|
except Exception as e:
|
||||||
f"Executing: {cmd.handler.name} args={cmd.args} redirect={cmd.redirect_target}"
|
logger.error(f"Pipeline execution failed: {e}")
|
||||||
)
|
logger.exception(e)
|
||||||
result = await cmd.handler.handle(env, current_stream, cmd.args)
|
results.append(
|
||||||
|
TextHandleResult(code=-1, ostream="处理流水线时出现 python 错误")
|
||||||
if result.code != 0:
|
)
|
||||||
# 组内出错,整条流水线中止
|
return results
|
||||||
results.append(result)
|
|
||||||
return results
|
|
||||||
|
|
||||||
# 处理重定向逻辑
|
|
||||||
if cmd.redirect_target:
|
|
||||||
content_to_write = result.ostream or ""
|
|
||||||
target_buffer = cmd.redirect_target
|
|
||||||
|
|
||||||
if cmd.redirect_append:
|
|
||||||
old_content = env.buffers.get(target_buffer, "")
|
|
||||||
env.buffers[target_buffer] = old_content + content_to_write
|
|
||||||
else:
|
|
||||||
env.buffers[target_buffer] = content_to_write
|
|
||||||
|
|
||||||
current_stream = None
|
|
||||||
group_result = TextHandleResult(
|
|
||||||
code=0, ostream=None, attachment=result.attachment
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
current_stream = result.ostream
|
|
||||||
group_result = result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Pipeline execution failed at {cmd.handler.name}")
|
|
||||||
logger.exception(e)
|
|
||||||
results.append(
|
|
||||||
TextHandleResult(
|
|
||||||
code=-1, ostream="处理流水线时出现 python 错误"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
results.append(group_result)
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@ -1,36 +1,53 @@
|
|||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from konabot.common.llm import get_llm
|
from konabot.common.llm import get_llm
|
||||||
from konabot.plugins.handle_text.base import TextHandler, TextHandlerEnvironment, TextHandleResult
|
from konabot.common.permsys import perm_manager
|
||||||
|
from konabot.plugins.handle_text.base import (
|
||||||
|
TextHandler,
|
||||||
|
TextHandlerEnvironment,
|
||||||
|
TextHandleResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class THQwen(TextHandler):
|
class THQwen(TextHandler):
|
||||||
name = "qwen"
|
name = "qwen"
|
||||||
|
|
||||||
async def handle(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
|
async def handle(
|
||||||
llm = get_llm("qwen3-max")
|
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
||||||
|
) -> TextHandleResult:
|
||||||
|
pm = perm_manager()
|
||||||
|
if env.event is None or not await pm.check_has_permission(
|
||||||
|
env.event, "textfx.qwen"
|
||||||
|
):
|
||||||
|
return TextHandleResult(
|
||||||
|
code=1,
|
||||||
|
ostream="你或当前环境没有使用 qwen 的权限。如有疑问请联系管理员",
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = get_llm()
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
if istream is not None:
|
if istream is not None:
|
||||||
messages.append({
|
messages.append({"role": "user", "content": istream})
|
||||||
"role": "user",
|
|
||||||
"content": istream
|
|
||||||
})
|
|
||||||
if len(args) > 0:
|
if len(args) > 0:
|
||||||
message = ' '.join(args)
|
message = " ".join(args)
|
||||||
messages.append({
|
messages.append(
|
||||||
"role": "user",
|
{
|
||||||
"content": message,
|
"role": "user",
|
||||||
})
|
"content": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
if len(messages) == 0:
|
if len(messages) == 0:
|
||||||
return TextHandleResult(
|
return TextHandleResult(
|
||||||
code=1,
|
code=1,
|
||||||
ostream="使用方法:qwen <提示词>",
|
ostream="使用方法:qwen <提示词>",
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = [{
|
messages = [
|
||||||
"role": "system",
|
{
|
||||||
"content": "除非用户要求,请尽可能短点回答。另外,当前环境不支持 Markdown 语法,如果可以,请使用纯文本回答"
|
"role": "system",
|
||||||
}] + messages
|
"content": "除非用户要求,请尽可能短点回答。另外,当前环境不支持 Markdown 语法,如果可以,请使用纯文本回答",
|
||||||
|
}
|
||||||
|
] + messages
|
||||||
result = await llm.chat(cast(Any, messages))
|
result = await llm.chat(cast(Any, messages))
|
||||||
content = result.content
|
content = result.content
|
||||||
if content is None:
|
if content is None:
|
||||||
|
|||||||
@ -13,10 +13,8 @@ class THEcho(TextHandler):
|
|||||||
async def handle(
|
async def handle(
|
||||||
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
||||||
) -> TextHandleResult:
|
) -> TextHandleResult:
|
||||||
if len(args) == 0 and istream is None:
|
# echo 不读 stdin,只输出参数(Unix 语义)
|
||||||
return TextHandleResult(1, "请在 echo 后面添加需要输出的文本")
|
# 无参数时输出空行(与 Unix echo 行为一致)
|
||||||
if istream is not None:
|
|
||||||
return TextHandleResult(0, "\n".join([istream] + args))
|
|
||||||
return TextHandleResult(0, "\n".join(args))
|
return TextHandleResult(0, "\n".join(args))
|
||||||
|
|
||||||
|
|
||||||
@ -26,7 +24,6 @@ class THCat(TextHandler):
|
|||||||
async def handle(
|
async def handle(
|
||||||
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
||||||
) -> TextHandleResult:
|
) -> TextHandleResult:
|
||||||
# No args: pass through stdin (like Unix cat with no arguments)
|
|
||||||
if len(args) == 0:
|
if len(args) == 0:
|
||||||
if istream is None:
|
if istream is None:
|
||||||
return TextHandleResult(
|
return TextHandleResult(
|
||||||
@ -35,7 +32,6 @@ class THCat(TextHandler):
|
|||||||
)
|
)
|
||||||
return TextHandleResult(0, istream)
|
return TextHandleResult(0, istream)
|
||||||
|
|
||||||
# Concatenate all specified sources in order
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for arg in args:
|
for arg in args:
|
||||||
if arg == "-":
|
if arg == "-":
|
||||||
@ -74,7 +70,6 @@ class THReplace(TextHandler):
|
|||||||
async def handle(
|
async def handle(
|
||||||
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
||||||
) -> TextHandleResult:
|
) -> TextHandleResult:
|
||||||
# 用法: replace <pattern> <replacement> [text]
|
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
return TextHandleResult(1, "用法:replace <正则> <替换内容> [文本]")
|
return TextHandleResult(1, "用法:replace <正则> <替换内容> [文本]")
|
||||||
|
|
||||||
@ -90,3 +85,77 @@ class THReplace(TextHandler):
|
|||||||
return TextHandleResult(0, res)
|
return TextHandleResult(0, res)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return TextHandleResult(1, f"正则错误: {str(e)}")
|
return TextHandleResult(1, f"正则错误: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class THTrue(TextHandler):
|
||||||
|
name = "true"
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
||||||
|
) -> TextHandleResult:
|
||||||
|
return TextHandleResult(0, istream)
|
||||||
|
|
||||||
|
|
||||||
|
class THFalse(TextHandler):
|
||||||
|
name = "false"
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
||||||
|
) -> TextHandleResult:
|
||||||
|
return TextHandleResult(1, None)
|
||||||
|
|
||||||
|
|
||||||
|
class THTest(TextHandler):
|
||||||
|
name = "test"
|
||||||
|
keywords = ["["]
|
||||||
|
|
||||||
|
def _bool_result(self, value: bool) -> TextHandleResult:
|
||||||
|
return TextHandleResult(0 if value else 1, None)
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
|
||||||
|
) -> TextHandleResult:
|
||||||
|
expr = list(args)
|
||||||
|
|
||||||
|
# 支持方括号语法:[ expr ] 会自动移除末尾的 ]
|
||||||
|
if expr and expr[-1] == "]":
|
||||||
|
expr = expr[:-1]
|
||||||
|
|
||||||
|
if not expr:
|
||||||
|
return TextHandleResult(1, None)
|
||||||
|
|
||||||
|
if len(expr) == 1:
|
||||||
|
return self._bool_result(len(expr[0]) > 0)
|
||||||
|
|
||||||
|
if len(expr) == 2:
|
||||||
|
op, value = expr
|
||||||
|
if op == "-n":
|
||||||
|
return self._bool_result(len(value) > 0)
|
||||||
|
if op == "-z":
|
||||||
|
return self._bool_result(len(value) == 0)
|
||||||
|
return TextHandleResult(2, f"test 不支持的表达式: {' '.join(args)}")
|
||||||
|
|
||||||
|
if len(expr) == 3:
|
||||||
|
left, op, right = expr
|
||||||
|
if op == "=":
|
||||||
|
return self._bool_result(left == right)
|
||||||
|
if op == "!=":
|
||||||
|
return self._bool_result(left != right)
|
||||||
|
if op in {"-eq", "-ne", "-gt", "-ge", "-lt", "-le"}:
|
||||||
|
try:
|
||||||
|
li = int(left)
|
||||||
|
ri = int(right)
|
||||||
|
except ValueError:
|
||||||
|
return TextHandleResult(2, "test 的数字比较参数必须是整数")
|
||||||
|
mapping = {
|
||||||
|
"-eq": li == ri,
|
||||||
|
"-ne": li != ri,
|
||||||
|
"-gt": li > ri,
|
||||||
|
"-ge": li >= ri,
|
||||||
|
"-lt": li < ri,
|
||||||
|
"-le": li <= ri,
|
||||||
|
}
|
||||||
|
return self._bool_result(mapping[op])
|
||||||
|
return TextHandleResult(2, f"test 不支持的操作符: {op}")
|
||||||
|
|
||||||
|
return TextHandleResult(2, f"test 不支持的表达式: {' '.join(args)}")
|
||||||
|
|||||||
@ -6,29 +6,34 @@ from loguru import logger
|
|||||||
from nonebot import on_message
|
from nonebot import on_message
|
||||||
import nonebot
|
import nonebot
|
||||||
from nonebot.rule import to_me
|
from nonebot.rule import to_me
|
||||||
from nonebot_plugin_alconna import (Alconna, Args, UniMessage, UniMsg,
|
from nonebot_plugin_alconna import Alconna, Args, UniMessage, UniMsg, on_alconna
|
||||||
on_alconna)
|
|
||||||
from nonebot_plugin_apscheduler import scheduler
|
from nonebot_plugin_apscheduler import scheduler
|
||||||
|
|
||||||
from konabot.common import username
|
from konabot.common import username
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.pager import PagerQuery
|
from konabot.common.pager import PagerQuery
|
||||||
from konabot.plugins.kona_ph.core.message import (get_daily_report,
|
from konabot.plugins.kona_ph.core.message import (
|
||||||
get_daily_report_v2,
|
get_daily_report,
|
||||||
get_puzzle_description,
|
get_daily_report_v2,
|
||||||
get_submission_message)
|
get_puzzle_description,
|
||||||
|
get_submission_message,
|
||||||
|
)
|
||||||
from konabot.plugins.kona_ph.core.storage import get_today_date
|
from konabot.plugins.kona_ph.core.storage import get_today_date
|
||||||
from konabot.plugins.kona_ph.manager import (PUZZLE_PAGE_SIZE,
|
from konabot.plugins.kona_ph.manager import (
|
||||||
create_admin_commands,
|
PUZZLE_PAGE_SIZE,
|
||||||
puzzle_manager)
|
create_admin_commands,
|
||||||
from konabot.plugins.poster.poster_info import PosterInfo, register_poster_info
|
puzzle_manager,
|
||||||
from konabot.plugins.poster.service import broadcast
|
)
|
||||||
|
from konabot.common.subscribe import PosterInfo, register_poster_info, broadcast
|
||||||
|
|
||||||
create_admin_commands()
|
create_admin_commands()
|
||||||
register_poster_info("每日谜题", info=PosterInfo(
|
register_poster_info(
|
||||||
aliases={"konaph", "kona_ph", "KonaPH", "此方谜题", "KONAPH"},
|
"每日谜题",
|
||||||
description="此方 BOT 每日谜题推送",
|
info=PosterInfo(
|
||||||
))
|
aliases={"konaph", "kona_ph", "KonaPH", "此方谜题", "KONAPH"},
|
||||||
|
description="此方 BOT 每日谜题推送",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
cmd_submit = on_message(rule=to_me())
|
cmd_submit = on_message(rule=to_me())
|
||||||
@ -44,16 +49,22 @@ async def _(msg: UniMsg, target: DepLongTaskTarget):
|
|||||||
if isinstance(result, str):
|
if isinstance(result, str):
|
||||||
await target.send_message(result)
|
await target.send_message(result)
|
||||||
else:
|
else:
|
||||||
await target.send_message(get_submission_message(
|
await target.send_message(
|
||||||
daily_puzzle_info=result.info,
|
get_submission_message(
|
||||||
submission=result.submission,
|
daily_puzzle_info=result.info,
|
||||||
puzzle=result.puzzle,
|
submission=result.submission,
|
||||||
))
|
puzzle=result.puzzle,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
cmd_query = on_alconna(Alconna(
|
cmd_query = on_alconna(
|
||||||
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
Alconna(
|
||||||
), rule=to_me())
|
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
||||||
|
),
|
||||||
|
rule=to_me(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cmd_query.handle()
|
@cmd_query.handle()
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
@ -64,9 +75,8 @@ async def _(target: DepLongTaskTarget):
|
|||||||
await target.send_message(get_puzzle_description(p))
|
await target.send_message(get_puzzle_description(p))
|
||||||
|
|
||||||
|
|
||||||
cmd_query_submission = on_alconna(Alconna(
|
cmd_query_submission = on_alconna(Alconna("今日答题情况"), rule=to_me())
|
||||||
"今日答题情况"
|
|
||||||
), rule=to_me())
|
|
||||||
|
|
||||||
@cmd_query_submission.handle()
|
@cmd_query_submission.handle()
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
@ -77,11 +87,15 @@ async def _(target: DepLongTaskTarget):
|
|||||||
await target.send_message(get_daily_report_v2(manager, gid))
|
await target.send_message(get_daily_report_v2(manager, gid))
|
||||||
|
|
||||||
|
|
||||||
cmd_history = on_alconna(Alconna(
|
cmd_history = on_alconna(
|
||||||
"re:历史(题目|谜题)",
|
Alconna(
|
||||||
Args["page?", int],
|
"re:历史(题目|谜题)",
|
||||||
Args["index_id?", str],
|
Args["page?", int],
|
||||||
), rule=to_me())
|
Args["index_id?", str],
|
||||||
|
),
|
||||||
|
rule=to_me(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cmd_history.handle()
|
@cmd_history.handle()
|
||||||
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
||||||
@ -105,10 +119,10 @@ async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
|||||||
puzzles = sorted(puzzles, key=lambda u: u[1], reverse=True)
|
puzzles = sorted(puzzles, key=lambda u: u[1], reverse=True)
|
||||||
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
||||||
if page <= 0 or page > count_pages:
|
if page <= 0 or page > count_pages:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
f"页数只有 1 ~ {count_pages} 啦!"
|
UniMessage.text(f"页数只有 1 ~ {count_pages} 啦!")
|
||||||
))
|
)
|
||||||
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
|
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE : page * PUZZLE_PAGE_SIZE]
|
||||||
for p, d in puzzles:
|
for p, d in puzzles:
|
||||||
info = manager.daily_puzzle[manager.daily_puzzle_of_date[d]]
|
info = manager.daily_puzzle[manager.daily_puzzle_of_date[d]]
|
||||||
msg = msg.text(
|
msg = msg.text(
|
||||||
@ -120,22 +134,26 @@ async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
|||||||
await target.send_message(msg)
|
await target.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
cmd_leadboard = on_alconna(Alconna(
|
cmd_leadboard = on_alconna(
|
||||||
"re:此方(解谜|谜题)排行榜",
|
Alconna(
|
||||||
Args["page?", int],
|
"re:此方(解谜|谜题)排行榜",
|
||||||
))
|
Args["page?", int],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cmd_leadboard.handle()
|
@cmd_leadboard.handle()
|
||||||
async def _(target: DepLongTaskTarget, page: int = 1):
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
result = manager.get_leadboard(PagerQuery(page, 10))
|
result = manager.get_leadboard(PagerQuery(page, 10))
|
||||||
await target.send_message(result.to_unimessage(
|
await target.send_message(
|
||||||
title="此方解谜排行榜",
|
result.to_unimessage(
|
||||||
formatter=lambda data: (
|
title="此方解谜排行榜",
|
||||||
f"✨ {data[1]} 已完成 | "
|
formatter=lambda data: (
|
||||||
f"{username.get_username(data[0])}"
|
f"✨ {data[1]} 已完成 | {username.get_username(data[0])}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
))
|
)
|
||||||
|
|
||||||
|
|
||||||
@scheduler.scheduled_job("cron", hour="8")
|
@scheduler.scheduled_job("cron", hour="8")
|
||||||
@ -155,4 +173,3 @@ async def _():
|
|||||||
|
|
||||||
|
|
||||||
driver = nonebot.get_driver()
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
|||||||
@ -1,50 +1,54 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from math import ceil
|
from math import ceil
|
||||||
|
|
||||||
from nonebot import get_plugin_config
|
from nonebot.adapters import Event
|
||||||
from nonebot_plugin_alconna import (Alconna, Args, Image, Option, Query,
|
from nonebot_plugin_alconna import (
|
||||||
Subcommand, SubcommandResult, UniMessage,
|
Alconna,
|
||||||
on_alconna)
|
Args,
|
||||||
from pydantic import BaseModel
|
Image,
|
||||||
|
Option,
|
||||||
|
Query,
|
||||||
|
Subcommand,
|
||||||
|
SubcommandResult,
|
||||||
|
UniMessage,
|
||||||
|
on_alconna,
|
||||||
|
)
|
||||||
|
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.nb.exc import BotExceptionMessage
|
from konabot.common.nb.exc import BotExceptionMessage
|
||||||
from konabot.common.nb.extract_image import download_image_bytes
|
from konabot.common.nb.extract_image import download_image_bytes
|
||||||
|
from konabot.common.permsys import DepPermManager, require_permission
|
||||||
from konabot.common.username import get_username
|
from konabot.common.username import get_username
|
||||||
from konabot.plugins.kona_ph.core.image import get_image_manager
|
from konabot.plugins.kona_ph.core.image import get_image_manager
|
||||||
from konabot.plugins.kona_ph.core.message import (get_puzzle_description, get_puzzle_hint_list,
|
from konabot.plugins.kona_ph.core.message import (
|
||||||
get_puzzle_info_message,
|
get_puzzle_description,
|
||||||
get_submission_message)
|
get_puzzle_hint_list,
|
||||||
from konabot.plugins.kona_ph.core.storage import (Puzzle, PuzzleHint, PuzzleManager,
|
get_puzzle_info_message,
|
||||||
get_today_date,
|
get_submission_message,
|
||||||
puzzle_manager)
|
)
|
||||||
from konabot.plugins.poster.service import broadcast
|
from konabot.plugins.kona_ph.core.storage import (
|
||||||
|
Puzzle,
|
||||||
|
PuzzleHint,
|
||||||
|
PuzzleManager,
|
||||||
|
get_today_date,
|
||||||
|
puzzle_manager,
|
||||||
|
)
|
||||||
|
from konabot.common.subscribe import broadcast
|
||||||
|
|
||||||
PUZZLE_PAGE_SIZE = 10
|
PUZZLE_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
class PuzzleConfig(BaseModel):
|
async def check_puzzle(
|
||||||
plugin_puzzle_manager: list[str] = []
|
manager: PuzzleManager,
|
||||||
plugin_puzzle_admin: list[str] = []
|
perm: DepPermManager,
|
||||||
plugin_puzzle_playgroup: list[str] = []
|
raw_id: str,
|
||||||
|
event: Event,
|
||||||
|
target: DepLongTaskTarget,
|
||||||
config = get_plugin_config(PuzzleConfig)
|
) -> Puzzle:
|
||||||
|
|
||||||
|
|
||||||
def is_puzzle_manager(target: DepLongTaskTarget):
|
|
||||||
return target.target_id in config.plugin_puzzle_manager or is_puzzle_admin(target)
|
|
||||||
|
|
||||||
|
|
||||||
def is_puzzle_admin(target: DepLongTaskTarget):
|
|
||||||
return target.target_id in config.plugin_puzzle_admin
|
|
||||||
|
|
||||||
|
|
||||||
def check_puzzle(manager: PuzzleManager, target: DepLongTaskTarget, raw_id: str) -> Puzzle:
|
|
||||||
if raw_id not in manager.puzzle_data:
|
if raw_id not in manager.puzzle_data:
|
||||||
raise BotExceptionMessage("没有这个谜题")
|
raise BotExceptionMessage("没有这个谜题")
|
||||||
puzzle = manager.puzzle_data[raw_id]
|
puzzle = manager.puzzle_data[raw_id]
|
||||||
if is_puzzle_admin(target):
|
if await perm.check_has_permission(event, "konaph.admin"):
|
||||||
return puzzle
|
return puzzle
|
||||||
if target.target_id != puzzle.author_id:
|
if target.target_id != puzzle.author_id:
|
||||||
raise BotExceptionMessage("你没有权限查看或编辑这个谜题")
|
raise BotExceptionMessage("你没有权限查看或编辑这个谜题")
|
||||||
@ -60,7 +64,9 @@ def create_admin_commands():
|
|||||||
Subcommand("unready", Args["raw_id", str], dest="unready"),
|
Subcommand("unready", Args["raw_id", str], dest="unready"),
|
||||||
Subcommand("info", Args["raw_id", str], dest="info"),
|
Subcommand("info", Args["raw_id", str], dest="info"),
|
||||||
Subcommand("my", Args["page?", int], dest="my"),
|
Subcommand("my", Args["page?", int], dest="my"),
|
||||||
Subcommand("all", Option("--ready", alias=["-r"]), Args["page?", int], dest="all"),
|
Subcommand(
|
||||||
|
"all", Option("--ready", alias=["-r"]), Args["page?", int], dest="all"
|
||||||
|
),
|
||||||
Subcommand("pin", Args["raw_id?", str], dest="pin"),
|
Subcommand("pin", Args["raw_id?", str], dest="pin"),
|
||||||
Subcommand("unpin", dest="unpin"),
|
Subcommand("unpin", dest="unpin"),
|
||||||
Subcommand(
|
Subcommand(
|
||||||
@ -115,11 +121,11 @@ def create_admin_commands():
|
|||||||
dest="hint",
|
dest="hint",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
rule=is_puzzle_manager,
|
rule=require_permission("konaph.manager"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@cmd_admin.assign("$main")
|
@cmd_admin.assign("$main")
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget, pm: DepPermManager, event: Event):
|
||||||
msg = UniMessage.text("==== [KonaPH] 指令一览 ====\n\n")
|
msg = UniMessage.text("==== [KonaPH] 指令一览 ====\n\n")
|
||||||
msg = msg.text("konaph create - 创建一个新的谜题\n")
|
msg = msg.text("konaph create - 创建一个新的谜题\n")
|
||||||
msg = msg.text("konaph ready <id> - 准备好一道谜题\n")
|
msg = msg.text("konaph ready <id> - 准备好一道谜题\n")
|
||||||
@ -132,7 +138,7 @@ def create_admin_commands():
|
|||||||
msg = msg.text("konaph test <id> <answer> - 尝试提交一个答案,看回答的效果\n")
|
msg = msg.text("konaph test <id> <answer> - 尝试提交一个答案,看回答的效果\n")
|
||||||
msg = msg.text("konaph hint - 查看如何编辑题目的中间答案\n")
|
msg = msg.text("konaph hint - 查看如何编辑题目的中间答案\n")
|
||||||
|
|
||||||
if is_puzzle_admin(target):
|
if await pm.check_has_permission(event, "konaph.admin"):
|
||||||
msg = msg.text("konaph all [--ready] <page?> - 查看所有谜题\n")
|
msg = msg.text("konaph all [--ready] <page?> - 查看所有谜题\n")
|
||||||
msg = msg.text("konaph pin - 查看当前置顶谜题\n")
|
msg = msg.text("konaph pin - 查看当前置顶谜题\n")
|
||||||
msg = msg.text("konaph pin <id> - 置顶一个谜题\n")
|
msg = msg.text("konaph pin <id> - 置顶一个谜题\n")
|
||||||
@ -145,48 +151,54 @@ def create_admin_commands():
|
|||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
puzzle = manager.admin_create_puzzle(target.target_id)
|
puzzle = manager.admin_create_puzzle(target.target_id)
|
||||||
await target.send_message(UniMessage.text(
|
await target.send_message(
|
||||||
f"✨ 创建好啦!谜题 ID 为 {puzzle.raw_id}\n\n"
|
UniMessage.text(
|
||||||
f"- 输入 `konaph info {puzzle.raw_id}` 获得谜题的信息\n"
|
f"✨ 创建好啦!谜题 ID 为 {puzzle.raw_id}\n\n"
|
||||||
f"- 输入 `konaph my` 查看你创建的谜题\n"
|
f"- 输入 `konaph info {puzzle.raw_id}` 获得谜题的信息\n"
|
||||||
f"- 输入 `konaph modify` 查看更改谜题的方法"
|
f"- 输入 `konaph my` 查看你创建的谜题\n"
|
||||||
))
|
f"- 输入 `konaph modify` 查看更改谜题的方法"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@cmd_admin.assign("ready")
|
@cmd_admin.assign("ready")
|
||||||
async def _(raw_id: str, target: DepLongTaskTarget):
|
async def _(
|
||||||
|
raw_id: str, target: DepLongTaskTarget, event: Event, perm: DepPermManager
|
||||||
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
if p.ready:
|
if p.ready:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(UniMessage.text("题目早就准备好啦!"))
|
||||||
"题目早就准备好啦!"
|
|
||||||
))
|
|
||||||
p.ready = True
|
p.ready = True
|
||||||
await target.send_message(UniMessage.text(
|
await target.send_message(
|
||||||
f"谜题「{p.title}」已经准备就绪!"
|
UniMessage.text(f"谜题「{p.title}」已经准备就绪!")
|
||||||
))
|
)
|
||||||
|
|
||||||
@cmd_admin.assign("unready")
|
@cmd_admin.assign("unready")
|
||||||
async def _(raw_id: str, target: DepLongTaskTarget):
|
async def _(
|
||||||
|
raw_id: str, target: DepLongTaskTarget, event: Event, perm: DepPermManager
|
||||||
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
if not p.ready:
|
if not p.ready:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
f"谜题「{p.title}」已经是未取消状态了!"
|
UniMessage.text(f"谜题「{p.title}」已经是未取消状态了!")
|
||||||
))
|
)
|
||||||
if manager.is_puzzle_published(p.raw_id):
|
if manager.is_puzzle_published(p.raw_id):
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
"已发布的谜题不能取消准备状态!"
|
UniMessage.text("已发布的谜题不能取消准备状态!")
|
||||||
))
|
)
|
||||||
|
|
||||||
p.ready = False
|
p.ready = False
|
||||||
await target.send_message(UniMessage.text(
|
await target.send_message(
|
||||||
f"谜题「{p.title}」已经取消准备!"
|
UniMessage.text(f"谜题「{p.title}」已经取消准备!")
|
||||||
))
|
)
|
||||||
|
|
||||||
@cmd_admin.assign("info")
|
@cmd_admin.assign("info")
|
||||||
async def _(raw_id: str, target: DepLongTaskTarget):
|
async def _(
|
||||||
|
raw_id: str, target: DepLongTaskTarget, event: Event, perm: DepPermManager
|
||||||
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
await target.send_message(get_puzzle_info_message(manager, p))
|
await target.send_message(get_puzzle_info_message(manager, p))
|
||||||
|
|
||||||
@cmd_admin.assign("my")
|
@cmd_admin.assign("my")
|
||||||
@ -194,15 +206,15 @@ def create_admin_commands():
|
|||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
puzzles = manager.get_puzzles_of_user(target.target_id)
|
puzzles = manager.get_puzzles_of_user(target.target_id)
|
||||||
if len(puzzles) == 0:
|
if len(puzzles) == 0:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
"你没有谜题哦,使用 `konaph create` 创建一个吧!"
|
UniMessage.text("你没有谜题哦,使用 `konaph create` 创建一个吧!")
|
||||||
))
|
)
|
||||||
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
||||||
if page <= 0 or page > count_pages:
|
if page <= 0 or page > count_pages:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
f"页数只有 1 ~ {count_pages} 啦!"
|
UniMessage.text(f"页数只有 1 ~ {count_pages} 啦!")
|
||||||
))
|
)
|
||||||
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
|
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE : page * PUZZLE_PAGE_SIZE]
|
||||||
message = UniMessage.text("==== 我的谜题 ====\n\n")
|
message = UniMessage.text("==== 我的谜题 ====\n\n")
|
||||||
for p in puzzles:
|
for p in puzzles:
|
||||||
message = message.text("- ")
|
message = message.text("- ")
|
||||||
@ -220,11 +232,15 @@ def create_admin_commands():
|
|||||||
await target.send_message(message)
|
await target.send_message(message)
|
||||||
|
|
||||||
@cmd_admin.assign("all")
|
@cmd_admin.assign("all")
|
||||||
async def _(target: DepLongTaskTarget, ready: Query[bool] = Query("all.ready"), page: int = 1):
|
async def _(
|
||||||
if not is_puzzle_admin(target):
|
target: DepLongTaskTarget,
|
||||||
return await target.send_message(UniMessage.text(
|
event: Event,
|
||||||
"你没有权限使用该指令"
|
perm: DepPermManager,
|
||||||
))
|
ready: Query[bool] = Query("all.ready"),
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
if not perm.check_has_permission(event, "konaph.admin"):
|
||||||
|
return await target.send_message(UniMessage.text("你没有权限使用该指令"))
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
puzzles = [*manager.puzzle_data.values()]
|
puzzles = [*manager.puzzle_data.values()]
|
||||||
if ready.available:
|
if ready.available:
|
||||||
@ -232,10 +248,10 @@ def create_admin_commands():
|
|||||||
puzzles = sorted(puzzles, key=lambda p: p.created_at, reverse=True)
|
puzzles = sorted(puzzles, key=lambda p: p.created_at, reverse=True)
|
||||||
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
||||||
if page <= 0 or page > count_pages:
|
if page <= 0 or page > count_pages:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
f"页数只有 1 ~ {count_pages} 啦!"
|
UniMessage.text(f"页数只有 1 ~ {count_pages} 啦!")
|
||||||
))
|
)
|
||||||
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
|
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE : page * PUZZLE_PAGE_SIZE]
|
||||||
message = UniMessage.text("==== 所有谜题 ====\n\n")
|
message = UniMessage.text("==== 所有谜题 ====\n\n")
|
||||||
for p in puzzles:
|
for p in puzzles:
|
||||||
message = message.text("- ")
|
message = message.text("- ")
|
||||||
@ -253,32 +269,30 @@ def create_admin_commands():
|
|||||||
await target.send_message(message)
|
await target.send_message(message)
|
||||||
|
|
||||||
@cmd_admin.assign("pin")
|
@cmd_admin.assign("pin")
|
||||||
async def _(target: DepLongTaskTarget, raw_id: str = ""):
|
async def _(
|
||||||
if not is_puzzle_admin(target):
|
target: DepLongTaskTarget, event: Event, perm: DepPermManager, raw_id: str = ""
|
||||||
return await target.send_message(UniMessage.text(
|
):
|
||||||
"你没有权限使用该指令"
|
if not perm.check_has_permission(event, "konaph.admin"):
|
||||||
))
|
return await target.send_message(UniMessage.text("你没有权限使用该指令"))
|
||||||
|
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
if raw_id == "":
|
if raw_id == "":
|
||||||
if manager.puzzle_pinned:
|
if manager.puzzle_pinned:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
f"被 Pin 的谜题 ID = {manager.puzzle_pinned}"
|
UniMessage.text(f"被 Pin 的谜题 ID = {manager.puzzle_pinned}")
|
||||||
))
|
)
|
||||||
return await target.send_message("没有置顶谜题")
|
return await target.send_message("没有置顶谜题")
|
||||||
if raw_id not in manager.unpublished_puzzles:
|
if raw_id not in manager.unpublished_puzzles:
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(
|
||||||
"这个谜题已经发布了,或者还没准备好,或者不存在"
|
UniMessage.text("这个谜题已经发布了,或者还没准备好,或者不存在")
|
||||||
))
|
)
|
||||||
manager.admin_pin_puzzle(raw_id)
|
manager.admin_pin_puzzle(raw_id)
|
||||||
return await target.send_message(f"已置顶谜题 {raw_id}")
|
return await target.send_message(f"已置顶谜题 {raw_id}")
|
||||||
|
|
||||||
@cmd_admin.assign("unpin")
|
@cmd_admin.assign("unpin")
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget, event: Event, perm: DepPermManager):
|
||||||
if not is_puzzle_admin(target):
|
if not perm.check_has_permission(event, "konaph.admin"):
|
||||||
return await target.send_message(UniMessage.text(
|
return await target.send_message(UniMessage.text("你没有权限使用该指令"))
|
||||||
"你没有权限使用该指令"
|
|
||||||
))
|
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
manager.admin_pin_puzzle("")
|
manager.admin_pin_puzzle("")
|
||||||
return await target.send_message("已取消所有置顶")
|
return await target.send_message("已取消所有置顶")
|
||||||
@ -286,6 +300,8 @@ def create_admin_commands():
|
|||||||
@cmd_admin.assign("modify")
|
@cmd_admin.assign("modify")
|
||||||
async def _(
|
async def _(
|
||||||
target: DepLongTaskTarget,
|
target: DepLongTaskTarget,
|
||||||
|
event: Event,
|
||||||
|
perm: DepPermManager,
|
||||||
raw_id: str = "",
|
raw_id: str = "",
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
@ -306,7 +322,7 @@ def create_admin_commands():
|
|||||||
image_manager = get_image_manager()
|
image_manager = get_image_manager()
|
||||||
|
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
if title is not None:
|
if title is not None:
|
||||||
p.title = title
|
p.title = title
|
||||||
if description is not None:
|
if description is not None:
|
||||||
@ -329,11 +345,14 @@ def create_admin_commands():
|
|||||||
return await target.send_message("修改好啦!看看效果:\n\n" + info2)
|
return await target.send_message("修改好啦!看看效果:\n\n" + info2)
|
||||||
|
|
||||||
@cmd_admin.assign("publish")
|
@cmd_admin.assign("publish")
|
||||||
async def _(target: DepLongTaskTarget, raw_id: str | None = None):
|
async def _(
|
||||||
if not is_puzzle_admin(target):
|
target: DepLongTaskTarget,
|
||||||
return await target.send_message(UniMessage.text(
|
event: Event,
|
||||||
"你没有权限使用该指令"
|
perm: DepPermManager,
|
||||||
))
|
raw_id: str | None = None,
|
||||||
|
):
|
||||||
|
if not perm.check_has_permission(event, "konaph.admin"):
|
||||||
|
return await target.send_message(UniMessage.text("你没有权限使用该指令"))
|
||||||
today = get_today_date()
|
today = get_today_date()
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
if today in manager.daily_puzzle_of_date:
|
if today in manager.daily_puzzle_of_date:
|
||||||
@ -348,46 +367,64 @@ def create_admin_commands():
|
|||||||
return await target.send_message("Ok!")
|
return await target.send_message("Ok!")
|
||||||
|
|
||||||
@cmd_admin.assign("preview")
|
@cmd_admin.assign("preview")
|
||||||
async def _(target: DepLongTaskTarget, raw_id: str):
|
async def _(
|
||||||
|
target: DepLongTaskTarget, event: Event, perm: DepPermManager, raw_id: str
|
||||||
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
return await target.send_message(get_puzzle_description(p))
|
return await target.send_message(get_puzzle_description(p))
|
||||||
|
|
||||||
@cmd_admin.assign("get-submits")
|
@cmd_admin.assign("get-submits")
|
||||||
async def _(target: DepLongTaskTarget, raw_id: str):
|
async def _(
|
||||||
|
target: DepLongTaskTarget, event: Event, perm: DepPermManager, raw_id: str
|
||||||
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
puzzle = manager.puzzle_data.get(raw_id)
|
puzzle = manager.puzzle_data.get(raw_id)
|
||||||
if puzzle is None:
|
if puzzle is None:
|
||||||
return await target.send_message("没有这个谜题")
|
return await target.send_message("没有这个谜题")
|
||||||
if not is_puzzle_admin(target) and target.target_id != puzzle.author_id:
|
if (
|
||||||
|
not perm.check_has_permission(event, "konaph.admin")
|
||||||
|
and target.target_id != puzzle.author_id
|
||||||
|
):
|
||||||
return await target.send_message("你没有权限预览这个谜题")
|
return await target.send_message("你没有权限预览这个谜题")
|
||||||
|
|
||||||
msg = UniMessage.text(f"==== {puzzle.title} 提交记录 ====\n\n")
|
msg = UniMessage.text(f"==== {puzzle.title} 提交记录 ====\n\n")
|
||||||
submits = manager.submissions.get(raw_id, {})
|
submits = manager.submissions.get(raw_id, {})
|
||||||
for uid, ls in submits.items():
|
for uid, ls in submits.items():
|
||||||
s = ', '.join((i.flag for i in ls))
|
s = ", ".join((i.flag for i in ls))
|
||||||
msg = msg.text(f"- {get_username(uid)}:{s}\n")
|
msg = msg.text(f"- {get_username(uid)}:{s}\n")
|
||||||
return await target.send_message(msg)
|
return await target.send_message(msg)
|
||||||
|
|
||||||
@cmd_admin.assign("test")
|
@cmd_admin.assign("test")
|
||||||
async def _(target: DepLongTaskTarget, raw_id: str, submission: str):
|
async def _(
|
||||||
|
target: DepLongTaskTarget,
|
||||||
|
raw_id: str,
|
||||||
|
submission: str,
|
||||||
|
event: Event,
|
||||||
|
perm: DepPermManager,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
测试一道谜题的回答,并给出结果
|
测试一道谜题的回答,并给出结果
|
||||||
"""
|
"""
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
result = p.check_submission(submission)
|
result = p.check_submission(submission)
|
||||||
msg = get_submission_message(p, result)
|
msg = get_submission_message(p, result)
|
||||||
return await target.send_message("[测试提交] " + msg)
|
return await target.send_message("[测试提交] " + msg)
|
||||||
|
|
||||||
@cmd_admin.assign("subcommands.hint")
|
@cmd_admin.assign("subcommands.hint")
|
||||||
async def _(target: DepLongTaskTarget, subcommands: Query[SubcommandResult] = Query("subcommands.hint")):
|
async def _(
|
||||||
|
target: DepLongTaskTarget,
|
||||||
|
subcommands: Query[SubcommandResult] = Query("subcommands.hint"),
|
||||||
|
):
|
||||||
if len(subcommands.result.subcommands) > 0:
|
if len(subcommands.result.subcommands) > 0:
|
||||||
return
|
return
|
||||||
return await target.send_message(
|
return await target.send_message(
|
||||||
UniMessage.text("==== 提示/中间答案编辑器 ====\n\n")
|
UniMessage.text("==== 提示/中间答案编辑器 ====\n\n")
|
||||||
.text("- konaph hint list <id>\n - 查看某道题的所有提示 / 中间答案\n")
|
.text("- konaph hint list <id>\n - 查看某道题的所有提示 / 中间答案\n")
|
||||||
.text("- konaph hint add <id> <pattern> <hint>\n - 添加一个提示 / 中间答案\n")
|
.text(
|
||||||
|
"- konaph hint add <id> <pattern> <hint>\n - 添加一个提示 / 中间答案\n"
|
||||||
|
)
|
||||||
.text("- konaph hint modify <id> <hint_id>\n")
|
.text("- konaph hint modify <id> <hint_id>\n")
|
||||||
.text(" - --pattern <pattern>\n - 更改匹配规则\n")
|
.text(" - --pattern <pattern>\n - 更改匹配规则\n")
|
||||||
.text(" - --message <message>\n - 更改提示文本\n")
|
.text(" - --message <message>\n - 更改提示文本\n")
|
||||||
@ -402,9 +439,11 @@ def create_admin_commands():
|
|||||||
raw_id: str,
|
raw_id: str,
|
||||||
pattern: str,
|
pattern: str,
|
||||||
message: str,
|
message: str,
|
||||||
|
event: Event,
|
||||||
|
perm: DepPermManager,
|
||||||
):
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
p.hints[p.hint_id_max + 1] = PuzzleHint(
|
p.hints[p.hint_id_max + 1] = PuzzleHint(
|
||||||
pattern=pattern,
|
pattern=pattern,
|
||||||
message=message,
|
message=message,
|
||||||
@ -416,9 +455,11 @@ def create_admin_commands():
|
|||||||
async def _(
|
async def _(
|
||||||
target: DepLongTaskTarget,
|
target: DepLongTaskTarget,
|
||||||
raw_id: str,
|
raw_id: str,
|
||||||
|
event: Event,
|
||||||
|
perm: DepPermManager,
|
||||||
):
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
await target.send_message(get_puzzle_hint_list(p))
|
await target.send_message(get_puzzle_hint_list(p))
|
||||||
|
|
||||||
@cmd_admin.assign("subcommands.hint.modify")
|
@cmd_admin.assign("subcommands.hint.modify")
|
||||||
@ -426,12 +467,14 @@ def create_admin_commands():
|
|||||||
target: DepLongTaskTarget,
|
target: DepLongTaskTarget,
|
||||||
raw_id: str,
|
raw_id: str,
|
||||||
hint_id: int,
|
hint_id: int,
|
||||||
|
event: Event,
|
||||||
|
perm: DepPermManager,
|
||||||
pattern: str | None = None,
|
pattern: str | None = None,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
is_checkpoint: bool | None = None,
|
is_checkpoint: bool | None = None,
|
||||||
):
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
if hint_id not in p.hints:
|
if hint_id not in p.hints:
|
||||||
raise BotExceptionMessage(
|
raise BotExceptionMessage(
|
||||||
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
|
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
|
||||||
@ -450,9 +493,11 @@ def create_admin_commands():
|
|||||||
target: DepLongTaskTarget,
|
target: DepLongTaskTarget,
|
||||||
raw_id: str,
|
raw_id: str,
|
||||||
hint_id: int,
|
hint_id: int,
|
||||||
|
event: Event,
|
||||||
|
perm: DepPermManager,
|
||||||
):
|
):
|
||||||
async with puzzle_manager() as manager:
|
async with puzzle_manager() as manager:
|
||||||
p = check_puzzle(manager, target, raw_id)
|
p = await check_puzzle(manager, perm, raw_id, event, target)
|
||||||
if hint_id not in p.hints:
|
if hint_id not in p.hints:
|
||||||
raise BotExceptionMessage(
|
raise BotExceptionMessage(
|
||||||
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
|
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
|
||||||
@ -460,5 +505,4 @@ def create_admin_commands():
|
|||||||
del p.hints[hint_id]
|
del p.hints[hint_id]
|
||||||
await target.send_message("删除成功!\n\n" + get_puzzle_hint_list(p))
|
await target.send_message("删除成功!\n\n" + get_puzzle_hint_list(p))
|
||||||
|
|
||||||
|
|
||||||
return cmd_admin
|
return cmd_admin
|
||||||
|
|||||||
50
konabot/plugins/krgsay.py
Normal file
50
konabot/plugins/krgsay.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from nonebot import on_message
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
from nonebot_plugin_alconna import UniMessage, UniMsg
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
from konabot.common.nb import match_keyword
|
||||||
|
from konabot.common.web_render import WebRenderer, konaweb
|
||||||
|
|
||||||
|
|
||||||
|
async def render_image(message: str, style: str = 'say') -> UniMessage[Any]:
|
||||||
|
"""
|
||||||
|
渲染文本为图片
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def page_function(page: Page):
|
||||||
|
await page.wait_for_function("typeof setContent === 'function'")
|
||||||
|
await page.evaluate(
|
||||||
|
"([ message, style ]) => { return setContent(message, style); }",
|
||||||
|
[ message, style ],
|
||||||
|
)
|
||||||
|
|
||||||
|
img_data = await WebRenderer.render(
|
||||||
|
url=konaweb("krgsay"),
|
||||||
|
target="#main",
|
||||||
|
other_function=page_function,
|
||||||
|
)
|
||||||
|
return UniMessage.image(raw=img_data)
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_STYLE = { "say", "cry", "hungry", "blush" }
|
||||||
|
|
||||||
|
|
||||||
|
cmd = on_message(
|
||||||
|
rule=match_keyword.match_keyword(
|
||||||
|
re.compile(r"^krg(" + '|'.join(ALLOWED_STYLE) + r")\s.+", re.I),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@cmd.handle()
|
||||||
|
async def _(event: Event, msg: UniMsg):
|
||||||
|
text = msg.extract_plain_text().lstrip()
|
||||||
|
command, content = text.split(maxsplit=1)
|
||||||
|
style = command.removeprefix("krg").lower()
|
||||||
|
if style not in ALLOWED_STYLE:
|
||||||
|
style = 'say'
|
||||||
|
msg = await render_image(content, style)
|
||||||
|
await msg.send(event)
|
||||||
|
|
||||||
@ -5,6 +5,7 @@ import nonebot.adapters
|
|||||||
import nonebot.rule
|
import nonebot.rule
|
||||||
from nonebot import on_command
|
from nonebot import on_command
|
||||||
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
||||||
|
import playwright.async_api
|
||||||
|
|
||||||
from konabot.common.nb.is_admin import is_admin
|
from konabot.common.nb.is_admin import is_admin
|
||||||
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
|
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
|
||||||
@ -87,7 +88,7 @@ async def _(
|
|||||||
return
|
return
|
||||||
mans_dict: dict[tuple[int, str], Path] = {}
|
mans_dict: dict[tuple[int, str], Path] = {}
|
||||||
for section in section_set:
|
for section in section_set:
|
||||||
mans_dict: dict[tuple[int, str], Path] = {**mans_dict, **search_man(section)}
|
mans_dict = {**mans_dict, **search_man(section)}
|
||||||
mans_dict_2 = {key[1]: val for key, val in mans_dict.items()}
|
mans_dict_2 = {key[1]: val for key, val in mans_dict.items()}
|
||||||
mans_fp = mans_dict_2.get(doc.lower())
|
mans_fp = mans_dict_2.get(doc.lower())
|
||||||
if mans_fp is None:
|
if mans_fp is None:
|
||||||
@ -95,8 +96,12 @@ async def _(
|
|||||||
return
|
return
|
||||||
mans_msg = mans_fp.read_text('utf-8', 'replace')
|
mans_msg = mans_fp.read_text('utf-8', 'replace')
|
||||||
# await man.send(UniMessage().text(mans_msg))
|
# await man.send(UniMessage().text(mans_msg))
|
||||||
img = await MarkDownCore.render_markdown(mans_msg)
|
try:
|
||||||
await man.send(UniMessage.image(raw=img))
|
img = await MarkDownCore.render_markdown(mans_msg)
|
||||||
|
await man.send(UniMessage.image(raw=img))
|
||||||
|
except (playwright.async_api.Error, ConnectionError):
|
||||||
|
# 图片渲染出错,改成发纯文本
|
||||||
|
await man.send(UniMessage.text(mans_msg))
|
||||||
|
|
||||||
|
|
||||||
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
from loguru import logger
|
from playwright.async_api import Page
|
||||||
from playwright.async_api import ConsoleMessage, Page
|
|
||||||
|
|
||||||
from konabot.common.web_render import konaweb
|
from konabot.common.web_render import konaweb
|
||||||
from konabot.common.web_render.core import WebRenderer
|
from konabot.common.web_render.core import WebRenderer
|
||||||
@ -12,7 +11,7 @@ class MarkDownCore:
|
|||||||
|
|
||||||
await page.locator('textarea[name=content]').fill(markdown_text)
|
await page.locator('textarea[name=content]').fill(markdown_text)
|
||||||
await page.locator('#button').click()
|
await page.locator('#button').click()
|
||||||
|
|
||||||
# 等待 checkState 函数加载完成
|
# 等待 checkState 函数加载完成
|
||||||
await page.wait_for_function("typeof checkState === 'function'", timeout=1000)
|
await page.wait_for_function("typeof checkState === 'function'", timeout=1000)
|
||||||
# 访问 checkState 函数,确保渲染完成
|
# 访问 checkState 函数,确保渲染完成
|
||||||
@ -27,7 +26,7 @@ class MarkDownCore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def render_latex(text: str, theme: str = "dark") -> bytes:
|
async def render_latex(text: str, theme: str = "dark") -> bytes:
|
||||||
params = {
|
params = {
|
||||||
@ -40,7 +39,7 @@ class MarkDownCore:
|
|||||||
await page.locator('textarea[name=content]').fill(f"$$ {text} $$")
|
await page.locator('textarea[name=content]').fill(f"$$ {text} $$")
|
||||||
page.wait_for_selector('#button')
|
page.wait_for_selector('#button')
|
||||||
await page.locator('#button').click()
|
await page.locator('#button').click()
|
||||||
|
|
||||||
# 等待 checkState 函数加载完成
|
# 等待 checkState 函数加载完成
|
||||||
await page.wait_for_function("typeof checkState === 'function'", timeout=2000)
|
await page.wait_for_function("typeof checkState === 'function'", timeout=2000)
|
||||||
# 访问 checkState 函数,确保渲染完成
|
# 访问 checkState 函数,确保渲染完成
|
||||||
@ -54,4 +53,4 @@ class MarkDownCore:
|
|||||||
params=params
|
params=params
|
||||||
)
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import mcstatus
|
|
||||||
|
|
||||||
from nonebot import on_command
|
|
||||||
from nonebot.adapters import Event
|
|
||||||
from nonebot_plugin_alconna import UniMessage
|
|
||||||
from konabot.common.nb.is_admin import is_admin
|
|
||||||
from mcstatus.responses import JavaStatusResponse
|
|
||||||
|
|
||||||
|
|
||||||
cmd = on_command("宾几人", aliases=set(("宾人数", "mcbingo")), rule=is_admin)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_status(motd: str) -> str:
|
|
||||||
if "[PRE-GAME]" in motd:
|
|
||||||
return "[✨ 空闲]"
|
|
||||||
if "[IN-GAME]" in motd:
|
|
||||||
return "[🕜 游戏中]"
|
|
||||||
if "[POST-GAME]" in motd:
|
|
||||||
return "[🕜 游戏中]"
|
|
||||||
return "[✨ 开放]"
|
|
||||||
|
|
||||||
|
|
||||||
def dump_server_status(name: str, status: JavaStatusResponse | BaseException) -> str:
|
|
||||||
if isinstance(status, JavaStatusResponse):
|
|
||||||
motd = status.motd.to_plain()
|
|
||||||
# Bingo Status: [PRE-GAME], [IN-GAME], [POST-GAME]
|
|
||||||
st = parse_status(motd)
|
|
||||||
players_sample = status.players.sample or []
|
|
||||||
players_sample_suffix = ""
|
|
||||||
if len(players_sample) > 0:
|
|
||||||
player_list = [s.name for s in players_sample]
|
|
||||||
players_sample_suffix = " (" + ", ".join(player_list) + ")"
|
|
||||||
return f"{name}: {st} {status.players.online} 人在线{players_sample_suffix}"
|
|
||||||
else:
|
|
||||||
return f"{name}: 好像没开"
|
|
||||||
|
|
||||||
|
|
||||||
@cmd.handle()
|
|
||||||
async def _(evt: Event):
|
|
||||||
servers = (
|
|
||||||
(mcstatus.JavaServer("play.simpfun.cn", 11495), "小帕 Bingo"),
|
|
||||||
(mcstatus.JavaServer("bingo.mujica.tech"), "坏枪 Bingo"),
|
|
||||||
(mcstatus.JavaServer("mc.mujica.tech", 11456), "齿轮盛宴"),
|
|
||||||
)
|
|
||||||
|
|
||||||
responses = await asyncio.gather(
|
|
||||||
*map(lambda s: s[0].async_status(), servers),
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
messages = "\n".join((
|
|
||||||
dump_server_status(n, r)
|
|
||||||
for n, r in zip(map(lambda s: s[1], servers), responses)
|
|
||||||
))
|
|
||||||
|
|
||||||
await UniMessage.text(messages).finish(evt, at_sender=False)
|
|
||||||
|
|
||||||
131
konabot/plugins/minecraft_servers/__init__.py
Normal file
131
konabot/plugins/minecraft_servers/__init__.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
from typing import Literal
|
||||||
|
import mcstatus
|
||||||
|
|
||||||
|
from nonebot import on_command
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
||||||
|
from mcstatus.responses import JavaStatusResponse
|
||||||
|
from nonebot_plugin_apscheduler import scheduler
|
||||||
|
|
||||||
|
from konabot.common.permsys import DepPermManager, require_permission
|
||||||
|
from konabot.plugins.minecraft_servers.simpfun_server import SimpfunServer
|
||||||
|
|
||||||
|
|
||||||
|
cmd = on_command(
|
||||||
|
"宾几人",
|
||||||
|
aliases=set(("宾人数", "mcbingo")),
|
||||||
|
rule=require_permission("minecraft.bingo.check"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_status(motd: str) -> str:
|
||||||
|
if "[PRE-GAME]" in motd:
|
||||||
|
return "[✨ 空闲]"
|
||||||
|
if "[IN-GAME]" in motd:
|
||||||
|
return "[🕜 游戏中]"
|
||||||
|
if "[POST-GAME]" in motd:
|
||||||
|
return "[🕜 游戏中]"
|
||||||
|
return "[✨ 开放]"
|
||||||
|
|
||||||
|
|
||||||
|
def dump_server_status(name: str, status: JavaStatusResponse | BaseException) -> str:
|
||||||
|
if isinstance(status, JavaStatusResponse):
|
||||||
|
motd = status.motd.to_plain()
|
||||||
|
# Bingo Status: [PRE-GAME], [IN-GAME], [POST-GAME]
|
||||||
|
st = parse_status(motd)
|
||||||
|
players_sample = status.players.sample or []
|
||||||
|
players_sample_suffix = ""
|
||||||
|
if len(players_sample) > 0:
|
||||||
|
player_list = [s.name for s in players_sample]
|
||||||
|
players_sample_suffix = " (" + ", ".join(player_list) + ")"
|
||||||
|
return f"{name}: {st} {status.players.online} 人在线{players_sample_suffix}"
|
||||||
|
else:
|
||||||
|
return f"{name}: 好像没开"
|
||||||
|
|
||||||
|
|
||||||
|
@cmd.handle()
|
||||||
|
async def _(evt: Event, pm: DepPermManager):
|
||||||
|
servers = (
|
||||||
|
(mcstatus.JavaServer("play.simpfun.cn", 11495), "小帕 Bingo"),
|
||||||
|
(mcstatus.JavaServer("bingo.mujica.tech"), "坏枪 Bingo"),
|
||||||
|
(mcstatus.JavaServer("mc.mujica.tech", 11456), "齿轮盛宴"),
|
||||||
|
)
|
||||||
|
|
||||||
|
responses = await asyncio.gather(
|
||||||
|
*map(lambda s: s[0].async_status(), servers),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
messages = "\n".join(
|
||||||
|
(
|
||||||
|
dump_server_status(n, r)
|
||||||
|
for n, r in zip(map(lambda s: s[1], servers), responses)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if await pm.check_has_permission(evt, "minecraft.bingo.manipulate"):
|
||||||
|
messages += "\n\n---\n\n你可以使用 bingoman start 开启小帕的 bingo 服,用 bingoman stop 关闭小帕的 bingo 服"
|
||||||
|
|
||||||
|
await UniMessage.text(messages).finish(evt, at_sender=False)
|
||||||
|
|
||||||
|
|
||||||
|
cmd_bingo_manipulate = on_alconna(
|
||||||
|
Alconna("bingoman", Args["action", str]),
|
||||||
|
aliases=("宾服务器", "bingo服"),
|
||||||
|
rule=require_permission("minecraft.bingo.manipulate"),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions: dict[str, Literal["start", "stop", "restart", "kill"]] = {
|
||||||
|
"up": "start",
|
||||||
|
"down": "stop",
|
||||||
|
"start": "start",
|
||||||
|
"stop": "stop",
|
||||||
|
"开机": "start",
|
||||||
|
"关机": "stop",
|
||||||
|
"restart": "restart",
|
||||||
|
"kill": "kill",
|
||||||
|
"重启": "restart",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_bingo_manipulate.handle()
|
||||||
|
async def _(action: str, event: Event):
|
||||||
|
server = SimpfunServer.new() # 使用默认配置管理服务器
|
||||||
|
a = actions.get(action.lower().strip())
|
||||||
|
if a is None:
|
||||||
|
await UniMessage.text(f"操作 {action} 不存在").send(event, at_sender=True)
|
||||||
|
return
|
||||||
|
resp = await server.power(a)
|
||||||
|
if resp.code == 200:
|
||||||
|
await UniMessage.text("好了").send(event, at_sender=True)
|
||||||
|
else:
|
||||||
|
await UniMessage.text(f"不好:{resp}").send(event, at_sender=True)
|
||||||
|
|
||||||
|
|
||||||
|
@scheduler.scheduled_job("cron", hour="4,23")
|
||||||
|
async def _():
|
||||||
|
server = SimpfunServer.new()
|
||||||
|
today = datetime.datetime.now()
|
||||||
|
|
||||||
|
# 获取服务器当前状态,重试多次以保证不会误判服务器未开启
|
||||||
|
server_up = False
|
||||||
|
server_players = 0
|
||||||
|
for _ in range(3):
|
||||||
|
mcs = mcstatus.JavaServer("play.simpfun.cn", 11495)
|
||||||
|
try:
|
||||||
|
resp = await mcs.async_status()
|
||||||
|
server_up = True
|
||||||
|
server_players = resp.players.online
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if today.weekday() == 5 and today.hour < 12:
|
||||||
|
# 每周六开机一天,保证可以让服务器不被自动销毁
|
||||||
|
if not server_up:
|
||||||
|
await server.power("start")
|
||||||
|
else:
|
||||||
|
# 每用一个自然日都会计费,所以要赶在这一天结束之前关服
|
||||||
|
# 平时如果没人,也自动关上
|
||||||
|
if server_up and server_players == 0:
|
||||||
|
await server.power("stop")
|
||||||
90
konabot/plugins/minecraft_servers/simpfun_server.py
Normal file
90
konabot/plugins/minecraft_servers/simpfun_server.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SimpfunServerConfig(BaseModel):
|
||||||
|
plugin_simpfun_api_key: str = ""
|
||||||
|
plugin_simpfun_base_url: str = "https://api.simpfun.cn"
|
||||||
|
plugin_simpfun_instance_id: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_config():
|
||||||
|
from nonebot import get_plugin_config
|
||||||
|
|
||||||
|
return get_plugin_config(SimpfunServerConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class PowerManageResult(BaseModel):
|
||||||
|
code: int
|
||||||
|
status: bool
|
||||||
|
msg: str
|
||||||
|
|
||||||
|
|
||||||
|
class SimpfunServerDetailUtilization(BaseModel):
|
||||||
|
memory_bytes: int
|
||||||
|
cpu_absolute: float
|
||||||
|
disk_bytes: int
|
||||||
|
network_rx_bytes: int
|
||||||
|
network_tx_bytes: int
|
||||||
|
uptime: float
|
||||||
|
disk_last_check_time: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SimpfunServerDetailData(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
is_pro: bool
|
||||||
|
|
||||||
|
status: str
|
||||||
|
"运行中的话,是 running"
|
||||||
|
|
||||||
|
is_suspended: bool
|
||||||
|
utilization: SimpfunServerDetailUtilization
|
||||||
|
|
||||||
|
|
||||||
|
class SimpfunServerDetailResp(BaseModel):
|
||||||
|
code: int
|
||||||
|
data: SimpfunServerDetailData
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimpfunServer:
|
||||||
|
instance_id: int
|
||||||
|
api_key: str
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
async def power(
|
||||||
|
self, action: Literal["start", "stop", "restart", "kill"]
|
||||||
|
) -> PowerManageResult:
|
||||||
|
url = f"{self.base_url}/api/ins/{self.instance_id}/power"
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
headers={"Authorization": self.api_key}
|
||||||
|
) as session:
|
||||||
|
async with session.get(url, params={"action": action}) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return PowerManageResult.model_validate_json(await resp.read())
|
||||||
|
|
||||||
|
async def detail(self) -> SimpfunServerDetailResp:
|
||||||
|
url = f"{self.base_url}/api/ins/{self.instance_id}/power"
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
headers={"Authorization": self.api_key}
|
||||||
|
) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return SimpfunServerDetailResp.model_validate_json(await resp.read())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new(config: SimpfunServerConfig | None = None):
|
||||||
|
if config is None:
|
||||||
|
config = get_config()
|
||||||
|
return SimpfunServer(
|
||||||
|
instance_id=config.plugin_simpfun_instance_id,
|
||||||
|
api_key=config.plugin_simpfun_api_key,
|
||||||
|
base_url=config.plugin_simpfun_base_url,
|
||||||
|
)
|
||||||
@ -6,6 +6,7 @@ from konabot.common.nb.match_keyword import match_keyword
|
|||||||
|
|
||||||
evt_nya = on_message(rule=match_keyword("喵"))
|
evt_nya = on_message(rule=match_keyword("喵"))
|
||||||
|
|
||||||
|
|
||||||
@evt_nya.handle()
|
@evt_nya.handle()
|
||||||
async def _():
|
async def _():
|
||||||
await evt_nya.send(await UniMessage().text("喵").export())
|
await evt_nya.send(await UniMessage().text("喵").export())
|
||||||
@ -25,8 +26,9 @@ NYA_SYMBOL_MAPPING = {
|
|||||||
"~": "~",
|
"~": "~",
|
||||||
"~": "~",
|
"~": "~",
|
||||||
" ": " ",
|
" ": " ",
|
||||||
"\n": "\n",
|
|
||||||
}
|
}
|
||||||
|
NYA_SYMBOL_KEEP = "—¹₁²₂³₃⁴₄⁵₅⁶₆⁷₇⁸₈⁹₉⁰₀\n"
|
||||||
|
NYA_SYMBOL_MAPPING.update((k, k) for k in NYA_SYMBOL_KEEP)
|
||||||
|
|
||||||
|
|
||||||
async def has_nya(msg: UniMsg) -> bool:
|
async def has_nya(msg: UniMsg) -> bool:
|
||||||
@ -49,10 +51,10 @@ async def has_nya(msg: UniMsg) -> bool:
|
|||||||
|
|
||||||
evt_nya_v2 = on_message(rule=has_nya)
|
evt_nya_v2 = on_message(rule=has_nya)
|
||||||
|
|
||||||
|
|
||||||
@evt_nya_v2.handle()
|
@evt_nya_v2.handle()
|
||||||
async def _(msg: UniMsg, evt: Event):
|
async def _(msg: UniMsg, evt: Event):
|
||||||
text = msg.extract_plain_text()
|
text = msg.extract_plain_text()
|
||||||
await UniMessage.text(''.join(
|
await UniMessage.text("".join((NYA_SYMBOL_MAPPING.get(c, "") for c in text))).send(
|
||||||
(NYA_SYMBOL_MAPPING.get(c, '') for c in text)
|
evt
|
||||||
)).send(evt)
|
)
|
||||||
|
|
||||||
|
|||||||
112
konabot/plugins/perm_manage/__init__.py
Normal file
112
konabot/plugins/perm_manage/__init__.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
from nonebot.params import Depends
|
||||||
|
from nonebot_plugin_alconna import Alconna, Args, Subcommand, UniMessage, on_alconna
|
||||||
|
from konabot.common.pager import PagerQuery
|
||||||
|
from konabot.common.permsys import DepPermManager, require_permission
|
||||||
|
from konabot.common.permsys.entity import PermEntity, get_entity_chain_of_entity
|
||||||
|
|
||||||
|
|
||||||
|
cmd = on_alconna(
|
||||||
|
Alconna(
|
||||||
|
"konaperm",
|
||||||
|
Subcommand(
|
||||||
|
"list",
|
||||||
|
Args["platform", str],
|
||||||
|
Args["entity_type", str],
|
||||||
|
Args["external_id", str],
|
||||||
|
Args["page?", int],
|
||||||
|
),
|
||||||
|
Subcommand(
|
||||||
|
"get",
|
||||||
|
Args["platform", str],
|
||||||
|
Args["entity_type", str],
|
||||||
|
Args["external_id", str],
|
||||||
|
Args["perm", str],
|
||||||
|
),
|
||||||
|
Subcommand(
|
||||||
|
"set",
|
||||||
|
Args["platform", str],
|
||||||
|
Args["entity_type", str],
|
||||||
|
Args["external_id", str],
|
||||||
|
Args["perm", str],
|
||||||
|
Args["val", str],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rule=require_permission("admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_perm_entity_chain(platform: str, entity_type: str, external_id: str):
|
||||||
|
return get_entity_chain_of_entity(PermEntity(platform, entity_type, external_id))
|
||||||
|
|
||||||
|
|
||||||
|
_DepEntityChain = Annotated[list[PermEntity], Depends(_get_perm_entity_chain)]
|
||||||
|
|
||||||
|
|
||||||
|
def make_formatter(parent: PermEntity):
|
||||||
|
def _formatter(d: tuple[PermEntity, str, bool]):
|
||||||
|
permmark = {True: "[✅ ALLOW] ", False: "[❌ DENY] "}[d[2]]
|
||||||
|
inheritmark = ""
|
||||||
|
if parent != d[0]:
|
||||||
|
inheritmark = (
|
||||||
|
f"[继承自 {d[0].platform}.{d[0].entity_type}.{d[0].external_id}] "
|
||||||
|
)
|
||||||
|
return f"{permmark}{inheritmark}{d[1]}"
|
||||||
|
|
||||||
|
return _formatter
|
||||||
|
|
||||||
|
|
||||||
|
@cmd.assign("list")
|
||||||
|
async def list_permission(
|
||||||
|
pm: DepPermManager,
|
||||||
|
ec: _DepEntityChain,
|
||||||
|
event: Event,
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
pq = PagerQuery(page, 10)
|
||||||
|
data = await pm.list_permission(ec, pq)
|
||||||
|
msg = data.to_unimessage(make_formatter(ec[0]))
|
||||||
|
await msg.send(event)
|
||||||
|
|
||||||
|
|
||||||
|
@cmd.assign("get")
|
||||||
|
async def get_permission(
|
||||||
|
pm: DepPermManager,
|
||||||
|
ec: _DepEntityChain,
|
||||||
|
perm: str,
|
||||||
|
event: Event,
|
||||||
|
):
|
||||||
|
data = await pm.check_has_permission_info(ec, perm)
|
||||||
|
|
||||||
|
obj_s = f"{ec[0].platform}.{ec[0].entity_type}.{ec[0].external_id}"
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
await UniMessage.text(f"对象 {obj_s} 无 {perm} 权限记录").send(event)
|
||||||
|
return
|
||||||
|
pe, k, p = data
|
||||||
|
inheritmark = ""
|
||||||
|
if ec[0] != pe or k != perm:
|
||||||
|
inheritmark = (
|
||||||
|
f"继承自 {pe.platform}.{pe.entity_type}.{pe.external_id} 对 {k} 的设置,"
|
||||||
|
)
|
||||||
|
await UniMessage.text(f"{inheritmark}对象 {obj_s} 对 {perm} 的权限为 {p}").send(
|
||||||
|
event
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cmd.assign("set")
|
||||||
|
async def set_permission(
|
||||||
|
pm: DepPermManager,
|
||||||
|
ec: _DepEntityChain,
|
||||||
|
perm: str,
|
||||||
|
val: str,
|
||||||
|
event: Event,
|
||||||
|
):
|
||||||
|
if any(i == val.lower() for i in ("y", "yes", "allow", "true", "t")):
|
||||||
|
await pm.update_permission(ec[0], perm, True)
|
||||||
|
elif any(i == val.lower() for i in ("n", "no", "deny", "false", "f")):
|
||||||
|
await pm.update_permission(ec[0], perm, False)
|
||||||
|
elif any(i == val.lower() for i in ("null", "none")):
|
||||||
|
await pm.update_permission(ec[0], perm, None)
|
||||||
|
await get_permission(pm, ec, perm, event)
|
||||||
@ -3,14 +3,15 @@ from nonebot_plugin_alconna import Alconna, Args, on_alconna
|
|||||||
|
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.pager import PagerQuery
|
from konabot.common.pager import PagerQuery
|
||||||
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
from konabot.common.subscribe import POSTER_INFO_DATA, dep_poster_service
|
||||||
from konabot.plugins.poster.service import dep_poster_service
|
|
||||||
|
|
||||||
|
|
||||||
cmd_subscribe = on_alconna(Alconna(
|
cmd_subscribe = on_alconna(
|
||||||
"订阅",
|
Alconna(
|
||||||
Args["channel", str],
|
"订阅",
|
||||||
))
|
Args["channel", str],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cmd_subscribe.handle()
|
@cmd_subscribe.handle()
|
||||||
@ -23,10 +24,12 @@ async def _(target: DepLongTaskTarget, channel: str):
|
|||||||
await target.send_message(f"已经订阅过「{channel}」了")
|
await target.send_message(f"已经订阅过「{channel}」了")
|
||||||
|
|
||||||
|
|
||||||
cmd_list = on_alconna(Alconna(
|
cmd_list = on_alconna(
|
||||||
"re:(?:查询|我的|获取)订阅(列表)?",
|
Alconna(
|
||||||
Args["page?", int],
|
"re:(?:查询|我的|获取)订阅(列表)?",
|
||||||
))
|
Args["page?", int],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def better_channel_message(channel_id: str) -> str:
|
def better_channel_message(channel_id: str) -> str:
|
||||||
@ -39,17 +42,24 @@ def better_channel_message(channel_id: str) -> str:
|
|||||||
@cmd_list.handle()
|
@cmd_list.handle()
|
||||||
async def _(target: DepLongTaskTarget, page: int = 1):
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
async with dep_poster_service() as service:
|
async with dep_poster_service() as service:
|
||||||
result = await service.get_channels(target, PagerQuery(
|
result = await service.get_channels(
|
||||||
page_index=page,
|
target,
|
||||||
page_size=10,
|
PagerQuery(
|
||||||
))
|
page_index=page,
|
||||||
await target.send_message(result.to_unimessage(title="订阅列表", formatter=better_channel_message))
|
page_size=10,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await target.send_message(
|
||||||
|
result.to_unimessage(title="订阅列表", formatter=better_channel_message)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
cmd_list_available = on_alconna(Alconna(
|
cmd_list_available = on_alconna(
|
||||||
"re:(查询)?可用订阅(列表)?",
|
Alconna(
|
||||||
Args["page?", int],
|
"re:(查询)?可用订阅(列表)?",
|
||||||
))
|
Args["page?", int],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cmd_list_available.handle()
|
@cmd_list_available.handle()
|
||||||
@ -58,13 +68,17 @@ async def _(target: DepLongTaskTarget, page: int = 1):
|
|||||||
page_index=page,
|
page_index=page,
|
||||||
page_size=10,
|
page_size=10,
|
||||||
).apply(sorted(POSTER_INFO_DATA.keys()))
|
).apply(sorted(POSTER_INFO_DATA.keys()))
|
||||||
await target.send_message(result.to_unimessage(title="可用订阅列表", formatter=better_channel_message))
|
await target.send_message(
|
||||||
|
result.to_unimessage(title="可用订阅列表", formatter=better_channel_message)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
cmd_unsubscribe = on_alconna(Alconna(
|
cmd_unsubscribe = on_alconna(
|
||||||
"取消订阅",
|
Alconna(
|
||||||
Args["channel", str],
|
"取消订阅",
|
||||||
))
|
Args["channel", str],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cmd_unsubscribe.handle()
|
@cmd_unsubscribe.handle()
|
||||||
@ -79,6 +93,7 @@ async def _(target: DepLongTaskTarget, channel: str):
|
|||||||
|
|
||||||
driver = nonebot.get_driver()
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
|
||||||
@driver.on_startup
|
@driver.on_startup
|
||||||
async def _():
|
async def _():
|
||||||
async with dep_poster_service() as service:
|
async with dep_poster_service() as service:
|
||||||
|
|||||||
@ -91,7 +91,7 @@ async def ask_ai(expression: str, now: datetime.datetime | None = None) -> tuple
|
|||||||
logger.info(f"提醒功能:消息被阿里绿网拦截 message={expression}")
|
logger.info(f"提醒功能:消息被阿里绿网拦截 message={expression}")
|
||||||
return None, ""
|
return None, ""
|
||||||
|
|
||||||
llm = get_llm("qwen3-max")
|
llm = get_llm()
|
||||||
message = await llm.chat([
|
message = await llm.chat([
|
||||||
{ "role": "system", "content": prompt },
|
{ "role": "system", "content": prompt },
|
||||||
{ "role": "user", "content": expression },
|
{ "role": "user", "content": expression },
|
||||||
|
|||||||
@ -4,8 +4,7 @@ from nonebot.internal.adapter.event import Event
|
|||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
from nonebot_plugin_apscheduler import scheduler
|
from nonebot_plugin_apscheduler import scheduler
|
||||||
|
|
||||||
from konabot.plugins.poster.poster_info import PosterInfo, register_poster_info
|
from konabot.common.subscribe import PosterInfo, register_poster_info, broadcast
|
||||||
from konabot.plugins.poster.service import broadcast
|
|
||||||
|
|
||||||
register_poster_info(
|
register_poster_info(
|
||||||
"二十四节气",
|
"二十四节气",
|
||||||
@ -98,4 +97,3 @@ async def _(event: Event):
|
|||||||
|
|
||||||
msg = UniMessage.text(f"现在的节气是{date.term}")
|
msg = UniMessage.text(f"现在的节气是{date.term}")
|
||||||
await msg.send(event)
|
await msg.send(event)
|
||||||
|
|
||||||
|
|||||||
35
konabot/plugins/startup_notify.py
Normal file
35
konabot/plugins/startup_notify.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import asyncio
|
||||||
|
from nonebot import get_driver
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
from konabot.common.subscribe import register_poster_info, PosterInfo, broadcast
|
||||||
|
|
||||||
|
|
||||||
|
CHANNEL_STARTUP = "启动通知"
|
||||||
|
|
||||||
|
|
||||||
|
register_poster_info(
|
||||||
|
CHANNEL_STARTUP,
|
||||||
|
PosterInfo(
|
||||||
|
aliases=set(),
|
||||||
|
description="当 Bot 重启时告知",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
driver = get_driver()
|
||||||
|
|
||||||
|
|
||||||
|
@driver.on_startup
|
||||||
|
async def _():
|
||||||
|
# 要尽量保证接受讯息的服务存在
|
||||||
|
# 所以在这里我们要等待一定时间后再发信
|
||||||
|
async def task():
|
||||||
|
while True:
|
||||||
|
if len(driver.bots) >= 1:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
# 在这个时候,需求的 bot 已经上线,再等待一小会
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
await broadcast(CHANNEL_STARTUP, UniMessage.text("此方 BOT 重启好了"))
|
||||||
|
|
||||||
|
asyncio.create_task(task())
|
||||||
210
konabot/plugins/syntactic_sugar/__init__.py
Normal file
210
konabot/plugins/syntactic_sugar/__init__.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import copy
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import nonebot
|
||||||
|
from nonebot import on_command
|
||||||
|
from nonebot.adapters import Bot, Event, Message
|
||||||
|
from nonebot.log import logger
|
||||||
|
from nonebot.message import handle_event
|
||||||
|
from nonebot.params import CommandArg
|
||||||
|
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
|
|
||||||
|
ROOT_PATH = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
cmd = on_command(cmd="语法糖", aliases={"糖", "sugar"}, block=True)
|
||||||
|
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
|
||||||
|
@driver.on_startup
|
||||||
|
async def register_startup_hook():
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
|
||||||
|
@driver.on_shutdown
|
||||||
|
async def register_shutdown_hook():
|
||||||
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
await db_manager.execute_by_sql_file(ROOT_PATH / "sql" / "create_table.sql")
|
||||||
|
|
||||||
|
table_info = await db_manager.query("PRAGMA table_info(syntactic_sugar)")
|
||||||
|
columns = {str(row.get("name")) for row in table_info}
|
||||||
|
if "channel_id" not in columns:
|
||||||
|
await db_manager.execute(
|
||||||
|
"ALTER TABLE syntactic_sugar ADD COLUMN channel_id VARCHAR(255) NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
|
||||||
|
await db_manager.execute("DROP INDEX IF EXISTS idx_syntactic_sugar_name_belong_to")
|
||||||
|
await db_manager.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_syntactic_sugar_name_channel_target "
|
||||||
|
"ON syntactic_sugar(name, channel_id, belong_to)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_reply_plain_text(evt: Event) -> str:
|
||||||
|
reply = getattr(evt, "reply", None)
|
||||||
|
if reply is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
reply_message = getattr(reply, "message", None)
|
||||||
|
if reply_message is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
extract_plain_text = getattr(reply_message, "extract_plain_text", None)
|
||||||
|
if callable(extract_plain_text):
|
||||||
|
return extract_plain_text().strip()
|
||||||
|
return str(reply_message).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_variables(tokens: list[str]) -> tuple[list[str], dict[str, str]]:
|
||||||
|
positional: list[str] = []
|
||||||
|
named: dict[str, str] = {}
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
if "=" in token:
|
||||||
|
key, value = token.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if key:
|
||||||
|
named[key] = value
|
||||||
|
continue
|
||||||
|
positional.append(token)
|
||||||
|
|
||||||
|
return positional, named
|
||||||
|
|
||||||
|
|
||||||
|
def _render_template(content: str, positional: list[str], named: dict[str, str]) -> str:
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
key = match.group(1).strip()
|
||||||
|
if key.isdigit():
|
||||||
|
idx = int(key) - 1
|
||||||
|
if 0 <= idx < len(positional):
|
||||||
|
return positional[idx]
|
||||||
|
return match.group(0)
|
||||||
|
return named.get(key, match.group(0))
|
||||||
|
|
||||||
|
return re.sub(r"\{([^{}]+)\}", replace, content)
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_sugar(name: str, content: str, belong_to: str, channel_id: str):
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "insert_sugar.sql",
|
||||||
|
(name, content, belong_to, channel_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_sugar(name: str, belong_to: str, channel_id: str):
|
||||||
|
await db_manager.execute(
|
||||||
|
"DELETE FROM syntactic_sugar WHERE name = ? AND belong_to = ? AND channel_id = ?",
|
||||||
|
(name, belong_to, channel_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_sugar(name: str, belong_to: str, channel_id: str) -> str | None:
|
||||||
|
rows = await db_manager.query(
|
||||||
|
(
|
||||||
|
"SELECT content FROM syntactic_sugar "
|
||||||
|
"WHERE name = ? AND channel_id = ? "
|
||||||
|
"ORDER BY CASE WHEN belong_to = ? THEN 0 ELSE 1 END, id ASC "
|
||||||
|
"LIMIT 1"
|
||||||
|
),
|
||||||
|
(name, channel_id, belong_to),
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return rows[0].get("content")
|
||||||
|
|
||||||
|
|
||||||
|
async def _reinject_command(bot: Bot, evt: Event, command_text: str) -> bool:
|
||||||
|
depth = int(getattr(evt, "_syntactic_sugar_depth", 0))
|
||||||
|
if depth >= 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cloned_evt = copy.deepcopy(evt)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("语法糖克隆事件失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
message = getattr(cloned_evt, "message", None)
|
||||||
|
if message is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg_obj = type(message)(command_text)
|
||||||
|
except Exception:
|
||||||
|
msg_obj = command_text
|
||||||
|
|
||||||
|
setattr(cloned_evt, "message", msg_obj)
|
||||||
|
if hasattr(cloned_evt, "original_message"):
|
||||||
|
setattr(cloned_evt, "original_message", msg_obj)
|
||||||
|
if hasattr(cloned_evt, "raw_message"):
|
||||||
|
setattr(cloned_evt, "raw_message", command_text)
|
||||||
|
|
||||||
|
setattr(cloned_evt, "_syntactic_sugar_depth", depth + 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await handle_event(bot, cloned_evt)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("语法糖回注事件失败")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@cmd.handle()
|
||||||
|
async def _(bot: Bot, evt: Event, target: DepLongTaskTarget, args: Message = CommandArg()):
|
||||||
|
raw = args.extract_plain_text().strip()
|
||||||
|
if not raw:
|
||||||
|
return
|
||||||
|
|
||||||
|
tokens = raw.split()
|
||||||
|
action = tokens[0]
|
||||||
|
target_id = target.target_id
|
||||||
|
channel_id = target.channel_id
|
||||||
|
|
||||||
|
if action == "存入":
|
||||||
|
if len(tokens) < 2:
|
||||||
|
await cmd.finish("请提供要存入的名称")
|
||||||
|
name = tokens[1].strip()
|
||||||
|
content = " ".join(tokens[2:]).strip()
|
||||||
|
if not content:
|
||||||
|
content = _extract_reply_plain_text(evt)
|
||||||
|
if not content:
|
||||||
|
await cmd.finish("请提供要存入的内容")
|
||||||
|
|
||||||
|
await _store_sugar(name, content, target_id, channel_id)
|
||||||
|
await cmd.finish(f"糖已存入:「{name}」!")
|
||||||
|
|
||||||
|
if action == "删除":
|
||||||
|
if len(tokens) < 2:
|
||||||
|
await cmd.finish("请提供要删除的名称")
|
||||||
|
name = tokens[1].strip()
|
||||||
|
await _delete_sugar(name, target_id, channel_id)
|
||||||
|
await cmd.finish(f"已删除糖:「{name}」!")
|
||||||
|
|
||||||
|
if action == "查看":
|
||||||
|
if len(tokens) < 2:
|
||||||
|
await cmd.finish("请提供要查看的名称")
|
||||||
|
name = tokens[1].strip()
|
||||||
|
content = await _find_sugar(name, target_id, channel_id)
|
||||||
|
if content is None:
|
||||||
|
await cmd.finish(f"没有糖:「{name}」")
|
||||||
|
await cmd.finish(f"糖的内容:「{content}」")
|
||||||
|
|
||||||
|
|
||||||
|
name = action
|
||||||
|
content = await _find_sugar(name, target_id, channel_id)
|
||||||
|
if content is None:
|
||||||
|
await cmd.finish(f"没有糖:「{name}」")
|
||||||
|
|
||||||
|
positional, named = _split_variables(tokens[1:])
|
||||||
|
rendered = _render_template(content, positional, named)
|
||||||
|
|
||||||
|
ok = await _reinject_command(bot, evt, rendered)
|
||||||
|
if not ok:
|
||||||
|
await cmd.finish(f"糖的展开结果:「{rendered}」")
|
||||||
12
konabot/plugins/syntactic_sugar/sql/create_table.sql
Normal file
12
konabot/plugins/syntactic_sugar/sql/create_table.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- 创建语法糖表
|
||||||
|
CREATE TABLE IF NOT EXISTS syntactic_sugar (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
belong_to VARCHAR(255) NOT NULL,
|
||||||
|
channel_id VARCHAR(255) NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_syntactic_sugar_name_channel_target
|
||||||
|
ON syntactic_sugar(name, channel_id, belong_to);
|
||||||
5
konabot/plugins/syntactic_sugar/sql/insert_sugar.sql
Normal file
5
konabot/plugins/syntactic_sugar/sql/insert_sugar.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- 插入语法糖,如果同一用户下名称已存在则更新内容
|
||||||
|
INSERT INTO syntactic_sugar (name, content, belong_to, channel_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name, channel_id, belong_to) DO UPDATE SET
|
||||||
|
content = excluded.content;
|
||||||
35
konabot/plugins/trpg_roll/__init__.py
Normal file
35
konabot/plugins/trpg_roll/__init__.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
import nonebot
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
from nonebot_plugin_alconna import UniMessage, UniMsg
|
||||||
|
|
||||||
|
from konabot.common.nb import match_keyword
|
||||||
|
from konabot.common.permsys import register_default_allow_permission, require_permission
|
||||||
|
from konabot.plugins.trpg_roll.core import RollError, roll_expression
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSION_KEY = "trpg.roll"
|
||||||
|
register_default_allow_permission(PERMISSION_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
matcher = nonebot.on_message(
|
||||||
|
rule=match_keyword.match_keyword(re.compile(r"^roll(?:\s+.+)?$", re.I))
|
||||||
|
& require_permission(PERMISSION_KEY),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@matcher.handle()
|
||||||
|
async def _(event: Event, msg: UniMsg):
|
||||||
|
text = msg.extract_plain_text().strip()
|
||||||
|
expr = text[4:].strip()
|
||||||
|
|
||||||
|
if not expr:
|
||||||
|
await UniMessage.text("用法:roll 3d6 / roll d20+5 / roll 2d8+1d4+3 / roll 4dF").send(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = roll_expression(expr)
|
||||||
|
await UniMessage.text(result.format()).send(event)
|
||||||
|
except RollError as e:
|
||||||
|
await UniMessage.text(str(e)).send(event)
|
||||||
143
konabot/plugins/trpg_roll/core.py
Normal file
143
konabot/plugins/trpg_roll/core.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import random
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
MAX_DICE_COUNT = 100
|
||||||
|
MAX_DICE_FACES = 1000
|
||||||
|
MAX_TERM_COUNT = 20
|
||||||
|
MAX_TOTAL_ROLLS = 200
|
||||||
|
MAX_EXPRESSION_LENGTH = 200
|
||||||
|
MAX_MESSAGE_LENGTH = 1200
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(r"([+-]?)(\d*d(?:%|[fF]|\d+)|\d+)")
|
||||||
|
_DICE_RE = re.compile(r"(?i)(\d*)d(%|f|\d+)")
|
||||||
|
|
||||||
|
# 常见跑团表达式示例:3d6、d20+5、2d8+1d4+3、d%、4dF
|
||||||
|
|
||||||
|
|
||||||
|
class RollError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RollTermResult:
|
||||||
|
sign: int
|
||||||
|
source: str
|
||||||
|
detail: str
|
||||||
|
value: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RollResult:
|
||||||
|
expression: str
|
||||||
|
total: int
|
||||||
|
terms: list[RollTermResult]
|
||||||
|
|
||||||
|
def format(self) -> str:
|
||||||
|
parts = [f"{term.source}={term.detail}" for term in self.terms]
|
||||||
|
detail = " ".join(parts)
|
||||||
|
text = f"{self.expression} = {self.total}"
|
||||||
|
if detail:
|
||||||
|
text += f"\n{detail}"
|
||||||
|
if len(text) > MAX_MESSAGE_LENGTH:
|
||||||
|
raise RollError("结果过长,请减少骰子数量或简化表达式")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_single_term(raw: str, sign: int, rng: random.Random) -> RollTermResult:
|
||||||
|
dice_match = _DICE_RE.fullmatch(raw)
|
||||||
|
if dice_match:
|
||||||
|
count_text, faces_text = dice_match.groups()
|
||||||
|
count = int(count_text) if count_text else 1
|
||||||
|
if count <= 0:
|
||||||
|
raise RollError("骰子个数必须大于 0")
|
||||||
|
if count > MAX_DICE_COUNT:
|
||||||
|
raise RollError(f"单项最多只能掷 {MAX_DICE_COUNT} 个骰子")
|
||||||
|
|
||||||
|
if faces_text == "%":
|
||||||
|
faces = 100
|
||||||
|
rolls = [rng.randint(1, 100) for _ in range(count)]
|
||||||
|
elif faces_text.lower() == "f":
|
||||||
|
rolls = [rng.choice((-1, 0, 1)) for _ in range(count)]
|
||||||
|
total = sum(rolls) * sign
|
||||||
|
signed = "+" if sign > 0 else "-"
|
||||||
|
return RollTermResult(
|
||||||
|
sign=sign,
|
||||||
|
source=f"{signed}{count}dF",
|
||||||
|
detail="[" + ", ".join(f"{v:+d}" for v in rolls) + "]",
|
||||||
|
value=total,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
faces = int(faces_text)
|
||||||
|
if faces <= 0:
|
||||||
|
raise RollError("骰子面数必须大于 0")
|
||||||
|
if faces > MAX_DICE_FACES:
|
||||||
|
raise RollError(f"骰子面数不能超过 {MAX_DICE_FACES}")
|
||||||
|
rolls = [rng.randint(1, faces) for _ in range(count)]
|
||||||
|
|
||||||
|
total = sum(rolls) * sign
|
||||||
|
signed = "+" if sign > 0 else "-"
|
||||||
|
return RollTermResult(
|
||||||
|
sign=sign,
|
||||||
|
source=f"{signed}{count}d{faces_text.upper()}",
|
||||||
|
detail="[" + ", ".join(map(str, rolls)) + "]",
|
||||||
|
value=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
value = int(raw) * sign
|
||||||
|
signed = "+" if sign > 0 else "-"
|
||||||
|
return RollTermResult(sign=sign, source=f"{signed}{raw}", detail=str(abs(value)), value=value)
|
||||||
|
|
||||||
|
|
||||||
|
def roll_expression(expr: str, rng: random.Random | None = None) -> RollResult:
|
||||||
|
expr = expr.strip().replace(" ", "")
|
||||||
|
if not expr:
|
||||||
|
raise RollError("请提供要掷的表达式,例如 roll 3d6 或 roll d20+5")
|
||||||
|
if len(expr) > MAX_EXPRESSION_LENGTH:
|
||||||
|
raise RollError("表达式太长了")
|
||||||
|
|
||||||
|
matches = list(_TOKEN_RE.finditer(expr))
|
||||||
|
if not matches:
|
||||||
|
raise RollError("无法解析表达式,请使用如 3d6、d20+5、2d8+1d4+3、4dF 这样的格式")
|
||||||
|
|
||||||
|
rebuilt = "".join(m.group(0) for m in matches)
|
||||||
|
if rebuilt != expr:
|
||||||
|
raise RollError("表达式中含有无法识别的内容")
|
||||||
|
if len(matches) > MAX_TERM_COUNT:
|
||||||
|
raise RollError(f"表达式项数不能超过 {MAX_TERM_COUNT}")
|
||||||
|
|
||||||
|
total_rolls = 0
|
||||||
|
for m in matches:
|
||||||
|
token = m.group(2)
|
||||||
|
dice_match = _DICE_RE.fullmatch(token)
|
||||||
|
if dice_match:
|
||||||
|
count_text, faces_text = dice_match.groups()
|
||||||
|
count = int(count_text) if count_text else 1
|
||||||
|
if count <= 0:
|
||||||
|
raise RollError("骰子个数必须大于 0")
|
||||||
|
if count > MAX_DICE_COUNT:
|
||||||
|
raise RollError(f"单项最多只能掷 {MAX_DICE_COUNT} 个骰子")
|
||||||
|
if faces_text not in {"%", "f", "F"}:
|
||||||
|
faces = int(faces_text)
|
||||||
|
if faces <= 0:
|
||||||
|
raise RollError("骰子面数必须大于 0")
|
||||||
|
if faces > MAX_DICE_FACES:
|
||||||
|
raise RollError(f"骰子面数不能超过 {MAX_DICE_FACES}")
|
||||||
|
total_rolls += count
|
||||||
|
if total_rolls > MAX_TOTAL_ROLLS:
|
||||||
|
raise RollError(f"一次最多只能实际掷 {MAX_TOTAL_ROLLS} 个骰子")
|
||||||
|
|
||||||
|
rng = rng or random.Random()
|
||||||
|
terms: list[RollTermResult] = []
|
||||||
|
total = 0
|
||||||
|
for idx, match in enumerate(matches):
|
||||||
|
sign_text, raw = match.groups()
|
||||||
|
sign = -1 if sign_text == "-" else 1
|
||||||
|
if idx == 0 and sign_text == "":
|
||||||
|
sign = 1
|
||||||
|
term = _parse_single_term(raw, sign, rng)
|
||||||
|
terms.append(term)
|
||||||
|
total += term.value
|
||||||
|
|
||||||
|
return RollResult(expression=expr, total=total, terms=terms)
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import on_command
|
from nonebot import on_command
|
||||||
@ -13,22 +15,99 @@ from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent
|
|||||||
from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot
|
from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot
|
||||||
from nonebot.adapters.onebot.v11.message import Message as OB11Message
|
from nonebot.adapters.onebot.v11.message import Message as OB11Message
|
||||||
|
|
||||||
|
from konabot.common.artifact import ArtifactDepends, ensure_artifact, register_artifacts
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.path import TMP_PATH
|
from konabot.common.path import BINARY_PATH, TMP_PATH
|
||||||
|
|
||||||
|
|
||||||
|
arti_typst_linux = ArtifactDepends(
|
||||||
|
url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz",
|
||||||
|
sha256="a6044cbad2a954deb921167e257e120ac0a16b20339ec01121194ff9d394996d",
|
||||||
|
target=BINARY_PATH / "typst.tar.xz",
|
||||||
|
required_os="Linux",
|
||||||
|
required_arch="x86_64",
|
||||||
|
)
|
||||||
|
arti_typst_windows = ArtifactDepends(
|
||||||
|
url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-pc-windows-msvc.zip",
|
||||||
|
sha256="51353994ac83218c3497052e89b2c432c53b9d4439cdc1b361e2ea4798ebfc13",
|
||||||
|
target=BINARY_PATH / "typst.zip",
|
||||||
|
required_os="Windows",
|
||||||
|
required_arch="AMD64",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
bin_path: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@arti_typst_linux.on_finished
|
||||||
|
async def _(downloaded: bool):
|
||||||
|
global bin_path
|
||||||
|
|
||||||
|
tar_path = arti_typst_linux.target
|
||||||
|
bin_path = BINARY_PATH / "typst"
|
||||||
|
|
||||||
|
if downloaded or not bin_path.exists():
|
||||||
|
bin_path.unlink(missing_ok=True)
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"tar",
|
||||||
|
"-xvf",
|
||||||
|
tar_path,
|
||||||
|
"--strip-components=1",
|
||||||
|
"-C",
|
||||||
|
BINARY_PATH,
|
||||||
|
"typst-x86_64-unknown-linux-musl/typst",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
if process.returncode != 0 or not bin_path.exists():
|
||||||
|
logger.warning(
|
||||||
|
"似乎没有成功解压 Typst 二进制文件,检查一下吧! "
|
||||||
|
f"stdout={stdout} stderr={stderr}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.chmod(bin_path, 0o755)
|
||||||
|
|
||||||
|
|
||||||
|
@arti_typst_windows.on_finished
|
||||||
|
async def _(downloaded: bool):
|
||||||
|
global bin_path
|
||||||
|
zip_path = arti_typst_windows.target
|
||||||
|
bin_path = BINARY_PATH / "typst.exe"
|
||||||
|
|
||||||
|
if downloaded or not bin_path.exists():
|
||||||
|
bin_path.unlink(missing_ok=True)
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
target_name = "typst-x86_64-pc-windows-msvc/typst.exe"
|
||||||
|
if target_name not in zf.namelist():
|
||||||
|
logger.warning("在 Zip 压缩包里面没有找到目标文件")
|
||||||
|
return
|
||||||
|
zf.extract(target_name, BINARY_PATH)
|
||||||
|
(BINARY_PATH / target_name).rename(bin_path)
|
||||||
|
(BINARY_PATH / "typst-x86_64-pc-windows-msvc").rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
register_artifacts(arti_typst_linux)
|
||||||
|
register_artifacts(arti_typst_windows)
|
||||||
|
|
||||||
|
|
||||||
TEMPLATE_PATH = Path(__file__).parent / "template.typ"
|
TEMPLATE_PATH = Path(__file__).parent / "template.typ"
|
||||||
TEMPLATE = TEMPLATE_PATH.read_text()
|
TEMPLATE = TEMPLATE_PATH.read_text()
|
||||||
|
|
||||||
|
|
||||||
def render_sync(code: str) -> bytes:
|
def render_sync(code: str) -> bytes | None:
|
||||||
|
global bin_path
|
||||||
|
|
||||||
|
if bin_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
with TemporaryDirectory(dir=TMP_PATH) as tmpdirname:
|
with TemporaryDirectory(dir=TMP_PATH) as tmpdirname:
|
||||||
temp_dir = Path(tmpdirname).resolve()
|
temp_dir = Path(tmpdirname).resolve()
|
||||||
temp_typ = temp_dir / "page.typ"
|
temp_typ = temp_dir / "page.typ"
|
||||||
temp_typ.write_text(TEMPLATE + "\n\n" + code)
|
temp_typ.write_text(TEMPLATE + "\n\n" + code)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"typst",
|
bin_path,
|
||||||
"compile",
|
"compile",
|
||||||
temp_typ.name,
|
temp_typ.name,
|
||||||
"--format",
|
"--format",
|
||||||
@ -61,7 +140,7 @@ def render_sync(code: str) -> bytes:
|
|||||||
return result_png.read_bytes()
|
return result_png.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
async def render(code: str) -> bytes:
|
async def render(code: str) -> bytes | None:
|
||||||
task = asyncio.to_thread(lambda: render_sync(code))
|
task = asyncio.to_thread(lambda: render_sync(code))
|
||||||
return await task
|
return await task
|
||||||
|
|
||||||
@ -70,7 +149,21 @@ cmd = on_command("typst")
|
|||||||
|
|
||||||
|
|
||||||
@cmd.handle()
|
@cmd.handle()
|
||||||
async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget):
|
async def _(
|
||||||
|
evt: Event,
|
||||||
|
bot: Bot,
|
||||||
|
msg: UniMsg,
|
||||||
|
target: DepLongTaskTarget,
|
||||||
|
):
|
||||||
|
global bin_path
|
||||||
|
|
||||||
|
# 对于本地机器,一般不会在应用启动时自动下载,这里再保证存在
|
||||||
|
await ensure_artifact(arti_typst_linux)
|
||||||
|
await ensure_artifact(arti_typst_windows)
|
||||||
|
if bin_path is None or not bin_path.exists():
|
||||||
|
logger.warning("当前环境不存在 Typst,但仍然调用了")
|
||||||
|
return
|
||||||
|
|
||||||
typst_code = ""
|
typst_code = ""
|
||||||
if isinstance(evt, OB11MessageEvent):
|
if isinstance(evt, OB11MessageEvent):
|
||||||
if evt.reply is not None:
|
if evt.reply is not None:
|
||||||
@ -92,6 +185,8 @@ async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
res = await render(typst_code)
|
res = await render(typst_code)
|
||||||
|
if res is None:
|
||||||
|
raise FileNotFoundError("没有渲染出来内容")
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
await target.send_message("渲染出错:内部错误")
|
await target.send_message("渲染出错:内部错误")
|
||||||
raise e from e
|
raise e from e
|
||||||
|
|||||||
115
konabot/plugins/wolfx_eew.py
Normal file
115
konabot/plugins/wolfx_eew.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import datetime
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
from konabot.common.apis.wolfx import CencEewReport, CencEqReport, wolfx_api
|
||||||
|
from konabot.common.subscribe import PosterInfo, broadcast, register_poster_info
|
||||||
|
|
||||||
|
|
||||||
|
provinces_short = [
|
||||||
|
"北京",
|
||||||
|
"天津",
|
||||||
|
"河北",
|
||||||
|
"山西",
|
||||||
|
"内蒙古",
|
||||||
|
"辽宁",
|
||||||
|
"吉林",
|
||||||
|
"黑龙江",
|
||||||
|
"上海",
|
||||||
|
"江苏",
|
||||||
|
"浙江",
|
||||||
|
"安徽",
|
||||||
|
"福建",
|
||||||
|
"江西",
|
||||||
|
"山东",
|
||||||
|
"河南",
|
||||||
|
"湖北",
|
||||||
|
"湖南",
|
||||||
|
"广东",
|
||||||
|
"广西",
|
||||||
|
"海南",
|
||||||
|
"重庆",
|
||||||
|
"四川",
|
||||||
|
"贵州",
|
||||||
|
"云南",
|
||||||
|
"西藏",
|
||||||
|
"陕西",
|
||||||
|
"甘肃",
|
||||||
|
"青海",
|
||||||
|
"宁夏",
|
||||||
|
"新疆",
|
||||||
|
"香港",
|
||||||
|
"澳门",
|
||||||
|
"台湾",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
register_poster_info(
|
||||||
|
"中国地震台网地震速报",
|
||||||
|
PosterInfo(
|
||||||
|
aliases={
|
||||||
|
"地震速报",
|
||||||
|
"地震预警",
|
||||||
|
},
|
||||||
|
description="来自中国地震台网的地震速报",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CENC_EEW_DISABLED = True
|
||||||
|
|
||||||
|
|
||||||
|
if not CENC_EEW_DISABLED:
|
||||||
|
|
||||||
|
@wolfx_api.cenc_eew.append
|
||||||
|
async def broadcast_eew(report: CencEewReport):
|
||||||
|
# 这个好像没那么准确...
|
||||||
|
is_cn = any(report.HypoCenter.startswith(prefix) for prefix in provinces_short)
|
||||||
|
if (is_cn and report.Magnitude >= 4.2) or (
|
||||||
|
(not is_cn) and report.Magnitude >= 7.0
|
||||||
|
):
|
||||||
|
# 这是中国地震台网网站上,会默认展示的地震信息的等级
|
||||||
|
origin_time_dt = datetime.datetime.strptime(
|
||||||
|
report.OriginTime, "%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
origin_time_str = (
|
||||||
|
f"{origin_time_dt.month}月"
|
||||||
|
f"{origin_time_dt.day}日"
|
||||||
|
f"{origin_time_dt.hour}时"
|
||||||
|
f"{origin_time_dt.minute}分"
|
||||||
|
)
|
||||||
|
|
||||||
|
# vvv 下面这个其实不准确
|
||||||
|
eid_in_link = report.EventID.split(".")[0]
|
||||||
|
link = f"https://www.cenc.ac.cn/earthquake-manage-publish-web/product-list/{eid_in_link}/summarize"
|
||||||
|
|
||||||
|
msg = UniMessage.text(
|
||||||
|
"据中国地震台网中心 (https://www.cenc.ac.cn/) 报道,"
|
||||||
|
f"北京时间{origin_time_str},"
|
||||||
|
f"{report.HypoCenter}发生{report.Magnitude:.1f}级地震。"
|
||||||
|
f"震源位于 {report.Longitude}° {report.Latitude}°,深度 {report.Depth}km。\n\n"
|
||||||
|
f"详细信息请见 {link}"
|
||||||
|
)
|
||||||
|
await broadcast("中国地震台网地震速报", msg)
|
||||||
|
|
||||||
|
|
||||||
|
@wolfx_api.cenc_eqlist.append
|
||||||
|
async def broadcast_cenc_eqlist(report: CencEqReport):
|
||||||
|
is_cn = any(report.location.startswith(prefix) for prefix in provinces_short)
|
||||||
|
if (is_cn and float(report.magnitude) >= 4.2) or (
|
||||||
|
(not is_cn) and float(report.magnitude) >= 7.0
|
||||||
|
):
|
||||||
|
origin_time_dt = datetime.datetime.strptime(report.time, "%Y-%m-%d %H:%M:%S")
|
||||||
|
origin_time_str = (
|
||||||
|
f"{origin_time_dt.month}月"
|
||||||
|
f"{origin_time_dt.day}日"
|
||||||
|
f"{origin_time_dt.hour}时"
|
||||||
|
f"{origin_time_dt.minute}分"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = UniMessage.text(
|
||||||
|
"据中国地震台网中心 (https://www.cenc.ac.cn/) 消息,"
|
||||||
|
f"北京时间{origin_time_str},"
|
||||||
|
f"{report.location}发生{report.magnitude}级地震。"
|
||||||
|
f"震源位于 {report.longtitude}° {report.latitude}°,深度 {report.depth}km。\n\n"
|
||||||
|
f"数据来源于 Wolfx OpenAPI,事件 ID: {report.EventID}"
|
||||||
|
)
|
||||||
|
await broadcast("中国地震台网地震速报", msg)
|
||||||
2746
poetry.lock
generated
2746
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -34,6 +34,11 @@ dependencies = [
|
|||||||
"shapely (>=2.1.2,<3.0.0)",
|
"shapely (>=2.1.2,<3.0.0)",
|
||||||
"mcstatus (>=12.2.1,<13.0.0)",
|
"mcstatus (>=12.2.1,<13.0.0)",
|
||||||
"borax (>=4.1.3,<5.0.0)",
|
"borax (>=4.1.3,<5.0.0)",
|
||||||
|
"pytest (>=8.0.0,<9.0.0)",
|
||||||
|
"nonebug (>=0.4.3,<0.5.0)",
|
||||||
|
"pytest-cov (>=7.0.0,<8.0.0)",
|
||||||
|
"aiosignal (>=1.4.0,<2.0.0)",
|
||||||
|
"pytest-mock (>=3.15.1,<4.0.0)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
@ -52,8 +57,15 @@ priority = "primary"
|
|||||||
|
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = ["rust-just (>=1.43.0,<2.0.0)", "pytest-asyncio (>=1.3.0,<2.0.0)"]
|
||||||
"rust-just (>=1.43.0,<2.0.0)",
|
|
||||||
"pytest (>=9.0.1,<10.0.0)",
|
[tool.pytest.ini_options]
|
||||||
"pytest-asyncio (>=1.3.0,<2.0.0)"
|
testpaths = "tests"
|
||||||
]
|
python_files = "test_*.py"
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "session"
|
||||||
|
addopts = "--cov=./konabot/"
|
||||||
|
|
||||||
|
[tool.nonebot]
|
||||||
|
# plugin_dirs = ["konabot/plugins/"]
|
||||||
|
plugin_dirs = []
|
||||||
|
|||||||
@ -12,8 +12,22 @@ def filter(change: Change, path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
if Path(path).absolute().is_relative_to((base / ".git").absolute()):
|
if Path(path).absolute().is_relative_to((base / ".git").absolute()):
|
||||||
return False
|
return False
|
||||||
if Path(path).absolute().is_relative_to((base / "assets" / "oracle" / "image").absolute()):
|
if (
|
||||||
|
Path(path)
|
||||||
|
.absolute()
|
||||||
|
.is_relative_to((base / "assets" / "oracle" / "image").absolute())
|
||||||
|
):
|
||||||
# 还要解决坏枪的这个问题
|
# 还要解决坏枪的这个问题
|
||||||
return False
|
return False
|
||||||
|
if Path(path).absolute().is_relative_to((base / "htmlcov").absolute()):
|
||||||
|
return False
|
||||||
|
if Path(path).absolute().is_relative_to((base / "test").absolute()):
|
||||||
|
return False
|
||||||
|
if Path(path).absolute().is_relative_to((base / ".pytest_cache").absolute()):
|
||||||
|
return False
|
||||||
|
if Path(path).absolute().is_relative_to((base / ".ruff_cache").absolute()):
|
||||||
|
return False
|
||||||
|
if path.endswith(".coverage"):
|
||||||
|
return False
|
||||||
print(path)
|
print(path)
|
||||||
return True
|
return True
|
||||||
|
|||||||
28
tests/conftest.py
Normal file
28
tests/conftest.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# 文件内容来源:
|
||||||
|
# https://nonebot.dev/docs/best-practice/testing/
|
||||||
|
# 保证 nonebug 测试框架正常运作
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import nonebot
|
||||||
|
from pytest_asyncio import is_async_test
|
||||||
|
from nonebot.adapters.console import Adapter as ConsoleAdapter
|
||||||
|
from nonebug import NONEBOT_START_LIFESPAN
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(items: list[pytest.Item]):
|
||||||
|
pytest_asyncio_tests = (item for item in items if is_async_test(item))
|
||||||
|
session_scope_marker = pytest.mark.asyncio(loop_scope="session")
|
||||||
|
for async_test in pytest_asyncio_tests:
|
||||||
|
async_test.add_marker(session_scope_marker, append=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
async def after_nonebot_init(after_nonebot_init: None):
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
driver.register_adapter(ConsoleAdapter)
|
||||||
|
|
||||||
|
nonebot.load_from_toml("pyproject.toml")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config):
|
||||||
|
config.stash[NONEBOT_START_LIFESPAN] = True
|
||||||
78
tests/services/test_wolfx_api.py
Normal file
78
tests/services/test_wolfx_api.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from konabot.common.apis.wolfx import CencEewReport, WolfxAPIService, WolfxWebSocket
|
||||||
|
|
||||||
|
|
||||||
|
obj_example = {
|
||||||
|
"ID": "bacby4yab1oyb",
|
||||||
|
"EventID": "202603100805.0001",
|
||||||
|
"ReportTime": "2026-03-10 08:05:29",
|
||||||
|
"ReportNum": 1,
|
||||||
|
"OriginTime": "2026-03-10 08:05:29",
|
||||||
|
"HypoCenter": "新疆昌吉州呼图壁县",
|
||||||
|
"Latitude": 43.687,
|
||||||
|
"Longitude": 86.427,
|
||||||
|
"Magnitude": 4.0,
|
||||||
|
"Depth": 14,
|
||||||
|
"MaxIntensity": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wolfx_websocket_handle():
|
||||||
|
ws = WolfxWebSocket("")
|
||||||
|
|
||||||
|
mock_callback = AsyncMock()
|
||||||
|
ws.signal.append(mock_callback)
|
||||||
|
ws.signal.freeze()
|
||||||
|
|
||||||
|
obj1 = {
|
||||||
|
"type": "heartbeat",
|
||||||
|
"ver": 18,
|
||||||
|
"id": "a69edf6436c5b605",
|
||||||
|
"timestamp": 1773111106701,
|
||||||
|
}
|
||||||
|
data1 = json.dumps(obj1).encode()
|
||||||
|
await ws.handle(data1)
|
||||||
|
mock_callback.assert_not_called()
|
||||||
|
mock_callback.reset_mock()
|
||||||
|
|
||||||
|
obj2 = obj_example
|
||||||
|
data2 = json.dumps(obj2).encode()
|
||||||
|
await ws.handle(data2)
|
||||||
|
mock_callback.assert_called_once_with(data2)
|
||||||
|
mock_callback.reset_mock()
|
||||||
|
|
||||||
|
data3 = b"what the f"
|
||||||
|
await ws.handle(data3)
|
||||||
|
mock_callback.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wolfx_bind_pydantic():
|
||||||
|
sv = WolfxAPIService()
|
||||||
|
called: list[CencEewReport] = []
|
||||||
|
|
||||||
|
@sv.cenc_eew.append
|
||||||
|
async def _(data: CencEewReport):
|
||||||
|
called.append(data)
|
||||||
|
|
||||||
|
sv._cenc_eew_ws.signal.freeze()
|
||||||
|
sv.cenc_eew.freeze()
|
||||||
|
|
||||||
|
data = json.dumps(obj_example).encode()
|
||||||
|
await sv._cenc_eew_ws.signal.send(data)
|
||||||
|
|
||||||
|
assert len(called) == 1
|
||||||
|
data = called[0]
|
||||||
|
|
||||||
|
assert data.HypoCenter == obj_example["HypoCenter"]
|
||||||
|
assert data.EventID == obj_example["EventID"]
|
||||||
|
|
||||||
|
# Don't panic when the object is invalid
|
||||||
|
data = json.dumps({"type": "给"}).encode()
|
||||||
|
await sv._cenc_eew_ws.signal.send(data)
|
||||||
|
|
||||||
|
assert len(called) == 1
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -12,13 +11,13 @@ from konabot.common.database import DatabaseManager
|
|||||||
async def test_database_manager():
|
async def test_database_manager():
|
||||||
"""测试数据库管理器的基本功能"""
|
"""测试数据库管理器的基本功能"""
|
||||||
# 创建临时数据库文件
|
# 创建临时数据库文件
|
||||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp_file:
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
|
||||||
db_path = tmp_file.name
|
db_path = tmp_file.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 初始化数据库管理器
|
# 初始化数据库管理器
|
||||||
db_manager = DatabaseManager(db_path)
|
db_manager = DatabaseManager(db_path)
|
||||||
|
|
||||||
# 创建测试表
|
# 创建测试表
|
||||||
create_table_sql = """
|
create_table_sql = """
|
||||||
CREATE TABLE IF NOT EXISTS test_users (
|
CREATE TABLE IF NOT EXISTS test_users (
|
||||||
@ -28,26 +27,27 @@ async def test_database_manager():
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
await db_manager.execute(create_table_sql)
|
await db_manager.execute(create_table_sql)
|
||||||
|
|
||||||
# 插入测试数据
|
# 插入测试数据
|
||||||
insert_sql = "INSERT INTO test_users (name, email) VALUES (?, ?)"
|
insert_sql = "INSERT INTO test_users (name, email) VALUES (?, ?)"
|
||||||
await db_manager.execute(insert_sql, ("张三", "zhangsan@example.com"))
|
await db_manager.execute(insert_sql, ("张三", "zhangsan@example.com"))
|
||||||
await db_manager.execute(insert_sql, ("李四", "lisi@example.com"))
|
await db_manager.execute(insert_sql, ("李四", "lisi@example.com"))
|
||||||
|
|
||||||
# 查询数据
|
# 查询数据
|
||||||
select_sql = "SELECT * FROM test_users WHERE name = ?"
|
select_sql = "SELECT * FROM test_users WHERE name = ?"
|
||||||
results = await db_manager.query(select_sql, ("张三",))
|
results = await db_manager.query(select_sql, ("张三",))
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0]["name"] == "张三"
|
assert results[0]["name"] == "张三"
|
||||||
assert results[0]["email"] == "zhangsan@example.com"
|
assert results[0]["email"] == "zhangsan@example.com"
|
||||||
|
|
||||||
# 测试使用Path对象
|
# 测试使用Path对象
|
||||||
results = await db_manager.query_by_sql_file(Path(__file__), ("李四",))
|
# results = await db_manager.query_by_sql_file(Path(__file__), ("李四",))
|
||||||
# 注意:这里只是测试参数传递,实际SQL文件内容不是有效的SQL
|
# 注意:这里只是测试参数传递,实际SQL文件内容不是有效的SQL
|
||||||
|
## ^^^ 卧了个槽的坏枪,你让 AI 写单元测试不检查一下吗
|
||||||
|
|
||||||
# 关闭所有连接
|
# 关闭所有连接
|
||||||
await db_manager.close_all_connections()
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 清理临时文件
|
# 清理临时文件
|
||||||
if os.path.exists(db_path):
|
if os.path.exists(db_path):
|
||||||
@ -58,13 +58,13 @@ async def test_database_manager():
|
|||||||
async def test_execute_script():
|
async def test_execute_script():
|
||||||
"""测试执行SQL脚本功能"""
|
"""测试执行SQL脚本功能"""
|
||||||
# 创建临时数据库文件
|
# 创建临时数据库文件
|
||||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp_file:
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
|
||||||
db_path = tmp_file.name
|
db_path = tmp_file.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 初始化数据库管理器
|
# 初始化数据库管理器
|
||||||
db_manager = DatabaseManager(db_path)
|
db_manager = DatabaseManager(db_path)
|
||||||
|
|
||||||
# 创建测试表的脚本
|
# 创建测试表的脚本
|
||||||
script = """
|
script = """
|
||||||
CREATE TABLE IF NOT EXISTS test_products (
|
CREATE TABLE IF NOT EXISTS test_products (
|
||||||
@ -75,19 +75,19 @@ async def test_execute_script():
|
|||||||
INSERT INTO test_products (name, price) VALUES ('苹果', 5.0);
|
INSERT INTO test_products (name, price) VALUES ('苹果', 5.0);
|
||||||
INSERT INTO test_products (name, price) VALUES ('香蕉', 3.0);
|
INSERT INTO test_products (name, price) VALUES ('香蕉', 3.0);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
await db_manager.execute_script(script)
|
await db_manager.execute_script(script)
|
||||||
|
|
||||||
# 查询数据
|
# 查询数据
|
||||||
results = await db_manager.query("SELECT * FROM test_products ORDER BY name")
|
results = await db_manager.query("SELECT * FROM test_products ORDER BY name")
|
||||||
assert len(results) == 2
|
assert len(results) == 2
|
||||||
assert results[0]["name"] == "苹果"
|
assert results[0]["name"] == "苹果"
|
||||||
assert results[1]["name"] == "香蕉"
|
assert results[1]["name"] == "香蕉"
|
||||||
|
|
||||||
# 关闭所有连接
|
# 关闭所有连接
|
||||||
await db_manager.close_all_connections()
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 清理临时文件
|
# 清理临时文件
|
||||||
if os.path.exists(db_path):
|
if os.path.exists(db_path):
|
||||||
os.unlink(db_path)
|
os.unlink(db_path)
|
||||||
|
|||||||
88
tests/test_fx_process.py
Normal file
88
tests/test_fx_process.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import nonebot
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
nonebot.init()
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "konabot/plugins/fx_process/fx_handle.py"
|
||||||
|
SPEC = spec_from_file_location("test_fx_handle_module", MODULE_PATH)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
fx_handle = module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(fx_handle)
|
||||||
|
ImageFilterImplement = fx_handle.ImageFilterImplement
|
||||||
|
|
||||||
|
INIT_MODULE_PATH = Path(__file__).resolve().parents[1] / "konabot/plugins/fx_process/__init__.py"
|
||||||
|
INIT_SPEC = spec_from_file_location("test_fx_init_module", INIT_MODULE_PATH)
|
||||||
|
assert INIT_SPEC is not None and INIT_SPEC.loader is not None
|
||||||
|
fx_init = module_from_spec(INIT_SPEC)
|
||||||
|
INIT_SPEC.loader.exec_module(fx_init)
|
||||||
|
prase_input_args = fx_init.prase_input_args
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_jpeg_damage_keeps_size_and_rgba_mode():
|
||||||
|
image = Image.new("RGBA", (32, 24), (255, 0, 0, 128))
|
||||||
|
|
||||||
|
result = ImageFilterImplement.apply_jpeg_damage(image, 5)
|
||||||
|
|
||||||
|
assert result.size == image.size
|
||||||
|
assert result.mode == "RGBA"
|
||||||
|
assert result.getchannel("A").getextrema() == (128, 128)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_jpeg_damage_clamps_quality_range():
|
||||||
|
image = Image.new("RGB", (16, 16), (123, 222, 111))
|
||||||
|
|
||||||
|
low = ImageFilterImplement.apply_jpeg_damage(image, -10)
|
||||||
|
high = ImageFilterImplement.apply_jpeg_damage(image, 999)
|
||||||
|
|
||||||
|
assert low.size == image.size
|
||||||
|
assert high.size == image.size
|
||||||
|
assert low.mode == "RGBA"
|
||||||
|
assert high.mode == "RGBA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_resize_clamps_small_result_to_at_least_one_pixel():
|
||||||
|
image = Image.new("RGBA", (10, 10), (255, 0, 0, 255))
|
||||||
|
|
||||||
|
result = ImageFilterImplement.apply_resize(image, 0.01)
|
||||||
|
|
||||||
|
assert result.size == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_resize_negative_x_with_positive_y_only_mirrors_horizontally():
|
||||||
|
image = Image.new("RGBA", (2, 1))
|
||||||
|
image.putpixel((0, 0), (255, 0, 0, 255))
|
||||||
|
image.putpixel((1, 0), (0, 0, 255, 255))
|
||||||
|
|
||||||
|
result = ImageFilterImplement.apply_resize(image, -1, 1)
|
||||||
|
|
||||||
|
assert result.size == (2, 1)
|
||||||
|
assert result.getpixel((0, 0)) == (0, 0, 255, 255)
|
||||||
|
assert result.getpixel((1, 0)) == (255, 0, 0, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_resize_negative_scale_without_y_flips_both_axes():
|
||||||
|
image = Image.new("RGBA", (2, 2))
|
||||||
|
image.putpixel((0, 0), (255, 0, 0, 255))
|
||||||
|
image.putpixel((1, 0), (0, 255, 0, 255))
|
||||||
|
image.putpixel((0, 1), (0, 0, 255, 255))
|
||||||
|
image.putpixel((1, 1), (255, 255, 0, 255))
|
||||||
|
|
||||||
|
result = ImageFilterImplement.apply_resize(image, -1)
|
||||||
|
|
||||||
|
assert result.size == (2, 2)
|
||||||
|
assert result.getpixel((0, 0)) == (255, 255, 0, 255)
|
||||||
|
assert result.getpixel((1, 0)) == (0, 0, 255, 255)
|
||||||
|
assert result.getpixel((0, 1)) == (0, 255, 0, 255)
|
||||||
|
assert result.getpixel((1, 1)) == (255, 0, 0, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prase_input_args_parses_resize_second_argument_as_float():
|
||||||
|
filters = prase_input_args("缩放 2 3")
|
||||||
|
|
||||||
|
assert len(filters) == 1
|
||||||
|
assert filters[0].name == "缩放"
|
||||||
|
assert filters[0].args == [2.0, 3.0]
|
||||||
105
tests/test_permsys.py
Normal file
105
tests/test_permsys.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
|
from konabot.common.permsys import PermManager
|
||||||
|
from konabot.common.permsys.entity import PermEntity
|
||||||
|
from konabot.common.permsys.migrates import execute_migration, get_current_version
|
||||||
|
from konabot.common.permsys.repo import PermRepo
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def tempdb():
|
||||||
|
with TemporaryDirectory() as _tempdir:
|
||||||
|
tempdir = Path(_tempdir)
|
||||||
|
db = DatabaseManager(tempdir / "perm.sqlite3")
|
||||||
|
yield db
|
||||||
|
await db.close_all_connections()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_db_version():
|
||||||
|
async with tempdb() as db:
|
||||||
|
async with db.get_conn() as conn:
|
||||||
|
v = await get_current_version(conn)
|
||||||
|
assert v == 0
|
||||||
|
v = await get_current_version(conn)
|
||||||
|
assert v == 0
|
||||||
|
await execute_migration(conn, version=1)
|
||||||
|
v = await get_current_version(conn)
|
||||||
|
assert v == 1
|
||||||
|
await execute_migration(conn, version=0)
|
||||||
|
v = await get_current_version(conn)
|
||||||
|
assert v == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perm():
|
||||||
|
async with tempdb() as db:
|
||||||
|
async with db.get_conn() as conn:
|
||||||
|
await execute_migration(conn)
|
||||||
|
|
||||||
|
service = PermManager(db)
|
||||||
|
entity_global = PermEntity("sys", "global", "global")
|
||||||
|
entity1 = PermEntity("nonexist-platform", "user", "passthem")
|
||||||
|
chain1 = [entity1, entity_global]
|
||||||
|
entity2 = PermEntity("nonexist-platform", "user", "jack")
|
||||||
|
chain2 = [entity2, entity_global]
|
||||||
|
|
||||||
|
async with db.get_conn() as conn:
|
||||||
|
repo = PermRepo(conn)
|
||||||
|
|
||||||
|
# 测试使用内置方法会创建 Entity 在数据库
|
||||||
|
assert await repo._get_entity_id_or_none(entity1) is None
|
||||||
|
assert await repo.get_entity_id(entity1) is not None
|
||||||
|
assert await repo._get_entity_id_or_none(entity1) is not None
|
||||||
|
|
||||||
|
# 测试使用内置方法获得 perm_info
|
||||||
|
assert await repo.get_perm_info(entity1, "module1") is None
|
||||||
|
|
||||||
|
assert not await service.check_has_permission(chain1, "*")
|
||||||
|
|
||||||
|
await service.update_permission(entity1, "*", True)
|
||||||
|
assert await service.check_has_permission(chain1, "*")
|
||||||
|
assert await service.check_has_permission(chain1, "module1")
|
||||||
|
assert await service.check_has_permission(chain1, "module1.pack1")
|
||||||
|
assert not await service.check_has_permission(chain2, "*")
|
||||||
|
assert not await service.check_has_permission(chain2, "module1")
|
||||||
|
assert not await service.check_has_permission(chain2, "module1.pack1")
|
||||||
|
|
||||||
|
await service.update_permission(entity2, "module1", True)
|
||||||
|
assert not await service.check_has_permission(chain2, "*")
|
||||||
|
assert await service.check_has_permission(chain2, "module1")
|
||||||
|
assert await service.check_has_permission(chain2, "module1.pack1")
|
||||||
|
assert await service.check_has_permission(chain2, "module1.pack2")
|
||||||
|
assert not await service.check_has_permission(chain2, "module2")
|
||||||
|
assert not await service.check_has_permission(chain2, "module2.pack1")
|
||||||
|
assert not await service.check_has_permission(chain2, "module2.pack2")
|
||||||
|
|
||||||
|
await service.update_permission(entity2, "module1.pack2", False)
|
||||||
|
assert not await service.check_has_permission(chain2, "*")
|
||||||
|
assert await service.check_has_permission(chain2, "module1")
|
||||||
|
assert await service.check_has_permission(chain2, "module1.pack1")
|
||||||
|
assert not await service.check_has_permission(chain2, "module1.pack2")
|
||||||
|
assert not await service.check_has_permission(chain2, "module2")
|
||||||
|
assert not await service.check_has_permission(chain2, "module2.pack1")
|
||||||
|
assert not await service.check_has_permission(chain2, "module2.pack2")
|
||||||
|
|
||||||
|
await service.update_permission(entity_global, "module2", True)
|
||||||
|
assert not await service.check_has_permission(chain2, "*")
|
||||||
|
assert await service.check_has_permission(chain2, "module1")
|
||||||
|
assert await service.check_has_permission(chain2, "module1.pack1")
|
||||||
|
assert not await service.check_has_permission(chain2, "module1.pack2")
|
||||||
|
assert await service.check_has_permission(chain2, "module2")
|
||||||
|
assert await service.check_has_permission(chain2, "module2.pack1")
|
||||||
|
assert await service.check_has_permission(chain2, "module2.pack2")
|
||||||
|
|
||||||
|
assert not await service.check_has_permission(entity2, "module2.pack2")
|
||||||
|
assert await service.check_has_permission(entity_global, "module2.pack2")
|
||||||
|
|
||||||
|
async with db.get_conn() as conn:
|
||||||
|
repo = PermRepo(conn)
|
||||||
|
assert await repo.get_perm_info(entity2, "module1") is True
|
||||||
|
assert await repo.get_perm_info(entity2, "module1.pack2") is False
|
||||||
40
tests/test_permsys_default_allow.py
Normal file
40
tests/test_permsys_default_allow.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
|
from konabot.common.permsys import PermManager, register_default_allow_permission
|
||||||
|
from konabot.common.permsys.entity import PermEntity
|
||||||
|
from konabot.common.permsys.migrates import execute_migration
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def tempdb():
|
||||||
|
with TemporaryDirectory() as _tempdir:
|
||||||
|
tempdir = Path(_tempdir)
|
||||||
|
db = DatabaseManager(tempdir / "perm.sqlite3")
|
||||||
|
yield db
|
||||||
|
await db.close_all_connections()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_default_allow_permission_records_key():
|
||||||
|
register_default_allow_permission("test.default.allow")
|
||||||
|
|
||||||
|
async with tempdb() as db:
|
||||||
|
async with db.get_conn() as conn:
|
||||||
|
await execute_migration(conn)
|
||||||
|
|
||||||
|
pm = PermManager(db)
|
||||||
|
await pm.update_permission(
|
||||||
|
PermEntity("sys", "global", "global"),
|
||||||
|
"test.default.allow",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await pm.check_has_permission(
|
||||||
|
[PermEntity("dummy", "user", "1"), PermEntity("sys", "global", "global")],
|
||||||
|
"test.default.allow.sub",
|
||||||
|
)
|
||||||
75
tests/test_textfx_runtime_limits.py
Normal file
75
tests/test_textfx_runtime_limits.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import nonebot
|
||||||
|
|
||||||
|
nonebot.init()
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from konabot.plugins.handle_text.__init__ import (
|
||||||
|
_get_textfx_user_key,
|
||||||
|
_textfx_running_users,
|
||||||
|
TEXTFX_MAX_RUNTIME_SECONDS,
|
||||||
|
)
|
||||||
|
from konabot.plugins.handle_text.base import PipelineRunner
|
||||||
|
|
||||||
|
|
||||||
|
class DummyEvent:
|
||||||
|
def __init__(self, self_id=None, user_id=None, group_id=None, session_id=None):
|
||||||
|
self.self_id = self_id
|
||||||
|
self.user_id = user_id
|
||||||
|
self.group_id = group_id
|
||||||
|
self._session_id = session_id
|
||||||
|
|
||||||
|
def get_session_id(self):
|
||||||
|
if self._session_id is None:
|
||||||
|
raise RuntimeError('no session')
|
||||||
|
return self._session_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_textfx_user_key_group():
|
||||||
|
evt = DummyEvent(self_id='123', user_id='456', group_id='789')
|
||||||
|
assert _get_textfx_user_key(evt) == '123:789:456'
|
||||||
|
|
||||||
|
|
||||||
|
def test_textfx_user_key_private():
|
||||||
|
evt = DummyEvent(self_id='123', user_id='456')
|
||||||
|
assert _get_textfx_user_key(evt) == '123:private:456'
|
||||||
|
|
||||||
|
|
||||||
|
def test_textfx_user_key_session_fallback():
|
||||||
|
evt = DummyEvent(session_id='console:alice')
|
||||||
|
assert _get_textfx_user_key(evt) == 'session:console:alice'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_textfx_timeout_limit():
|
||||||
|
"""测试脚本执行超时限制"""
|
||||||
|
runner = PipelineRunner.get_runner()
|
||||||
|
|
||||||
|
# 创建一个会超时的脚本(while true 会触发迭代限制,但我们用 sleep 模拟长时间运行)
|
||||||
|
# 由于实际超时是 60 秒,我们不能真的等那么久,所以这个测试验证超时机制存在
|
||||||
|
script = "echo start"
|
||||||
|
parsed = runner.parse_pipeline(script)
|
||||||
|
assert not isinstance(parsed, str), "脚本解析应该成功"
|
||||||
|
|
||||||
|
# 验证 TEXTFX_MAX_RUNTIME_SECONDS 常量存在且合理
|
||||||
|
assert TEXTFX_MAX_RUNTIME_SECONDS == 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_textfx_concurrent_limit():
|
||||||
|
"""测试同一用户并发执行限制"""
|
||||||
|
user_key = "test:group:user123"
|
||||||
|
|
||||||
|
# 清理可能的残留状态
|
||||||
|
_textfx_running_users.discard(user_key)
|
||||||
|
|
||||||
|
# 模拟第一个脚本正在运行
|
||||||
|
assert user_key not in _textfx_running_users
|
||||||
|
_textfx_running_users.add(user_key)
|
||||||
|
|
||||||
|
# 验证用户已被标记为运行中
|
||||||
|
assert user_key in _textfx_running_users
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
_textfx_running_users.discard(user_key)
|
||||||
|
assert user_key not in _textfx_running_users
|
||||||
225
tests/test_textfx_shell.py
Normal file
225
tests/test_textfx_shell.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import pytest
|
||||||
|
import nonebot
|
||||||
|
|
||||||
|
nonebot.init()
|
||||||
|
|
||||||
|
from konabot.plugins.handle_text.base import IfNode, PipelineRunner, TextHandlerEnvironment, WhileNode
|
||||||
|
from konabot.plugins.handle_text.handlers.encoding_handlers import THReverse
|
||||||
|
from konabot.plugins.handle_text.handlers.unix_handlers import (
|
||||||
|
THCat,
|
||||||
|
THEcho,
|
||||||
|
THFalse,
|
||||||
|
THRm,
|
||||||
|
THTest,
|
||||||
|
THTrue,
|
||||||
|
)
|
||||||
|
from konabot.plugins.handle_text.handlers.whitespace_handlers import THTrim
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner() -> PipelineRunner:
|
||||||
|
runner = PipelineRunner()
|
||||||
|
runner.register(THEcho())
|
||||||
|
runner.register(THCat())
|
||||||
|
runner.register(THRm())
|
||||||
|
runner.register(THTrue())
|
||||||
|
runner.register(THFalse())
|
||||||
|
runner.register(THTest())
|
||||||
|
runner.register(THReverse())
|
||||||
|
runner.register(THTrim())
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pipeline_shell_ops(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('echo hello | reverse && test a = a || echo no; echo done > out')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
assert len(parsed.statements) == 2
|
||||||
|
first = parsed.statements[0]
|
||||||
|
second = parsed.statements[1]
|
||||||
|
assert not isinstance(first, IfNode)
|
||||||
|
assert not isinstance(first, WhileNode)
|
||||||
|
assert not isinstance(second, IfNode)
|
||||||
|
assert not isinstance(second, WhileNode)
|
||||||
|
assert len(first.chains) == 3
|
||||||
|
assert first.chains[0].pipeline.commands[0].name == 'echo'
|
||||||
|
assert first.chains[0].pipeline.commands[1].name == 'reverse'
|
||||||
|
assert second.chains[0].pipeline.commands[0].redirects[0].target == 'out'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_if_statement(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('if test a = a; then echo yes; else echo no; fi')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
assert len(parsed.statements) == 1
|
||||||
|
stmt = parsed.statements[0]
|
||||||
|
assert isinstance(stmt, IfNode)
|
||||||
|
assert stmt.else_body is not None
|
||||||
|
assert len(stmt.then_body.statements) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_while_statement(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('while false; do echo yes; done')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
assert len(parsed.statements) == 1
|
||||||
|
stmt = parsed.statements[0]
|
||||||
|
assert isinstance(stmt, WhileNode)
|
||||||
|
assert len(stmt.body.statements) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pipeline_pipe(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('echo hello | reverse')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].code == 0
|
||||||
|
assert results[0].ostream == 'olleh'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redirect_and_cat(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('echo hello > a; cat a')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
env = TextHandlerEnvironment(False)
|
||||||
|
results = await runner.run_pipeline(parsed, None, env)
|
||||||
|
assert env.buffers['a'] == 'hello'
|
||||||
|
assert results[-1].ostream == 'hello'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_append_redirect(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('echo hello > a; echo world >> a; cat a')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
env = TextHandlerEnvironment(False)
|
||||||
|
results = await runner.run_pipeline(parsed, None, env)
|
||||||
|
assert env.buffers['a'] == 'helloworld'
|
||||||
|
assert results[-1].ostream == 'helloworld'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_and_or_short_circuit(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('test a = b && echo bad || echo ok')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].code == 0
|
||||||
|
assert results[0].ostream == 'ok'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_bracket_alias(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('[ 2 -gt 1 ] && echo yes')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].code == 0
|
||||||
|
assert results[0].ostream == 'yes'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_string_ops(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('test -n abc && echo yes; test -z abc || echo no')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert [r.ostream for r in results] == ['yes', 'no']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quote_and_trim(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('echo " hello world " | trim')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].ostream == 'hello world'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_if_then_else(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('if test a = b; then echo yes; else echo no; fi')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].code == 0
|
||||||
|
assert results[0].ostream == 'no'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_if_then_without_else(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('if test a = a; then echo yes; fi')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].ostream == 'yes'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nested_if(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline(
|
||||||
|
'if test a = a; then if test b = c; then echo x; else echo y; fi; else echo z; fi'
|
||||||
|
)
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].ostream == 'y'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_negate_pipeline(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('! test a = b && echo ok')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].ostream == 'ok'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_true_false(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('true && echo yes; false || echo no')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert [r.ostream for r in results] == ['yes', 'no']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_while_false_noop(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('while false; do echo yes; done')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].code == 0
|
||||||
|
assert results[0].ostream is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_while_limit_guard(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('while true; do echo yes; done')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].code == 2
|
||||||
|
assert 'while 循环超过最大迭代次数限制' in (results[0].ostream or '')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_while_with_immediate_break_condition(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('while ! false; do false; done')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].code == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_while_body_can_use_if(runner: PipelineRunner):
|
||||||
|
parsed = runner.parse_pipeline('while ! false; do if true; then false; fi; done')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].code == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_echo_empty_string(runner: PipelineRunner):
|
||||||
|
"""测试 echo 空字符串"""
|
||||||
|
# 双引号空字符串
|
||||||
|
parsed = runner.parse_pipeline('echo ""')
|
||||||
|
assert not isinstance(parsed, str)
|
||||||
|
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
|
||||||
|
assert results[0].code == 0
|
||||||
|
assert results[0].ostream == ''
|
||||||
|
|
||||||
|
# 单引号空字符串
|
||||||
|
parsed2 = runner.parse_pipeline("echo ''")
|
||||||
|
assert not isinstance(parsed2, str)
|
||||||
|
results2 = await runner.run_pipeline(parsed2, None, TextHandlerEnvironment(False))
|
||||||
|
assert results2[0].code == 0
|
||||||
|
assert results2[0].ostream == ''
|
||||||
66
tests/test_trpg_roll.py
Normal file
66
tests/test_trpg_roll.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from konabot.plugins.trpg_roll.core import RollError, roll_expression
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRandom:
|
||||||
|
def __init__(self, randint_values: list[int] | None = None, choice_values: list[int] | None = None):
|
||||||
|
self._randint_values = list(randint_values or [])
|
||||||
|
self._choice_values = list(choice_values or [])
|
||||||
|
|
||||||
|
def randint(self, _a: int, _b: int) -> int:
|
||||||
|
assert self._randint_values
|
||||||
|
return self._randint_values.pop(0)
|
||||||
|
|
||||||
|
def choice(self, _seq):
|
||||||
|
assert self._choice_values
|
||||||
|
return self._choice_values.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_roll_expression_basic():
|
||||||
|
rng = FakeRandom(randint_values=[2, 4, 5])
|
||||||
|
result = roll_expression("3d6", rng=rng)
|
||||||
|
|
||||||
|
assert result.total == 11
|
||||||
|
assert result.format() == "3d6 = 11\n+3d6=[2, 4, 5]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_roll_expression_multiple_terms():
|
||||||
|
rng = FakeRandom(randint_values=[14, 3, 1])
|
||||||
|
result = roll_expression("d20+1d4-2", rng=rng)
|
||||||
|
|
||||||
|
assert result.total == 15
|
||||||
|
assert result.format() == "d20+1d4-2 = 15\n+1d20=[14] +1d4=[3] -2=2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_roll_expression_df():
|
||||||
|
rng = FakeRandom(choice_values=[-1, 0, 1, 1])
|
||||||
|
result = roll_expression("4dF", rng=rng)
|
||||||
|
|
||||||
|
assert result.total == 1
|
||||||
|
assert result.format() == "4dF = 1\n+4dF=[-1, +0, +1, +1]"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("expr", "message"),
|
||||||
|
[
|
||||||
|
("", "请提供要掷的表达式"),
|
||||||
|
("abc", "无法解析表达式"),
|
||||||
|
("1d0", "骰子面数必须大于 0"),
|
||||||
|
("0d6", "骰子个数必须大于 0"),
|
||||||
|
("101d6", "单项最多只能掷 100 个骰子"),
|
||||||
|
("1d1001", "骰子面数不能超过 1000"),
|
||||||
|
("201d1", "单项最多只能掷 100 个骰子"),
|
||||||
|
("1d6*2", "表达式中含有无法识别的内容"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_roll_expression_invalid(expr: str, message: str):
|
||||||
|
with pytest.raises(RollError, match=message):
|
||||||
|
roll_expression(expr, rng=random.Random(0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_roll_expression_total_roll_limit():
|
||||||
|
with pytest.raises(RollError, match="一次最多只能实际掷 200 个骰子"):
|
||||||
|
roll_expression("100d6+100d6+1d6", rng=random.Random(0))
|
||||||
Reference in New Issue
Block a user