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.
@@ -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
+ ]