diff --git a/docs/README.skills.md b/docs/README.skills.md index 24473fa6f..be15c8426 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -59,6 +59,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [arize-trace](../skills/arize-trace/SKILL.md)
`gh skills install github/awesome-copilot arize-trace` | Downloads, exports, and inspects existing Arize traces and spans to understand what an LLM app is doing or debug runtime issues. Covers exporting traces by ID, spans by ID, sessions by ID, and root-cause investigation using the ax CLI. Use when the user wants to look at existing trace data, see what their LLM app is doing, export traces, download spans, investigate errors, or analyze behavior regressions. | `references/ax-profiles.md`
`references/ax-setup.md` | | [aspire](../skills/aspire/SKILL.md)
`gh skills install github/awesome-copilot aspire` | Aspire skill covering the Aspire CLI, AppHost orchestration, service discovery, integrations, MCP server, VS Code extension, Dev Containers, GitHub Codespaces, templates, dashboard, and deployment. Use when the user asks to create, run, debug, configure, deploy, or troubleshoot an Aspire distributed application. | `references/architecture.md`
`references/cli-reference.md`
`references/dashboard.md`
`references/deployment.md`
`references/integrations-catalog.md`
`references/mcp-server.md`
`references/polyglot-apis.md`
`references/testing.md`
`references/troubleshooting.md` | | [aspnet-minimal-api-openapi](../skills/aspnet-minimal-api-openapi/SKILL.md)
`gh skills install github/awesome-copilot aspnet-minimal-api-openapi` | Create ASP.NET Minimal API endpoints with proper OpenAPI documentation | None | +| [astrbot-plugin-maker](../skills/astrbot-plugin-maker/SKILL.md)
`gh skills install github/awesome-copilot astrbot-plugin-maker` | Create, repair, and test AstrBot Python plugins from requirements or an existing repository. Use for Star handlers, commands, plugin configuration, storage, LLM tools, metadata, and plugin HTTP integrations; also prepare plugin releases when requested. | `assets/_conf_schema.json.template`
`assets/dev-commands.txt`
`assets/main.py.template`
`assets/metadata.yaml.template`
`assets/openapi_client.py.template`
`assets/plugin-readme.md.template`
`assets/plugin_logic.py.template`
`assets/requirements-dev.txt.template`
`assets/requirements.txt.template`
`assets/ruff.toml.template`
`assets/test_openapi_auth_and_shape.py.template`
`assets/test_plugin_behavior.py.template`
`assets/test_plugin_smoke.py.template`
`references/api-patterns.md`
`references/compliance-checklist.md`
`references/nl-to-implementation.md`
`references/openapi-integration.md`
`references/plugin-new-checklist.md`
`references/sources.md`
`references/testing-guide.md`
`scripts/scaffold_plugin.py`
`scripts/validate_plugin.py` | | [audit-integrity](../skills/audit-integrity/SKILL.md)
`gh skills install github/awesome-copilot audit-integrity` | Shared audit integrity framework for all AppSec agents — enforces output quality, intellectual honesty, and continuous improvement through anti-rationalization guards, self-critique loops, retry protocols, non-negotiable behaviors, self-reflection quality gates (1-10 scoring, ≥8 threshold), and a self-learning system with lesson/memory governance for security analysis agents. | `references/anti-rationalization-guard.md`
`references/clarification-protocol.md`
`references/non-negotiable-behaviors.md`
`references/retry-protocol.md`
`references/self-critique-loop.md`
`references/self-learning-system.md`
`references/self-reflection-quality-gate.md` | | [automate-this](../skills/automate-this/SKILL.md)
`gh skills install github/awesome-copilot automate-this` | Analyze a screen recording of a manual process and produce targeted, working automation scripts. Extracts frames and audio narration from video files, reconstructs the step-by-step workflow, and proposes automation at multiple complexity levels using tools already installed on the user machine. | None | | [autoresearch](../skills/autoresearch/SKILL.md)
`gh skills install github/awesome-copilot autoresearch` | Autonomous iterative experimentation loop for any programming task. Guides the user through defining goals, measurable metrics, and scope constraints, then runs an autonomous loop of code changes, testing, measuring, and keeping/discarding results. Inspired by Karpathy's autoresearch. USE FOR: autonomous improvement, iterative optimization, experiment loop, auto research, performance tuning, automated experimentation, hill climbing, try things automatically, optimize code, run experiments, autonomous coding loop. DO NOT USE FOR: one-shot tasks, simple bug fixes, code review, or tasks without a measurable metric. | None | diff --git a/skills/astrbot-plugin-maker/SKILL.md b/skills/astrbot-plugin-maker/SKILL.md new file mode 100644 index 000000000..6e7ba94f7 --- /dev/null +++ b/skills/astrbot-plugin-maker/SKILL.md @@ -0,0 +1,96 @@ +--- +name: astrbot-plugin-maker +description: 'Create, repair, and test AstrBot Python plugins from requirements or an existing repository. Use for Star handlers, commands, plugin configuration, storage, LLM tools, metadata, and plugin HTTP integrations; also prepare plugin releases when requested.' +--- + +# AstrBot Plugin Maker + +Turn the requested behavior into a working AstrBot plugin, or make a focused repair +to an existing one. Preserve the user's chosen plugin, platform, version, and scope. +An ordinary command plugin uses AstrBot's Python API; HTTP OpenAPI is optional. + +## Establish the target + +- Inspect the existing `main.py`, `metadata.yaml`, `_conf_schema.json`, dependencies, + tests, and repository instructions before changing an existing plugin. +- Identify the trigger, expected reply or side effect, configuration, and supported + platform. Infer routine choices; ask only for missing information that changes the + behavior, compatibility, or publication destination. Do not require a questionnaire + or a separate plan approval for a clear implementation request. +- Check the installed AstrBot version or the runtime checkout's `pyproject.toml`. + The bundled examples were checked against **v4.28.0, Python 3.12+**; this is a + verification baseline, not a minimum imposed on every plugin. For an older target, + verify the APIs there before choosing `astrbot_version`. +- Use [sources](references/sources.md) to find the relevant official guide and pinned + implementation. Prefer the target runtime's source/signatures when examples differ. + If live sources are unavailable, use the recorded baseline and state that limit. + +## Implement the requested behavior + +For a new command plugin, use the small configurable greeting scaffold as a starting +point, then replace its behavior and tests with the requested feature: + +```bash +python /scripts/scaffold_plugin.py --author "Author" --description "Plugin purpose" --command greet +``` + +`` must be a new `astrbot_plugin_` directory. The script refuses to +overwrite an existing path. It creates a real `main.py`, configuration, metadata, +business logic, offline tests, and a separately invoked AstrBot runtime smoke test. +It does not clone AstrBot, install dependencies, or publish anything. +Use `--repo` for a known repository URL, `--astrbot-version` for a verified target, +and `--with-openapi` only for a plugin that needs the optional HTTP client example. +For an existing plugin, edit it directly; do not regenerate over it. + +The [scaffolder](scripts/scaffold_plugin.py) renders the bundled [templates](assets/), +including production modules, metadata/configuration, and offline/SDK test examples. +The [static validator](scripts/validate_plugin.py) checks a plugin without importing it. + +Read only the references needed for the feature: + +| Need | Reference | +| --- | --- | +| Translate an open-ended request into concrete behavior | [Requirement mapping](references/nl-to-implementation.md) | +| Metadata, supported platforms, dependencies, release preparation | [Plugin packaging](references/plugin-new-checklist.md) | +| Commands, lifecycle, configuration, storage, messages, LLM calls/tools | [Python API patterns](references/api-patterns.md) | +| External access to an AstrBot server | [HTTP API integration](references/openapi-integration.md) | +| Offline tests, real SDK smoke tests, reload troubleshooting | [Testing guide](references/testing-guide.md) | + +Keep these framework constraints in the implementation: + +- Put the `Star` subclass in `main.py`; register handlers as methods with `self, event`. + Current AstrBot discovers subclasses automatically. Do not add the deprecated + `@register` to a new plugin; preserve old-version compatibility when repairing one. +- Use AstrBot's config schema and the injected `AstrBotConfig`. Read the supplied + values, not hardcoded copies of defaults. Do not log config objects or credentials. +- Store durable data under `data/plugin_data/` or the plugin KV API. + Use the runtime's path helper, not the process's current directory. +- Use async network clients with explicit timeouts. Create tasks/connections in + `initialize()` when needed and cancel/await/close them in `terminate()` so reloading + does not leave duplicate jobs or open sessions. +- Use generic message components where possible. Check the selected adapter before + using platform-specific calls or claiming support for additional platforms. +- Inspect a hook's contract before choosing `yield`, a return value, or + `await event.send(...)`; LLM lifecycle hooks cannot be treated as command generators. + +## Validate and deliver + +1. Run the existing relevant tests. For new logic, test actual production functions + and error paths; the supplied tests demonstrate this with the greeting module and + optional HTTP client. Never leave passing placeholder assertions in the deliverable. +2. Run `python /scripts/validate_plugin.py ` after installing + `PyYAML` and `packaging` in the development environment. This checks source syntax, + metadata types/version constraints, and basic config shape without importing the + plugin. It does **not** certify API compatibility or marketplace acceptance. +3. Run Ruff on changed Python files. If the target SDK is installed, run the separate + SDK smoke test. Exercise load/reload and the requested command when a selected + local instance is running or runtime integration testing is within the task's + scope. SDK availability alone does not call for starting a server. Use + [testing](references/testing-guide.md) for commands. +4. Apply the relevant [delivery checks](references/compliance-checklist.md). Report + changed files, usage/configuration, checks actually run, and any untested runtime + or adapter behavior. Missing runtime access must not be reported as a passing + integration test. + +Prepare a release or PR when requested. Use the already authorized destination; +plugin implementation alone does not imply publication to AstrBot Cloud. diff --git a/skills/astrbot-plugin-maker/assets/_conf_schema.json.template b/skills/astrbot-plugin-maker/assets/_conf_schema.json.template new file mode 100644 index 000000000..e0a7337e6 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/_conf_schema.json.template @@ -0,0 +1,13 @@ +{ + "greeting": { + "description": "Greeting prefix", + "type": "string", + "default": "Hello" + }, + "max_name_length": { + "description": "Maximum displayed sender name length", + "type": "int", + "default": 40, + "hint": "Choose an integer from 1 to 200." + } +} diff --git a/skills/astrbot-plugin-maker/assets/dev-commands.txt b/skills/astrbot-plugin-maker/assets/dev-commands.txt new file mode 100644 index 000000000..b9ad5dfaa --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/dev-commands.txt @@ -0,0 +1,25 @@ +# Run only when the task needs a local AstrBot development runtime. +# Official source setup: https://docs.astrbot.app/deploy/astrbot/cli.html +# Baseline verified: v4.28.0 requires Python 3.12+ and uv. + +# If no existing checkout was selected: +git clone --branch v4.28.0 --depth 1 https://github.com/AstrBotDevs/AstrBot.git AstrBot + +# POSIX shell: +mkdir -p AstrBot/data/plugins +git clone AstrBot/data/plugins/ +cd AstrBot +uv sync +uv run main.py + +# PowerShell (alternative to the POSIX block): +New-Item -ItemType Directory -Force -Path AstrBot/data/plugins +git clone AstrBot/data/plugins/ +Set-Location AstrBot +uv sync +uv run main.py + +# Replace placeholders with the user's real plugin repo and directory. +# For another target, use its version and setup instructions. +# After startup: WebUI -> plugin management -> reload the plugin. +# Check logs and invoke the configured command to verify the actual behavior. diff --git a/skills/astrbot-plugin-maker/assets/main.py.template b/skills/astrbot-plugin-maker/assets/main.py.template new file mode 100644 index 000000000..7267489f1 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/main.py.template @@ -0,0 +1,29 @@ +from astrbot.api import AstrBotConfig +from astrbot.api.event import AstrMessageEvent, filter +from astrbot.api.star import Context, Star + +from .plugin_logic import build_greeting + + +class GreetingPlugin(Star): + """A small command plugin with settings supplied by AstrBot.""" + + def __init__(self, context: Context, config: AstrBotConfig): + super().__init__(context) + self.config = config + + @filter.command("{{command}}") + async def greet(self, event: AstrMessageEvent): + """Greet the sender using the configured greeting and name length.""" + try: + reply = build_greeting( + event.get_sender_name(), + self.config.get("greeting", "Hello"), + self.config.get("max_name_length", 40), + ) + except (TypeError, ValueError): + yield event.plain_result( + "Please check greeting and max_name_length in plugin settings." + ) + return + yield event.plain_result(reply) diff --git a/skills/astrbot-plugin-maker/assets/metadata.yaml.template b/skills/astrbot-plugin-maker/assets/metadata.yaml.template new file mode 100644 index 000000000..ceecdfb1a --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/metadata.yaml.template @@ -0,0 +1,11 @@ +name: {{name_yaml}} +desc: {{description_yaml}} +version: {{version_yaml}} +author: {{author_yaml}} +astrbot_version: {{astrbot_version_yaml}} +{{repo_line}} +# Add only when appropriate; verify adapter keys for the target runtime. +# display_name: My Plugin +# short_desc: A one-line summary +# support_platforms: +# - telegram diff --git a/skills/astrbot-plugin-maker/assets/openapi_client.py.template b/skills/astrbot-plugin-maker/assets/openapi_client.py.template new file mode 100644 index 000000000..469603969 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/openapi_client.py.template @@ -0,0 +1,44 @@ +"""Optional client for GET /api/v1/im/bots, verified against AstrBot v4.28.0. + +The caller owns the AsyncClient lifetime. Other endpoints need their own parsers; +chat can return SSE and file endpoints return bytes. +""" + +import httpx + + +class ApiAuthError(RuntimeError): + """The API key is invalid or lacks the required scope.""" + + +class ApiResponseError(ValueError): + """The server returned an unexpected JSON envelope or bot list.""" + + +async def get_bot_ids( + client: httpx.AsyncClient, base_url: str, api_key: str +) -> list[str]: + if not api_key.strip(): + raise ValueError("Configure an AstrBot API key before making requests") + response = await client.get( + f"{base_url.rstrip('/')}/api/v1/im/bots", + headers={"X-API-Key": api_key}, + timeout=15.0, + follow_redirects=False, + ) + if response.status_code == 401: + raise ApiAuthError("AstrBot rejected the API key (401)") + if response.status_code == 403: + raise ApiAuthError("AstrBot API key requires the im scope (403)") + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise ApiResponseError("Expected a JSON response from /im/bots") from exc + if not isinstance(payload, dict) or payload.get("status") != "ok": + raise ApiResponseError("AstrBot returned an unsuccessful response") + data = payload.get("data") + bot_ids = data.get("bot_ids") if isinstance(data, dict) else None + if not isinstance(bot_ids, list) or any(not isinstance(x, str) for x in bot_ids): + raise ApiResponseError("Expected data.bot_ids to be a list of strings") + return bot_ids diff --git a/skills/astrbot-plugin-maker/assets/plugin-readme.md.template b/skills/astrbot-plugin-maker/assets/plugin-readme.md.template new file mode 100644 index 000000000..60ff9f82a --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/plugin-readme.md.template @@ -0,0 +1,43 @@ +# {{plugin_name}} + +Configurable greeting example. Replace its command, business logic, and tests with +your intended feature before release. In AstrBot, send `/{{command}}` using your +configured command prefix. + +Settings in `_conf_schema.json`: + +- `greeting`: greeting prefix; default `Hello`. +- `max_name_length`: displayed name length, 1–200; default `40`. + +## Development + +Run from the plugin directory in an isolated development environment: + +```bash +python -m pip install -r requirements-dev.txt +{{runtime_install}} +python -m pytest -q +python -m ruff check . +python -m ruff format --check . +``` + +The default tests exercise `plugin_logic.py` without importing AstrBot. If generated +with `--with-openapi`, they also test `openapi_client.py` through a mocked HTTP +transport. That client is an optional building block; wire it into your requested +handler/configuration before claiming an HTTP feature is implemented. + +## Runtime smoke test + +In a development environment with the **target AstrBot and its dependencies** +installed, run `python -m pytest runtime_tests -q`. The template baseline is AstrBot +4.28.0 on Python 3.12+. The test uses real SDK registration, config, and event/result +classes. It does not boot the loader or contact a messaging platform. + +For a loader/reload check, place the plugin in the runtime's +`data/plugins/{{plugin_name}}`, start AstrBot, and use WebUI plugin management to +reload it. Confirm the command reply and a changed configuration value. Check the +log for load errors. Adapt the runtime test when changing the example class/handler. + +Set real author, description, repository, supported platforms, and verified version +constraints before publication. Keep runtime data and credentials outside the plugin +source tree. No platform compatibility claim is implied by this scaffold. diff --git a/skills/astrbot-plugin-maker/assets/plugin_logic.py.template b/skills/astrbot-plugin-maker/assets/plugin_logic.py.template new file mode 100644 index 000000000..0b49dc31e --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/plugin_logic.py.template @@ -0,0 +1,10 @@ +"""Business logic shared by the command handler and offline tests.""" + + +def build_greeting(name: str, greeting: str, max_name_length: int) -> str: + if not isinstance(greeting, str): + raise TypeError("greeting must be a string") + if type(max_name_length) is not int or not 1 <= max_name_length <= 200: + raise ValueError("max_name_length must be an integer between 1 and 200") + display_name = (name.strip() or "friend")[:max_name_length] + return f"{greeting}, {display_name}!" diff --git a/skills/astrbot-plugin-maker/assets/requirements-dev.txt.template b/skills/astrbot-plugin-maker/assets/requirements-dev.txt.template new file mode 100644 index 000000000..6a730bc69 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/requirements-dev.txt.template @@ -0,0 +1,5 @@ +pytest>=8,<10 +PyYAML>=6,<7 +packaging>=24 +ruff>=0.15 +# Install the target AstrBot separately to run runtime_tests/. diff --git a/skills/astrbot-plugin-maker/assets/requirements.txt.template b/skills/astrbot-plugin-maker/assets/requirements.txt.template new file mode 100644 index 000000000..b9b347c61 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/requirements.txt.template @@ -0,0 +1,3 @@ +# Runtime dependency for the optional openapi_client.py example. +# Add dependencies actually imported by your implementation. +httpx>=0.28,<1 diff --git a/skills/astrbot-plugin-maker/assets/ruff.toml.template b/skills/astrbot-plugin-maker/assets/ruff.toml.template new file mode 100644 index 000000000..6fb1d33ef --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/ruff.toml.template @@ -0,0 +1,7 @@ +target-version = "py312" + +[lint] +select = ["E", "F", "I"] + +[lint.isort] +known-first-party = ["plugin_logic", "openapi_client"] diff --git a/skills/astrbot-plugin-maker/assets/test_openapi_auth_and_shape.py.template b/skills/astrbot-plugin-maker/assets/test_openapi_auth_and_shape.py.template new file mode 100644 index 000000000..8098403a4 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/test_openapi_auth_and_shape.py.template @@ -0,0 +1,75 @@ +"""Offline transport tests of the production HTTP client, with no real secrets.""" + +import asyncio + +import httpx +import pytest + +from openapi_client import ApiAuthError, ApiResponseError, get_bot_ids + + +def request_with(handler, key="test-key"): + async def run(): + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + return await get_bot_ids(client, "https://astrbot.example/", key) + + return asyncio.run(run()) + + +def test_request_and_success_shape(): + def handler(request): + assert request.method == "GET" + assert str(request.url) == "https://astrbot.example/api/v1/im/bots" + assert request.headers["X-API-Key"] == "test-key" + return httpx.Response( + 200, json={"status": "ok", "data": {"bot_ids": ["bot-1"]}} + ) + + assert request_with(handler) == ["bot-1"] + + +@pytest.mark.parametrize("status_code", [401, 403]) +def test_auth_failures(status_code): + with pytest.raises(ApiAuthError, match=str(status_code)): + request_with(lambda request: httpx.Response(status_code)) + + +@pytest.mark.parametrize( + "payload", + [ + {"status": "error", "data": {}}, + {"status": "ok", "data": []}, + {"status": "ok", "data": {"bot_ids": [123]}}, + {"status": "ok", "data": {}}, + [], + ], +) +def test_invalid_response_shape(payload): + with pytest.raises(ApiResponseError): + request_with(lambda request: httpx.Response(200, json=payload)) + + +def test_invalid_json(): + with pytest.raises(ApiResponseError): + request_with(lambda request: httpx.Response(200, text="not json")) + + +def test_missing_key_makes_no_request(): + def handler(request): + pytest.fail("A request was made without a key") + + with pytest.raises(ValueError, match="Configure"): + request_with(handler, key=" ") + + +def test_timeout_reaches_caller(): + def handler(request): + raise httpx.ReadTimeout("Timed out", request=request) + + with pytest.raises(httpx.ReadTimeout): + request_with(handler) + + +def test_server_error_reaches_caller(): + with pytest.raises(httpx.HTTPStatusError): + request_with(lambda request: httpx.Response(500)) diff --git a/skills/astrbot-plugin-maker/assets/test_plugin_behavior.py.template b/skills/astrbot-plugin-maker/assets/test_plugin_behavior.py.template new file mode 100644 index 000000000..2723c9237 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/test_plugin_behavior.py.template @@ -0,0 +1,24 @@ +"""Offline tests for the production function used by main.py.""" + +import pytest + +from plugin_logic import build_greeting + + +@pytest.mark.parametrize( + ("name", "greeting", "limit", "expected"), + [ + (" Alice ", "Hello", 40, "Hello, Alice!"), + ("小明", "你好", 40, "你好, 小明!"), + (" ", "Hello", 40, "Hello, friend!"), + ("abcdef", "Hi", 3, "Hi, abc!"), + ], +) +def test_greeting(name, greeting, limit, expected): + assert build_greeting(name, greeting, limit) == expected + + +@pytest.mark.parametrize("limit", [0, -1, 201]) +def test_invalid_name_limit(limit): + with pytest.raises(ValueError): + build_greeting("Alice", "Hello", limit) diff --git a/skills/astrbot-plugin-maker/assets/test_plugin_smoke.py.template b/skills/astrbot-plugin-maker/assets/test_plugin_smoke.py.template new file mode 100644 index 000000000..98d4dd7f8 --- /dev/null +++ b/skills/astrbot-plugin-maker/assets/test_plugin_smoke.py.template @@ -0,0 +1,87 @@ +"""Run explicitly with the real AstrBot SDK: python -m pytest runtime_tests -q. + +This uses real registration, config, and event/result classes, but does not start +AstrBot's loader, send messages to a platform, or verify WebUI reload behavior. +Missing AstrBot is a failure, not a silently skipped integration test. +""" + +import asyncio +import importlib +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from astrbot.api import AstrBotConfig +from astrbot.api.event import AstrMessageEvent +from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType +from astrbot.api.star import Star +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.star.filter.command import CommandFilter +from astrbot.core.star.star import star_map +from astrbot.core.star.star_handler import star_handlers_registry + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def plugin_class(): + sys.path.insert(0, str(PLUGIN_ROOT.parent)) + try: + module = importlib.import_module(f"{PLUGIN_ROOT.name}.main") + return module.GreetingPlugin + finally: + sys.path.remove(str(PLUGIN_ROOT.parent)) + + +def registered_command(plugin_class): + handler = star_handlers_registry.get_handler_by_full_name( + f"{plugin_class.__module__}_greet" + ) + assert handler is not None + return next( + item for item in handler.event_filters if isinstance(item, CommandFilter) + ) + + +def test_real_sdk_registration(plugin_class): + assert issubclass(plugin_class, Star) + assert star_map[plugin_class.__module__].star_cls_type is plugin_class + assert registered_command(plugin_class).command_name == "{{command}}" + + +def test_real_config_and_command_result(plugin_class, tmp_path): + schema = json.loads((PLUGIN_ROOT / "_conf_schema.json").read_text(encoding="utf-8")) + config = AstrBotConfig(str(tmp_path / "plugin_config.json"), schema=schema) + config["greeting"] = "Welcome" + config["max_name_length"] = 3 + plugin = plugin_class(context=SimpleNamespace(), config=config) + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.sender = MessageMember(user_id="offline-user", nickname="Alice") + message.message = [] + message.message_str = "/{{command}}" + event = AstrMessageEvent( + "{{command}}", # The framework strips the wake prefix before CommandFilter. + message, + PlatformMetadata(name="webchat", description="Offline test", id="test-bot"), + "test-session", + ) + event.is_at_or_wake_command = True + assert registered_command(plugin_class).filter(event, config) + + async def collect(): + return [ + result + async for result in plugin.greet(event, **event.get_extra("parsed_params")) + ] + + result = asyncio.run(collect()) + assert len(result) == 1 + assert result[0].chain[0].text == "Welcome, Ali!" + + config["greeting"] = "Hi" + assert asyncio.run(collect())[0].chain[0].text == "Hi, Ali!" + config["max_name_length"] = 0 + assert "plugin settings" in asyncio.run(collect())[0].chain[0].text diff --git a/skills/astrbot-plugin-maker/references/api-patterns.md b/skills/astrbot-plugin-maker/references/api-patterns.md new file mode 100644 index 000000000..c2248d63f --- /dev/null +++ b/skills/astrbot-plugin-maker/references/api-patterns.md @@ -0,0 +1,150 @@ +# Python API patterns + +Verified against the source baseline in [sources](sources.md). The generated +[main template](../assets/main.py.template) is the smallest complete example. +The snippets below belong inside an existing `Star` subclass unless noted. + +## Entrypoint, commands, and configuration + +Import `filter` from `astrbot.api.event`; Python's built-in `filter` is unrelated. +Handlers are methods beginning with `self, event`. A command decorator takes a +command name without the wake prefix; spaces need a command group or an explicit +argument parser. Typed command arguments are parsed by the framework: + +```python +@filter.command("add") +async def add(self, event: AstrMessageEvent, a: int, b: int): + """Add two integers.""" + yield event.plain_result(str(a + b)) +``` + +Use `filter.permission_type(filter.PermissionType.ADMIN)` for an admin-only +command. Platform and group/private filters are in the receive-events guide. +Check a hook's particular signature: `on_llm_request`, `on_llm_response`, and +other LLM lifecycle hooks use coroutine callbacks, not `yield` command handlers. + +`_conf_schema.json` is AstrBot's setting-definition mapping, **not JSON Schema**: + +```json +{ + "api_key": { + "type": "string", + "description": "API key for the configured service", + "default": "", + "secret": true + } +} +``` + +When that file exists, accept `config: AstrBotConfig` in the constructor and keep +`self.config = config`. The runtime manages the saved configuration under its data +directory. Read this injected config, and use `save_config()` only when the plugin +intentionally changes a setting. `secret: true` masks the WebUI field; it does not +encrypt the stored value. Never print the whole config. + +For an `object` setting, nested definitions are under `items`. For a configurable +provider, the documented `_special: "select_provider"` picker returns an ID. +Consult the guide for template lists, file uploads, or custom Pages rather than +inventing JSON Schema keywords. + +## Async resource lifecycle + +Keep registration/lightweight assignments in `__init__`. Add lifecycle methods only +when the plugin owns resources. This example reuses an HTTP session across calls: + +```python +# Module imports: import httpx +async def initialize(self): + self.http = httpx.AsyncClient(timeout=15.0) + +async def terminate(self): + await self.http.aclose() +``` + +If initialization can fail partway, initialize attributes to `None` and close only +resources actually created. For a recurring job, retain its task, cancel it, and +await its completion during termination (handling `asyncio.CancelledError`). +Do not swallow cancellation in a broad retry loop. Repeated reloads must not +duplicate timers, sessions, or message sends. + +## Persistent data and session identity + +For files in the verified runtime: + +```python +from astrbot.api.star import StarTools + +# Resolve after the runtime has established the plugin's metadata/name. +data_dir = StarTools.get_data_dir(self.name) +``` + +It creates an absolute directory under `data/plugin_data/`. +Do not derive durable storage from `Path(__file__).parent` or `Path("data")`. +For an older version, verify the helper or use the official +`get_astrbot_data_path()` pattern. Simple plugin KV methods +`put_kv_data`, `get_kv_data`, and `delete_kv_data` are async and documented for +AstrBot 4.9.2+. + +Store `event.unified_msg_origin` for a later reply, and scope conversational data to +that UMO when isolation is required. It includes the configured platform identity +and message type; a bare group ID can collide across bots/platforms. + +## Sending messages + +A command can `yield event.plain_result(text)` or a +`yield event.chain_result(components)`. Build components from +`astrbot.api.message_components`, for example `Plain` or `Image.fromURL(url)`. + +For a coroutine hook, send explicitly with `await event.send(...)`. For a later +proactive message, use: + +```python +from astrbot.api.event import MessageChain + +sent = await self.context.send_message( + saved_umo, MessageChain().message("The requested job has finished.") +) +``` + +Handle `sent == False` and adapter errors. Verify proactive-message support on the +selected adapter; SDK construction alone cannot demonstrate delivery. + +## LLM calls and tools + +For the current session's configured provider: + +```python +provider_id = await self.context.get_current_chat_provider_id( + umo=event.unified_msg_origin +) +response = await self.context.llm_generate( + chat_provider_id=provider_id, + prompt="Summarize the supplied text.", +) +yield event.plain_result(response.completion_text) +``` + +Handle an unavailable provider and provider errors. This direct call returns data +to the plugin. When the feature requires the ordinary conversation pipeline, +inspect `event.request_llm(...)` and its conversation/history semantics instead of +assuming direct calls automatically persist chat history. + +A tool with arguments needs the framework's docstring schema: + +```python +@filter.llm_tool(name="lookup_item") +async def lookup_item(self, event: AstrMessageEvent, item_id: str): + """Look up an item. + + Args: + item_id(string): The item identifier. + """ + return await self.lookup_service(item_id) +``` + +This snippet requires the plugin's actual `lookup_service` implementation. +Type annotations alone do not define the tool's parameter schema. +Do not pass an invented `parameters=` argument to this decorator. If explicit +schema control is required, use the documented `FunctionTool` + +`context.add_llm_tools()` route. Avoid new uses of the deprecated +`context.register_llm_tool()`. diff --git a/skills/astrbot-plugin-maker/references/compliance-checklist.md b/skills/astrbot-plugin-maker/references/compliance-checklist.md new file mode 100644 index 000000000..66a096d69 --- /dev/null +++ b/skills/astrbot-plugin-maker/references/compliance-checklist.md @@ -0,0 +1,22 @@ +# Delivery checks + +Use the checks that apply to the change; this is a review aid, not an additional +approval gate or a claim of official certification. + +- **Behavior:** the requested command/event/tool is implemented, and changed config + values reach it. Example greeting code is replaced when the requested feature differs. +- **Framework:** entrypoint, decorators, hook signatures, and APIs match the selected + AstrBot release. Version constraints reflect that evidence. +- **Packaging:** required metadata strings are present; author/repo values are real. + Runtime and development dependencies are separate. See [packaging](plugin-new-checklist.md). +- **State/lifecycle:** durable files use AstrBot's plugin data directory; session + keys do not collide across bots. Owned tasks and clients are cleaned up on reload. +- **Network:** async client, timeouts, relevant error paths, and no credential logging. + OpenAPI parsing uses the actual endpoint's JSON/SSE/binary contract. +- **Tests:** assertions call the real implementation. Run existing relevant checks + and add regression coverage proportionate to the behavior change. +- **Evidence:** distinguish static checks, offline behavior, actual SDK smoke tests, + and live loader/adapter checks. State unavailable checks explicitly. +- **Release:** when requested, prepare the authorized PR or publication with a real + destination and current requirements. Do not infer marketplace approval from a + local validator. diff --git a/skills/astrbot-plugin-maker/references/nl-to-implementation.md b/skills/astrbot-plugin-maker/references/nl-to-implementation.md new file mode 100644 index 000000000..171d55257 --- /dev/null +++ b/skills/astrbot-plugin-maker/references/nl-to-implementation.md @@ -0,0 +1,32 @@ +# Map a request to implementation + +For an open-ended plugin request, derive a short behavior contract from the user's +words and the existing repository. Ask only about a missing detail that affects the +outcome; normal choices can be recorded as assumptions. + +| Requirement | Concrete implementation question | +| --- | --- | +| Trigger | A command, message filter, LLM tool, lifecycle hook, or recurring job? | +| Input | Typed command arguments, full message chain, file, or config value? | +| Reply/action | Plain text, media, a tool result, or a later message to the saved UMO? | +| State | Per-user/session/plugin state? How is it retained over reloads? | +| Failures | Invalid input, missing configuration/provider, timeout, bad response? | +| Compatibility | Which AstrBot version and adapters are actually needed? | + +For a repair, reproduce the reported failure with the current implementation and +follow its existing structure. Preserve metadata identity and configuration keys +unless a migration is necessary. + +Example: "a command that fetches status from my service" normally needs one handler, +an async client with a timeout, settings for the service URL/credential, and tests +using mocked service responses. It does not by itself require AstrBot's own HTTP +OpenAPI, an LLM tool, or an AstrBot runtime installation. + +Implement the smallest complete behavior. Separate business logic from framework +wiring when this makes offline tests useful; avoid splitting a tiny fix into +unnecessary modules. Use the generated greeting only as an initial scaffold and +replace its example behavior/tests before presenting the requested feature as done. + +Verify an observable success case and the relevant failure paths. Report actual +validation and any remaining runtime/platform checks, without turning routine +implementation into a required approval sequence. diff --git a/skills/astrbot-plugin-maker/references/openapi-integration.md b/skills/astrbot-plugin-maker/references/openapi-integration.md new file mode 100644 index 000000000..d4e4fc3ae --- /dev/null +++ b/skills/astrbot-plugin-maker/references/openapi-integration.md @@ -0,0 +1,65 @@ +# AstrBot HTTP API integration + +Read this only for a caller that needs HTTP access to an AstrBot server. A normal +in-process plugin uses `Context` and `AstrMessageEvent` directly. + +## Choose a version-specific contract + +API-key HTTP access was introduced in v4.18.0. See [sources](sources.md) for pinned +v4.28.0 evidence and known documentation differences. The server's own +`/api/v1/openapi.json` and `/api/v1/docs` are preferable to assuming the public +docs match an older installation. + +Keep the server origin configurable (local default `http://localhost:6185`). +Keys can be sent as `X-API-Key` or `Authorization: Bearer`; use one scheme +consistently. Load the key from user configuration/environment. A WebUI login +session and a scoped API key have different authorization behavior. + +| v4.28.0 canonical operation | Key scope | Response/important input | +| --- | --- | --- | +| `GET /api/v1/im/bots` | `im` | JSON envelope; `data.bot_ids` is a string list | +| `POST /api/v1/im/messages` | `im` | Supply `umo` and `message`; singular `/im/message` is an alias | +| `POST /api/v1/chat` | `chat` | API-key calls need `username`; SSE stream | +| `GET /api/v1/chat/sessions` | `chat` | API-key calls need a `username` query parameter | +| `GET /api/v1/chat/configs` | `chat` | Available configurations for chat | +| `POST /api/v1/files` | `file` | Multipart upload; `/api/v1/file` also exists | +| `GET /api/v1/file?attachment_id=...` | `file` | File bytes or an error response | + +This is a small routing aid, not a complete API catalog. For additional endpoints, +read their operation/schema and scope. Never infer pluralization, request bodies, +or response types from a neighboring endpoint. + +## Parsing and errors + +- `401`: missing/invalid credential. `403`: insufficient scope or another + authorization condition; tell the user which operation needs which scope. +- Check HTTP status before success parsing. A JSON `status: "error"` envelope + must also fail even if the transport returned HTTP 200. +- For `/im/bots`, validate `data.bot_ids`; do not require every endpoint's `data` + to have that shape or even to be a dictionary. +- Chat is `text/event-stream`: consume complete SSE events, handle stream errors + and cancellation, and inspect the event payload contract for that version. + Do not call `response.json()` on a successful chat stream. +- Downloads return bytes. Verify status/content type instead of decoding them as + JSON. Uploads require the actual multipart field names from the target schema. +- Set explicit timeouts, close clients/streams, and surface failures without + echoing credentials or full response bodies. Do not blindly retry sends/uploads + after a timeout; the remote operation may have completed. + +## Reusable client and tests + +The [client template](../assets/openapi_client.py.template) implements only the +read-only bot-ID endpoint. It accepts an owned `httpx.AsyncClient`, sends the key, +checks auth/HTTP/envelope errors, and validates the real endpoint-specific shape. +Use `--with-openapi` with the scaffold generator to include it and +[transport tests](../assets/test_openapi_auth_and_shape.py.template). + +Wire the client into the requested handler only when needed; add configurable +origin/key fields, resource cleanup, and a useful user-facing error path. The +example greeting does not call it automatically. + +Tests use `httpx.MockTransport` and exercise the production function. They check +the request URL/header, success, 401/403, bad JSON/data, missing keys, server errors, +and timeout propagation. For other endpoints, add tests of their real request and +response contracts, including SSE chunks when relevant. Do not define a substitute +parser or raise the expected exception directly inside the test. diff --git a/skills/astrbot-plugin-maker/references/plugin-new-checklist.md b/skills/astrbot-plugin-maker/references/plugin-new-checklist.md new file mode 100644 index 000000000..f26294dc1 --- /dev/null +++ b/skills/astrbot-plugin-maker/references/plugin-new-checklist.md @@ -0,0 +1,70 @@ +# Plugin packaging and release + +Use this for a new plugin or packaging changes. See [sources](sources.md) for the +official guides and the v4.28.0 verification baseline. + +## Files and metadata + +`main.py` contains the plugin class. New plugins should include `metadata.yaml`; +the pinned loader also accepts `metadata.yml`. Runtime validation requires +non-empty string values for `name`, `desc`, `version`, and `author`. +It accepts `description` as a legacy alias for `desc`; use `desc` for new work. +Quote version-like YAML values so, for example, `1.0` does not become a number. + +Add the **real** `repo` URL when known, particularly for releases and updates. +It is not part of the four-field runtime minimum. Never invent an author or +repository to make a release check pass. + +Common optional fields include `display_name`, `short_desc`, +`support_platforms`, and `astrbot_version`. The publishing guide additionally +describes `social_link` and `tags`. The runtime also has version-specific metadata +such as Pages. Consult the relevant source instead of rejecting every unlisted key. + +- `version`: the plugin's own version, preferably a quoted semantic version. +- `astrbot_version`: a PEP 440 constraint on AstrBot, e.g. `">=4.28.0,<5"`. + Do not use `">=v4.28.0"`. Select the lower bound from APIs actually needed and + verified. The scaffold's default is its tested baseline. +- Keep the existing metadata name stable during repairs; changing it can change + plugin identity, configuration, and storage. +- Prefer a lowercase directory/repository name beginning `astrbot_plugin_`. + +## Adapter declarations + +`support_platforms` is optional. Leaving it absent makes no tested-compatibility +claim. When present, use a list of adapter keys, not display labels or configured +bot IDs. Verified v4.28.0 keys from `ADAPTER_NAME_2_TYPE`: + +```text +aiocqhttp qq_official qq_official_webhook telegram wecom wecom_ai_bot +lark dingtalk discord slack kook vocechat weixin_official_account +satori misskey line matrix weixin_oc mattermost webchat +``` + +This is a dated reference, not an eternal allowlist. Recheck it for the target +release. Generic components do not prove every adapter supports a feature; +OneBot-specific forwarding or raw calls require adapter-specific validation. + +## Dependencies and assets + +- Put third-party runtime dependencies actually used in `requirements.txt`. + The greeting scaffold uses only AstrBot and the standard library, so it needs no + runtime requirements file. +- Keep pytest, Ruff, and other development tools in `requirements-dev.txt`. + Do not add AstrBot itself to a plugin's runtime dependencies merely to test it. +- Use `_conf_schema.json` only for configurable behavior. For a few settings it is + simpler than a custom Page. See [API patterns](api-patterns.md). +- `logo.png` is optional; the guide recommends a square 256×256 image. +- Preserve required notices when adapting external code/assets. Link design sources + when borrowing an implementation idea. + +## When publication is requested + +Confirm the real repository metadata, dependency installation, requested behavior, +and supported versions/platforms. Run the static validator with `--require-repo`; +its result is only a local file check. + +The official publishing route is [AstrBot Cloud](https://cloud.astrbot.app/publish). +The guide currently limits marketplace ZIPs to 16 MB. Prepare the actual archive +without caches, virtual environments, credentials, or runtime data, and recheck the +current publishing requirements. A local check or a GitHub PR is not marketplace +acceptance. Follow the user's existing publication authorization and destination. diff --git a/skills/astrbot-plugin-maker/references/sources.md b/skills/astrbot-plugin-maker/references/sources.md new file mode 100644 index 000000000..f83f59850 --- /dev/null +++ b/skills/astrbot-plugin-maker/references/sources.md @@ -0,0 +1,63 @@ +# Verified sources and refresh map + +Checked **2026-09-09** against [AstrBot v4.28.0](https://github.com/AstrBotDevs/AstrBot/releases/tag/v4.28.0), +commit `a412146401426c0cdff8bbefb8627a03da519da8`. The release requires Python 3.12+. +These are baseline observations, not claims about every AstrBot version. + +## Retrieve only the material needed + +| Subject | Official guide | Pinned implementation / evidence | +| --- | --- | --- | +| Plugin entrypoint and handlers | [Minimal example](https://docs.astrbot.app/dev/star/guides/simple.html) | [Star discovery and lifecycle](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/base.py) | +| Metadata and compatibility | [New plugin](https://docs.astrbot.app/dev/star/plugin-new.html) | [Metadata validation](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/updater.py), [loader](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/star_manager.py) | +| Adapter identifiers | [Platform declarations](https://docs.astrbot.app/dev/star/plugin-new.html) | [ADAPTER_NAME_2_TYPE](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/filter/platform_adapter_type.py) | +| Commands and hooks | [Receive events](https://docs.astrbot.app/dev/star/guides/listen-message-event.html) | [Handler registration](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/register/star_handler.py) | +| Message chains and UMO | [Send messages](https://docs.astrbot.app/dev/star/guides/send-message.html) | [Event API](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/platform/astr_message_event.py) | +| Configuration | [Plugin config](https://docs.astrbot.app/dev/star/guides/plugin-config.html) | [AstrBotConfig](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/config/astrbot_config.py) | +| Persistent data | [Storage](https://docs.astrbot.app/dev/star/guides/storage.html) | [StarTools.get_data_dir](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/star_tools.py) | +| LLM and tools | [AI guide](https://docs.astrbot.app/dev/star/guides/ai.html) | [Context methods](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/context.py) | +| HTTP endpoints and scopes | [HTTP API](https://docs.astrbot.app/dev/openapi.html), [scope table](https://docs.astrbot.app/dev/openapi-scopes.html) | [OpenAPI spec](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/openspec/openapi-v1.yaml), [route handlers](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/dashboard/api/open_api.py) | +| HTTP response bodies | [Interactive reference](https://docs.astrbot.app/scalar.html) | [OpenApiService](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/dashboard/services/open_api_service.py) | +| Custom plugin pages | [Pages](https://docs.astrbot.app/dev/star/guides/plugin-pages.html) | [Public web helpers](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/api/web.py) | +| Release and runtime setup | [Publish](https://docs.astrbot.app/dev/star/plugin-publish.html), [source deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) | [pyproject.toml](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/pyproject.toml) | + +The documentation source is in the **AstrBot repository**, under +`docs/zh/dev/star/` and `docs/zh/dev/openapi*.md`. Do not assume a separate docs +repository exists. The [official starter](https://github.com/Soulter/helloworld/tree/0c0d52b17e2feb76a7bcff887cac3d0e12eecc09) +was also inspected; use its project shape, with the version differences below. + +## Observed differences to resolve deliberately + +- The starter still uses `@register`. In the pinned runtime, + [register_star](https://github.com/AstrBotDevs/AstrBot/blob/a412146401426c0cdff8bbefb8627a03da519da8/astrbot/core/star/register/star.py) + marks this decorator deprecated; subclasses are auto-discovered after 3.5.19. +- The prose HTTP guide uses `POST /api/v1/im/message`. The pinned spec's canonical + path is `/api/v1/im/messages`; the runtime retains the singular path as an alias. + Likewise, use `GET /api/v1/chat/configs` for chat configuration choices, checking + older aliases against the target instead of extrapolating a path. +- The static spec represents `POST /api/v1/chat` with a generic success response. + The API-key route implementation returns `text/event-stream`. It also enforces + a `username` even though the generic schema cannot fully express the auth-specific + requirement. Inspect content type and route logic, not only a generic envelope. +- The prose adapter list omits `webchat`; the pinned `ADAPTER_NAME_2_TYPE` includes + it. Metadata declarations should reflect the intended, tested platform behavior. +- Plugin `version` and `astrbot_version` are different fields. The starter uses + `version: v1.3.0`; the compatibility constraint should omit the `v` prefix. + +## Refresh for another target + +Read the installed version or checkout tag first. Fetch the matching guide/source +sections from that tag and record the relevant signatures and any differences. +For HTTP integrations, prefer the target server's `/api/v1/openapi.json` and +`/api/v1/docs`, then its handlers when the schema is ambiguous. Access only the +server already selected for the task. + +Keep references concise: retain verified decisions, links, and small examples. +Do not vendor the full AstrBot documentation or copy third-party plugin code without +its required attribution/license material. + +## Skill provenance + +Adapted from [Elysium-Seeker/astrbot_plugin_maker_skill](https://github.com/Elysium-Seeker/astrbot_plugin_maker_skill/tree/40887d7827fa6ef3dd8e656f38eddbd369efe689), +which contains the scaffold regression suite and Windows/Linux/real-SDK CI workflow. +The contributed copy is self-contained; using it does not require cloning that repository. diff --git a/skills/astrbot-plugin-maker/references/testing-guide.md b/skills/astrbot-plugin-maker/references/testing-guide.md new file mode 100644 index 000000000..102f33c95 --- /dev/null +++ b/skills/astrbot-plugin-maker/references/testing-guide.md @@ -0,0 +1,85 @@ +# Testing generated and repaired plugins + +Test real production behavior and report the level of evidence obtained. + +## Offline checks + +For a generated plugin, run from its directory in a development environment: + +```bash +python -m pip install -r requirements-dev.txt +python -m pytest -q +python -m ruff check . +python -m ruff format --check . +``` + +If generated with `--with-openapi`, also install its `requirements.txt` first. +The default `pytest.ini` selects `tests/`; these tests import `plugin_logic.py` +and, when included, the actual HTTP client. They use no AstrBot services or real +network calls. Async HTTP tests use `asyncio.run`, so pytest-asyncio is not needed. + +The skill's static checker needs `PyYAML` and `packaging`: + +```bash +python /scripts/validate_plugin.py +``` + +It reads the real metadata/schema/source files and checks syntax, required string +fields, version constraint syntax, and basic configuration shape. It rejects +duplicate YAML keys. It intentionally does not impose a frozen metadata/adapter +allowlist or import the plugin. A passing result proves none of loadability, +complete JSON/schema semantics, adapter delivery, or marketplace acceptance. + +## Actual AstrBot SDK smoke test + +In an isolated environment with the selected AstrBot version and its dependencies: + +```bash +python -m pytest runtime_tests -q +``` + +For the unmodified example baseline, install `astrbot==4.28.0` on Python 3.12+. +The generated smoke tests import the actual SDK and plugin as a package, check +`Star` discovery, construct a real `AstrBotConfig` using the generated schema, +and invoke the command with real event/result objects. Config changes must alter +the resulting text. A missing SDK fails collection rather than silently skipping. + +This is SDK-level evidence. It does not start `PluginManager`, route a real +message, or verify reload behavior. Keep runtime-created data in an isolated +working directory; AstrBot imports may initialize local data/logging paths. + +When adapting the scaffold, update the smoke test's class, handler, and expectations. +For an existing plugin, use its own test conventions rather than copying a +greeting-specific test verbatim. + +## Loader, reload, and adapter check + +Use an already selected local development runtime, or set one up when within the +requested scope. The [startup reference](../assets/dev-commands.txt) points to the +official source workflow and shows platform-specific directory commands. + +1. Place the plugin under the runtime's `data/plugins/` directory. +2. Start that runtime, check plugin discovery and its log, and exercise the requested + command with the configured prefix. +3. Change a setting in WebUI, save/reload as appropriate, and verify the reply changes. +4. If the plugin owns sessions/jobs, reload twice and check they are cleaned up. +5. Exercise platform-specific features on the actual selected adapter. + +Report a concrete untested check if the runtime or adapter is unavailable. + +## Troubleshooting + +| Symptom | Inspect | +| --- | --- | +| Import fails | Target SDK/interpreter and runtime dependencies; package-relative imports | +| Plugin missing | `main.py`, metadata, version constraint, startup log | +| Command ignored | Registered method, command prefix, permission/platform filters | +| Config ignored | Schema field names and constructor injection; hardcoded defaults | +| Duplicate replies after reload | Uncancelled background tasks, repeated registrations | +| Tool argument missing | Docstring `Args:` schema; annotations alone are insufficient | +| API key works on one endpoint only | Endpoint's required scope and deployed version | +| JSON parse fails on chat | SSE content type and stream parser | + +A test that creates its own metadata file and checks it exists, evaluates unrelated +string operations, or raises its own expected auth exception cannot verify a plugin. +Do not keep such placeholders as passing tests. diff --git a/skills/astrbot-plugin-maker/scripts/scaffold_plugin.py b/skills/astrbot-plugin-maker/scripts/scaffold_plugin.py new file mode 100644 index 000000000..2a81a17b8 --- /dev/null +++ b/skills/astrbot-plugin-maker/scripts/scaffold_plugin.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Generate a small AstrBot plugin without overwriting existing files. + +Uses only the Python standard library. Run validate_plugin.py and the generated +tests after adapting the example to the actual requirement. +""" + +import argparse +import json +import re +from pathlib import Path + +ASSETS = Path(__file__).resolve().parents[1] / "assets" +TOKEN = re.compile(r"\{\{([a-z_]+)\}\}") + + +def nonempty(value: str) -> str: + if not value.strip(): + raise argparse.ArgumentTypeError("must be a non-empty string") + return value + + +def scaffold(args: argparse.Namespace) -> Path: + destination = Path(args.destination).expanduser().absolute() + if destination.exists() or destination.is_symlink(): + raise ValueError( + f"Destination already exists; refusing to overwrite: {destination}" + ) + if not re.fullmatch(r"astrbot_plugin_[a-z0-9][a-z0-9_]*", destination.name): + raise ValueError("Use a directory named astrbot_plugin_") + if not re.fullmatch(r"[a-z][a-z0-9_-]*", args.command): + raise ValueError( + "Command must start with a lowercase letter and contain no spaces" + ) + + # JSON-quoted strings are valid YAML scalars, including colons and newlines. + values = { + "plugin_name": destination.name, + "command": args.command, + "runtime_install": ( + "python -m pip install -r requirements.txt" if args.with_openapi else "" + ), + "repo_line": ( + "repo: " + json.dumps(args.repo, ensure_ascii=False) if args.repo else "" + ), + } + for key, value in { + "name": destination.name, + "author": args.author, + "description": args.description, + "version": args.version, + "astrbot_version": args.astrbot_version, + }.items(): + values[f"{key}_yaml"] = json.dumps(value, ensure_ascii=False) + + templates = { + "main.py": "main.py.template", + "plugin_logic.py": "plugin_logic.py.template", + "metadata.yaml": "metadata.yaml.template", + "_conf_schema.json": "_conf_schema.json.template", + "requirements-dev.txt": "requirements-dev.txt.template", + "tests/test_plugin_behavior.py": "test_plugin_behavior.py.template", + "runtime_tests/test_plugin_runtime.py": "test_plugin_smoke.py.template", + "README.md": "plugin-readme.md.template", + "ruff.toml": "ruff.toml.template", + } + if args.with_openapi: + templates.update( + { + "openapi_client.py": "openapi_client.py.template", + "requirements.txt": "requirements.txt.template", + "tests/test_openapi.py": "test_openapi_auth_and_shape.py.template", + } + ) + + # Render everything before creating the destination. Replacements are single + # pass so user text containing {{tokens}} is never interpreted as a template. + rendered = { + name: TOKEN.sub( + lambda match: values[match.group(1)], + (ASSETS / template).read_text(encoding="utf-8"), + ) + for name, template in templates.items() + } + rendered["pytest.ini"] = "[pytest]\ntestpaths = tests\n" + rendered[".gitignore"] = ( + ".venv/\n__pycache__/\n.pytest_cache/\n.ruff_cache/\n*.pyc\ndata/\n.env\n" + ) + destination.mkdir(parents=True, exist_ok=False) + for name, content in rendered.items(): + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8", newline="\n") + return destination + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("destination", help="New astrbot_plugin_ directory") + parser.add_argument("--author", required=True, type=nonempty) + parser.add_argument("--description", required=True, type=nonempty) + parser.add_argument("--command", default="greet") + parser.add_argument("--repo", type=nonempty, help="Known plugin repository URL") + parser.add_argument("--version", default="0.1.0", type=nonempty) + parser.add_argument("--astrbot-version", default=">=4.28.0", type=nonempty) + parser.add_argument("--with-openapi", action="store_true") + args = parser.parse_args() + try: + destination = scaffold(args) + except (OSError, ValueError, KeyError) as exc: + parser.exit(1, f"Scaffold failed: {exc}\n") + print(f"Created {destination}") + print("Adapt the greeting example, then run the checks in its README.md.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/astrbot-plugin-maker/scripts/validate_plugin.py b/skills/astrbot-plugin-maker/scripts/validate_plugin.py new file mode 100644 index 000000000..009cc0d51 --- /dev/null +++ b/skills/astrbot-plugin-maker/scripts/validate_plugin.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Static checks for AstrBot plugin files; no plugin imports or network access. + +Requires PyYAML and packaging. This intentionally does not freeze adapter names +or reject new metadata fields. Check those against the target AstrBot release. +""" + +import argparse +import ast +import json +import os +import re +from pathlib import Path + +import yaml +from packaging.specifiers import InvalidSpecifier, SpecifierSet + +REQUIRED_METADATA = ("name", "desc", "version", "author") +IGNORED_DIRS = {".git", ".venv", "venv", "__pycache__", "node_modules"} +CONFIG_TYPES = { + "string": str, + "text": str, + "int": int, + "float": (int, float), + "bool": bool, + "object": dict, + "dict": dict, + "list": list, + "template_list": list, + "file": list, +} + + +class UniqueKeyLoader(yaml.SafeLoader): + """Reject duplicate metadata keys rather than silently accepting the last.""" + + +def unique_mapping(loader, node, deep=False): + result = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if not isinstance(key, str): + raise ValueError("metadata keys must be strings") + if key in result: + raise ValueError(f"duplicate metadata key: {key}") + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping +) + + +def check_schema(schema, errors, warnings, prefix="_conf_schema.json"): + if not isinstance(schema, dict): + errors.append( + f"{prefix}: expected an object mapping setting names to definitions" + ) + return + for key, definition in schema.items(): + label = f"{prefix}.{key}" + if not isinstance(definition, dict) or not isinstance( + definition.get("type"), str + ): + errors.append(f"{label}: setting needs a string type") + continue + kind = definition["type"] + if kind not in CONFIG_TYPES: + warnings.append( + f"{label}: verify unrecognized type {kind!r} in target AstrBot" + ) + continue + if "default" in definition: + value = definition["default"] + valid = isinstance(value, CONFIG_TYPES[kind]) + if kind in {"int", "float"} and isinstance(value, bool): + valid = False + if not valid: + errors.append(f"{label}: default does not match type {kind}") + if kind == "object": + check_schema(definition.get("items"), errors, warnings, f"{label}.items") + if kind == "template_list": + templates = definition.get("templates") + if not isinstance(templates, dict): + errors.append(f"{label}: template_list needs a templates object") + else: + for name, template in templates.items(): + items = ( + template.get("items") if isinstance(template, dict) else None + ) + check_schema( + items, errors, warnings, f"{label}.templates.{name}.items" + ) + + +def validate_plugin(root: Path, require_repo: bool = False): + errors, warnings = [], [] + python_count = 0 + if not root.is_dir(): + return [f"Plugin directory does not exist: {root}"], warnings, python_count + metadata_path = root / "metadata.yaml" + if not metadata_path.exists(): + metadata_path = root / "metadata.yml" + try: + if not metadata_path.is_file(): + raise ValueError("metadata.yaml or metadata.yml is missing") + metadata = yaml.load( + metadata_path.read_text(encoding="utf-8-sig"), UniqueKeyLoader + ) + if not isinstance(metadata, dict): + raise ValueError("expected a YAML mapping") + # AstrBot v4.28.0 accepts description as a legacy alias for desc. + if "desc" not in metadata and "description" in metadata: + metadata["desc"] = metadata["description"] + for key in REQUIRED_METADATA: + if not isinstance(metadata.get(key), str) or not metadata[key].strip(): + errors.append(f"metadata: {key} must be a non-empty string") + if require_repo and not ( + isinstance(metadata.get("repo"), str) and metadata["repo"].strip() + ): + errors.append("metadata: set the real repo URL before release") + if "support_platforms" in metadata: + platforms = metadata["support_platforms"] + if not isinstance(platforms, list) or any( + not isinstance(item, str) or not item.strip() for item in platforms + ): + errors.append( + "metadata: support_platforms must be a list of non-empty strings" + ) + if "astrbot_version" in metadata: + constraint = metadata["astrbot_version"] + try: + if not isinstance(constraint, str) or not constraint.strip(): + raise InvalidSpecifier("use a non-empty string") + if re.search(r"(?:^|[<>=!~,])\s*v(?=\d)", constraint): + raise InvalidSpecifier("omit the v prefix in astrbot_version") + SpecifierSet(constraint) + except InvalidSpecifier as exc: + errors.append(f"metadata: invalid astrbot_version: {exc}") + except (OSError, ValueError, yaml.YAMLError) as exc: + errors.append(f"metadata: {exc}") + + if not (root / "main.py").is_file(): + errors.append("main.py is missing") + for directory, dirs, files in os.walk(root, followlinks=False): + dirs[:] = [ + name + for name in dirs + if name not in IGNORED_DIRS and not name.startswith(".") + ] + for name in files: + if not name.endswith(".py"): + continue + path = Path(directory) / name + python_count += 1 + try: + ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + except (OSError, SyntaxError, ValueError) as exc: + errors.append(f"{path.relative_to(root)}: {exc}") + + schema_path = root / "_conf_schema.json" + if schema_path.exists(): + try: + schema = json.loads(schema_path.read_text(encoding="utf-8-sig")) + check_schema(schema, errors, warnings) + except (OSError, ValueError) as exc: + errors.append(f"_conf_schema.json: {exc}") + return errors, warnings, python_count + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("plugin_dir", type=Path) + parser.add_argument( + "--require-repo", action="store_true", help="Release metadata check" + ) + args = parser.parse_args() + errors, warnings, count = validate_plugin(args.plugin_dir, args.require_repo) + for warning in warnings: + print(f"WARNING: {warning}") + for error in errors: + print(f"ERROR: {error}") + if errors: + return 1 + print( + f"Static checks passed ({count} Python files). " + "Runtime import/load was not tested." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())