diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index e168bbf400..9016b37add 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -90,6 +90,7 @@
SendMessageToUserTool,
)
from astrbot.core.tools.web_search_tools import (
+ AnySearchWebSearchTool,
BaiduWebSearchTool,
BochaWebSearchTool,
BraveWebSearchTool,
@@ -145,6 +146,7 @@
"web_search_bocha",
"web_search_brave",
"web_search_exa",
+ "web_search_anysearch",
}
)
WEB_SEARCH_CITATION_PROMPT = (
@@ -1285,6 +1287,8 @@ async def _apply_web_search_tools(
elif provider == "exa":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool))
+ elif provider == "anysearch":
+ req.func_tool.add_tool(tool_mgr.get_builtin_tool(AnySearchWebSearchTool))
def _apply_web_search_citation_prompt(
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 942bcda65f..0637fa8ff9 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -116,6 +116,7 @@
"websearch_baidu_app_builder_key": "",
"websearch_firecrawl_key": [],
"websearch_exa_key": [],
+ "websearch_anysearch_key": [],
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
@@ -3395,6 +3396,7 @@
"brave",
"firecrawl",
"exa",
+ "anysearch",
],
"condition": {
"provider_settings.web_search": True,
@@ -3459,6 +3461,16 @@
"provider_settings.web_search": True,
},
},
+ "provider_settings.websearch_anysearch_key": {
+ "description": "AnySearch API Key",
+ "type": "list",
+ "items": {"type": "string"},
+ "hint": "可添加多个 Key 进行轮询。留空则使用匿名模式(每日免费额度)。申请地址:https://anysearch.com/console/api-keys",
+ "condition": {
+ "provider_settings.websearch_provider": "anysearch",
+ "provider_settings.web_search": True,
+ },
+ },
"provider_settings.web_search_link": {
"description": "显示来源引用",
"type": "bool",
diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py
index 0d85c40dc6..bece9b66df 100644
--- a/astrbot/core/tools/web_search_tools.py
+++ b/astrbot/core/tools/web_search_tools.py
@@ -23,6 +23,7 @@
"firecrawl_extract_web_page",
"web_search_exa",
"exa_get_contents",
+ "web_search_anysearch",
]
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
@@ -48,6 +49,10 @@
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "exa",
}
+_ANYSEARCH_WEB_SEARCH_TOOL_CONFIG = {
+ "provider_settings.web_search": True,
+ "provider_settings.websearch_provider": "anysearch",
+}
@std_dataclass
@@ -104,12 +109,14 @@ async def get(self, provider_settings: dict) -> str:
# 429 - Rate limited.
# 432 - Tavily quota exceeded.
_RETRYABLE_HTTP_STATUSES: frozenset[int] = frozenset({401, 403, 429, 432})
+_ANYSEARCH_RETRYABLE_HTTP_STATUSES: frozenset[int] = frozenset({401, 402, 403, 429})
_TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
_EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa")
+_ANYSEARCH_KEY_ROTATOR = _KeyRotator("websearch_anysearch_key", "AnySearch")
def normalize_legacy_web_search_config(cfg) -> None:
@@ -134,6 +141,7 @@ def normalize_legacy_web_search_config(cfg) -> None:
"websearch_brave_key",
"websearch_firecrawl_key",
"websearch_exa_key",
+ "websearch_anysearch_key",
):
value = provider_settings.get(setting_name)
if isinstance(value, str):
@@ -1240,7 +1248,146 @@ async def call(self, context, **kwargs) -> ToolExecResult:
return ret or "Error: Exa get contents does not return any results."
+async def _anysearch_search(
+ provider_settings: dict,
+ payload: dict,
+) -> list[SearchResult]:
+ """Call the AnySearch /v1/search endpoint and return normalized results.
+
+ AnySearch also serves anonymous traffic with a daily free quota, so an empty
+ key list is valid and results in a single unauthenticated request.
+
+ Args:
+ provider_settings: Provider settings containing AnySearch API keys.
+ payload: Request payload for the AnySearch search endpoint.
+
+ Returns:
+ Normalized search results.
+
+ Raises:
+ Exception: If the request fails after all configured keys are exhausted,
+ or if a non-retryable HTTP error is returned.
+ """
+ keys = provider_settings.get("websearch_anysearch_key", [])
+ # `None` marks the anonymous attempt used when no key is configured.
+ attempts: list[str | None] = list(keys) if keys else [None]
+
+ last_error = None
+ for _ in range(len(attempts)):
+ headers = {"Content-Type": "application/json"}
+ if keys:
+ anysearch_key = await _ANYSEARCH_KEY_ROTATOR.get(provider_settings)
+ headers["Authorization"] = f"Bearer {anysearch_key}"
+
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.anysearch.com/v1/search",
+ json=payload,
+ headers=headers,
+ ) as response:
+ if response.status == 200:
+ data = await response.json()
+ # Results live under `data.results`; fall back to the
+ # top-level `results` field for forward compatibility.
+ body = data.get("data") or data
+ return [
+ SearchResult(
+ title=item.get("title", ""),
+ url=item.get("url", ""),
+ snippet=item.get("snippet") or item.get("content", ""),
+ )
+ for item in body.get("results", [])
+ if item.get("url")
+ ]
+ reason = await response.text()
+ if response.status in _ANYSEARCH_RETRYABLE_HTTP_STATUSES:
+ last_error = Exception(
+ f"AnySearch web search failed: {reason}, status: {response.status}",
+ )
+ continue
+ raise Exception(
+ f"AnySearch web search failed: {reason}, status: {response.status}",
+ )
+
+ if last_error is not None:
+ raise last_error
+ raise Exception("AnySearch web search failed with all configured keys.")
+
+
+@builtin_tool(config=_ANYSEARCH_WEB_SEARCH_TOOL_CONFIG)
+@pydantic_dataclass
+class AnySearchWebSearchTool(FunctionTool[AstrAgentContext]):
+ """Web search tool powered by the AnySearch API."""
+
+ name: str = "web_search_anysearch"
+ description: str = (
+ "A web search tool powered by AnySearch. Supports general web search and "
+ "domain-specific search over academic, code, finance, legal and security sources."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Required. Search query."},
+ "max_results": {
+ "type": "integer",
+ "description": "Optional. The maximum number of results to return. Default is 10. Range is 1-20.",
+ },
+ "tag": {
+ "type": "string",
+ "description": (
+ 'Optional. Domain capability tag in "{domain}.{subdomain}" form, '
+ 'for example "academic.paper" or "finance.news". Omit it for general web search.'
+ ),
+ },
+ "zone": {
+ "type": "string",
+ "description": 'Optional. Result region, must be one of "cn", "intl".',
+ },
+ "language": {
+ "type": "string",
+ "description": 'Optional. Preferred result language, for example "zh-CN" or "en".',
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+
+ try:
+ max_results = int(kwargs.get("max_results", 10))
+ except (TypeError, ValueError):
+ max_results = 10
+ max_results = min(max(max_results, 1), 20)
+
+ payload: dict = {
+ "query": kwargs["query"],
+ "max_results": max_results,
+ "format": "json",
+ }
+
+ tag = str(kwargs.get("tag", "")).strip()
+ if tag:
+ payload["tag"] = tag
+
+ zone = kwargs.get("zone", "")
+ if zone in ("cn", "intl"):
+ payload["zone"] = zone
+
+ language = str(kwargs.get("language", "")).strip()
+ if language:
+ payload["language"] = language
+
+ results = await _anysearch_search(provider_settings, payload)
+ if not results:
+ return "Error: AnySearch web search does not return any results."
+ return _search_result_payload(results)
+
+
__all__ = [
+ "AnySearchWebSearchTool",
"BaiduWebSearchTool",
"BochaWebSearchTool",
"BraveWebSearchTool",
diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
index 0e6905cc5c..85c00ea548 100644
--- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
@@ -143,6 +143,10 @@
"websearch_exa_key": {
"description": "Exa API Key",
"hint": "Multiple keys can be added for rotation. Get a key at https://dashboard.exa.ai"
+ },
+ "websearch_anysearch_key": {
+ "description": "AnySearch API Key",
+ "hint": "Multiple keys can be added for rotation. Leave empty to use anonymous mode with a daily free quota."
}
}
},
diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
index dc0bbca5d6..790e8bb019 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
@@ -143,6 +143,10 @@
"websearch_exa_key": {
"description": "API-ключ Exa",
"hint": "Можно добавить несколько ключей для ротации. Получить ключ: https://dashboard.exa.ai"
+ },
+ "websearch_anysearch_key": {
+ "description": "AnySearch API-ключ",
+ "hint": "Можно добавить несколько ключей для ротации. Оставьте пустым для анонимного режима с ежедневной бесплатной квотой."
}
}
},
diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
index 089e9ba91f..ed9fe93ca9 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
@@ -145,6 +145,10 @@
"websearch_exa_key": {
"description": "Exa API Key",
"hint": "可添加多个 Key 进行轮询。获取 Key: https://dashboard.exa.ai"
+ },
+ "websearch_anysearch_key": {
+ "description": "AnySearch API Key",
+ "hint": "可添加多个 Key 进行轮询。留空则使用匿名模式(每日免费额度)。"
}
}
},
diff --git a/docs/en/dev/plugin-platform-adapter.md b/docs/en/dev/plugin-platform-adapter.md
index 090df5d0a0..99072032b8 100644
--- a/docs/en/dev/plugin-platform-adapter.md
+++ b/docs/en/dev/plugin-platform-adapter.md
@@ -17,30 +17,34 @@ Assume FakePlatform's client SDK looks like this:
```py
import asyncio
-class FakeClient():
- '''Simulates a messaging platform that sends a message every 5 seconds'''
+
+class FakeClient:
+ """Simulates a messaging platform that sends a message every 5 seconds"""
+
def __init__(self, token: str, username: str):
self.token = token
self.username = username
# ...
-
+
async def start_polling(self):
while True:
await asyncio.sleep(5)
- await getattr(self, 'on_message_received')({
- 'bot_id': '123',
- 'content': 'new message',
- 'username': 'zhangsan',
- 'userid': '123',
- 'message_id': 'asdhoashd',
- 'group_id': 'group123',
- })
-
+ await getattr(self, "on_message_received")(
+ {
+ "bot_id": "123",
+ "content": "new message",
+ "username": "zhangsan",
+ "userid": "123",
+ "message_id": "asdhoashd",
+ "group_id": "group123",
+ }
+ )
+
async def send_text(self, to: str, message: str):
- print('Message sent:', to, message)
-
+ print("Message sent:", to, message)
+
async def send_image(self, to: str, image_path: str):
- print('Image sent:', to, image_path)
+ print("Image sent:", to, image_path)
```
Now create `fake_platform_adapter.py`:
@@ -48,31 +52,46 @@ Now create `fake_platform_adapter.py`:
```py
import asyncio
-from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, PlatformMetadata, MessageType
+from astrbot.api.platform import (
+ Platform,
+ AstrBotMessage,
+ MessageMember,
+ PlatformMetadata,
+ MessageType,
+)
from astrbot.api.event import MessageChain
-from astrbot.api.message_components import Plain, Image, Record # Message chain components, import as needed
+from astrbot.api.message_components import (
+ Plain,
+ Image,
+ Record,
+) # Message chain components, import as needed
from astrbot.core.platform.message_session import MessageSesion
from astrbot.api.platform import register_platform_adapter
from astrbot import logger
from .client import FakeClient
from .fake_platform_event import FakePlatformEvent
-
+
+
# Register the platform adapter. First param: platform name, second: description, third: default config.
-@register_platform_adapter("fake", "fake adapter", default_config_tmpl={
- "token": "your_token",
- "username": "bot_username"
-})
+@register_platform_adapter(
+ "fake",
+ "fake adapter",
+ default_config_tmpl={"token": "your_token", "username": "bot_username"},
+)
class FakePlatformAdapter(Platform):
-
- def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
+ def __init__(
+ self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
+ ) -> None:
super().__init__(event_queue)
- self.config = platform_config # The default config above; filled in by the user and passed here
- self.settings = platform_settings # platform_settings: platform settings
-
- async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
+ self.config = platform_config # The default config above; filled in by the user and passed here
+ self.settings = platform_settings # platform_settings: platform settings
+
+ async def send_by_session(
+ self, session: MessageSesion, message_chain: MessageChain
+ ):
# Must be implemented
await super().send_by_session(session, message_chain)
-
+
def meta(self) -> PlatformMetadata:
# Must be implemented. Simply return as shown below.
return PlatformMetadata(
@@ -86,31 +105,39 @@ class FakePlatformAdapter(Platform):
# FakeClient is defined by us — this is just an example. This is its callback function.
async def on_received(data):
logger.info(data)
- abm = await self.convert_message(data=data) # Convert to AstrBotMessage
- await self.handle_msg(abm)
-
+ abm = await self.convert_message(data=data) # Convert to AstrBotMessage
+ await self.handle_msg(abm)
+
# Initialize FakeClient
- self.client = FakeClient(self.config['token'], self.config['username'])
+ self.client = FakeClient(self.config["token"], self.config["username"])
self.client.on_message_received = on_received
- await self.client.start_polling() # Continuously listens for messages; this is a blocking call.
+ await (
+ self.client.start_polling()
+ ) # Continuously listens for messages; this is a blocking call.
async def convert_message(self, data: dict) -> AstrBotMessage:
# Convert the platform message to AstrBotMessage.
# The degree of adaptation is reflected here. Different platforms have different message
# structures; convert accordingly.
abm = AstrBotMessage()
- abm.type = MessageType.GROUP_MESSAGE # Also friend_message for private chats. Analyze per platform. Important!
- abm.group_id = data['group_id'] # Can be omitted for private chats
- abm.message_str = data['content'] # Plain text message. Important!
- abm.sender = MessageMember(user_id=data['userid'], nickname=data['username']) # Sender. Important!
- abm.message = [Plain(text=data['content'])] # Message chain. Append other message types as needed. Important!
- abm.raw_message = data # Raw message.
- abm.self_id = data['bot_id']
- abm.session_id = data['userid'] # Session ID. Important!
- abm.message_id = data['message_id'] # Message ID.
-
+ abm.type = (
+ MessageType.GROUP_MESSAGE
+ ) # Also friend_message for private chats. Analyze per platform. Important!
+ abm.group_id = data["group_id"] # Can be omitted for private chats
+ abm.message_str = data["content"] # Plain text message. Important!
+ abm.sender = MessageMember(
+ user_id=data["userid"], nickname=data["username"]
+ ) # Sender. Important!
+ abm.message = [
+ Plain(text=data["content"])
+ ] # Message chain. Append other message types as needed. Important!
+ abm.raw_message = data # Raw message.
+ abm.self_id = data["bot_id"]
+ abm.session_id = data["userid"] # Session ID. Important!
+ abm.message_id = data["message_id"] # Message ID.
+
return abm
-
+
async def handle_msg(self, message: AstrBotMessage):
# Handle the message
message_event = FakePlatformEvent(
@@ -118,9 +145,11 @@ class FakePlatformAdapter(Platform):
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
- client=self.client
+ client=self.client,
)
- self.commit_event(message_event) # Submit the event to the event queue. Don't forget this!
+ self.commit_event(
+ message_event
+ ) # Submit the event to the event queue. Don't forget this!
```
@@ -132,22 +161,34 @@ from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image
from .client import FakeClient
+
class FakePlatformEvent(AstrMessageEvent):
- def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: FakeClient):
+ def __init__(
+ self,
+ message_str: str,
+ message_obj: AstrBotMessage,
+ platform_meta: PlatformMetadata,
+ session_id: str,
+ client: FakeClient,
+ ):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
-
+
async def send(self, message: MessageChain):
- for i in message.chain: # Iterate over the message chain
- if isinstance(i, Plain): # If it's a text message
+ for i in message.chain: # Iterate over the message chain
+ if isinstance(i, Plain): # If it's a text message
await self.client.send_text(to=self.get_sender_id(), message=i.text)
- elif isinstance(i, Image): # If it's an image
+ elif isinstance(i, Image): # If it's an image
# convert_to_file_path() resolves supported media refs through
# the shared media utilities.
img_path = await i.convert_to_file_path()
- await self.client.send_image(to=self.get_sender_id(), image_path=img_path)
+ await self.client.send_image(
+ to=self.get_sender_id(), image_path=img_path
+ )
- await super().send(message) # Must be called at the end to invoke the parent class's send method.
+ await super().send(
+ message
+ ) # Must be called at the end to invoke the parent class's send method.
```
## Media Message Handling
@@ -211,9 +252,10 @@ Finally, in `main.py`, simply import the `fake_platform_adapter` module during i
```py
from astrbot.api.star import Context, Star
+
class MyPlugin(Star):
def __init__(self, context: Context):
- from .fake_platform_adapter import FakePlatformAdapter # noqa
+ from .fake_platform_adapter import FakePlatformAdapter # noqa
```
Once set up, run AstrBot:
diff --git a/docs/en/dev/star/guides/ai.md b/docs/en/dev/star/guides/ai.md
index 0014b25bbf..3914c33f00 100644
--- a/docs/en/dev/star/guides/ai.md
+++ b/docs/en/dev/star/guides/ai.md
@@ -24,7 +24,7 @@ provider_id = await self.context.get_current_chat_provider_id(umo=umo)
```py
llm_resp = await self.context.llm_generate(
- chat_provider_id=provider_id, # Chat model ID
+ chat_provider_id=provider_id, # Chat model ID
prompt="Hello, world!",
)
# print(llm_resp.completion_text) # Get the returned text
@@ -97,12 +97,14 @@ Alternatively, you can use the `@filter.llm_tool` decorator to define and regist
```py{3,4,5,6,7}
@filter.llm_tool(name="get_weather") # If name is omitted, the function name is used
-async def get_weather(self, event: AstrMessageEvent, location: str) -> MessageEventResult:
- '''Get weather information.
+async def get_weather(
+ self, event: AstrMessageEvent, location: str
+) -> MessageEventResult:
+ """Get weather information.
Args:
location(string): The location to query
- '''
+ """
resp = self.get_weather_from_api(location)
yield event.plain_result("Weather: " + resp)
```
@@ -134,8 +136,8 @@ llm_resp = await self.context.tool_loop_agent(
chat_provider_id=prov_id,
prompt="Search for videos related to AstrBot on Bilibili.",
tools=ToolSet([BilibiliTool()]),
- max_steps=30, # Maximum agent execution steps
- tool_call_timeout=120, # Tool invocation timeout
+ max_steps=30, # Maximum agent execution steps
+ tool_call_timeout=120, # Tool invocation timeout
)
# print(llm_resp.completion_text) # Get the returned text
```
@@ -358,7 +360,9 @@ curr_cid = await conv_mgr.get_curr_conversation_id(event.unified_msg_origin)
user_msg = UserMessageSegment(content=[TextPart(text="hi")])
llm_resp = await self.context.llm_generate(
chat_provider_id=provider_id, # Chat model ID
- contexts=[user_msg], # When prompt is not specified, contexts is used as input; if both prompt and contexts are provided, prompt is appended to the end of the LLM input
+ contexts=[
+ user_msg
+ ], # When prompt is not specified, contexts is used as input; if both prompt and contexts are provided, prompt is appended to the end of the LLM input
)
await conv_mgr.add_message_pair(
cid=curr_cid,
@@ -526,7 +530,6 @@ persona_mgr = self.context.persona_manager
::: details Persona / Personality Type Definition
```py
-
class Persona(SQLModel, table=True):
"""Persona is a set of instructions for LLMs to follow.
diff --git a/docs/en/dev/star/guides/html-to-pic.md b/docs/en/dev/star/guides/html-to-pic.md
index b04e5c119f..2d3c651dc7 100644
--- a/docs/en/dev/star/guides/html-to-pic.md
+++ b/docs/en/dev/star/guides/html-to-pic.md
@@ -9,12 +9,13 @@
AstrBot supports rendering text into images.
```python
-@filter.command("image") # Register an /image command that accepts a text parameter.
+@filter.command("image") # Register an /image command that accepts a text parameter.
async def on_aiocqhttp(self, event: AstrMessageEvent, text: str):
- url = await self.text_to_image(text) # text_to_image() is a method of the Star class.
+ url = await self.text_to_image(
+ text
+ ) # text_to_image() is a method of the Star class.
# path = await self.text_to_image(text, return_url = False) # If you want to save the image locally
yield event.image_result(url)
-
```

@@ -27,7 +28,7 @@ AstrBot supports rendering text-to-image templates using `HTML + Jinja2`.
```py{7}
# Custom Jinja2 template with CSS support
-TMPL = '''
+TMPL = """
Todo List
@@ -36,12 +37,15 @@ TMPL = '''
{{ item }}
{% endfor %}
-'''
+"""
+
@filter.command("todo")
async def custom_t2i_tmpl(self, event: AstrMessageEvent):
- options = {} # Optionally pass rendering options.
- url = await self.html_render(TMPL, {"items": ["Eat", "Sleep", "Play Genshin"]}, options=options) # The second parameter is the data for Jinja2 rendering
+ options = {} # Optionally pass rendering options.
+ url = await self.html_render(
+ TMPL, {"items": ["Eat", "Sleep", "Play Genshin"]}, options=options
+ ) # The second parameter is the data for Jinja2 rendering
yield event.image_result(url)
```
diff --git a/docs/en/dev/star/guides/listen-message-event.md b/docs/en/dev/star/guides/listen-message-event.md
index 15c958de7d..7d2e30bfec 100644
--- a/docs/en/dev/star/guides/listen-message-event.md
+++ b/docs/en/dev/star/guides/listen-message-event.md
@@ -24,14 +24,17 @@ AstrBot receives messages delivered by messaging platforms and encapsulates them
```py{11}
class AstrBotMessage:
- '''AstrBot's message object'''
+ """AstrBot's message object"""
+
type: MessageType # Message type
self_id: str # Bot's identification ID
session_id: str # Session ID. Depends on the unique_session setting.
message_id: str # Message ID
- group_id: str = "" # Group ID, empty if it's a private chat
+ group_id: str = "" # Group ID, empty if it's a private chat
sender: MessageMember # Sender
- message: List[BaseMessageComponent] # Message chain. For example: [Plain("Hello"), At(qq=123456)]
+ message: List[
+ BaseMessageComponent
+ ] # Message chain. For example: [Plain("Hello"), At(qq=123456)]
message_str: str # The most straightforward plain text message string, concatenating Plain messages (text messages) from the message chain
raw_message: object
timestamp: int # Message timestamp
@@ -73,15 +76,16 @@ In AstrBot, message chains are represented as lists of type `List[BaseMessageCom
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
+
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
- @filter.command("helloworld") # from astrbot.api.event.filter import command
+ @filter.command("helloworld") # from astrbot.api.event.filter import command
async def helloworld(self, event: AstrMessageEvent):
- '''This is a hello world command'''
+ """This is a hello world command"""
user_name = event.get_sender_name()
- message_str = event.message_str # Get the plain text content of the message
+ message_str = event.message_str # Get the plain text content of the message
yield event.plain_result(f"Hello, {user_name}!")
```
@@ -110,11 +114,13 @@ Command groups help you organize commands.
def math():
pass
+
@math.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
# /math add 1 2 -> Result is: 3
yield event.plain_result(f"Result is: {a + b}")
+
@math.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
# /math sub 1 2 -> Result is: -1
@@ -134,30 +140,35 @@ When a user doesn't input a subcommand, an error will be reported and the tree s
Theoretically, command groups can be nested infinitely!
```py
-'''
+"""
math
├── calc
│ ├── add (a(int),b(int),)
│ ├── sub (a(int),b(int),)
│ ├── help (command with no parameters)
-'''
+"""
+
@filter.command_group("math")
def math():
pass
-@math.group("calc") # Note: this is group, not command_group
+
+@math.group("calc") # Note: this is group, not command_group
def calc():
pass
+
@calc.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"Result is: {a + b}")
+
@calc.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"Result is: {a - b}")
+
@calc.command("help")
async def calc_help(self, event: AstrMessageEvent):
# /math calc help
@@ -171,7 +182,7 @@ async def calc_help(self, event: AstrMessageEvent):
You can add different aliases for commands or command groups:
```python
-@filter.command("help", alias={'帮助', 'helpme'})
+@filter.command("help", alias={"帮助", "helpme"})
async def help(self, event: AstrMessageEvent):
yield event.plain_result("This is a calculator plugin with add and sub commands.")
```
@@ -193,7 +204,7 @@ async def on_all_message(self, event: AstrMessageEvent):
```python
@filter.event_message_type(filter.EventMessageType.PRIVATE_MESSAGE)
async def on_private_message(self, event: AstrMessageEvent):
- message_str = event.message_str # Get the plain text content of the message
+ message_str = event.message_str # Get the plain text content of the message
yield event.plain_result("Received a private message.")
```
@@ -202,9 +213,11 @@ async def on_private_message(self, event: AstrMessageEvent):
#### Messaging Platform
```python
-@filter.platform_adapter_type(filter.PlatformAdapterType.AIOCQHTTP | filter.PlatformAdapterType.QQOFFICIAL)
+@filter.platform_adapter_type(
+ filter.PlatformAdapterType.AIOCQHTTP | filter.PlatformAdapterType.QQOFFICIAL
+)
async def on_aiocqhttp(self, event: AstrMessageEvent):
- '''Only receive messages from AIOCQHTTP and QQOFFICIAL'''
+ """Only receive messages from AIOCQHTTP and QQOFFICIAL"""
yield event.plain_result("Received a message")
```
@@ -244,10 +257,10 @@ async def helloworld(self, event: AstrMessageEvent):
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.on_astrbot_loaded()
async def on_astrbot_loaded(self):
print("AstrBot initialization complete")
-
```
#### On Waiting for LLM Request
@@ -259,6 +272,7 @@ It is suitable for sending feedback such as "Waiting for request..." to the user
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.on_waiting_llm_request()
async def on_waiting_llm(self, event: AstrMessageEvent):
await event.send(event.plain_result("🤔 Waiting for request..."))
@@ -278,11 +292,13 @@ The ProviderRequest object contains all information about the LLM request, inclu
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import ProviderRequest
-@filter.on_llm_request()
-async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest): # Note there are three parameters
- print(req) # Print the request text
- req.system_prompt += "Custom system_prompt" # If there is another suitable approach, avoid using this to append prompts that change every round. It can break prompt caching and greatly increase cost (7 - 20x).
+@filter.on_llm_request()
+async def my_custom_hook_1(
+ self, event: AstrMessageEvent, req: ProviderRequest
+): # Note there are three parameters
+ print(req) # Print the request text
+ req.system_prompt += "Custom system_prompt" # If there is another suitable approach, avoid using this to append prompts that change every round. It can break prompt caching and greatly increase cost (7 - 20x).
```
> [!WARNING]
@@ -332,8 +348,11 @@ You can obtain the `ProviderResponse` object and modify it.
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import LLMResponse
+
@filter.on_llm_response()
-async def on_llm_resp(self, event: AstrMessageEvent, resp: LLMResponse): # Note there are three parameters
+async def on_llm_resp(
+ self, event: AstrMessageEvent, resp: LLMResponse
+): # Note there are three parameters
print(resp)
```
@@ -350,8 +369,11 @@ from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
+
@filter.on_agent_begin()
-async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]): # Note there are three parameters
+async def on_agent_begin(
+ self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]
+): # Note there are three parameters
print("Agent started")
```
@@ -369,6 +391,7 @@ You can obtain the `FunctionTool` object and tool call arguments.
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.tool import FunctionTool
+
@filter.on_using_llm_tool()
async def on_using_llm_tool(
self,
@@ -395,6 +418,7 @@ from mcp.types import CallToolResult
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.tool import FunctionTool
+
@filter.on_llm_tool_respond()
async def on_llm_tool_respond(
self,
@@ -420,8 +444,14 @@ from astrbot.api.provider import LLMResponse
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
+
@filter.on_agent_done()
-async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext], resp: LLMResponse): # Note there are four parameters
+async def on_agent_done(
+ self,
+ event: AstrMessageEvent,
+ run_context: ContextWrapper[AstrAgentContext],
+ resp: LLMResponse,
+): # Note there are four parameters
print(resp)
```
@@ -437,12 +467,15 @@ You can implement some message decoration here, such as converting to voice, con
from astrbot.api.event import filter, AstrMessageEvent
import astrbot.api.message_components as Comp
+
@filter.on_decorating_result()
async def on_decorating_result(self, event: AstrMessageEvent):
result = event.get_result()
chain = result.chain
- print(chain) # Print the message chain
- chain.append(Comp.Plain("!")) # Add an exclamation mark at the end of the message chain
+ print(chain) # Print the message chain
+ chain.append(
+ Comp.Plain("!")
+ ) # Add an exclamation mark at the end of the message chain
```
> You cannot use yield to send messages here. This hook is only for decorating event.get_result().chain. If you need to send, please use the `event.send()` method directly.
@@ -454,6 +487,7 @@ After a message is sent to the messaging platform, the `after_message_sent` hook
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.after_message_sent()
async def after_message_sent(self, event: AstrMessageEvent):
pass
@@ -476,10 +510,10 @@ async def helloworld(self, event: AstrMessageEvent):
```python{6}
@filter.command("check_ok")
async def check_ok(self, event: AstrMessageEvent):
- ok = self.check() # Your own logic
+ ok = self.check() # Your own logic
if not ok:
yield event.plain_result("Check failed")
- event.stop_event() # Stop event propagation
+ event.stop_event() # Stop event propagation
```
When event propagation is stopped, all subsequent steps will not be executed.
diff --git a/docs/en/dev/star/guides/plugin-config.md b/docs/en/dev/star/guides/plugin-config.md
index cf05c94818..6e6bd0cfbb 100644
--- a/docs/en/dev/star/guides/plugin-config.md
+++ b/docs/en/dev/star/guides/plugin-config.md
@@ -221,8 +221,11 @@ When loading plugins, AstrBot will check if there's a `_conf_schema.json` file i
```py
from astrbot.api import AstrBotConfig
+
class ConfigPlugin(Star):
- def __init__(self, context: Context, config: AstrBotConfig): # AstrBotConfig inherits from Dict and has all dictionary methods
+ def __init__(
+ self, context: Context, config: AstrBotConfig
+ ): # AstrBotConfig inherits from Dict and has all dictionary methods
super().__init__(context)
self.config = config
print(self.config)
diff --git a/docs/en/dev/star/guides/send-message.md b/docs/en/dev/star/guides/send-message.md
index 961cc76fb5..ef034f2c5b 100644
--- a/docs/en/dev/star/guides/send-message.md
+++ b/docs/en/dev/star/guides/send-message.md
@@ -10,8 +10,10 @@ async def helloworld(self, event: AstrMessageEvent):
yield event.plain_result("Hello!")
yield event.plain_result("你好!")
- yield event.image_result("path/to/image.jpg") # Send an image
- yield event.image_result("https://example.com/image.jpg") # Send an image from URL, must start with http or https
+ yield event.image_result("path/to/image.jpg") # Send an image
+ yield event.image_result(
+ "https://example.com/image.jpg"
+ ) # Send an image from URL, must start with http or https
```
## Active Messages
@@ -23,6 +25,7 @@ For scheduled tasks or when you don't want to send messages immediately, you can
```python
from astrbot.api.event import MessageChain
+
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
umo = event.unified_msg_origin
@@ -43,14 +46,17 @@ AstrBot supports sending rich media messages such as images, audio, videos, etc.
```python
import astrbot.api.message_components as Comp
+
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
chain = [
- Comp.At(qq=event.get_sender_id()), # Mention the message sender
+ Comp.At(qq=event.get_sender_id()), # Mention the message sender
Comp.Plain("Check out this image:"),
- Comp.Image.fromURL("https://example.com/image.jpg"), # Send image from URL
- Comp.Image.fromFileSystem("path/to/image.jpg"), # Send image from local file system
- Comp.Plain("This is an image.")
+ Comp.Image.fromURL("https://example.com/image.jpg"), # Send image from URL
+ Comp.Image.fromFileSystem(
+ "path/to/image.jpg"
+ ), # Send image from local file system
+ Comp.Plain("This is an image."),
]
yield event.chain_result(chain)
```
@@ -65,13 +71,13 @@ Similarly,
**File**
```py
-Comp.File(file="path/to/file.txt", name="file.txt") # Not supported by some platforms
+Comp.File(file="path/to/file.txt", name="file.txt") # Not supported by some platforms
```
**Audio Record**
```py
-path = "path/to/record.wav" # Currently only accepts wav format, please convert other formats yourself
+path = "path/to/record.wav" # Currently only accepts wav format, please convert other formats yourself
Comp.Record(file=path, url=path)
```
@@ -88,17 +94,15 @@ Comp.Video.fromURL(url="https://example.com/video.mp4")
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Video
+
# fromFileSystem requires the user's protocol client and bot to be on the same system.
- video = Video.fromFileSystem(
- path="test.mp4"
- )
+ video = Video.fromFileSystem(path="test.mp4")
# More universal approach
- video = Video.fromURL(
- url="https://example.com/video.mp4"
- )
+ video = Video.fromURL(url="https://example.com/video.mp4")
yield event.chain_result([video])
```
@@ -113,16 +117,15 @@ You can send group forward messages as follows.
```py
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Node, Plain, Image
+
node = Node(
uin=905617992,
name="Soulter",
- content=[
- Plain("hi"),
- Image.fromFileSystem("test.jpg")
- ]
+ content=[Plain("hi"), Image.fromFileSystem("test.jpg")],
)
yield event.chain_result([node])
```
diff --git a/docs/en/dev/star/guides/session-control.md b/docs/en/dev/star/guides/session-control.md
index e08bae7ae1..faf40569e8 100644
--- a/docs/en/dev/star/guides/session-control.md
+++ b/docs/en/dev/star/guides/session-control.md
@@ -31,6 +31,7 @@ Code within the handler can be written as follows:
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("idiom-chain")
async def handle_empty_mention(self, event: AstrMessageEvent):
"""Idiom chain game implementation"""
@@ -38,36 +39,54 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
yield event.plain_result("Please send an idiom~")
# How to use the session controller
- @session_waiter(timeout=60, record_history_chains=False) # Register a session controller with a 60-second timeout, without recording message history
- async def empty_mention_waiter(controller: SessionController, event: AstrMessageEvent):
- idiom = event.message_str # The idiom sent by the user, e.g., "one horse takes the lead"
-
- if idiom == "exit": # If the user wants to exit the idiom chain game by typing "exit"
+ @session_waiter(
+ timeout=60, record_history_chains=False
+ ) # Register a session controller with a 60-second timeout, without recording message history
+ async def empty_mention_waiter(
+ controller: SessionController, event: AstrMessageEvent
+ ):
+ idiom = (
+ event.message_str
+ ) # The idiom sent by the user, e.g., "one horse takes the lead"
+
+ if (
+ idiom == "exit"
+ ): # If the user wants to exit the idiom chain game by typing "exit"
await event.send(event.plain_result("Exited the idiom chain game~"))
- controller.stop() # Stop the session controller, which will end immediately.
+ controller.stop() # Stop the session controller, which will end immediately.
return
- if len(idiom) != 4: # If the user's input is not a 4-character idiom
- await event.send(event.plain_result("The idiom must be four characters~")) # Send a reply, cannot use yield
+ if len(idiom) != 4: # If the user's input is not a 4-character idiom
+ await event.send(
+ event.plain_result("The idiom must be four characters~")
+ ) # Send a reply, cannot use yield
return
# Exit the current method without executing subsequent logic, but the session is not interrupted; subsequent user input will still enter the current session
# ...
message_result = event.make_result()
- message_result.chain = [Comp.Plain("Foresight")] # import astrbot.api.message_components as Comp
- await event.send(message_result) # Send a reply, cannot use yield
+ message_result.chain = [
+ Comp.Plain("Foresight")
+ ] # import astrbot.api.message_components as Comp
+ await event.send(message_result) # Send a reply, cannot use yield
- controller.keep(timeout=60, reset_timeout=True) # Reset timeout to 60s. If not reset, it will continue the previous timeout countdown.
+ controller.keep(
+ timeout=60, reset_timeout=True
+ ) # Reset timeout to 60s. If not reset, it will continue the previous timeout countdown.
# controller.stop() # Stop the session controller, which will end immediately.
# If history chains are recorded, you can retrieve them via controller.get_history_chains()
try:
await empty_mention_waiter(event)
- except TimeoutError as _: # When timeout occurs, the session controller will raise TimeoutError
+ except (
+ TimeoutError
+ ) as _: # When timeout occurs, the session controller will raise TimeoutError
yield event.plain_result("You timed out!")
except Exception as e:
- yield event.plain_result("An error occurred, please contact the administrator: " + str(e))
+ yield event.plain_result(
+ "An error occurred, please contact the administrator: " + str(e)
+ )
finally:
event.stop_event()
except Exception as e:
@@ -98,13 +117,19 @@ from astrbot.core.utils.session_waiter import (
SessionController,
)
+
# Using the handler from above
# ...
class CustomFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
- return event.get_group_id() if event.get_group_id() else event.unified_msg_origin
+ return (
+ event.get_group_id() if event.get_group_id() else event.unified_msg_origin
+ )
+
-await empty_mention_waiter(event, session_filter=CustomFilter()) # Pass in session_filter here
+await empty_mention_waiter(
+ event, session_filter=CustomFilter()
+) # Pass in session_filter here
# ...
```
diff --git a/docs/en/dev/star/guides/simple.md b/docs/en/dev/star/guides/simple.md
index 7ce124098a..b42138e80a 100644
--- a/docs/en/dev/star/guides/simple.md
+++ b/docs/en/dev/star/guides/simple.md
@@ -5,7 +5,8 @@ The `main.py` file in the plugin template is a minimal plugin instance.
```python
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
from astrbot.api.star import Context, Star
-from astrbot.api import logger # Use the logger interface provided by AstrBot
+from astrbot.api import logger # Use the logger interface provided by AstrBot
+
class MyPlugin(Star):
def __init__(self, context: Context):
@@ -14,14 +15,14 @@ class MyPlugin(Star):
# Decorator to register a command. The command name is "helloworld". Once registered, sending `/helloworld` will trigger this command and respond with `Hello, {user_name}!`
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
- '''This is a hello world command''' # This is the handler's description, which will be parsed to help users understand the plugin's functionality. Highly recommended to provide.
+ """This is a hello world command""" # This is the handler's description, which will be parsed to help users understand the plugin's functionality. Highly recommended to provide.
user_name = event.get_sender_name()
- message_str = event.message_str # Get the plain text content of the message
+ message_str = event.message_str # Get the plain text content of the message
logger.info("Hello world command triggered!")
- yield event.plain_result(f"Hello, {user_name}!") # Send a plain text message
+ yield event.plain_result(f"Hello, {user_name}!") # Send a plain text message
async def terminate(self):
- '''Optionally implement the terminate function, which will be called when the plugin is uninstalled/disabled.'''
+ """Optionally implement the terminate function, which will be called when the plugin is uninstalled/disabled."""
```
Explanation:
diff --git a/docs/en/dev/star/guides/storage.md b/docs/en/dev/star/guides/storage.md
index 286d2382be..6063289c7c 100644
--- a/docs/en/dev/star/guides/storage.md
+++ b/docs/en/dev/star/guides/storage.md
@@ -28,5 +28,7 @@ You can fetch the plugin data directory with:
from pathlib import Path
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-plugin_data_path = Path(get_astrbot_data_path()) / "plugin_data" / self.name # self.name is the plugin name; available in v4.9.2 and above. For lower versions, specify the plugin name yourself.
+plugin_data_path = (
+ Path(get_astrbot_data_path()) / "plugin_data" / self.name
+) # self.name is the plugin name; available in v4.9.2 and above. For lower versions, specify the plugin name yourself.
```
diff --git a/docs/en/use/websearch.md b/docs/en/use/websearch.md
index 798df2dcaa..503de95df0 100644
--- a/docs/en/use/websearch.md
+++ b/docs/en/use/websearch.md
@@ -14,11 +14,11 @@ When using a large language model that supports function calling with the web se
And other prompts with search intent to trigger the model to invoke the search tool.
-AstrBot currently supports 6 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, and `Exa`.
+AstrBot currently supports 7 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, `Exa` ,and `AnySearch`.

-Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, or `Exa`.
+Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, `Exa` ,or `AnySearch`.
### Tavily
@@ -47,3 +47,8 @@ Go to [Exa](https://dashboard.exa.ai) to get an API Key, then fill it in the cor
If you use Tavily as your web search source, you will get a better experience optimization on AstrBot ChatUI, including citation source display and more:

+
+### AnySearch
+Go to the [AnySearch Console](https://anysearch.com/console/api-keys) to get your API Key, then fill it in the corresponding configuration field.
+
+In addition to general web search, AnySearch also provides domain-specific retrieval capabilities across academic research, code documentation, finance, legal, and security intelligence. If the API Key is left empty, it will use anonymous mode with a daily free quota, making it easy to try out quickly.
\ No newline at end of file
diff --git a/docs/zh/dev/plugin-platform-adapter.md b/docs/zh/dev/plugin-platform-adapter.md
index 5c151738d2..c832e36806 100644
--- a/docs/zh/dev/plugin-platform-adapter.md
+++ b/docs/zh/dev/plugin-platform-adapter.md
@@ -17,30 +17,34 @@ AstrBot 支持以插件的形式接入平台适配器,你可以自行接入 As
```py
import asyncio
-class FakeClient():
- '''模拟一个消息平台,这里 5 秒钟下发一个消息'''
+
+class FakeClient:
+ """模拟一个消息平台,这里 5 秒钟下发一个消息"""
+
def __init__(self, token: str, username: str):
self.token = token
self.username = username
# ...
-
+
async def start_polling(self):
while True:
await asyncio.sleep(5)
- await getattr(self, 'on_message_received')({
- 'bot_id': '123',
- 'content': '新消息',
- 'username': 'zhangsan',
- 'userid': '123',
- 'message_id': 'asdhoashd',
- 'group_id': 'group123',
- })
-
+ await getattr(self, "on_message_received")(
+ {
+ "bot_id": "123",
+ "content": "新消息",
+ "username": "zhangsan",
+ "userid": "123",
+ "message_id": "asdhoashd",
+ "group_id": "group123",
+ }
+ )
+
async def send_text(self, to: str, message: str):
- print('发了消息:', to, message)
-
+ print("发了消息:", to, message)
+
async def send_image(self, to: str, image_path: str):
- print('发了消息:', to, image_path)
+ print("发了消息:", to, image_path)
```
我们创建 `fake_platform_adapter.py`:
@@ -48,31 +52,46 @@ class FakeClient():
```py
import asyncio
-from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, PlatformMetadata, MessageType
+from astrbot.api.platform import (
+ Platform,
+ AstrBotMessage,
+ MessageMember,
+ PlatformMetadata,
+ MessageType,
+)
from astrbot.api.event import MessageChain
-from astrbot.api.message_components import Plain, Image, Record # 消息链中的组件,可以根据需要导入
+from astrbot.api.message_components import (
+ Plain,
+ Image,
+ Record,
+) # 消息链中的组件,可以根据需要导入
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.api.platform import register_platform_adapter
from astrbot import logger
from .client import FakeClient
from .fake_platform_event import FakePlatformEvent
-
+
+
# 注册平台适配器。第一个参数为平台名,第二个为描述。第三个为默认配置。
-@register_platform_adapter("fake", "fake 适配器", default_config_tmpl={
- "token": "your_token",
- "username": "bot_username"
-})
+@register_platform_adapter(
+ "fake",
+ "fake 适配器",
+ default_config_tmpl={"token": "your_token", "username": "bot_username"},
+)
class FakePlatformAdapter(Platform):
-
- def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
+ def __init__(
+ self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
+ ) -> None:
super().__init__(event_queue)
- self.config = platform_config # 上面的默认配置,用户填写后会传到这里
- self.settings = platform_settings # platform_settings 平台设置。
-
- async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
+ self.config = platform_config # 上面的默认配置,用户填写后会传到这里
+ self.settings = platform_settings # platform_settings 平台设置。
+
+ async def send_by_session(
+ self, session: MessageSesion, message_chain: MessageChain
+ ):
# 必须实现
await super().send_by_session(session, message_chain)
-
+
def meta(self) -> PlatformMetadata:
# 必须实现,直接像下面一样返回即可。
return PlatformMetadata(
@@ -86,30 +105,36 @@ class FakePlatformAdapter(Platform):
# FakeClient 是我们自己定义的,这里只是示例。这个是其回调函数
async def on_received(data):
logger.info(data)
- abm = await self.convert_message(data=data) # 转换成 AstrBotMessage
- await self.handle_msg(abm)
-
+ abm = await self.convert_message(data=data) # 转换成 AstrBotMessage
+ await self.handle_msg(abm)
+
# 初始化 FakeClient
- self.client = FakeClient(self.config['token'], self.config['username'])
+ self.client = FakeClient(self.config["token"], self.config["username"])
self.client.on_message_received = on_received
- await self.client.start_polling() # 持续监听消息,这是个堵塞方法。
+ await self.client.start_polling() # 持续监听消息,这是个堵塞方法。
async def convert_message(self, data: dict) -> AstrBotMessage:
# 将平台消息转换成 AstrBotMessage
# 这里就体现了适配程度,不同平台的消息结构不一样,这里需要根据实际情况进行转换。
abm = AstrBotMessage()
- abm.type = MessageType.GROUP_MESSAGE # 还有 friend_message,对应私聊。具体平台具体分析。重要!
- abm.group_id = data['group_id'] # 如果是私聊,这里可以不填
- abm.message_str = data['content'] # 纯文本消息。重要!
- abm.sender = MessageMember(user_id=data['userid'], nickname=data['username']) # 发送者。重要!
- abm.message = [Plain(text=data['content'])] # 消息链。如果有其他类型的消息,直接 append 即可。重要!
- abm.raw_message = data # 原始消息。
- abm.self_id = data['bot_id']
- abm.session_id = data['userid'] # 会话 ID。重要!
- abm.message_id = data['message_id'] # 消息 ID。
-
+ abm.type = (
+ MessageType.GROUP_MESSAGE
+ ) # 还有 friend_message,对应私聊。具体平台具体分析。重要!
+ abm.group_id = data["group_id"] # 如果是私聊,这里可以不填
+ abm.message_str = data["content"] # 纯文本消息。重要!
+ abm.sender = MessageMember(
+ user_id=data["userid"], nickname=data["username"]
+ ) # 发送者。重要!
+ abm.message = [
+ Plain(text=data["content"])
+ ] # 消息链。如果有其他类型的消息,直接 append 即可。重要!
+ abm.raw_message = data # 原始消息。
+ abm.self_id = data["bot_id"]
+ abm.session_id = data["userid"] # 会话 ID。重要!
+ abm.message_id = data["message_id"] # 消息 ID。
+
return abm
-
+
async def handle_msg(self, message: AstrBotMessage):
# 处理消息
message_event = FakePlatformEvent(
@@ -117,9 +142,9 @@ class FakePlatformAdapter(Platform):
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
- client=self.client
+ client=self.client,
)
- self.commit_event(message_event) # 提交事件到事件队列。不要忘记!
+ self.commit_event(message_event) # 提交事件到事件队列。不要忘记!
```
@@ -131,22 +156,32 @@ from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image
from .client import FakeClient
+
class FakePlatformEvent(AstrMessageEvent):
- def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: FakeClient):
+ def __init__(
+ self,
+ message_str: str,
+ message_obj: AstrBotMessage,
+ platform_meta: PlatformMetadata,
+ session_id: str,
+ client: FakeClient,
+ ):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
-
+
async def send(self, message: MessageChain):
- for i in message.chain: # 遍历消息链
- if isinstance(i, Plain): # 如果是文字类型的
+ for i in message.chain: # 遍历消息链
+ if isinstance(i, Plain): # 如果是文字类型的
await self.client.send_text(to=self.get_sender_id(), message=i.text)
- elif isinstance(i, Image): # 如果是图片类型的
+ elif isinstance(i, Image): # 如果是图片类型的
# convert_to_file_path() resolves supported media refs through
# the shared media utilities.
img_path = await i.convert_to_file_path()
- await self.client.send_image(to=self.get_sender_id(), image_path=img_path)
+ await self.client.send_image(
+ to=self.get_sender_id(), image_path=img_path
+ )
- await super().send(message) # 需要最后加上这一段,执行父类的 send 方法。
+ await super().send(message) # 需要最后加上这一段,执行父类的 send 方法。
```
## 媒体消息处理
@@ -210,9 +245,10 @@ message_event.track_temporary_local_file(temp_media_path)
```py
from astrbot.api.star import Context, Star
+
class MyPlugin(Star):
def __init__(self, context: Context):
- from .fake_platform_adapter import FakePlatformAdapter # noqa
+ from .fake_platform_adapter import FakePlatformAdapter # noqa
```
搞好后,运行 AstrBot:
diff --git a/docs/zh/dev/star/guides/ai.md b/docs/zh/dev/star/guides/ai.md
index 9de4b498a3..2b35ab1caa 100644
--- a/docs/zh/dev/star/guides/ai.md
+++ b/docs/zh/dev/star/guides/ai.md
@@ -23,7 +23,7 @@ provider_id = await self.context.get_current_chat_provider_id(umo=umo)
```py
llm_resp = await self.context.llm_generate(
- chat_provider_id=provider_id, # 聊天模型 ID
+ chat_provider_id=provider_id, # 聊天模型 ID
prompt="Hello, world!",
)
# print(llm_resp.completion_text) # 获取返回的文本
@@ -95,13 +95,15 @@ class MyPlugin(Star):
除了上述的通过 `@dataclass` 定义 Tool 的方式之外,你也可以使用装饰器的方式注册 tool 到 AstrBot。请务必按照以下格式编写一个工具(包括函数注释,AstrBot 会解析该函数注释,请务必将注释格式写对):
```py{3,4,5,6,7}
-@filter.llm_tool(name="get_weather") # 如果 name 不填,将使用函数名
-async def get_weather(self, event: AstrMessageEvent, location: str) -> MessageEventResult:
- '''获取天气信息。
+@filter.llm_tool(name="get_weather") # 如果 name 不填,将使用函数名
+async def get_weather(
+ self, event: AstrMessageEvent, location: str
+) -> MessageEventResult:
+ """获取天气信息。
Args:
location(string): 地点
- '''
+ """
resp = self.get_weather_from_api(location)
yield event.plain_result("天气信息: " + resp)
```
@@ -132,8 +134,8 @@ llm_resp = await self.context.tool_loop_agent(
chat_provider_id=prov_id,
prompt="搜索一下 bilibili 上关于 AstrBot 的相关视频。",
tools=ToolSet([BilibiliTool()]),
- max_steps=30, # Agent 最大执行步骤
- tool_call_timeout=60, # 工具调用超时时间
+ max_steps=30, # Agent 最大执行步骤
+ tool_call_timeout=60, # 工具调用超时时间
)
# print(llm_resp.completion_text) # 获取返回的文本
```
@@ -162,6 +164,7 @@ from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult, ToolSet
from astrbot.core.astr_agent_context import AstrAgentContext
+
@dataclass
class AssignAgentTool(FunctionTool[AstrAgentContext]):
"""Main agent uses this tool to decide which sub-agent to delegate a task to."""
@@ -354,8 +357,10 @@ provider_id = await self.context.get_current_chat_provider_id(event.unified_msg_
curr_cid = await conv_mgr.get_curr_conversation_id(event.unified_msg_origin)
user_msg = UserMessageSegment(content=[TextPart(text="hi")])
llm_resp = await self.context.llm_generate(
- chat_provider_id=provider_id, # 聊天模型 ID
- contexts=[user_msg], # 当未指定 prompt 时,使用 contexts 作为输入;同时指定 prompt 和 contexts 时,prompt 会被添加到 LLM 输入的最后
+ chat_provider_id=provider_id, # 聊天模型 ID
+ contexts=[
+ user_msg
+ ], # 当未指定 prompt 时,使用 contexts 作为输入;同时指定 prompt 和 contexts 时,prompt 会被添加到 LLM 输入的最后
)
await conv_mgr.add_message_pair(
cid=curr_cid,
@@ -523,7 +528,6 @@ persona_mgr = self.context.persona_manager
::: details Persona / Personality 类型定义
```py
-
class Persona(SQLModel, table=True):
"""Persona is a set of instructions for LLMs to follow.
diff --git a/docs/zh/dev/star/guides/html-to-pic.md b/docs/zh/dev/star/guides/html-to-pic.md
index 6249f2db1d..a6231884f5 100644
--- a/docs/zh/dev/star/guides/html-to-pic.md
+++ b/docs/zh/dev/star/guides/html-to-pic.md
@@ -9,12 +9,11 @@
AstrBot 支持将文字渲染成图片。
```python
-@filter.command("image") # 注册一个 /image 指令,接收 text 参数。
+@filter.command("image") # 注册一个 /image 指令,接收 text 参数。
async def on_aiocqhttp(self, event: AstrMessageEvent, text: str):
- url = await self.text_to_image(text) # text_to_image() 是 Star 类的一个方法。
+ url = await self.text_to_image(text) # text_to_image() 是 Star 类的一个方法。
# path = await self.text_to_image(text, return_url = False) # 如果你想保存图片到本地
yield event.image_result(url)
-
```

@@ -27,7 +26,7 @@ AstrBot 支持使用 `HTML + Jinja2` 的方式来渲染文转图模板。
```py{7}
# 自定义的 Jinja2 模板,支持 CSS
-TMPL = '''
+TMPL = """
Todo List
@@ -36,12 +35,15 @@ TMPL = '''
{{ item }}
{% endfor %}
-'''
+"""
+
@filter.command("todo")
async def custom_t2i_tmpl(self, event: AstrMessageEvent):
- options = {} # 可选择传入渲染选项。
- url = await self.html_render(TMPL, {"items": ["吃饭", "睡觉", "玩原神"]}, options=options) # 第二个参数是 Jinja2 的渲染数据
+ options = {} # 可选择传入渲染选项。
+ url = await self.html_render(
+ TMPL, {"items": ["吃饭", "睡觉", "玩原神"]}, options=options
+ ) # 第二个参数是 Jinja2 的渲染数据
yield event.image_result(url)
```
diff --git a/docs/zh/dev/star/guides/listen-message-event.md b/docs/zh/dev/star/guides/listen-message-event.md
index 18d1f5f2cc..2f78173b44 100644
--- a/docs/zh/dev/star/guides/listen-message-event.md
+++ b/docs/zh/dev/star/guides/listen-message-event.md
@@ -24,15 +24,18 @@ AstrBot 接收消息平台下发的消息,并将其封装为 `AstrMessageEvent
```py{11}
class AstrBotMessage:
- '''AstrBot 的消息对象'''
+ """AstrBot 的消息对象"""
+
type: MessageType # 消息类型
self_id: str # 机器人的识别id
session_id: str # 会话id。取决于 unique_session 的设置。
message_id: str # 消息id
- group_id: str = "" # 群组id,如果为私聊,则为空
+ group_id: str = "" # 群组id,如果为私聊,则为空
sender: MessageMember # 发送者
message: List[BaseMessageComponent] # 消息链。比如 [Plain("Hello"), At(qq=123456)]
- message_str: str # 最直观的纯文本消息字符串,将消息链中的 Plain 消息(文本消息)连接起来
+ message_str: (
+ str # 最直观的纯文本消息字符串,将消息链中的 Plain 消息(文本消息)连接起来
+ )
raw_message: object
timestamp: int # 消息时间戳
```
@@ -73,15 +76,16 @@ class AstrBotMessage:
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
+
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
- @filter.command("helloworld") # from astrbot.api.event.filter import command
+ @filter.command("helloworld") # from astrbot.api.event.filter import command
async def helloworld(self, event: AstrMessageEvent):
- '''这是 hello world 指令'''
+ """这是 hello world 指令"""
user_name = event.get_sender_name()
- message_str = event.message_str # 获取消息的纯文本内容
+ message_str = event.message_str # 获取消息的纯文本内容
yield event.plain_result(f"Hello, {user_name}!")
```
@@ -110,11 +114,13 @@ async def add(self, event: AstrMessageEvent, a: int, b: int):
def math():
pass
+
@math.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
# /math add 1 2 -> 结果是: 3
yield event.plain_result(f"结果是: {a + b}")
+
@math.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
# /math sub 1 2 -> 结果是: -1
@@ -134,30 +140,35 @@ async def sub(self, event: AstrMessageEvent, a: int, b: int):
理论上,指令组可以无限嵌套!
```py
-'''
+"""
math
├── calc
│ ├── add (a(int),b(int),)
│ ├── sub (a(int),b(int),)
│ ├── help (无参数指令)
-'''
+"""
+
@filter.command_group("math")
def math():
pass
-@math.group("calc") # 请注意,这里是 group,而不是 command_group
+
+@math.group("calc") # 请注意,这里是 group,而不是 command_group
def calc():
pass
+
@calc.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"结果是: {a + b}")
+
@calc.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"结果是: {a - b}")
+
@calc.command("help")
async def calc_help(self, event: AstrMessageEvent):
# /math calc help
@@ -171,7 +182,7 @@ async def calc_help(self, event: AstrMessageEvent):
可以为指令或指令组添加不同的别名:
```python
-@filter.command("help", alias={'帮助', 'helpme'})
+@filter.command("help", alias={"帮助", "helpme"})
async def help(self, event: AstrMessageEvent):
yield event.plain_result("这是一个计算器插件,拥有 add, sub 指令。")
```
@@ -193,7 +204,7 @@ async def on_all_message(self, event: AstrMessageEvent):
```python
@filter.event_message_type(filter.EventMessageType.PRIVATE_MESSAGE)
async def on_private_message(self, event: AstrMessageEvent):
- message_str = event.message_str # 获取消息的纯文本内容
+ message_str = event.message_str # 获取消息的纯文本内容
yield event.plain_result("收到了一条私聊消息。")
```
@@ -202,9 +213,11 @@ async def on_private_message(self, event: AstrMessageEvent):
#### 消息平台
```python
-@filter.platform_adapter_type(filter.PlatformAdapterType.AIOCQHTTP | filter.PlatformAdapterType.QQOFFICIAL)
+@filter.platform_adapter_type(
+ filter.PlatformAdapterType.AIOCQHTTP | filter.PlatformAdapterType.QQOFFICIAL
+)
async def on_aiocqhttp(self, event: AstrMessageEvent):
- '''只接收 AIOCQHTTP 和 QQOFFICIAL 的消息'''
+ """只接收 AIOCQHTTP 和 QQOFFICIAL 的消息"""
yield event.plain_result("收到了一条信息")
```
@@ -244,10 +257,10 @@ async def helloworld(self, event: AstrMessageEvent):
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.on_astrbot_loaded()
async def on_astrbot_loaded(self):
print("AstrBot 初始化完成")
-
```
#### 等待 LLM 请求时
@@ -259,6 +272,7 @@ async def on_astrbot_loaded(self):
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.on_waiting_llm_request()
async def on_waiting_llm(self, event: AstrMessageEvent):
await event.send(event.plain_result("🤔 正在等待请求..."))
@@ -280,12 +294,14 @@ ProviderRequest 对象包含了 LLM 请求的所有信息,包括请求的文
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import ProviderRequest
+
@filter.on_llm_request()
-async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest): # 请注意有三个参数
- print(req) # 打印请求的文本
- req.system_prompt += "自定义 system_prompt" # 如果有其他替代方法,不建议使用此种方式来追加每轮对话都会改变的提示词,否则会破坏缓存,大大增加价格(约增加 7-20 倍的价格)。
+async def my_custom_hook_1(
+ self, event: AstrMessageEvent, req: ProviderRequest
+): # 请注意有三个参数
+ print(req) # 打印请求的文本
+ req.system_prompt += "自定义 system_prompt" # 如果有其他替代方法,不建议使用此种方式来追加每轮对话都会改变的提示词,否则会破坏缓存,大大增加价格(约增加 7-20 倍的价格)。
req.extra_user_content_parts.append(...)
-
```
> [!WARNING]
@@ -333,8 +349,11 @@ async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest):
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import LLMResponse
+
@filter.on_llm_response()
-async def on_llm_resp(self, event: AstrMessageEvent, resp: LLMResponse): # 请注意有三个参数
+async def on_llm_resp(
+ self, event: AstrMessageEvent, resp: LLMResponse
+): # 请注意有三个参数
print(resp)
```
@@ -351,8 +370,11 @@ from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
+
@filter.on_agent_begin()
-async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]):
+async def on_agent_begin(
+ self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]
+):
print("Agent 开始运行")
```
@@ -370,6 +392,7 @@ async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrap
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.tool import FunctionTool
+
@filter.on_using_llm_tool()
async def on_using_llm_tool(
self,
@@ -396,6 +419,7 @@ from mcp.types import CallToolResult
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.tool import FunctionTool
+
@filter.on_llm_tool_respond()
async def on_llm_tool_respond(
self,
@@ -421,8 +445,14 @@ from astrbot.api.provider import LLMResponse
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
+
@filter.on_agent_done()
-async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext], resp: LLMResponse):
+async def on_agent_done(
+ self,
+ event: AstrMessageEvent,
+ run_context: ContextWrapper[AstrAgentContext],
+ resp: LLMResponse,
+):
print(resp)
```
@@ -438,12 +468,13 @@ async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapp
from astrbot.api.event import filter, AstrMessageEvent
import astrbot.api.message_components as Comp
+
@filter.on_decorating_result()
async def on_decorating_result(self, event: AstrMessageEvent):
result = event.get_result()
chain = result.chain
- print(chain) # 打印消息链
- chain.append(Comp.Plain("!")) # 在消息链的最后添加一个感叹号
+ print(chain) # 打印消息链
+ chain.append(Comp.Plain("!")) # 在消息链的最后添加一个感叹号
```
> 这里不能使用 yield 来发送消息。这个钩子只是用来装饰 event.get_result().chain 的。如需发送,请直接使用 `event.send()` 方法。
@@ -455,6 +486,7 @@ async def on_decorating_result(self, event: AstrMessageEvent):
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.after_message_sent()
async def after_message_sent(self, event: AstrMessageEvent):
pass
@@ -477,10 +509,10 @@ async def helloworld(self, event: AstrMessageEvent):
```python{6}
@filter.command("check_ok")
async def check_ok(self, event: AstrMessageEvent):
- ok = self.check() # 自己的逻辑
+ ok = self.check() # 自己的逻辑
if not ok:
yield event.plain_result("检查失败")
- event.stop_event() # 停止事件传播
+ event.stop_event() # 停止事件传播
```
当事件停止传播,后续所有步骤将不会被执行。
diff --git a/docs/zh/dev/star/guides/other.md b/docs/zh/dev/star/guides/other.md
index 496582bf67..2541b17fe9 100644
--- a/docs/zh/dev/star/guides/other.md
+++ b/docs/zh/dev/star/guides/other.md
@@ -7,9 +7,13 @@
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test_(self, event: AstrMessageEvent):
- from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter # 其他平台同理
+ from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import (
+ AiocqhttpAdapter,
+ ) # 其他平台同理
+
# >= v4.0.0 使用:
platform_id = event.get_platform_id()
platform = self.context.get_platform_inst(platform_id)
@@ -26,13 +30,16 @@ async def test_(self, event: AstrMessageEvent):
async def helloworld(self, event: AstrMessageEvent):
if event.get_platform_name() == "aiocqhttp":
# qq
- from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent
+ from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
+ AiocqhttpMessageEvent,
+ )
+
assert isinstance(event, AiocqhttpMessageEvent)
- client = event.bot # 得到 client
+ client = event.bot # 得到 client
payloads = {
"message_id": event.message_obj.message_id,
}
- ret = await client.api.call_action('delete_msg', **payloads) # 调用 协议端 API
+ ret = await client.api.call_action("delete_msg", **payloads) # 调用 协议端 API
logger.info(f"delete_msg: {ret}")
```
@@ -45,12 +52,13 @@ Lagrange API 文档:
## 获取载入的所有插件
```py
-plugins = self.context.get_all_stars() # 返回 StarMetadata 包含了插件类实例、配置等等
+plugins = self.context.get_all_stars() # 返回 StarMetadata 包含了插件类实例、配置等等
```
## 获取加载的所有平台
```py
from astrbot.api.platform import Platform
-platforms = self.context.platform_manager.get_insts() # List[Platform]
+
+platforms = self.context.platform_manager.get_insts() # List[Platform]
```
diff --git a/docs/zh/dev/star/guides/plugin-config.md b/docs/zh/dev/star/guides/plugin-config.md
index 4016f70ba9..346662775d 100644
--- a/docs/zh/dev/star/guides/plugin-config.md
+++ b/docs/zh/dev/star/guides/plugin-config.md
@@ -220,8 +220,11 @@ AstrBot 在载入插件时会检测插件目录下是否有 `_conf_schema.json`
```py
from astrbot.api import AstrBotConfig
+
class ConfigPlugin(Star):
- def __init__(self, context: Context, config: AstrBotConfig): # AstrBotConfig 继承自 Dict,拥有字典的所有方法
+ def __init__(
+ self, context: Context, config: AstrBotConfig
+ ): # AstrBotConfig 继承自 Dict,拥有字典的所有方法
super().__init__(context)
self.config = config
print(self.config)
diff --git a/docs/zh/dev/star/guides/send-message.md b/docs/zh/dev/star/guides/send-message.md
index 0875876650..e252a8bec6 100644
--- a/docs/zh/dev/star/guides/send-message.md
+++ b/docs/zh/dev/star/guides/send-message.md
@@ -10,8 +10,10 @@ async def helloworld(self, event: AstrMessageEvent):
yield event.plain_result("Hello!")
yield event.plain_result("你好!")
- yield event.image_result("path/to/image.jpg") # 发送图片
- yield event.image_result("https://example.com/image.jpg") # 发送 URL 图片,务必以 http 或 https 开头
+ yield event.image_result("path/to/image.jpg") # 发送图片
+ yield event.image_result(
+ "https://example.com/image.jpg"
+ ) # 发送 URL 图片,务必以 http 或 https 开头
```
## 主动消息
@@ -23,6 +25,7 @@ async def helloworld(self, event: AstrMessageEvent):
```python
from astrbot.api.event import MessageChain
+
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
umo = event.unified_msg_origin
@@ -43,14 +46,15 @@ AstrBot 支持发送富媒体消息,比如图片、语音、视频等。使用
```python
import astrbot.api.message_components as Comp
+
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
chain = [
- Comp.At(qq=event.get_sender_id()), # At 消息发送者
+ Comp.At(qq=event.get_sender_id()), # At 消息发送者
Comp.Plain("来看这个图:"),
- Comp.Image.fromURL("https://example.com/image.jpg"), # 从 URL 发送图片
- Comp.Image.fromFileSystem("path/to/image.jpg"), # 从本地文件目录发送图片
- Comp.Plain("这是一个图片。")
+ Comp.Image.fromURL("https://example.com/image.jpg"), # 从 URL 发送图片
+ Comp.Image.fromFileSystem("path/to/image.jpg"), # 从本地文件目录发送图片
+ Comp.Plain("这是一个图片。"),
]
yield event.chain_result(chain)
```
@@ -65,13 +69,13 @@ async def helloworld(self, event: AstrMessageEvent):
**文件 File**
```py
-Comp.File(file="path/to/file.txt", name="file.txt") # 部分平台不支持
+Comp.File(file="path/to/file.txt", name="file.txt") # 部分平台不支持
```
**语音 Record**
```py
-path = "path/to/record.wav" # 暂时只接受 wav 格式,其他格式请自行转换
+path = "path/to/record.wav" # 暂时只接受 wav 格式,其他格式请自行转换
Comp.Record(file=path, url=path)
```
@@ -88,17 +92,15 @@ Comp.Video.fromURL(url="https://example.com/video.mp4")
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Video
+
# fromFileSystem 需要用户的协议端和机器人端处于一个系统中。
- video = Video.fromFileSystem(
- path="test.mp4"
- )
+ video = Video.fromFileSystem(path="test.mp4")
# 更通用
- video = Video.fromURL(
- url="https://example.com/video.mp4"
- )
+ video = Video.fromURL(url="https://example.com/video.mp4")
yield event.chain_result([video])
```
@@ -113,16 +115,15 @@ async def test(self, event: AstrMessageEvent):
```py
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Node, Plain, Image
+
node = Node(
uin=905617992,
name="Soulter",
- content=[
- Plain("hi"),
- Image.fromFileSystem("test.jpg")
- ]
+ content=[Plain("hi"), Image.fromFileSystem("test.jpg")],
)
yield event.chain_result([node])
```
diff --git a/docs/zh/dev/star/guides/session-control.md b/docs/zh/dev/star/guides/session-control.md
index beaea69c61..0792b12fdc 100644
--- a/docs/zh/dev/star/guides/session-control.md
+++ b/docs/zh/dev/star/guides/session-control.md
@@ -31,6 +31,7 @@ handler 内的代码可以如下:
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("成语接龙")
async def handle_empty_mention(self, event: AstrMessageEvent):
"""成语接龙具体实现"""
@@ -38,33 +39,43 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
yield event.plain_result("请发送一个成语~")
# 具体的会话控制器使用方法
- @session_waiter(timeout=60, record_history_chains=False) # 注册一个会话控制器,设置超时时间为 60 秒,不记录历史消息链
- async def empty_mention_waiter(controller: SessionController, event: AstrMessageEvent):
- idiom = event.message_str # 用户发来的成语,假设是 "一马当先"
-
- if idiom == "退出": # 假设用户想主动退出成语接龙,输入了 "退出"
+ @session_waiter(
+ timeout=60, record_history_chains=False
+ ) # 注册一个会话控制器,设置超时时间为 60 秒,不记录历史消息链
+ async def empty_mention_waiter(
+ controller: SessionController, event: AstrMessageEvent
+ ):
+ idiom = event.message_str # 用户发来的成语,假设是 "一马当先"
+
+ if idiom == "退出": # 假设用户想主动退出成语接龙,输入了 "退出"
await event.send(event.plain_result("已退出成语接龙~"))
- controller.stop() # 停止会话控制器,会立即结束。
+ controller.stop() # 停止会话控制器,会立即结束。
return
- if len(idiom) != 4: # 假设用户输入的不是4字成语
- await event.send(event.plain_result("成语必须是四个字的呢~")) # 发送回复,不能使用 yield
+ if len(idiom) != 4: # 假设用户输入的不是4字成语
+ await event.send(
+ event.plain_result("成语必须是四个字的呢~")
+ ) # 发送回复,不能使用 yield
return
# 退出当前方法,不执行后续逻辑,但此会话并未中断,后续的用户输入仍然会进入当前会话
# ...
message_result = event.make_result()
- message_result.chain = [Comp.Plain("先见之明")] # import astrbot.api.message_components as Comp
- await event.send(message_result) # 发送回复,不能使用 yield
+ message_result.chain = [
+ Comp.Plain("先见之明")
+ ] # import astrbot.api.message_components as Comp
+ await event.send(message_result) # 发送回复,不能使用 yield
- controller.keep(timeout=60, reset_timeout=True) # 重置超时时间为 60s,如果不重置,则会继续之前的超时时间计时。
+ controller.keep(
+ timeout=60, reset_timeout=True
+ ) # 重置超时时间为 60s,如果不重置,则会继续之前的超时时间计时。
# controller.stop() # 停止会话控制器,会立即结束。
# 如果记录了历史消息链,可以通过 controller.get_history_chains() 获取历史消息链
try:
await empty_mention_waiter(event)
- except TimeoutError as _: # 当超时后,会话控制器会抛出 TimeoutError
+ except TimeoutError as _: # 当超时后,会话控制器会抛出 TimeoutError
yield event.plain_result("你超时了!")
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
@@ -98,13 +109,19 @@ from astrbot.core.utils.session_waiter import (
SessionController,
)
+
# 沿用上面的 handler
# ...
class CustomFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
- return event.get_group_id() if event.get_group_id() else event.unified_msg_origin
+ return (
+ event.get_group_id() if event.get_group_id() else event.unified_msg_origin
+ )
+
-await empty_mention_waiter(event, session_filter=CustomFilter()) # 这里传入 session_filter
+await empty_mention_waiter(
+ event, session_filter=CustomFilter()
+) # 这里传入 session_filter
# ...
```
diff --git a/docs/zh/dev/star/guides/simple.md b/docs/zh/dev/star/guides/simple.md
index b700bef068..9254d354b6 100644
--- a/docs/zh/dev/star/guides/simple.md
+++ b/docs/zh/dev/star/guides/simple.md
@@ -5,7 +5,8 @@
```python
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
from astrbot.api.star import Context, Star
-from astrbot.api import logger # 使用 astrbot 提供的 logger 接口
+from astrbot.api import logger # 使用 astrbot 提供的 logger 接口
+
class MyPlugin(Star):
def __init__(self, context: Context):
@@ -14,14 +15,14 @@ class MyPlugin(Star):
# 注册指令的装饰器。指令名为 helloworld。注册成功后,发送 `/helloworld` 就会触发这个指令,并回复 `你好, {user_name}!`
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
- '''这是一个 hello world 指令''' # 这是 handler 的描述,将会被解析方便用户了解插件内容。非常建议填写。
+ """这是一个 hello world 指令""" # 这是 handler 的描述,将会被解析方便用户了解插件内容。非常建议填写。
user_name = event.get_sender_name()
- message_str = event.message_str # 获取消息的纯文本内容
+ message_str = event.message_str # 获取消息的纯文本内容
logger.info("触发hello world指令!")
- yield event.plain_result(f"Hello, {user_name}!") # 发送一条纯文本消息
+ yield event.plain_result(f"Hello, {user_name}!") # 发送一条纯文本消息
async def terminate(self):
- '''可选择实现 terminate 函数,当插件被卸载/停用时会调用。'''
+ """可选择实现 terminate 函数,当插件被卸载/停用时会调用。"""
```
解释如下:
diff --git a/docs/zh/dev/star/guides/storage.md b/docs/zh/dev/star/guides/storage.md
index a03ac29931..cd2166852a 100644
--- a/docs/zh/dev/star/guides/storage.md
+++ b/docs/zh/dev/star/guides/storage.md
@@ -28,5 +28,7 @@ class Main(star.Star):
from pathlib import Path
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-plugin_data_path = Path(get_astrbot_data_path()) / "plugin_data" / self.name # self.name 为插件名称,在 v4.9.2 及以上版本可用,低于此版本请自行指定插件名称
+plugin_data_path = (
+ Path(get_astrbot_data_path()) / "plugin_data" / self.name
+) # self.name 为插件名称,在 v4.9.2 及以上版本可用,低于此版本请自行指定插件名称
```
diff --git a/docs/zh/dev/star/plugin.md b/docs/zh/dev/star/plugin.md
index b54e6ba98b..33d6643e25 100644
--- a/docs/zh/dev/star/plugin.md
+++ b/docs/zh/dev/star/plugin.md
@@ -67,7 +67,8 @@ AstrBot 采用在运行时注入插件的机制。因此,在调试插件时,
```python
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
from astrbot.api.star import Context, Star
-from astrbot.api import logger # 使用 astrbot 提供的 logger 接口
+from astrbot.api import logger # 使用 astrbot 提供的 logger 接口
+
class MyPlugin(Star):
def __init__(self, context: Context):
@@ -76,14 +77,14 @@ class MyPlugin(Star):
# 注册指令的装饰器。指令名为 helloworld。注册成功后,发送 `/helloworld` 就会触发这个指令,并回复 `你好, {user_name}!`
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
- '''这是一个 hello world 指令''' # 这是 handler 的描述,将会被解析方便用户了解插件内容。非常建议填写。
+ """这是一个 hello world 指令""" # 这是 handler 的描述,将会被解析方便用户了解插件内容。非常建议填写。
user_name = event.get_sender_name()
- message_str = event.message_str # 获取消息的纯文本内容
+ message_str = event.message_str # 获取消息的纯文本内容
logger.info("触发hello world指令!")
- yield event.plain_result(f"Hello, {user_name}!") # 发送一条纯文本消息
+ yield event.plain_result(f"Hello, {user_name}!") # 发送一条纯文本消息
async def terminate(self):
- '''可选择实现 terminate 函数,当插件被卸载/停用时会调用。'''
+ """可选择实现 terminate 函数,当插件被卸载/停用时会调用。"""
```
解释如下:
@@ -111,15 +112,18 @@ class MyPlugin(Star):
```py{11}
class AstrBotMessage:
- '''AstrBot 的消息对象'''
+ """AstrBot 的消息对象"""
+
type: MessageType # 消息类型
self_id: str # 机器人的识别id
session_id: str # 会话id。取决于 unique_session 的设置。
message_id: str # 消息id
- group_id: str = "" # 群组id,如果为私聊,则为空
+ group_id: str = "" # 群组id,如果为私聊,则为空
sender: MessageMember # 发送者
message: List[BaseMessageComponent] # 消息链。比如 [Plain("Hello"), At(qq=123456)]
- message_str: str # 最直观的纯文本消息字符串,将消息链中的 Plain 消息(文本消息)连接起来
+ message_str: (
+ str # 最直观的纯文本消息字符串,将消息链中的 Plain 消息(文本消息)连接起来
+ )
raw_message: object
timestamp: int # 消息时间戳
```
@@ -146,29 +150,29 @@ import astrbot.api.message_components as Comp
```py
ComponentTypes = {
- "plain": Plain, # 文本消息
- "text": Plain, # 文本消息,同上
- "face": Face, # QQ 表情
- "record": Record, # 语音
- "video": Video, # 视频
- "at": At, # At 消息发送者
- "music": Music, # 音乐
- "image": Image, # 图片
- "reply": Reply, # 回复消息
- "forward": Forward, # 转发消息
- "node": Node, # 转发消息中的节点
- "nodes": Nodes, # Node 的列表,用于支持一个转发消息中的多个节点
- "poke": Poke, # 戳一戳
+ "plain": Plain, # 文本消息
+ "text": Plain, # 文本消息,同上
+ "face": Face, # QQ 表情
+ "record": Record, # 语音
+ "video": Video, # 视频
+ "at": At, # At 消息发送者
+ "music": Music, # 音乐
+ "image": Image, # 图片
+ "reply": Reply, # 回复消息
+ "forward": Forward, # 转发消息
+ "node": Node, # 转发消息中的节点
+ "nodes": Nodes, # Node 的列表,用于支持一个转发消息中的多个节点
+ "poke": Poke, # 戳一戳
}
```
请善于 debug 来了解消息结构:
```python{3,4}
-@event_message_type(EventMessageType.ALL) # 注册一个过滤器,参见下文。
+@event_message_type(EventMessageType.ALL) # 注册一个过滤器,参见下文。
async def on_message(self, event: AstrMessageEvent):
- print(event.message_obj.raw_message) # 平台下发的原始消息在这里
- print(event.message_obj.message) # AstrBot 解析出来的消息链内容
+ print(event.message_obj.raw_message) # 平台下发的原始消息在这里
+ print(event.message_obj.message) # AstrBot 解析出来的消息链内容
```
> [!TIP]
@@ -328,15 +332,16 @@ from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
+
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
- @filter.command("helloworld") # from astrbot.api.event.filter import command
+ @filter.command("helloworld") # from astrbot.api.event.filter import command
async def helloworld(self, event: AstrMessageEvent):
- '''这是 hello world 指令'''
+ """这是 hello world 指令"""
user_name = event.get_sender_name()
- message_str = event.message_str # 获取消息的纯文本内容
+ message_str = event.message_str # 获取消息的纯文本内容
yield event.plain_result(f"Hello, {user_name}!")
```
@@ -352,6 +357,7 @@ AstrBot 会自动帮你解析指令的参数。
async def echo(self, event: AstrMessageEvent, message: str):
yield event.plain_result(f"你发了: {message}")
+
@filter.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
# /add 1 2 -> 结果是: 3
@@ -367,11 +373,13 @@ async def add(self, event: AstrMessageEvent, a: int, b: int):
def math(self):
pass
+
@math.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
# /math add 1 2 -> 结果是: 3
yield event.plain_result(f"结果是: {a + b}")
+
@math.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
# /math sub 1 2 -> 结果是: -1
@@ -391,30 +399,35 @@ async def sub(self, event: AstrMessageEvent, a: int, b: int):
理论上,指令组可以无限嵌套!
```py
-'''
+"""
math
├── calc
│ ├── add (a(int),b(int),)
│ ├── sub (a(int),b(int),)
│ ├── help (无参数指令)
-'''
+"""
+
@filter.command_group("math")
def math():
pass
-@math.group("calc") # 请注意,这里是 group,而不是 command_group
+
+@math.group("calc") # 请注意,这里是 group,而不是 command_group
def calc():
pass
+
@calc.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"结果是: {a + b}")
+
@calc.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"结果是: {a - b}")
+
@calc.command("help")
def calc_help(self, event: AstrMessageEvent):
# /math calc help
@@ -428,7 +441,7 @@ def calc_help(self, event: AstrMessageEvent):
可以为指令或指令组添加不同的别名:
```python
-@filter.command("help", alias={'帮助', 'helpme'})
+@filter.command("help", alias={"帮助", "helpme"})
def help(self, event: AstrMessageEvent):
yield event.plain_result("这是一个计算器插件,拥有 add, sub 指令。")
```
@@ -450,7 +463,7 @@ async def on_all_message(self, event: AstrMessageEvent):
```python
@filter.event_message_type(filter.EventMessageType.PRIVATE_MESSAGE)
async def on_private_message(self, event: AstrMessageEvent):
- message_str = event.message_str # 获取消息的纯文本内容
+ message_str = event.message_str # 获取消息的纯文本内容
yield event.plain_result("收到了一条私聊消息。")
```
@@ -459,9 +472,11 @@ async def on_private_message(self, event: AstrMessageEvent):
##### 消息平台
```python
-@filter.platform_adapter_type(filter.PlatformAdapterType.AIOCQHTTP | filter.PlatformAdapterType.QQOFFICIAL)
+@filter.platform_adapter_type(
+ filter.PlatformAdapterType.AIOCQHTTP | filter.PlatformAdapterType.QQOFFICIAL
+)
async def on_aiocqhttp(self, event: AstrMessageEvent):
- '''只接收 AIOCQHTTP 和 QQOFFICIAL 的消息'''
+ """只接收 AIOCQHTTP 和 QQOFFICIAL 的消息"""
yield event.plain_result("收到了一条信息")
```
@@ -501,10 +516,10 @@ async def helloworld(self, event: AstrMessageEvent):
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.on_astrbot_loaded()
async def on_astrbot_loaded(self):
print("AstrBot 初始化完成")
-
```
##### LLM 请求时
@@ -519,11 +534,13 @@ ProviderRequest 对象包含了 LLM 请求的所有信息,包括请求的文
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import ProviderRequest
-@filter.on_llm_request()
-async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest): # 请注意有三个参数
- print(req) # 打印请求的文本
- req.system_prompt += "自定义 system_prompt" # 如果有其他替代方法,不建议使用此种方式来追加每轮对话都会改变的提示词,否则会破坏缓存,大大增加价格(约增加 7-20 倍的价格)。
+@filter.on_llm_request()
+async def my_custom_hook_1(
+ self, event: AstrMessageEvent, req: ProviderRequest
+): # 请注意有三个参数
+ print(req) # 打印请求的文本
+ req.system_prompt += "自定义 system_prompt" # 如果有其他替代方法,不建议使用此种方式来追加每轮对话都会改变的提示词,否则会破坏缓存,大大增加价格(约增加 7-20 倍的价格)。
```
> [!WARNING]
@@ -573,8 +590,11 @@ async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest):
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import LLMResponse
+
@filter.on_llm_response()
-async def on_llm_resp(self, event: AstrMessageEvent, resp: LLMResponse): # 请注意有三个参数
+async def on_llm_resp(
+ self, event: AstrMessageEvent, resp: LLMResponse
+): # 请注意有三个参数
print(resp)
```
@@ -591,8 +611,11 @@ from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
+
@filter.on_agent_begin()
-async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]): # 请注意有三个参数
+async def on_agent_begin(
+ self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]
+): # 请注意有三个参数
print("Agent 开始运行")
```
@@ -610,6 +633,7 @@ async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrap
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.tool import FunctionTool
+
@filter.on_using_llm_tool()
async def on_using_llm_tool(
self,
@@ -636,6 +660,7 @@ from mcp.types import CallToolResult
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.core.agent.tool import FunctionTool
+
@filter.on_llm_tool_respond()
async def on_llm_tool_respond(
self,
@@ -663,8 +688,14 @@ from astrbot.api.provider import LLMResponse
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
+
@filter.on_agent_done()
-async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext], resp: LLMResponse): # 请注意有四个参数
+async def on_agent_done(
+ self,
+ event: AstrMessageEvent,
+ run_context: ContextWrapper[AstrAgentContext],
+ resp: LLMResponse,
+): # 请注意有四个参数
print(resp)
```
@@ -679,12 +710,13 @@ async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapp
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.on_decorating_result()
async def on_decorating_result(self, event: AstrMessageEvent):
result = event.get_result()
chain = result.chain
- print(chain) # 打印消息链
- chain.append(Plain("!")) # 在消息链的最后添加一个感叹号
+ print(chain) # 打印消息链
+ chain.append(Plain("!")) # 在消息链的最后添加一个感叹号
```
> 这里不能使用 yield 来发送消息。这个钩子只是用来装饰 event.get_result().chain 的。如需发送,请直接使用 `event.send()` 方法。
@@ -696,6 +728,7 @@ async def on_decorating_result(self, event: AstrMessageEvent):
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.after_message_sent()
async def after_message_sent(self, event: AstrMessageEvent):
pass
@@ -727,8 +760,10 @@ async def helloworld(self, event: AstrMessageEvent):
yield event.plain_result("Hello!")
yield event.plain_result("你好!")
- yield event.image_result("path/to/image.jpg") # 发送图片
- yield event.image_result("https://example.com/image.jpg") # 发送 URL 图片,务必以 http 或 https 开头
+ yield event.image_result("path/to/image.jpg") # 发送图片
+ yield event.image_result(
+ "https://example.com/image.jpg"
+ ) # 发送 URL 图片,务必以 http 或 https 开头
```
#### 主动消息
@@ -740,6 +775,7 @@ async def helloworld(self, event: AstrMessageEvent):
```python
from astrbot.api.event import MessageChain
+
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
umo = event.unified_msg_origin
@@ -760,14 +796,15 @@ AstrBot 支持发送富媒体消息,比如图片、语音、视频等。使用
```python
import astrbot.api.message_components as Comp
+
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
chain = [
- Comp.At(qq=event.get_sender_id()), # At 消息发送者
+ Comp.At(qq=event.get_sender_id()), # At 消息发送者
Comp.Plain("来看这个图:"),
- Comp.Image.fromURL("https://example.com/image.jpg"), # 从 URL 发送图片
- Comp.Image.fromFileSystem("path/to/image.jpg"), # 从本地文件目录发送图片
- Comp.Plain("这是一个图片。")
+ Comp.Image.fromURL("https://example.com/image.jpg"), # 从 URL 发送图片
+ Comp.Image.fromFileSystem("path/to/image.jpg"), # 从本地文件目录发送图片
+ Comp.Plain("这是一个图片。"),
]
yield event.chain_result(chain)
```
@@ -779,13 +816,13 @@ async def helloworld(self, event: AstrMessageEvent):
**文件 File**
```py
-Comp.File(file="path/to/file.txt", name="file.txt") # 部分平台不支持
+Comp.File(file="path/to/file.txt", name="file.txt") # 部分平台不支持
```
**语音 Record**
```py
-path = "path/to/record.wav" # 暂时只接受 wav 格式,其他格式请自行转换
+path = "path/to/record.wav" # 暂时只接受 wav 格式,其他格式请自行转换
Comp.Record(file=path, url=path)
```
@@ -806,16 +843,15 @@ Comp.Video.fromURL(url="https://example.com/video.mp4")
```py
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Node, Plain, Image
+
node = Node(
uin=905617992,
name="Soulter",
- content=[
- Plain("hi"),
- Image.fromFileSystem("test.jpg")
- ]
+ content=[Plain("hi"), Image.fromFileSystem("test.jpg")],
)
yield event.chain_result([node])
```
@@ -829,17 +865,15 @@ async def test(self, event: AstrMessageEvent):
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Video
+
# fromFileSystem 需要用户的协议端和机器人端处于一个系统中。
- music = Video.fromFileSystem(
- path="test.mp4"
- )
+ music = Video.fromFileSystem(path="test.mp4")
# 更通用
- music = Video.fromURL(
- url="https://example.com/video.mp4"
- )
+ music = Video.fromURL(url="https://example.com/video.mp4")
yield event.chain_result([music])
```
@@ -854,9 +888,11 @@ QQ 表情 ID 参考:
Todo List
@@ -1005,12 +1043,15 @@ TMPL = '''
{{ item }}
{% endfor %}
-'''
+"""
+
@filter.command("todo")
async def custom_t2i_tmpl(self, event: AstrMessageEvent):
- options = {} # 可选择传入渲染选项。
- url = await self.html_render(TMPL, {"items": ["吃饭", "睡觉", "玩原神"]}, options=options) # 第二个参数是 Jinja2 的渲染数据
+ options = {} # 可选择传入渲染选项。
+ url = await self.html_render(
+ TMPL, {"items": ["吃饭", "睡觉", "玩原神"]}, options=options
+ ) # 第二个参数是 Jinja2 的渲染数据
yield event.image_result(url)
```
@@ -1067,6 +1108,7 @@ handler 内的代码可以如下:
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("成语接龙")
async def handle_empty_mention(self, event: AstrMessageEvent):
"""成语接龙具体实现"""
@@ -1074,33 +1116,43 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
yield event.plain_result("请发送一个成语~")
# 具体的会话控制器使用方法
- @session_waiter(timeout=60, record_history_chains=False) # 注册一个会话控制器,设置超时时间为 60 秒,不记录历史消息链
- async def empty_mention_waiter(controller: SessionController, event: AstrMessageEvent):
- idiom = event.message_str # 用户发来的成语,假设是 "一马当先"
-
- if idiom == "退出": # 假设用户想主动退出成语接龙,输入了 "退出"
+ @session_waiter(
+ timeout=60, record_history_chains=False
+ ) # 注册一个会话控制器,设置超时时间为 60 秒,不记录历史消息链
+ async def empty_mention_waiter(
+ controller: SessionController, event: AstrMessageEvent
+ ):
+ idiom = event.message_str # 用户发来的成语,假设是 "一马当先"
+
+ if idiom == "退出": # 假设用户想主动退出成语接龙,输入了 "退出"
await event.send(event.plain_result("已退出成语接龙~"))
- controller.stop() # 停止会话控制器,会立即结束。
+ controller.stop() # 停止会话控制器,会立即结束。
return
- if len(idiom) != 4: # 假设用户输入的不是4字成语
- await event.send(event.plain_result("成语必须是四个字的呢~")) # 发送回复,不能使用 yield
+ if len(idiom) != 4: # 假设用户输入的不是4字成语
+ await event.send(
+ event.plain_result("成语必须是四个字的呢~")
+ ) # 发送回复,不能使用 yield
return
# 退出当前方法,不执行后续逻辑,但此会话并未中断,后续的用户输入仍然会进入当前会话
# ...
message_result = event.make_result()
- message_result.chain = [Comp.Plain("先见之明")] # import astrbot.api.message_components as Comp
- await event.send(message_result) # 发送回复,不能使用 yield
+ message_result.chain = [
+ Comp.Plain("先见之明")
+ ] # import astrbot.api.message_components as Comp
+ await event.send(message_result) # 发送回复,不能使用 yield
- controller.keep(timeout=60, reset_timeout=True) # 重置超时时间为 60s,如果不重置,则会继续之前的超时时间计时。
+ controller.keep(
+ timeout=60, reset_timeout=True
+ ) # 重置超时时间为 60s,如果不重置,则会继续之前的超时时间计时。
# controller.stop() # 停止会话控制器,会立即结束。
# 如果记录了历史消息链,可以通过 controller.get_history_chains() 获取历史消息链
try:
await empty_mention_waiter(event)
- except TimeoutError as _: # 当超时后,会话控制器会抛出 TimeoutError
+ except TimeoutError as _: # 当超时后,会话控制器会抛出 TimeoutError
yield event.plain_result("你超时了!")
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
@@ -1134,13 +1186,19 @@ from astrbot.core.utils.session_waiter import (
SessionController,
)
+
# 沿用上面的 handler
# ...
class CustomFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
- return event.get_group_id() if event.get_group_id() else event.unified_msg_origin
+ return (
+ event.get_group_id() if event.get_group_id() else event.unified_msg_origin
+ )
+
-await empty_mention_waiter(event, session_filter=CustomFilter()) # 这里传入 session_filter
+await empty_mention_waiter(
+ event, session_filter=CustomFilter()
+) # 这里传入 session_filter
# ...
```
@@ -1163,20 +1221,19 @@ await empty_mention_waiter(event, session_filter=CustomFilter()) # 这里传入
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test(self, event: AstrMessageEvent):
# func_tools_mgr = self.context.get_llm_tool_manager()
- prov = await self.context.get_using_provider_async(
- umo=event.unified_msg_origin
- )
+ prov = await self.context.get_using_provider_async(umo=event.unified_msg_origin)
if prov:
llm_resp = await prov.text_chat(
prompt="Hi!",
context=[
{"role": "user", "content": "balabala"},
- {"role": "assistant", "content": "response balabala"}
+ {"role": "assistant", "content": "response balabala"},
],
- system_prompt="You are a helpful assistant."
+ system_prompt="You are a helpful assistant.",
)
print(llm_resp)
```
@@ -1191,7 +1248,6 @@ async def test(self, event: AstrMessageEvent):
::: details LLMResponse 类型定义
```py
-
@dataclass
class LLMResponse:
role: str
@@ -1337,6 +1393,7 @@ class EmbeddingProvider(AbstractProvider):
"""获取向量的维度"""
...
+
class STTProvider(AbstractProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config)
@@ -1366,10 +1423,11 @@ from astrbot.api import FunctionTool
from astrbot.api.event import AstrMessageEvent
from dataclasses import dataclass, field
+
@dataclass
class HelloWorldTool(FunctionTool):
- name: str = "hello_world" # 工具名称
- description: str = "Say hello to the world." # 工具描述
+ name: str = "hello_world" # 工具名称
+ description: str = "Say hello to the world." # 工具描述
parameters: dict = field(
default_factory=lambda: {
"type": "object",
@@ -1381,14 +1439,14 @@ class HelloWorldTool(FunctionTool):
},
"required": ["greeting"],
}
- ) # 工具参数定义,见 OpenAI 官网或 https://json-schema.org/understanding-json-schema/
+ ) # 工具参数定义,见 OpenAI 官网或 https://json-schema.org/understanding-json-schema/
async def run(
self,
- event: AstrMessageEvent, # 必须包含此 event 参数在前面,用于获取上下文
- greeting: str, # 工具参数,必须与 parameters 中定义的参数名一致
+ event: AstrMessageEvent, # 必须包含此 event 参数在前面,用于获取上下文
+ greeting: str, # 工具参数,必须与 parameters 中定义的参数名一致
):
- return f"{greeting}, World!" # 也支持 mcp.types.CallToolResult 类型
+ return f"{greeting}, World!" # 也支持 mcp.types.CallToolResult 类型
```
要将上述工具注册到 AstrBot,可以在插件主文件的 `__init__.py` 中添加以下代码:
@@ -1396,6 +1454,7 @@ class HelloWorldTool(FunctionTool):
```py
from .tools.search import HelloWorldTool
+
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
@@ -1414,13 +1473,15 @@ class MyPlugin(Star):
请务必按照以下格式编写一个工具(包括**函数注释**,AstrBot 会解析该函数注释,请务必将注释格式写对)
```py{3,4,5,6,7}
-@filter.llm_tool(name="get_weather") # 如果 name 不填,将使用函数名
-async def get_weather(self, event: AstrMessageEvent, location: str) -> MessageEventResult:
- '''获取天气信息。
+@filter.llm_tool(name="get_weather") # 如果 name 不填,将使用函数名
+async def get_weather(
+ self, event: AstrMessageEvent, location: str
+) -> MessageEventResult:
+ """获取天气信息。
Args:
location(string): 地点
- '''
+ """
resp = self.get_weather_from_api(location)
yield event.plain_result("天气信息: " + resp)
```
@@ -1667,7 +1728,6 @@ persona_mgr = self.context.persona_manager
::: details Persona / Personality 类型定义
```py
-
class Persona(SQLModel, table=True):
"""Persona is a set of instructions for LLMs to follow.
@@ -1742,9 +1802,11 @@ config = self.context.get_config(umo=umo)
```python
from astrbot.api.event import filter, AstrMessageEvent
+
@filter.command("test")
async def test_(self, event: AstrMessageEvent):
- from astrbot.api.platform import AiocqhttpAdapter # 其他平台同理
+ from astrbot.api.platform import AiocqhttpAdapter # 其他平台同理
+
platform = self.context.get_platform(filter.PlatformAdapterType.AIOCQHTTP)
assert isinstance(platform, AiocqhttpAdapter)
# platform.get_client().api.call_action()
@@ -1757,13 +1819,16 @@ async def test_(self, event: AstrMessageEvent):
async def helloworld(self, event: AstrMessageEvent):
if event.get_platform_name() == "aiocqhttp":
# qq
- from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent
+ from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
+ AiocqhttpMessageEvent,
+ )
+
assert isinstance(event, AiocqhttpMessageEvent)
- client = event.bot # 得到 client
+ client = event.bot # 得到 client
payloads = {
"message_id": event.message_obj.message_id,
}
- ret = await client.api.call_action('delete_msg', **payloads) # 调用 协议端 API
+ ret = await client.api.call_action("delete_msg", **payloads) # 调用 协议端 API
logger.info(f"delete_msg: {ret}")
```
@@ -1776,7 +1841,7 @@ Lagrange API 文档:
#### 载入的所有插件
```py
-plugins = self.context.get_all_stars() # 返回 StarMetadata 包含了插件类实例、配置等等
+plugins = self.context.get_all_stars() # 返回 StarMetadata 包含了插件类实例、配置等等
```
#### 注册一个异步任务
@@ -1786,6 +1851,7 @@ plugins = self.context.get_all_stars() # 返回 StarMetadata 包含了插件类
```py
import asyncio
+
class TaskPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
@@ -1800,5 +1866,6 @@ class TaskPlugin(Star):
```py
from astrbot.api.platform import Platform
-platforms = self.context.platform_manager.get_insts() # List[Platform]
+
+platforms = self.context.platform_manager.get_insts() # List[Platform]
```
diff --git a/docs/zh/use/websearch.md b/docs/zh/use/websearch.md
index c3b7f48a42..342da22967 100644
--- a/docs/zh/use/websearch.md
+++ b/docs/zh/use/websearch.md
@@ -13,11 +13,11 @@ AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力
等等带有搜索意味的提示让大模型触发调用搜索工具。
-AstrBot 当前支持 6 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`、`Exa`。
+AstrBot 当前支持 7 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`、`Exa`、`AnySearch`。

-进入 `配置`,下拉找到网页搜索,您可选择 `Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl` 或 `Exa`。
+进入 `配置`,下拉找到网页搜索,您可选择 `Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl` 、`Exa` 或`AnySearch`。
### Tavily
@@ -46,3 +46,9 @@ AstrBot 当前支持 6 种网页搜索源接入方式:`Tavily`、`BoCha`、`
如果您使用 Tavily 作为网页搜索源,在 AstrBot ChatUI 上将会获得更好的体验优化,包括引用来源展示等:

+
+### AnySearch
+
+前往 [AnySearch 控制台](https://anysearch.com/console/api-keys) 获取 API Key,然后填写在相应的配置项。
+
+AnySearch 除通用网页搜索外,还提供学术、代码文档、金融、法律、安全情报等垂直领域检索能力。若 API Key 留空,将以匿名模式调用,每日有免费额度,便于快速试用。
\ No newline at end of file
diff --git a/tests/unit/test_web_search_tools.py b/tests/unit/test_web_search_tools.py
index fc8d1bb56a..3eda20928a 100644
--- a/tests/unit/test_web_search_tools.py
+++ b/tests/unit/test_web_search_tools.py
@@ -1,10 +1,79 @@
import json
from types import SimpleNamespace
-
import pytest
-
from astrbot.core.tools import web_search_tools as tools
+from astrbot.core.tools.web_search_tools import (
+ _anysearch_search,
+ AnySearchWebSearchTool,
+ normalize_legacy_web_search_config,
+)
+
+class _FakeAnysearchResponse:
+ """Fake HTTP response for AnySearch API tests."""
+ def __init__(self, status=200, json_data=None, text_data=""):
+ self.status = status
+ self.json_data = json_data or {}
+ self.text_data = text_data
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return None
+
+ async def json(self):
+ return self.json_data
+
+ async def text(self):
+ return self.text_data
+
+
+class _FakeAnysearchSession:
+ """Fake ClientSession for AnySearch API tests."""
+ def __init__(self, response):
+ self.response = response
+ self.trust_env = None
+ self.entered = False
+ self.exited = False
+ self.posted = None
+
+ async def __aenter__(self):
+ self.entered = True
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ self.exited = True
+ return None
+
+ def post(self, url, json, headers):
+ self.posted = {"url": url, "json": json, "headers": headers}
+ return self.response
+
+
+class _FakeAnysearchCycleSession:
+ """Return the next response for each post() call in key rotation tests."""
+ def __init__(self, responses: list):
+ self.responses = responses
+ self.cursor = 0
+ self.trust_env = None
+ self.entered = False
+ self.exited = False
+ self.calls: list[dict] = []
+
+ async def __aenter__(self):
+ self.entered = True
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ self.exited = True
+ return None
+
+ def post(self, url, json, headers):
+ resp = self.responses[self.cursor]
+ self.cursor = (self.cursor + 1) % len(self.responses)
+ self.calls.append({"url": url, "json": json, "headers": headers})
+ return resp
class _FakeConfig(dict):
def __init__(self, *args, **kwargs):
@@ -805,3 +874,199 @@ def fake_client_session(*, trust_env):
{"websearch_exa_key": ["exa-key"]},
{"ids": ["https://example.com"]},
)
+
+
+
+# ============================================================================
+# AnySearch provider tests
+# ============================================================================
+
+@pytest.mark.asyncio
+async def test_anysearch_search_maps_results(monkeypatch):
+ """Results nested under `data` are normalized into SearchResult items."""
+ session = _FakeAnysearchSession(
+ _FakeAnysearchResponse(
+ status=200,
+ json_data={
+ "code": 0,
+ "message": "success",
+ "request_id": "req_12345",
+ "data": {
+ "results": [
+ {
+ "title": "AstrBot - AI Chatbot Framework",
+ "url": "https://github.com/AstrBotDevs/AstrBot",
+ "snippet": "A powerful AI chatbot framework for Python",
+ "content": "AstrBot is a flexible AI chatbot framework..."
+ },
+ {
+ "title": "AstrBot Documentation",
+ "url": "https://astrbot.dev/docs",
+ "snippet": "Official documentation for AstrBot",
+ "content": "Getting started with AstrBot..."
+ }
+ ],
+ "metadata": {
+ "total_results": 100,
+ "search_time_ms": 150
+ }
+ }
+ }
+ )
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ provider_settings = {"websearch_anysearch_key": ["test-key"]}
+ results = await _anysearch_search(provider_settings, {"query": "AstrBot"})
+
+ assert len(results) == 2
+ assert results[0].title == "AstrBot - AI Chatbot Framework"
+ assert results[0].url == "https://github.com/AstrBotDevs/AstrBot"
+ assert results[0].snippet == "A powerful AI chatbot framework for Python"
+ assert results[1].title == "AstrBot Documentation"
+ assert results[1].url == "https://astrbot.dev/docs"
+ assert results[1].snippet == "Official documentation for AstrBot"
+ for result in results:
+ assert result.url is not None
+ assert result.url != ""
+
+
+@pytest.mark.asyncio
+async def test_anysearch_search_supports_anonymous_mode(monkeypatch):
+ """An empty key list issues one request without an Authorization header."""
+ session = _FakeAnysearchSession(
+ _FakeAnysearchResponse(status=200, json_data={"data": {"results": []}})
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ provider_settings = {"websearch_anysearch_key": []} # ���б�
+ await _anysearch_search(provider_settings, {"query": "test"})
+
+ # ��֤û�� Authorization header
+ assert session.posted is not None
+ headers = session.posted.get("headers", {})
+ assert "Authorization" not in headers
+
+
+@pytest.mark.asyncio
+async def test_anysearch_search_key_failover_on_quota_exhausted_402(monkeypatch):
+ """A 402 response retries with the next configured key."""
+ session = _FakeAnysearchCycleSession([
+ _FakeAnysearchResponse(status=402, text_data="quota exhausted"),
+ _FakeAnysearchResponse(status=200, json_data={"data": {"results": []}}),
+ ])
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ provider_settings = {"websearch_anysearch_key": ["key1", "key2"]}
+ await _anysearch_search(provider_settings, {"query": "test"})
+
+ assert len(session.calls) == 2 # ��һ�� 402 ʧ�ܣ��ڶ��γɹ�
+
+
+@pytest.mark.asyncio
+async def test_anysearch_search_does_not_failover_on_server_error_500(monkeypatch):
+ """A 500 response fails fast instead of burning through keys."""
+ session = _FakeAnysearchCycleSession([
+ _FakeAnysearchResponse(status=500, text_data="internal server error"),
+ _FakeAnysearchResponse(status=200, json_data={"data": {"results": []}}),
+ ])
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ provider_settings = {"websearch_anysearch_key": ["key1", "key2"]}
+
+ with pytest.raises(Exception) as exc_info:
+ await _anysearch_search(provider_settings, {"query": "test"})
+
+ assert "internal server error" in str(exc_info.value)
+ assert len(session.calls) == 1 # 500 �����ԣ�ֻ�� 1 ��
+
+
+@pytest.mark.asyncio
+async def test_anysearch_search_tool_clamps_max_results(monkeypatch):
+ """max_results is clamped into the documented 1-20 range."""
+ session = _FakeAnysearchSession(
+ _FakeAnysearchResponse(status=200, json_data={"data": {"results": []}})
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ tool = AnySearchWebSearchTool()
+ context = _context_with_provider_settings({"websearch_anysearch_key": ["test-key"]})
+
+ # �� 99 �� Ӧ�ñ�� 20
+ await tool.call(context, query="test", max_results=99)
+ payload = session.posted.get("json", {})
+ assert payload.get("max_results") == 20
+
+ # �� 0 �� Ӧ�ñ�� 1
+ await tool.call(context, query="test", max_results=0)
+ payload = session.posted.get("json", {})
+ assert payload.get("max_results") == 1
+
+
+def test_normalize_legacy_config_converts_anysearch_string_key():
+ """A legacy string key is migrated to a single-element list."""
+ config = _FakeConfig({"provider_settings": {"websearch_anysearch_key": "old-string-key"}})
+ normalize_legacy_web_search_config(config) # ֱ�ӵ��ã������շ���ֵ
+ assert config["provider_settings"]["websearch_anysearch_key"] == ["old-string-key"]
+
+
+@pytest.mark.asyncio
+async def test_anysearch_search_falls_back_to_content_for_snippet(monkeypatch):
+ """When snippet is missing, content is used as the fallback."""
+ session = _FakeAnysearchSession(
+ _FakeAnysearchResponse(
+ status=200,
+ json_data={
+ "data": {
+ "results": [
+ {
+ "title": "Test Title",
+ "url": "https://example.com",
+ "content": "Full content text here"
+ # ע�⣺û�� snippet �ֶ�
+ }
+ ]
+ }
+ }
+ )
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ provider_settings = {"websearch_anysearch_key": ["test-key"]}
+ results = await _anysearch_search(provider_settings, {"query": "test"})
+
+ assert len(results) == 1
+ assert results[0].snippet == "Full content text here"
+ assert results[0].title == "Test Title"
+ assert results[0].url == "https://example.com"
+