Files
AstrBot/test_plugin/new/commands/hello.py
whatevertogo 84184c4c0a feat(errors): Enhance AstrBotError with detailed documentation and examples
feat(events): Expand MessageEvent with reply capabilities and detailed docstrings

fix(loader): Ensure plugin path is correctly managed in sys.path

feat(star): Improve Star class documentation and lifecycle method descriptions

feat(testing): Add plugin metadata handling in MockContext and enhance PluginHarness

feat(hello): Refactor HelloPlugin to utilize new reply methods and structured capabilities

test(decorators): Add tests for input/output model support in provide_capability

test(events): Implement tests for reply_image and reply_chain methods in MessageEvent

test(http): Validate API registration with capability handler references and error handling

test(tests): Enhance tests for plugin harness and directory handling in dev commands
2026-03-14 23:37:09 +08:00

439 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""V4 sample plugin used by integration tests.
This fixture exercises the full public v4 API surface:
Decorators:
- on_command: command handling with aliases and description
- on_message: message handling with regex, keywords, platforms
- on_event: event subscription
- on_schedule: scheduled tasks
- require_admin: permission control
- provide_capability: custom capabilities (normal + stream)
Context clients:
- ctx.llm: LLM client (chat, chat_raw, stream_chat, with images)
- ctx.memory: Memory client (save, get, delete, search)
- ctx.db: DB client (get, set, delete, list, get_many, set_many, watch)
- ctx.platform: Platform client (send, send_image, send_chain, get_members)
- ctx.http: HTTP client (register_api, unregister_api, list_apis)
- ctx.metadata: Metadata client (get_plugin, list_plugins, get_plugin_config)
MessageEvent:
- reply(), plain_result(), target, to_payload()
- user_id, group_id, session_id, platform, text
Star lifecycle:
- on_start, on_stop, on_error
"""
from __future__ import annotations
import asyncio
from pydantic import BaseModel, Field
from astrbot_sdk import (
Context,
MessageEvent,
Star,
on_command,
on_event,
on_message,
on_schedule,
provide_capability,
require_admin,
)
from astrbot_sdk.context import CancelToken
class EchoCapabilityInput(BaseModel):
text: str
class EchoCapabilityOutput(BaseModel):
echo: str
plugin_id: str
class StreamCapabilityInput(BaseModel):
text: str
class StreamCapabilityOutput(BaseModel):
items: list[dict[str, object]]
class HttpHandlerInput(BaseModel):
method: str = "GET"
body: dict[str, object] = Field(default_factory=dict)
class HttpHandlerOutput(BaseModel):
status: int
body: dict[str, object]
class HelloPlugin(Star):
"""Representative v4 plugin fixture covering all SDK capabilities."""
# ============================================================
# Lifecycle hooks
# ============================================================
async def on_start(self, ctx: Context) -> None:
"""Called when the plugin starts."""
ctx.logger.info("HelloPlugin starting up")
# Store startup timestamp
await ctx.db.set("demo:started", {"status": "ok"})
async def on_stop(self, ctx: Context) -> None:
"""Called when the plugin stops."""
ctx.logger.info("HelloPlugin shutting down")
# Cleanup
await ctx.db.delete("demo:started")
# ============================================================
# Command handlers
# ============================================================
@on_command("hello", aliases=["hi"], description="发送问候消息")
async def hello(self, event: MessageEvent, ctx: Context) -> None:
"""Basic command with LLM response."""
reply = await ctx.llm.chat(event.text)
await event.reply(reply)
@on_command("raw", description="调用 llm.chat_raw 并返回结构化信息")
async def raw(self, event: MessageEvent, ctx: Context):
"""LLM chat with full response metadata."""
response = await ctx.llm.chat_raw(
event.text,
system="be concise",
history=[{"role": "user", "content": "history"}],
)
return event.plain_result(
f"raw={response.text}|finish={response.finish_reason}|"
f"usage={response.usage}"
)
@on_command("stream", description="流式 LLM 调用")
async def stream(self, event: MessageEvent, ctx: Context) -> None:
"""Streaming LLM response."""
chunks: list[str] = []
async for chunk in ctx.llm.stream_chat(event.text or "stream"):
chunks.append(chunk)
# Real-time feedback
await event.reply(f"[streaming...] {chunk}")
await event.reply(f"[完成] {''.join(chunks)}")
@on_command("vision", description="带图片的 LLM 调用")
async def vision(self, event: MessageEvent, ctx: Context) -> None:
"""LLM with image input."""
# Extract image URL from message or use default
image_url = "https://example.com/demo.png"
response = await ctx.llm.chat(
event.text or "描述这张图片",
image_urls=[image_url],
)
await event.reply(response)
# ============================================================
# Memory operations
# ============================================================
@on_command("remember", description="记忆操作演示")
async def remember(self, event: MessageEvent, ctx: Context):
"""Memory client full API demo."""
# Save with metadata
await ctx.memory.save(
"demo:last_message",
{"user_id": event.user_id or "", "text": event.text},
source="fixture",
tags=["demo"],
)
# Get exact match
remembered = await ctx.memory.get("demo:last_message") or {}
# Semantic search
searched = await ctx.memory.search("demo")
# Delete
await ctx.memory.delete("demo:last_message")
return event.plain_result(
f"remembered={remembered.get('user_id', 'unknown')}|"
f"searched={len(searched)}"
)
# ============================================================
# Database operations
# ============================================================
@on_command("db", description="数据库操作演示")
async def db_ops(self, event: MessageEvent, ctx: Context):
"""DB client full API demo."""
# Basic operations
await ctx.db.set("demo:key1", {"value": "data1"})
await ctx.db.set("demo:key2", {"value": "data2"})
await ctx.db.set("demo:key3", {"value": "data3"})
value1 = await ctx.db.get("demo:key1")
# List keys with prefix
keys = await ctx.db.list("demo:")
# Batch operations
values = await ctx.db.get_many(["demo:key1", "demo:key2"])
await ctx.db.set_many(
{
"demo:batch1": {"batch": True},
"demo:batch2": {"batch": True},
}
)
# Cleanup
for key in [
"demo:key1",
"demo:key2",
"demo:key3",
"demo:batch1",
"demo:batch2",
]:
await ctx.db.delete(key)
return event.plain_result(
f"value1={value1}|keys={len(keys)}|batch_get={len(values)}"
)
@on_command("watch", description="监听数据库变更")
async def watch_db(self, event: MessageEvent, ctx: Context) -> None:
"""Watch for DB changes (demonstration)."""
await event.reply("开始监听 demo: 前缀的变更 (5秒)...")
async def watcher():
count = 0
async for change in ctx.db.watch("demo:"):
count += 1
await event.reply(f"变更: {change['op']} {change['key']}")
if count >= 3:
break
# Run watcher with timeout
try:
await asyncio.wait_for(watcher(), timeout=5.0)
except asyncio.TimeoutError:
await event.reply("监听超时结束")
# ============================================================
# Platform operations
# ============================================================
@on_command("platforms", description="平台操作演示")
async def platforms(self, event: MessageEvent, ctx: Context) -> None:
"""Platform client full API demo."""
target = event.target or event.session_id
# Get group members
members = await ctx.platform.get_members(target)
# Send text
await ctx.platform.send(target, f"成员数: {len(members)}")
# Send image back to the current conversation
await event.reply_image("https://example.com/demo.png")
# Send message chain back to the current conversation
await event.reply_chain(
[
{"type": "Plain", "text": "消息链 "},
{"type": "Image", "file": "https://example.com/demo.png"},
],
)
@on_command("announce", description="发送富消息链")
async def announce(self, event: MessageEvent, ctx: Context) -> None:
"""Send rich message chain."""
await event.reply_chain(
[
{"type": "Plain", "text": "公告: "},
{"type": "Plain", "text": event.text or "无内容"},
],
)
# ============================================================
# HTTP API operations
# ============================================================
@on_command("register_api", description="注册 HTTP API")
async def register_http_api(self, event: MessageEvent, ctx: Context) -> None:
"""Register a custom HTTP API endpoint."""
await ctx.http.register_api(
route="/demo/api",
handler=self.http_handler_capability,
methods=["GET", "POST"],
description="Demo HTTP API",
)
apis = await ctx.http.list_apis()
return event.plain_result(f"已注册 API当前共 {len(apis)}")
@on_command("unregister_api", description="注销 HTTP API")
async def unregister_http_api(self, event: MessageEvent, ctx: Context) -> None:
"""Unregister the HTTP API endpoint."""
await ctx.http.unregister_api("/demo/api")
return event.plain_result("已注销 API")
# ============================================================
# Metadata operations
# ============================================================
@on_command("plugins", description="列出所有插件")
async def list_plugins(self, event: MessageEvent, ctx: Context):
"""List all loaded plugins."""
plugins = await ctx.metadata.list_plugins()
names = [p.name for p in plugins]
return event.plain_result(f"插件: {', '.join(names)}")
@on_command("plugin_info", description="获取插件信息")
async def plugin_info(self, event: MessageEvent, ctx: Context):
"""Get current plugin metadata."""
me = await ctx.metadata.get_current_plugin()
if me:
return event.plain_result(
f"name={me.name}|version={me.version}|author={me.author}"
)
return event.plain_result("无法获取插件信息")
@on_command("config", description="获取插件配置")
async def get_config(self, event: MessageEvent, ctx: Context):
"""Get plugin configuration."""
config = await ctx.metadata.get_plugin_config()
if config:
return event.plain_result(f"config={config}")
return event.plain_result("无配置")
# ============================================================
# Permission control
# ============================================================
@require_admin
@on_command("secure", description="管理员专用命令")
async def secure(self, event: MessageEvent):
"""Admin-only command."""
return event.plain_result(f"secure:{event.user_id or 'unknown'}")
# ============================================================
# Message handlers
# ============================================================
@on_message(regex=r"^ping$", keywords=["ping"], platforms=["test"])
async def ping(self, event: MessageEvent):
"""Regex and keyword matching."""
return event.plain_result("pong")
@on_message(keywords=["hello"])
async def on_hello(self, event: MessageEvent, ctx: Context) -> None:
"""Keyword-based message handler."""
await event.reply("检测到 hello 关键词!")
# ============================================================
# Event handlers
# ============================================================
@on_event("group_join")
async def on_group_join(self, event: MessageEvent, ctx: Context) -> None:
"""Handle group join events."""
ctx.logger.info("用户加入群组: {}", event.user_id)
await ctx.platform.send(event.session_id, f"欢迎 {event.user_id} 加入群组!")
@on_event("group_leave")
async def on_group_leave(self, event: MessageEvent, ctx: Context) -> None:
"""Handle group leave events."""
ctx.logger.info("用户离开群组: {}", event.user_id)
# ============================================================
# Scheduled tasks
# ============================================================
@on_schedule(interval_seconds=3600)
async def hourly_heartbeat(self, ctx: Context) -> None:
"""Hourly scheduled task."""
await ctx.db.set("demo:last_heartbeat", {"time": "hourly"})
ctx.logger.info("执行每小时心跳")
@on_schedule(cron="0 9 * * *")
async def morning_greeting(self, ctx: Context) -> None:
"""Cron-based scheduled task (9 AM daily)."""
ctx.logger.info("早安问候任务触发")
# ============================================================
# Custom capabilities
# ============================================================
@provide_capability(
"demo.echo",
description="回显输入文本",
input_model=EchoCapabilityInput,
output_model=EchoCapabilityOutput,
)
async def echo_capability(
self,
payload: dict[str, object],
ctx: Context,
cancel_token: CancelToken,
) -> dict[str, str]:
"""Simple echo capability."""
cancel_token.raise_if_cancelled()
text = str(payload.get("text", ""))
await ctx.db.set("demo:capability_echo", {"text": text})
return {
"echo": text,
"plugin_id": ctx.plugin_id,
}
@provide_capability(
"demo.stream",
description="流式回显输入文本",
input_model=StreamCapabilityInput,
output_model=StreamCapabilityOutput,
supports_stream=True,
cancelable=True,
)
async def stream_capability(
self,
payload: dict[str, object],
ctx: Context,
cancel_token: CancelToken,
):
"""Streaming echo capability."""
text = str(payload.get("text", ""))
await ctx.db.set("demo:last_stream", {"text": text})
for char in text:
cancel_token.raise_if_cancelled()
await asyncio.sleep(0)
yield {"text": char}
@provide_capability(
"demo.http_handler",
description="处理 /demo/api HTTP 请求",
input_model=HttpHandlerInput,
output_model=HttpHandlerOutput,
)
async def http_handler_capability(
self,
payload: dict[str, object],
ctx: Context,
cancel_token: CancelToken,
) -> dict[str, object]:
"""Handle HTTP API requests."""
method = payload.get("method", "GET")
body = payload.get("body", {})
ctx.logger.info(f"HTTP {method} request: {body}")
return {
"status": 200,
"body": {
"message": "Hello from plugin!",
"method": method,
"plugin_id": ctx.plugin_id,
},
}