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) - ``` ![image](https://files.astrbot.app/docs/source/images/plugin/image-3.png) @@ -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`. ![image](https://files.astrbot.app/docs/source/images/websearch/image.png) -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: ![](https://files.astrbot.app/docs/source/images/websearch/image1.png) + +### 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) - ``` ![image](https://files.astrbot.app/docs/source/images/plugin/image-3.png) @@ -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`。 ![image](https://files.astrbot.app/docs/source/images/websearch/image.png) -进入 `配置`,下拉找到网页搜索,您可选择 `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 上将会获得更好的体验优化,包括引用来源展示等: ![](https://files.astrbot.app/docs/source/images/websearch/image1.png) + +### 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" +