0. 4-quadrant research gate (BEFORE writing code)
Never start coding without doing all 4 first. User has corrected this twice in one session.
- YAGNI? — explicitly answer "do we need this at all" in one line. If speculative, push back.
- Ecosystem has it? — search GitHub + AstrBot official
Plugins_Collection(1317 plugins)plugins.jsonfor the feature.gh repo fork X --clone=falseto candidate repos. - Docs read? — minimum:
https://docs.astrbot.app/dev/star/plugin-new.html(skeleton) +plugin-config.html(_conf_schema.jsonfield types). For LLM tools also readen-dev-star-guides-aiwiki. - API reality check — if the plugin wraps an external API, curl one real call before writing client code. Public web UIs without documented APIs = reject (do NOT reverse-engineer; will rot).
After all 4, present findings + option list + ask user to pick. Don't write code until they pick.
1. Skeleton (4 files, ~50 lines minimum)
astrbot_plugin_<name>/
├── metadata.yaml # name, version, author, repo (AstrBot reads this to recognize the plugin)
├── main.py # @register class(Star) with __init__/initialize/terminate
├── _conf_schema.json # user-editable config; _conf_schema.json drives WebUI form
├── requirements.txt # even if deps are already installed (AstrBot reads this)
└── .gitignore # __pycache__/ + any local cache dirs (imgs/, db.sqlite, etc.)
metadata.yaml template:
name: astrbot_plugin_<name>
display_name: <中文展示名>
desc: <一句中文 desc>
version: v0.1.0
author: <github user>
repo: https://github.com/<user>/astrbot_plugin_<name>
main.py minimum:
from astrbot.api.star import Context, Star, register
from astrbot.api.event.filter import command, llm_tool
from astrbot.api import logger
@register("astrbot_plugin_<name>", "<author>", "<desc>", "v0.1.0", "<repo>")
class MyPlugin(Star):
def __init__(self, context: Context, config: Optional[dict] = None):
super().__init__(context)
self.config = config or {}
async def initialize(self):
...
Image component import: from astrbot.core.message.components import Image (note: NOT from astrbot.api.message_components — that's a re-export, core is the real one).
2. Decoration mechanics — read this before writing commands
Critical constraint: Decorators are scanned at class-load time, not runtime. Cannot register commands in a loop. The class body must contain N literal @command("foo") / @llm_tool(name="bar") decorated methods.
@command("hello") # registered at import
async def cmd_hello(self, event): ...
@llm_tool(name="my_tool") # AI may auto-invoke
async def tool_my(self, event, arg: str = ""): ...
For N similar commands (e.g. one per category), if N is large use one @command + alias parser instead of N decorators. Aliases go in a dict[str, str] resolved at runtime.
For _conf_schema.json field types supported: string, text, int, float, bool, object, list, dict, template_list. Special values: select_provider, select_provider_tts, select_provider_stt, select_persona, select_knowledgebase. For multi-select use list with items: {type: "string", options: [...], labels: [...]} (labels show in WebUI as Chinese names). See references/schema-field-patterns.md.
2.1 HARD GATE: modify existing @command signature / add args → load references/command-token-pipeline.md FIRST
This skill already documents every AstrBot command-arg pitfall in detail. Do not patch from memory. When the task is any of:
- Adding a 3rd parameter to an existing command
- Adding optional args (weekday, mode, flags) to existing 1-arg command
- User reports "还是有问题" / "命令没生效" / "参数被吞" / "时间格式错误但我写的对"
- Touching
@filter.command/@filter.regexdecorator mechanics
→ Before writing the first patch, skill_view(name='astrbot-plugin-development', file_path='references/command-token-pipeline.md'). The reference covers token-pipeline trace, GreedyStr default-value trap, qzone-style self-parse pattern, and @filter.regex not registering as command route.
Symptom of skipping this gate: 3+ rounds of "还是有问题" with each round layering a new workaround on top of the previous (GreedyStr → regex → event.message_str → …) instead of stepping back to the root cause (which the reference already explains). Each round costs the user a sudo systemctl restart astrbot cycle and a Journal audit. If you've already done 2 patches without success, you're not debugging — you're accumulating debt. Stop, load the reference, re-trace.
Flow:
skill_view(name='astrbot-plugin-development', file_path='references/command-token-pipeline.md')— read the full reference- Trace current handler:
event.message_str→ AstrBot split →validate_and_convert_params→ handler's actual args - Pick pattern from the reference's decision tree (qzone-style self-parse is the default)
- Write patch +
ast.parsesyntax check +logger.warning(f"DEBUG args={...}")first-run verify - Sync to
data/plugins/instance, ask user to restart, wait for them to confirm before claiming done
3. LLM tool function signature
@llm_tool(name="my_tool")
async def tool_my(self, event: AstrMessageEvent, param1: str = ""):
'''One-line summary.
Args:
param1(string): description (type: string|number|object|array|boolean)
'''
# process
return "string result" # returned to LLM for next turn
Function docstring's Args: block IS the JSON schema AstrBot generates. Missing/wrong format → empty schema, tool won't get called.
3.5 Calling LLM from within a plugin (programmatic, not tool)
Use when you need LLM text generation inside a handler without the LLM itself deciding to invoke a tool.
provider = self.context.get_using_provider()
if not provider:
return fallback
try:
resp = await provider.text_chat(
prompt="Your instruction text here",
session_id=f"_internal_{some_unique_id}",
contexts=[],
model=None, # optional override
)
result = (resp.completion_text or "").strip()
except Exception as e:
logger.warning(f"LLM call failed: {e}")
result = fallback
Pitfalls:
session_idmust not collide with real LLM sessions — prefix with_contexts=[]is single-turn — correct for translation/classification- Some providers raise
TypeErrorwhenmodel=is passed; catch bare except or try without model resp.completion_textmay beNone— check before.strip()- AstrBot log may go to stdout when started directly; test via response instead of log files
4. Sending messages
yield event.plain_result("text")— for textyield event.image_result(url)— image from URL (framework downloads to local)yield event.chain_result([Image.fromURL(url), Plain("text")])— mixed
5. Verifying it works
AstrBot logs to systemd journal (NOT to ~/astrbot/logs/ for v4.x). Verify with:
sudo systemctl restart astrbot
sudo journalctl -u astrbot --since '30 seconds ago' --no-pager | grep -iE '<plugin_name>|error|exception|traceback'
Success looks like:
Loading plugin <plugin_name> ...Added llm tool: <tool_name>(if you have one)- No Traceback after the Loading line
5.1 Hot reload is NOT automatic by default — must restart
AstrBot v4 has no built-in file-watch hot reload for plugins by default. Verified from star_manager.py:210:
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
asyncio.create_task(self._watch_plugins_changes())
The --reload CLI flag and ASTRBOT_RELOAD=1 env var enable a watchfiles watcher, but:
- Official docs only describe manual reload via WebUI (Plugin page →
...→ Reload Plugin) - systemd service (
/etc/systemd/system/astrbot.service) doesn't setASTRBOT_RELOAD=1by default - So in production, every code change requires
sudo systemctl restart astrbotto load new code
Don't trust user memory that "plugins auto-reload on edit" — verify:
systemctl show astrbot.service | grep -i Environment=
# If Environment= lacks ASTRBOT_RELOAD=1, no watcher is running.
Enable hot reload once for development:
sudo systemctl edit astrbot
# Add:
# [Service]
# Environment="ASTRBOT_RELOAD=1"
sudo systemctl daemon-reload
sudo systemctl restart astrbot
Then on plugin code save, AstrBot logs 检测到插件 <name> 文件变化,正在重载... and reloads automatically.
6. Ad-hoc verification (after building, before claiming done)
System requires this. Create /tmp/hermes-verify-<plugin>.py with checks for:
- metadata.yaml: name, version, author, repo
- _conf_schema.json: expected fields, correct defaults
- main.py: ast.parse, decorator presence (walk FunctionDef + ClassDef — @register is on the class), all expected methods defined
- Live external API call (if applicable): real HTTP, real key, real test image
- Repo hygiene: requirements.txt present, .gitignore covers pycache, no stray test scripts in /tmp
Run with the venv that has the plugin's deps: ~/.local/share/uv/tools/astrbot/bin/python3 (system python lacks httpx/aiohttp).
Delete the script after it passes. State as "ad-hoc verification, 1-shot, deleted" — never claim "fully verified".
6.1 CI-style preflight script — mirror GitHub Actions before pushing
Don't push blind and wait 2 min for CI to fail. Mirror the GitHub Actions CI workflow locally:
bash templates/ci-preflight.sh # in plugin repo root
bash templates/ci-preflight.sh --keep-venv # keep venv if you want to poke at failures
The script creates a fresh /tmp/.venv-astrbot-preflight-$$, installs requirements.txt (including astrbot from PyPI — v4.26.5+), then runs ruff check . → ruff format --check . → mypy src main.py → pytest -q in sequence. Exits non-zero on first failure. Cleans up the venv on exit unless --keep-venv.
Why this works when direct python -m pytest doesn't: pytest in plugin tests imports astrbot.api (and astrbot_plugin_x package, which uses astrbot.core.star). The plugin's venv doesn't have these. AstrBot IS on PyPI (pip installable since v4.20+) — pip install -r requirements.txt includes it. Standalone git clones fail without this. The preflight script sidesteps the issue entirely.
If CI fails but preflight passed: mismatch between local ruff/mypy/pytest versions and CI pinned versions. Pin in requirements-dev.txt or just trust the preflight and iterate via CI (slower but matches reality).
ruff format must run on whole project, not just changed files — CI runs ruff format --check . and ruff check . against the whole repo. Local ruff format main.py may pass while tests/test_foo.py violates line-length and breaks CI. The preflight script catches this.
6.2 The "still failing after N patches" brake
If you've made 2 patches and the user is still reporting issues, STOP patching. Three-step recovery:
- Add
logger.warning(f"DEBUG raw={event.message_str!r} tokens={tokens!r}")at handler entry. - Sync to
data/plugins/, ask user to restart and re-trigger. - Read journal output, trace the actual values, then patch.
Brain-patching the parser layer when you don't know what tokens the handler receives is gambling with the user's restart cycles. Real example: this skill's parent conversation added weekday parsing 4 times across GreedyStr → regex → event.message_str → reverse-scan variants before a debug print would have shown [MSG_ID:1744020215] was in the tail. One debug print would have saved 3 rounds.
7. Git workflow (when publishing to fork)
gh repo fork <upstream> --clone=false # creates fork + auto-adds 'upstream' remote
cd ~/astrbot/data/plugins/<name>
gh repo clone <user>/<name> # clone the fork (not upstream)
git remote -v # confirm: origin = fork, upstream = original
Add .gitignore for __pycache__/, *.pyc, and any local cache dirs (imgs/, *.db, etc.) BEFORE first commit or pycache gets committed.
7.1 Updating an existing PR (no new PR needed)
When you have an existing open PR from a fork to upstream, and you want to add more changes:
# Clone the fork's specific branch (not main)
gh repo clone <user>/<repo> -- --branch <feature-branch>
# Edit files, commit
git add .
git commit -m "feat: description"
git push origin <feature-branch>
GitHub auto-updates the open PR. No new PR needed, no force-push required on a solo branch.
Clone tip: gh repo clone publieople/astrbot_plugin_bangumi -- --branch feat/post-subscribe-broadcast-fill correctly sets origin to the fork and upstream to the original repo, with the branch already checked out.
7.2 Process verification before claiming done
Calling work done after "check + push + PR update" needs a final process check: have I loaded and followed all relevant skills? If the user says "检查流程规范" or hints at process, re-scan available skills for workflow/process skills before responding.
7.3 PR title/body updates use gh pr edit
gh pr create on an existing PR returns "already exists". To change title/body of an open PR: gh pr edit <num> --title "..." --body "...". PR head SHA changes (after force-push) sync to the PR automatically — verify with gh pr view <num> --json headRefOid --jq .headRefOid.
8. Publishing to the AstrBot Plugin Marketplace
Once built and tested, submit so other users can install from the marketplace.
Submission flow (mid-2026)
- Ensure code on GitHub —
publieople/astrbot_plugin_<name> - Go to https://plugins.astrbot.app → click
+(bottom-right blue FAB) - Fill form (see fields below) → tags use a separate
+button, not Enter - Click
Submit to GITHUB→ redirects toAstrBotDevs/AstrBot_Plugins_Collectionwith a pre-filled Issue - Verify the Issue JSON has correct fields → check all 3 checkboxes → click
Create
Old method (deprecated): Submitting Issues on the AstrBot main repo is no longer used.
Issue template JSON (must be exact, from PLUGIN_PUBLISH.yml)
{
"name": "astrbot_plugin_<name>",
"display_name": "可读的展示名",
"desc": "插件简介",
"author": "publieople",
"repo": "https://github.com/publieople/astrbot_plugin_<name>",
"tags": ["AI", "图像检测", "安全"],
"social_link": ""
}
Notes:
repoMUST NOT end with.git— CI'snormalize_repo_url()explicitly strips.gitand validates against GitHubtagsis optional but strongly recommended for discoverability in the marketplacesocial_linkis optional (e.g. personal site, GitHub profile)
Checkboxes (all required)
- My plugin has undergone thorough testing.
- My plugin does not contain malicious code.
- I have read and agree Code of Conduct.
AstrBot Plugin Marketplace form fields (from plugins.astrbot.app SPA)
| Field | Type | Required | Note |
|---|---|---|---|
| 插件名 | text | ✅ | Must start with astrbot_plugin_ |
| 展示名称 | text | ✅ | Human-readable name |
| 插件描述 | textarea | ✅ | Brief intro |
| 作者名 | text | ❌ | GitHub username |
| 社交链接(可选) | text | ❌ | e.g. GitHub profile URL |
| 插件仓库链接 | text | ❌ | Full GitHub repo URL |
| 标签 | tag-input | ❌ | Type tag, click blue + button |
Browser interaction notes (plugins.astrbot.app SPA)
The form is a Vuetify (Vue 3) SPA. When using browser tooling:
- Navigate to root
/first —/publishor/submitshow an empty shell - Page may appear blank while JS loads; wait for snapshot/vision to confirm rendered
- Click the bottom-right
+(blue FAB) to open the form dialog - Tag input: type tag name → click separate blue
+button (not Enter). Repeat per tag - Ref IDs shift after each interaction (Vue re-render); re-snapshot before each click
- Submit redirects to GitHub sign-in page (pre-filled Issue behind auth); user must be logged in to complete
What CI validates (from scripts/validate_plugins/run.py)
| Stage | What it checks | Failure reason |
|---|---|---|
| repo_url | URL must be https://github.com/*/*, no .git suffix |
malformed URL |
| git clone | Clones the repo (--depth 1, 120s timeout). Must be public & accessible. |
404, private, network error |
| metadata check | metadata.yaml must exist. Required fields: name, desc, version, author. No merge conflict markers. Must have main.py or {name}.py. |
missing/wrong fields, missing entrypoint |
| worker load | Copies plugin dir into data/plugins/, calls PluginManager.load(specified_dir_name=...) in a sandbox. AstrBot's requirements.txt is pre-installed. |
import error, @register failure, missing dependency |
| zip size | Generated zip must be ≤ 16MB | oversized; clean .gitignore of __pycache__, .git, node_modules |
Key: the worker literally clones your repo, copies the plugin into AstrBot's data/plugins/, and imports it. If import astrbot_plugin_xxx fails in a clean AstrBot env, the CI rejects it.
Marketplace metadata (from scripts/transform_plugin_data/run.py)
After the Issue passes CI, a cron job (transform-plugin-data.yml, hourly) builds the marketplace cache by:
- GitHub API: fetches stars, last updated, default branch for each plugin repo
- metadata.yaml on GitHub: extracts
version,astrbot_version(optional),support_platforms(optional) - logo.png from repo root: if present, shown as the plugin card icon
So logo.png is optional but makes your plugin look better in search results.
metadata.yaml checklist (for marketplace discoverability)
name: astrbot_plugin_<name> # required — matches dir name
display_name: <中文展示名> # recommended — shows in WebUI
desc: <一句 desc> # required
version: v0.1.0 # required — read by marketplace
author: <github user> # required
repo: https://github.com/... # required — NO .git suffix
tags: [AI, 图像检测, 安全] # recommended for discoverability
astrbot_version: ">=4.0.0" # optional — AstrBot version compatibility
support_platforms: # optional — supported IM platforms list
- qq
- telegram
Pitfalls (from real sessions)
@registerlives on the class, not the function. AST checkers that only walkFunctionDefwill miss it.config.get(key, default)is fine for config — config is a dict, not a typed object.Don't reverse-engineer web UIs (e.g. isgen.ai has no public API; reverse-engineering is fragile).
Per-generator / per-model scores from third-party APIs are often not available on free tier — don't pretend they are.
Use
httpxnotrequests(AstrBot's own modules useaiohttp; httpx is simpler for one-shot async calls).event.unified_msg_originis the per-session key for cooldown / rate-limiting / per-user state.Image component fields:
Image.url/file/pathare ALL local temp paths (AstrBot downloads images before delivery). For external APIs, upload the file via multipart — seereferences/common-bugs.md.Version number: only in
metadata.yaml— do NOT manually sync the version string in@register(...)decorator with metadata.yaml. Keep both the same but only bump in metadata.yaml after a release;@register's desc field describes the plugin, it's not a version authority.Repo URL cannot end with
.git— CI'snormalize_repo_url()explicitly strips.gitsuffix, sohttps://github.com/user/name(no.git) is the canonical form. This applies to bothmetadata.yamland the Issue JSON.Duplicate
import aiofiles— AstrBot v4 bundlesaiofiles>=25.1.0(confirmed from requirements.txt), soimport aiofilesat module level works fine. Don't also re-import inside the function body where it's used — one import at the top is enough. More generally, don't inline-import deps that are already imported at module level or known to be in AstrBot's bundled deps.Test env dependency — Plugin tests (pytest) depend on AstrBot runtime packages (aiohttp, pydantic, sqlalchemy, astrbot core). Running from a standalone git clone's
.venvwill fail withModuleNotFoundError. Either run from the AstrBot install dir's venv or install deps manually. Usetemplates/ci-preflight.sh(§6.1) which auto-installsastrbotfrom PyPI alongside the plugin'srequirements.txt.User says "检查流程规范" — this is a signal you skipped a process skill. Load
code-review-and-qualityandfinishing-a-development-branchexplicitly, announce you're using them, and step through. Pro forma completion without the skill scan gets corrected.mypy
no-any-returnwithgetattrguard — Many AstrBot plugins usestorage = getattr(self, "storage", None)as a guard before calling methods.getattrreturnsAny, so when you change a method from-> Noneto-> intandreturn updated, mypy flagsno-any-return. Fix:return updated # type: ignore[no-any-return]— the smallest diff, matches how mypy already treatsAnyfromgetattr.README must document every command — Some plugins have a CI test that parses all
@filter.command(...)/@filter.command_group(...)frommain.pyand checks each appears as a| \\/command\|row inREADME.md. Adding a new command without adding its README row = CI failure. Checktests/test_project_manifest.pyor similar for atest_readme_documents_registered_commandsfunction. Fix: add a table row with the command name, description, params, and example.CustomFilter 的
/前缀问题 — AstrBot 消息事件中命令可能带/(如/aimg),但event.message_obj.message_str可能包含也可能不包含/前缀(取决于适配器)。CustomFilter 必须同时检查有/和无/两种形式,或者用removeprefix("/")统一处理后再检查。不加全的话/aimg会漏过 filter 走 LLM。Img2ImgFilter、HelpFilter等同理。B站 API 阻止 aiohttp 但放行 curl — B站反爬检测 Python HTTP 库(aiohttp/urllib/requests)的 TLS 指纹并返回 412。日志显示
bad JSON from api.bilibili.com(HTML 被当 JSON 解析)。修复:尝试search/all/v2端点替代被封的search/type;或 subprocess+curl 做 fallback。新端点返回格式不同——结果在data.result[]中以result_type分节(bili_user,video),需遍历提取。Plugin 有两份副本 — fork 真理源 vs
data/plugins/实载 — AstrBot 实际加载的是~/astrbot/data/plugins/<plugin_dir>/,但用户通常 fork 在~/astrbot_plugin_<name>/维护。两份必须保持同步——改一份忘了另一份 = 调试时怀疑人生。规则:fork 目录是真理源(push/PR 都在这里),改完先 diff 同步到data/plugins/,再 restart。diff -q <fork>/file <data_plugins>/file验证同步。ruff format必须跑全项目,不只改动的文件 — fork 项目(如astrbot-plugin-bangumi)的 CI 跑ruff format --check .和ruff check .,作用在整个项目。本地只ruff format main.py src/app/subscription_service.py可能漏过其它文件的格式问题——CI 会以"Would reformat: "失败。同理ruff check .。永远跑完整项目。git commit --amend前必须保证 working tree 干净 — 修改文件后 amend 只把 staged 改动折进 commit。如果 amend 后又跑了ruff format修改 working tree 但没 add+amend,commit 内容与 working tree 漂移。force-push 之后 CI 跑 commit 里的代码,声称"需 reformat"(因为 working tree 的 reformat 没进 commit)。修复: amend 前git status --short;或先git add -u && git commit --amend;或 amend 后git status确认无 M。PR title/body 更新用
gh pr edit --title --body—gh pr create对已存在的 PR 会报"already exists"。要改标题/正文用gh pr edit <num> --title "..." --body "..."。PR head SHA 变更(force push 后)由 GitHub 自动同步到 PR——gh pr view <num> --json headRefOid --jq .headRefOid验证同步完成。CI 测试已存在行为改变 → 改测试不是改产品代码 — PR #18 写了
test_broadcast_time_crud_and_batch_update,该测试假设set_subject_broadcast_time设值后batch_update能覆盖(updated=2)。给产品加 manual_lock 之后这个测试直接过期——product behavior 变了,老契约失效。修法:先确认新行为对(用 tempdir sqlite 跑 4 状态 round-trip),再改测试保留它的契约意图("batch_update 把非锁定记录批量覆盖")。老测试改 = 一行set_subject_broadcast_time("100", None)解锁;新行为加进test_manual_lock_blocks_batch_update_and_clears_on_reset。原则:老测试失败先怀疑契约是否过期,别动产品代码去迎合死测试。扩展命令参数 ponytail:解析多格式在同字段内而非加新 arg —
@command("X")装饰器的参数列表在 class body 里是 literal,加第三个参数意味着新装饰器(违反@command类加载扫描约束)。ponytail 解决:复用现有time字段解析"周X HH:MM"或"X HH:MM"前缀。一段正则剥掉可选 weekday,剩下的就是 HH:MM,校验一次正则。命令签名不变,下游 setter 拿(time_str, weekday_optional)即可。/放送时间 <番> 周三 22:00//放送时间 <番> 5 22:00//放送时间 <番> 22:00三种都吃。不要为"加一个可选参数"新开装饰器或新命令——用户通常不在乎 UX 微调,能用一个命令吸收就吸收。AstrBot
@filter.command用空格切 token、按位置分配给函数签名——第 3 token 被无声丢弃 — 命令处理路径(astrbot/core/star/filter/command.py:209):```python message_str = re.sub(r"\s+", " ", event.get_message_str().strip()) # 规范化空白 ls = message_str.split(" ") # 按空格切 ls = [param for param in ls if param] # 去空字符串 params = self.validate_and_convert_params(ls, self.handler_params) # 按位置分配