tenko-plugin-example 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tenko_plugin_example-0.1.0/.github/workflows/publish.yml +51 -0
- tenko_plugin_example-0.1.0/.github/workflows/test.yml +34 -0
- tenko_plugin_example-0.1.0/.gitignore +223 -0
- tenko_plugin_example-0.1.0/PKG-INFO +113 -0
- tenko_plugin_example-0.1.0/README.md +101 -0
- tenko_plugin_example-0.1.0/pyproject.toml +44 -0
- tenko_plugin_example-0.1.0/src/tenko_plugin_example/__init__.py +98 -0
- tenko_plugin_example-0.1.0/tests/conftest.py +81 -0
- tenko_plugin_example-0.1.0/tests/test_plugin.py +46 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# 推送与 pyproject.toml 版本一致的标签(如 v0.1.0)时发布到 PyPI。
|
|
4
|
+
# 使用 PyPI Trusted Publishing,不需要保存 token:先在 PyPI 上为该项目建立
|
|
5
|
+
# (pending)publisher,工作流填 publish.yml,环境填 pypi。
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v5
|
|
21
|
+
|
|
22
|
+
- name: Verify the tag matches the project version
|
|
23
|
+
run: |
|
|
24
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
25
|
+
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
|
26
|
+
echo "::error::标签 $GITHUB_REF_NAME 与 pyproject.toml 版本 v$version 不一致"
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
- name: Build sdist and wheel
|
|
31
|
+
run: uv build --out-dir dist
|
|
32
|
+
|
|
33
|
+
- uses: actions/upload-artifact@v4
|
|
34
|
+
with:
|
|
35
|
+
name: dist
|
|
36
|
+
path: dist/
|
|
37
|
+
|
|
38
|
+
publish:
|
|
39
|
+
needs: build
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
environment: pypi
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/download-artifact@v4
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist/
|
|
49
|
+
|
|
50
|
+
- name: Publish with Trusted Publishing
|
|
51
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.11", "3.12"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
|
|
26
|
+
# 开发依赖里包含从 Git 安装的 Tenko 宿主,测试会加载真实的宿主。
|
|
27
|
+
- name: Sync dependencies
|
|
28
|
+
run: uv sync
|
|
29
|
+
|
|
30
|
+
- name: Lint
|
|
31
|
+
run: uv run ruff check .
|
|
32
|
+
|
|
33
|
+
- name: Test
|
|
34
|
+
run: uv run pytest -q
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
### Python template
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
.python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
#Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# poetry
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
103
|
+
#poetry.lock
|
|
104
|
+
|
|
105
|
+
# pdm
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
107
|
+
#pdm.lock
|
|
108
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
109
|
+
# in version control.
|
|
110
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
111
|
+
.pdm.toml
|
|
112
|
+
.pdm-python
|
|
113
|
+
.pdm-build/
|
|
114
|
+
|
|
115
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
116
|
+
__pypackages__/
|
|
117
|
+
|
|
118
|
+
# Celery stuff
|
|
119
|
+
celerybeat-schedule
|
|
120
|
+
celerybeat.pid
|
|
121
|
+
|
|
122
|
+
# SageMath parsed files
|
|
123
|
+
*.sage.py
|
|
124
|
+
|
|
125
|
+
# Environments
|
|
126
|
+
.env
|
|
127
|
+
.venv
|
|
128
|
+
env/
|
|
129
|
+
venv/
|
|
130
|
+
ENV/
|
|
131
|
+
env.bak/
|
|
132
|
+
venv.bak/
|
|
133
|
+
|
|
134
|
+
# Spyder project settings
|
|
135
|
+
.spyderproject
|
|
136
|
+
.spyproject
|
|
137
|
+
|
|
138
|
+
# Rope project settings
|
|
139
|
+
.ropeproject
|
|
140
|
+
|
|
141
|
+
# mkdocs documentation
|
|
142
|
+
/site
|
|
143
|
+
|
|
144
|
+
# mypy
|
|
145
|
+
.mypy_cache/
|
|
146
|
+
.dmypy.json
|
|
147
|
+
dmypy.json
|
|
148
|
+
|
|
149
|
+
# Pyre type checker
|
|
150
|
+
.pyre/
|
|
151
|
+
|
|
152
|
+
# pytype static type analyzer
|
|
153
|
+
.pytype/
|
|
154
|
+
|
|
155
|
+
# Cython debug symbols
|
|
156
|
+
cython_debug/
|
|
157
|
+
|
|
158
|
+
# PyCharm
|
|
159
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
160
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
161
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
162
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
163
|
+
.idea/
|
|
164
|
+
|
|
165
|
+
# Python binary files
|
|
166
|
+
*/__pycache__
|
|
167
|
+
*.pyc
|
|
168
|
+
|
|
169
|
+
# VS Code
|
|
170
|
+
.vscode/
|
|
171
|
+
|
|
172
|
+
# 配置文件、日志
|
|
173
|
+
/config/config.yaml
|
|
174
|
+
/config/tenko.toml
|
|
175
|
+
/log
|
|
176
|
+
|
|
177
|
+
# 测试文件
|
|
178
|
+
/modules/test_modules/
|
|
179
|
+
auto_get_server.py
|
|
180
|
+
test.py
|
|
181
|
+
drafts/
|
|
182
|
+
|
|
183
|
+
# 数据库
|
|
184
|
+
data.db
|
|
185
|
+
|
|
186
|
+
# Alembic files
|
|
187
|
+
/alembic/
|
|
188
|
+
alembic.ini
|
|
189
|
+
|
|
190
|
+
# 数据文件
|
|
191
|
+
/data/
|
|
192
|
+
/.entari/
|
|
193
|
+
/.tenko/
|
|
194
|
+
/modules/self_contained/bilibili_dynamic/dynamic_list.json
|
|
195
|
+
/modules/self_contained/emoji_mix/assets/update.json
|
|
196
|
+
/modules/self_contained/chat_gpt/gpt_mode.json
|
|
197
|
+
/modules/self_contained/claude/config.json
|
|
198
|
+
/utils/bf1/default_account.json
|
|
199
|
+
/utils/bf1/ap_info.json
|
|
200
|
+
|
|
201
|
+
# 第三方插件
|
|
202
|
+
/modules/third_party/
|
|
203
|
+
/modules/self_contained/bf1_warm_server/
|
|
204
|
+
|
|
205
|
+
#文件修改记录
|
|
206
|
+
.history/
|
|
207
|
+
|
|
208
|
+
# Claude Code 本地配置
|
|
209
|
+
.claude/settings.local.json
|
|
210
|
+
.claude/*.local.*
|
|
211
|
+
.claude/local/
|
|
212
|
+
.claude/plugins.json
|
|
213
|
+
|
|
214
|
+
# Sigma 本地检查点(含嵌套 .git,禁止提交以免产生误判的子模块)
|
|
215
|
+
.sigmadev/
|
|
216
|
+
|
|
217
|
+
# 临时交接文档
|
|
218
|
+
HANDOFF_BF_MANAGER.md
|
|
219
|
+
|
|
220
|
+
.serena/
|
|
221
|
+
|
|
222
|
+
# WebUI 前端依赖(构建产物 tenko/webui/static 需要提交)
|
|
223
|
+
webui/node_modules
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tenko-plugin-example
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tenko 第三方插件示例与模板
|
|
5
|
+
Project-URL: Homepage, https://github.com/g1331/tenko/tree/main/examples/tenko-plugin-example
|
|
6
|
+
Project-URL: Repository, https://github.com/g1331/tenko
|
|
7
|
+
License-Expression: GPL-3.0-only
|
|
8
|
+
Requires-Python: <3.14,>=3.11
|
|
9
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
10
|
+
Requires-Dist: tenko-api<0.3,>=0.2
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# tenko-plugin-example
|
|
14
|
+
|
|
15
|
+
[Tenko](https://github.com/g1331/tenko) 第三方插件的示例与起步模板。它演示:
|
|
16
|
+
|
|
17
|
+
- 用 `tenko_api.features.declare` 声明按群开关、默认状态、限流权重和群等级;
|
|
18
|
+
- 用 `[plugin_config.example]` 配置插件(Entari 的 `BasicConfModel`);
|
|
19
|
+
- 用 `tenko_api.storage.Store` 保存带版本号的 JSON 数据,用 `Model` 和 `get_session()` 使用插件自己的 SQLite 库;
|
|
20
|
+
- 用 `tenko_api.perm` 的装饰器(`@require` 等)声明命令权限,用 Entari 原生的 `command.on` 注册命令。
|
|
21
|
+
|
|
22
|
+
插件命令 `/示例 [备注]` 记录你使用命令的次数(JSON)和备注历史(SQLite)。
|
|
23
|
+
|
|
24
|
+
## 从模板开始
|
|
25
|
+
|
|
26
|
+
1. 复制本目录作为新项目(在 GitHub 上可以把它放进自己的仓库)。
|
|
27
|
+
2. 改名:分发名用 `tenko-plugin-xxx`,模块名用 `tenko_plugin_xxx`。同步修改
|
|
28
|
+
`pyproject.toml` 的 `name`、`[tool.hatch.build.targets.wheel]`、`src/` 目录名,以及
|
|
29
|
+
入口 `[project.entry-points."tenko.plugins"]` 里的 `<插件 ID> = "<模块>"`。
|
|
30
|
+
插件 ID 匹配 `[a-z][a-z0-9_]*`,全局唯一,不能和内置插件同名;它是功能开关、
|
|
31
|
+
配置节和数据目录的键。
|
|
32
|
+
3. 换成你自己的信息:`plugin.metadata` 的 `author`、`description`,以及 `pyproject.toml`
|
|
33
|
+
的 `[project.urls]`(插件目录靠这里的 GitHub 地址确认仓库归你所有)。示例本身作为真实
|
|
34
|
+
插件发布在 PyPI 上,这些地方写的是 Tenko 维护者的信息。
|
|
35
|
+
4. **不要声明 `entari.plugin` 入口**:Entari 会无视 Tenko 的启用状态自动加载它。
|
|
36
|
+
5. 依赖只写 `tenko-api` 的兼容范围(预览期为 `>=0.2,<0.3`)和插件自己的依赖,
|
|
37
|
+
不要依赖 `tenko` 本体,也不要用 `==` 锁死依赖版本。
|
|
38
|
+
|
|
39
|
+
## 开发与测试
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
uv sync # 创建环境,开发依赖里包含从 Git 安装的 Tenko 宿主(发布后请固定到版本标签)
|
|
43
|
+
uv run pytest -q
|
|
44
|
+
uv run ruff check .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`tests/conftest.py` 用真实的宿主加载插件:按 `tenko.plugins` 入口发现、启用、传入配置,
|
|
48
|
+
然后直接调用命令处理函数。不需要启动 Tenko,也不需要连接聊天平台。
|
|
49
|
+
|
|
50
|
+
写法要点:
|
|
51
|
+
|
|
52
|
+
- 宿主能力一律从 `tenko_api` 导入,不要导入 `tenko.*` 内部模块。
|
|
53
|
+
- `declare(...)` 必须在插件模块导入期间调用(与 `plugin.metadata(...)` 放在一起)。
|
|
54
|
+
- `data_dir()` 和 `Store` 只能在插件自己的模块里使用,数据位于 `.tenko/plugin-data/<插件 ID>/`。
|
|
55
|
+
数据结构变化时提高 `Store` 的版本号并提供 `migrate`,宿主会先把旧文件备份为
|
|
56
|
+
`<名称>.json.bak-v<旧版本>`;插件被降级、读到更新版本的文件时会报错而不是覆盖。
|
|
57
|
+
- `Model` 子类在插件模块导入期间定义,表建在 `data.db`;启动时自动建表和增加可空列,
|
|
58
|
+
删除或修改列会被拒绝,需要时写官方的 `register_custom_migration` 脚本。测试里要像宿主
|
|
59
|
+
那样先加载数据库插件、再加载插件、最后初始化并迁移(见 `tests/conftest.py`)。
|
|
60
|
+
版本变化后宿主会先备份库到 `backups/`。
|
|
61
|
+
- 命令权限用 `tenko_api.perm` 的装饰器声明,不要在函数体里重复检查;装饰器在
|
|
62
|
+
`@command.on(...)` 之上或之下都生效。因为装饰器由 Entari 调度,测试里直接调用
|
|
63
|
+
`callable_target` 不会经过它们,应检查登记的要求(见 `tests/test_plugin.py`),
|
|
64
|
+
或像 `tests/tenko` 里那样走真实分发。
|
|
65
|
+
- Entari 对标准 dataclass 配置模型不做类型校验,需要时在 `__post_init__` 里检查。
|
|
66
|
+
|
|
67
|
+
## 在 Tenko 里启用
|
|
68
|
+
|
|
69
|
+
1. 在运行 Tenko 的环境里安装插件:`uv pip install tenko-plugin-xxx`(本地调试用
|
|
70
|
+
`uv pip install -e /path/to/plugin`)。
|
|
71
|
+
2. 打开 WebUI 的插件页,启用该插件(带“第三方”标记)。第三方插件默认关闭,启用后
|
|
72
|
+
写入 `.tenko/plugins/state.json`。
|
|
73
|
+
3. 如有配置,在 `config/tenko.toml` 里添加 `[plugin_config.<插件 ID>]`,见仓库中
|
|
74
|
+
`config/tenko.toml.example` 末尾的说明。
|
|
75
|
+
4. 重启 Tenko(聊天里的 `/重启` 或重新运行进程),插件才会被加载。
|
|
76
|
+
|
|
77
|
+
之后可以在 WebUI 开关页或用 `/开启`、`/关闭` 按群控制,插件命令与内置命令走同一套
|
|
78
|
+
维护模式、功能开关和限流。
|
|
79
|
+
|
|
80
|
+
## 发布
|
|
81
|
+
|
|
82
|
+
**发布到 PyPI(推荐,使用 Trusted Publishing,不保存 token)**
|
|
83
|
+
|
|
84
|
+
1. 在 PyPI 上添加 pending publisher:项目名填你的分发名,所有者和仓库填你的 GitHub
|
|
85
|
+
仓库,工作流填 `publish.yml`,环境填 `pypi`。
|
|
86
|
+
2. 在 GitHub 仓库设置里创建名为 `pypi` 的 Environment。
|
|
87
|
+
3. 把 `pyproject.toml` 的版本改为 `X.Y.Z`,提交后打标签并推送:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
git tag vX.Y.Z && git push origin vX.Y.Z
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`.github/workflows/publish.yml` 会检查标签与版本一致、构建并发布。
|
|
94
|
+
|
|
95
|
+
**只用 GitHub**:不发布到 PyPI 时,使用者可以直接
|
|
96
|
+
`uv pip install "tenko-plugin-xxx @ git+https://github.com/<你>/<仓库>@<标签或提交>"`,
|
|
97
|
+
请固定到标签或提交哈希。
|
|
98
|
+
|
|
99
|
+
## 收录到插件目录
|
|
100
|
+
|
|
101
|
+
发布到 PyPI(或打好 Git 标签)后,在 [tenko-registry](https://github.com/g1331/tenko-registry)
|
|
102
|
+
用投稿表单填写来源即可。插件 ID、名称、简介、作者、许可证和兼容范围都由目录的 CI 从
|
|
103
|
+
`pyproject.toml` 和 `plugin.metadata(...)` 读取,之后的新版本也会自动追加。
|
|
104
|
+
|
|
105
|
+
图标可选:在 `plugin.metadata(icon="https://…")` 里给出图片地址,或在投稿表单里上传。要求
|
|
106
|
+
透明背景的单色图形(PNG 或 WebP,不接受 JPEG、SVG 和动图),短边至少 64 px;目录会裁成
|
|
107
|
+
正方形、缩放到 128×128 PNG 并自行托管,Tenko 只显示目录托管的图标。
|
|
108
|
+
|
|
109
|
+
## 兼容性
|
|
110
|
+
|
|
111
|
+
`tenko-api` 遵循语义化版本(1.0 之前为预览,接口可能调整)。插件声明的范围由宿主在
|
|
112
|
+
加载前检查:宿主提供的 `tenko-api` 不在范围内时,插件不加载,日志说明原因。注意 `uv pip install`
|
|
113
|
+
遇到不兼容的范围不会报错,而是把环境里的 `tenko-api` 换成插件要的版本,所以不能靠安装结果判断兼容性。
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# tenko-plugin-example
|
|
2
|
+
|
|
3
|
+
[Tenko](https://github.com/g1331/tenko) 第三方插件的示例与起步模板。它演示:
|
|
4
|
+
|
|
5
|
+
- 用 `tenko_api.features.declare` 声明按群开关、默认状态、限流权重和群等级;
|
|
6
|
+
- 用 `[plugin_config.example]` 配置插件(Entari 的 `BasicConfModel`);
|
|
7
|
+
- 用 `tenko_api.storage.Store` 保存带版本号的 JSON 数据,用 `Model` 和 `get_session()` 使用插件自己的 SQLite 库;
|
|
8
|
+
- 用 `tenko_api.perm` 的装饰器(`@require` 等)声明命令权限,用 Entari 原生的 `command.on` 注册命令。
|
|
9
|
+
|
|
10
|
+
插件命令 `/示例 [备注]` 记录你使用命令的次数(JSON)和备注历史(SQLite)。
|
|
11
|
+
|
|
12
|
+
## 从模板开始
|
|
13
|
+
|
|
14
|
+
1. 复制本目录作为新项目(在 GitHub 上可以把它放进自己的仓库)。
|
|
15
|
+
2. 改名:分发名用 `tenko-plugin-xxx`,模块名用 `tenko_plugin_xxx`。同步修改
|
|
16
|
+
`pyproject.toml` 的 `name`、`[tool.hatch.build.targets.wheel]`、`src/` 目录名,以及
|
|
17
|
+
入口 `[project.entry-points."tenko.plugins"]` 里的 `<插件 ID> = "<模块>"`。
|
|
18
|
+
插件 ID 匹配 `[a-z][a-z0-9_]*`,全局唯一,不能和内置插件同名;它是功能开关、
|
|
19
|
+
配置节和数据目录的键。
|
|
20
|
+
3. 换成你自己的信息:`plugin.metadata` 的 `author`、`description`,以及 `pyproject.toml`
|
|
21
|
+
的 `[project.urls]`(插件目录靠这里的 GitHub 地址确认仓库归你所有)。示例本身作为真实
|
|
22
|
+
插件发布在 PyPI 上,这些地方写的是 Tenko 维护者的信息。
|
|
23
|
+
4. **不要声明 `entari.plugin` 入口**:Entari 会无视 Tenko 的启用状态自动加载它。
|
|
24
|
+
5. 依赖只写 `tenko-api` 的兼容范围(预览期为 `>=0.2,<0.3`)和插件自己的依赖,
|
|
25
|
+
不要依赖 `tenko` 本体,也不要用 `==` 锁死依赖版本。
|
|
26
|
+
|
|
27
|
+
## 开发与测试
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
uv sync # 创建环境,开发依赖里包含从 Git 安装的 Tenko 宿主(发布后请固定到版本标签)
|
|
31
|
+
uv run pytest -q
|
|
32
|
+
uv run ruff check .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`tests/conftest.py` 用真实的宿主加载插件:按 `tenko.plugins` 入口发现、启用、传入配置,
|
|
36
|
+
然后直接调用命令处理函数。不需要启动 Tenko,也不需要连接聊天平台。
|
|
37
|
+
|
|
38
|
+
写法要点:
|
|
39
|
+
|
|
40
|
+
- 宿主能力一律从 `tenko_api` 导入,不要导入 `tenko.*` 内部模块。
|
|
41
|
+
- `declare(...)` 必须在插件模块导入期间调用(与 `plugin.metadata(...)` 放在一起)。
|
|
42
|
+
- `data_dir()` 和 `Store` 只能在插件自己的模块里使用,数据位于 `.tenko/plugin-data/<插件 ID>/`。
|
|
43
|
+
数据结构变化时提高 `Store` 的版本号并提供 `migrate`,宿主会先把旧文件备份为
|
|
44
|
+
`<名称>.json.bak-v<旧版本>`;插件被降级、读到更新版本的文件时会报错而不是覆盖。
|
|
45
|
+
- `Model` 子类在插件模块导入期间定义,表建在 `data.db`;启动时自动建表和增加可空列,
|
|
46
|
+
删除或修改列会被拒绝,需要时写官方的 `register_custom_migration` 脚本。测试里要像宿主
|
|
47
|
+
那样先加载数据库插件、再加载插件、最后初始化并迁移(见 `tests/conftest.py`)。
|
|
48
|
+
版本变化后宿主会先备份库到 `backups/`。
|
|
49
|
+
- 命令权限用 `tenko_api.perm` 的装饰器声明,不要在函数体里重复检查;装饰器在
|
|
50
|
+
`@command.on(...)` 之上或之下都生效。因为装饰器由 Entari 调度,测试里直接调用
|
|
51
|
+
`callable_target` 不会经过它们,应检查登记的要求(见 `tests/test_plugin.py`),
|
|
52
|
+
或像 `tests/tenko` 里那样走真实分发。
|
|
53
|
+
- Entari 对标准 dataclass 配置模型不做类型校验,需要时在 `__post_init__` 里检查。
|
|
54
|
+
|
|
55
|
+
## 在 Tenko 里启用
|
|
56
|
+
|
|
57
|
+
1. 在运行 Tenko 的环境里安装插件:`uv pip install tenko-plugin-xxx`(本地调试用
|
|
58
|
+
`uv pip install -e /path/to/plugin`)。
|
|
59
|
+
2. 打开 WebUI 的插件页,启用该插件(带“第三方”标记)。第三方插件默认关闭,启用后
|
|
60
|
+
写入 `.tenko/plugins/state.json`。
|
|
61
|
+
3. 如有配置,在 `config/tenko.toml` 里添加 `[plugin_config.<插件 ID>]`,见仓库中
|
|
62
|
+
`config/tenko.toml.example` 末尾的说明。
|
|
63
|
+
4. 重启 Tenko(聊天里的 `/重启` 或重新运行进程),插件才会被加载。
|
|
64
|
+
|
|
65
|
+
之后可以在 WebUI 开关页或用 `/开启`、`/关闭` 按群控制,插件命令与内置命令走同一套
|
|
66
|
+
维护模式、功能开关和限流。
|
|
67
|
+
|
|
68
|
+
## 发布
|
|
69
|
+
|
|
70
|
+
**发布到 PyPI(推荐,使用 Trusted Publishing,不保存 token)**
|
|
71
|
+
|
|
72
|
+
1. 在 PyPI 上添加 pending publisher:项目名填你的分发名,所有者和仓库填你的 GitHub
|
|
73
|
+
仓库,工作流填 `publish.yml`,环境填 `pypi`。
|
|
74
|
+
2. 在 GitHub 仓库设置里创建名为 `pypi` 的 Environment。
|
|
75
|
+
3. 把 `pyproject.toml` 的版本改为 `X.Y.Z`,提交后打标签并推送:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
git tag vX.Y.Z && git push origin vX.Y.Z
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`.github/workflows/publish.yml` 会检查标签与版本一致、构建并发布。
|
|
82
|
+
|
|
83
|
+
**只用 GitHub**:不发布到 PyPI 时,使用者可以直接
|
|
84
|
+
`uv pip install "tenko-plugin-xxx @ git+https://github.com/<你>/<仓库>@<标签或提交>"`,
|
|
85
|
+
请固定到标签或提交哈希。
|
|
86
|
+
|
|
87
|
+
## 收录到插件目录
|
|
88
|
+
|
|
89
|
+
发布到 PyPI(或打好 Git 标签)后,在 [tenko-registry](https://github.com/g1331/tenko-registry)
|
|
90
|
+
用投稿表单填写来源即可。插件 ID、名称、简介、作者、许可证和兼容范围都由目录的 CI 从
|
|
91
|
+
`pyproject.toml` 和 `plugin.metadata(...)` 读取,之后的新版本也会自动追加。
|
|
92
|
+
|
|
93
|
+
图标可选:在 `plugin.metadata(icon="https://…")` 里给出图片地址,或在投稿表单里上传。要求
|
|
94
|
+
透明背景的单色图形(PNG 或 WebP,不接受 JPEG、SVG 和动图),短边至少 64 px;目录会裁成
|
|
95
|
+
正方形、缩放到 128×128 PNG 并自行托管,Tenko 只显示目录托管的图标。
|
|
96
|
+
|
|
97
|
+
## 兼容性
|
|
98
|
+
|
|
99
|
+
`tenko-api` 遵循语义化版本(1.0 之前为预览,接口可能调整)。插件声明的范围由宿主在
|
|
100
|
+
加载前检查:宿主提供的 `tenko-api` 不在范围内时,插件不加载,日志说明原因。注意 `uv pip install`
|
|
101
|
+
遇到不兼容的范围不会报错,而是把环境里的 `tenko-api` 换成插件要的版本,所以不能靠安装结果判断兼容性。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tenko-plugin-example"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tenko 第三方插件示例与模板"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "GPL-3.0-only"
|
|
7
|
+
requires-python = ">=3.11,<3.14"
|
|
8
|
+
dependencies = [
|
|
9
|
+
# 兼容声明:解析器据此拒绝不兼容的宿主。上限写到下一个次版本(1.0 之后写到主版本)。
|
|
10
|
+
"tenko-api>=0.2,<0.3",
|
|
11
|
+
# 插件自己的依赖写在这里:只写下限,不要用 == 锁死,以免与宿主冲突。
|
|
12
|
+
# Model 基于宿主已有的 SQLAlchemy,声明下限即可。
|
|
13
|
+
"sqlalchemy>=2.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
# 插件目录用这些链接确认提交者对仓库的归属,并取 Homepage 作为主页。
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/g1331/tenko/tree/main/examples/tenko-plugin-example"
|
|
19
|
+
Repository = "https://github.com/g1331/tenko"
|
|
20
|
+
|
|
21
|
+
# 入口名就是插件 ID(小写字母、数字、下划线,全局唯一);值是纯模块路径。
|
|
22
|
+
# 不要声明 entari.plugin 入口,Entari 会无视 Tenko 的启用状态自动加载它。
|
|
23
|
+
[project.entry-points."tenko.plugins"]
|
|
24
|
+
example = "tenko_plugin_example"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/tenko_plugin_example"]
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8",
|
|
36
|
+
"pytest-asyncio>=1",
|
|
37
|
+
"ruff",
|
|
38
|
+
# 测试要加载真实的宿主。发布后请把 main 换成固定的 Tenko 版本标签。
|
|
39
|
+
"tenko @ git+https://github.com/g1331/tenko@main",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tenko 第三方插件示例:问候计数。
|
|
2
|
+
|
|
3
|
+
演示 tenko_api 的主要能力:声明元数据(含群等级)、读取配置、带版本的 JSON
|
|
4
|
+
存储、插件自己的 SQLite 库(``Model``),以及声明式权限装饰器。写法与内置插件一样,
|
|
5
|
+
都是 Entari 原生的 ``plugin.metadata``、``command.on``。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import String, select
|
|
9
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
10
|
+
|
|
11
|
+
from arclet.alconna import Alconna, Args, CommandMeta
|
|
12
|
+
from arclet.entari import BasicConfModel, Session, command, plugin
|
|
13
|
+
from arclet.entari.command import Query
|
|
14
|
+
|
|
15
|
+
from tenko_api.context import context_from_session
|
|
16
|
+
from tenko_api.features import declare
|
|
17
|
+
from tenko_api.perm import Permission, require
|
|
18
|
+
from tenko_api.storage import Model, Store, get_session
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExampleConfig(BasicConfModel):
|
|
22
|
+
"""对应 config/tenko.toml 中的 ``[plugin_config.example]``。"""
|
|
23
|
+
|
|
24
|
+
greeting: str = "你好"
|
|
25
|
+
"""回复使用的问候语。"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
plugin.metadata(
|
|
29
|
+
"示例插件",
|
|
30
|
+
author=["13"],
|
|
31
|
+
version="0.1.0",
|
|
32
|
+
description="记录每位用户使用示例命令的次数,并保存备注。",
|
|
33
|
+
config=ExampleConfig,
|
|
34
|
+
# 可选:插件目录会下载这张图,处理成 128×128 PNG 后自行托管。
|
|
35
|
+
# icon="https://raw.githubusercontent.com/you/tenko-plugin-example/main/icon.png",
|
|
36
|
+
)
|
|
37
|
+
# Tenko 专有元数据:按群开关、新群默认开启、每次调用计入限流窗口的权重,以及
|
|
38
|
+
# 插件要求的群等级(低于它的群里命令被静默忽略,ActiveGroup 即默认值)。
|
|
39
|
+
declare(
|
|
40
|
+
scope="group",
|
|
41
|
+
default_enabled=True,
|
|
42
|
+
rate_weight=1,
|
|
43
|
+
level=Permission.ActiveGroup,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
config = plugin.get_config(ExampleConfig)
|
|
47
|
+
# .tenko/plugin-data/example/visits.json;以后改数据结构时提高 version,并提供 migrate。
|
|
48
|
+
visits = Store("visits", version=1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Note(Model):
|
|
52
|
+
"""备注历史。表建在 .tenko/plugin-data/example/data.db,表名默认为 example_note。
|
|
53
|
+
|
|
54
|
+
宿主启动时自动建表,并自动执行新增表、新增可空列、新增索引;删除或修改列需要
|
|
55
|
+
显式迁移脚本,否则会被拒绝。
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
59
|
+
user_id: Mapped[str] = mapped_column(String(32), index=True)
|
|
60
|
+
text: Mapped[str] = mapped_column(String(200))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
example_command = Alconna(
|
|
64
|
+
"示例",
|
|
65
|
+
Args["note?", str],
|
|
66
|
+
meta=CommandMeta(
|
|
67
|
+
"统计你使用示例命令的次数,可附带一条备注",
|
|
68
|
+
usage="示例 [备注]",
|
|
69
|
+
example="/示例 今天也要加油",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# 权限用装饰器声明(放在 command.on 之下):不足时宿主统一回复,/帮助 <编号> 也会显示。
|
|
75
|
+
@command.on(example_command)
|
|
76
|
+
@require(Permission.User)
|
|
77
|
+
async def example(session: Session, note: Query[str] = Query("note", "")):
|
|
78
|
+
ctx = context_from_session(session)
|
|
79
|
+
# Store 是同步读写:读改写之间没有 await,协程不会交错,所以不需要加锁。
|
|
80
|
+
records = visits.load(default={})
|
|
81
|
+
record = records.setdefault(ctx.user_id, {"count": 0})
|
|
82
|
+
record["count"] += 1
|
|
83
|
+
visits.save(records)
|
|
84
|
+
|
|
85
|
+
# 会话按模型自动路由到插件自己的库;事务由 commit 显式提交。
|
|
86
|
+
async with get_session() as db:
|
|
87
|
+
previous = await db.scalar(
|
|
88
|
+
select(Note.text)
|
|
89
|
+
.where(Note.user_id == ctx.user_id)
|
|
90
|
+
.order_by(Note.id.desc())
|
|
91
|
+
.limit(1)
|
|
92
|
+
)
|
|
93
|
+
if note.result:
|
|
94
|
+
db.add(Note(user_id=ctx.user_id, text=note.result))
|
|
95
|
+
await db.commit()
|
|
96
|
+
|
|
97
|
+
reply = f"{config.greeting},这是你第 {record['count']} 次使用示例命令"
|
|
98
|
+
return f"{reply}(上次备注:{previous})" if previous else reply
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""用真实的 Tenko 宿主加载插件,不启动网络服务。
|
|
2
|
+
|
|
3
|
+
插件依赖的宿主能力都从 ``tenko_api`` 取得,所以这里只需要:Entari 运行环境、
|
|
4
|
+
插件启用状态和配置。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import pytest_asyncio
|
|
14
|
+
from arclet.entari import Entari
|
|
15
|
+
from arclet.entari.config import EntariConfig
|
|
16
|
+
from satori import Channel, ChannelType, EventType, Guild, Login, MessageObject, User
|
|
17
|
+
from satori.model import Event, Member
|
|
18
|
+
|
|
19
|
+
from tenko.config import DatabaseConfig
|
|
20
|
+
from tenko.db.bootstrap import load_database_plugin
|
|
21
|
+
from tenko.db.migration import run_database_migrations
|
|
22
|
+
from tenko.host.plugins import PluginRuntime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session")
|
|
26
|
+
def host(tmp_path_factory):
|
|
27
|
+
"""会话级宿主:工作目录切到临时目录,数据目录 ``.tenko/plugin-data`` 落在其中。"""
|
|
28
|
+
|
|
29
|
+
root = tmp_path_factory.mktemp("tenko-host")
|
|
30
|
+
previous = os.getcwd()
|
|
31
|
+
os.chdir(root)
|
|
32
|
+
EntariConfig(root / "entari.yml")
|
|
33
|
+
Entari(ignore_self_message=True)
|
|
34
|
+
(root / "state.json").write_text(json.dumps({"enabled": ["example"]}))
|
|
35
|
+
yield SimpleNamespace(root=root)
|
|
36
|
+
os.chdir(previous)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
|
40
|
+
async def runtime(host):
|
|
41
|
+
"""按真实的入口发现机制加载 example 插件,并传入 ``[plugin_config.example]``。
|
|
42
|
+
|
|
43
|
+
和宿主启动一样:先加载数据库插件,加载插件时登记它的库,再初始化服务并迁移。
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
service = load_database_plugin(
|
|
47
|
+
DatabaseConfig(url=f"sqlite+aiosqlite:///{host.root / 'main.db'}")
|
|
48
|
+
)
|
|
49
|
+
runtime = PluginRuntime(
|
|
50
|
+
host.root / "no-builtin", state_file=host.root / "state.json"
|
|
51
|
+
)
|
|
52
|
+
await runtime.load_all(external_configs={"example": {"greeting": "测试问候"}})
|
|
53
|
+
await service.initialize()
|
|
54
|
+
await run_database_migrations(service)
|
|
55
|
+
yield runtime
|
|
56
|
+
runtime.unload("example")
|
|
57
|
+
for engine in service.engines.values():
|
|
58
|
+
await engine.dispose()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture(scope="session")
|
|
62
|
+
def module(runtime):
|
|
63
|
+
"""已加载的插件模块。"""
|
|
64
|
+
|
|
65
|
+
return runtime.loaded_plugins["example"].module
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_session(user_id: str, text: str = ""):
|
|
69
|
+
"""构造 context_from_session 能解析的最小群聊 Session。"""
|
|
70
|
+
|
|
71
|
+
event = Event(
|
|
72
|
+
type=EventType.MESSAGE_CREATED,
|
|
73
|
+
timestamp=datetime.now(),
|
|
74
|
+
login=Login(platform="onebot", user=User("10001")),
|
|
75
|
+
channel=Channel("40001", ChannelType.TEXT),
|
|
76
|
+
guild=Guild("40001", "Tenko"),
|
|
77
|
+
member=Member(user=User(user_id)),
|
|
78
|
+
user=User(user_id),
|
|
79
|
+
message=MessageObject.from_elements("50001", []),
|
|
80
|
+
)
|
|
81
|
+
return SimpleNamespace(event=SimpleNamespace(_origin=event))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
|
|
3
|
+
from arclet.entari.command import Query
|
|
4
|
+
from conftest import make_session
|
|
5
|
+
|
|
6
|
+
from tenko.host.guards import command_requirements
|
|
7
|
+
from tenko.host.perm import Permission
|
|
8
|
+
from tenko.host.plugins import PluginRuntime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_entry_point_is_discovered_but_disabled_by_default(tmp_path):
|
|
12
|
+
"""第三方插件默认关闭:没有状态文件时只被发现,不被加载。"""
|
|
13
|
+
|
|
14
|
+
runtime = PluginRuntime(tmp_path / "no-builtin", state_file=tmp_path / "none.json")
|
|
15
|
+
info = next(item for item in runtime.discover() if item.name == "example")
|
|
16
|
+
assert info.source == "external"
|
|
17
|
+
assert not runtime.is_enabled(info)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def test_plugin_loads_with_config_and_declared_metadata(runtime, module):
|
|
21
|
+
assert runtime.is_loaded("example")
|
|
22
|
+
assert module.config.greeting == "测试问候"
|
|
23
|
+
metadata = runtime.loaded_plugins["example"].metadata
|
|
24
|
+
assert (metadata.feature_scope, metadata.rate_weight) == ("group", 1)
|
|
25
|
+
assert metadata.group_level == Permission.ActiveGroup
|
|
26
|
+
# 装饰器声明的权限登记在命令上,/帮助 <编号> 据此显示。
|
|
27
|
+
requires = command_requirements(runtime.loaded_plugins["example"])
|
|
28
|
+
assert requires["示例"] == {"user": Permission.User}
|
|
29
|
+
# 命令归属于 example,所以功能开关和限流会按插件 ID 生效。
|
|
30
|
+
assert runtime.command_owner("示例").name == "example"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def test_command_counts_visits_and_remembers_the_last_note(module, host):
|
|
34
|
+
session = make_session("20001")
|
|
35
|
+
|
|
36
|
+
first = await module.example.callable_target(session, Query("note", "备忘"))
|
|
37
|
+
second = await module.example.callable_target(session, Query("note", ""))
|
|
38
|
+
|
|
39
|
+
assert str(first) == "测试问候,这是你第 1 次使用示例命令"
|
|
40
|
+
assert "第 2 次" in str(second) and "上次备注:备忘" in str(second)
|
|
41
|
+
assert module.visits.load()["20001"] == {"count": 2}
|
|
42
|
+
# 备注在插件自己的库里,不在宿主主库。
|
|
43
|
+
with sqlite3.connect(host.root / ".tenko/plugin-data/example/data.db") as db:
|
|
44
|
+
assert db.execute("SELECT user_id, text FROM example_note").fetchall() == [
|
|
45
|
+
("20001", "备忘")
|
|
46
|
+
]
|