From bddf7b8623ab0b079fefa3987dd28fc0cf301961 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 1 Feb 2026 16:49:10 +0800 Subject: [PATCH] feat: add proactive messaging support and enhance message handling in SendMessageToUserTool --- astrbot/core/astr_main_agent.py | 6 + astrbot/core/astr_main_agent_resources.py | 128 ++++++++++++++---- astrbot/core/platform/platform_metadata.py | 2 + .../sources/dingtalk/dingtalk_adapter.py | 1 + .../qqofficial/qqofficial_platform_adapter.py | 1 + .../qqofficial_webhook/qo_webhook_adapter.py | 1 + .../platform/sources/wecom/wecom_adapter.py | 1 + .../sources/wecom_ai_bot/wecomai_adapter.py | 1 + .../weixin_offacc_adapter.py | 1 + 9 files changed, 117 insertions(+), 25 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 169556047..86596d43c 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -33,6 +33,7 @@ from astrbot.core.astr_main_agent_resources import ( SANDBOX_MODE_PROMPT, TOOL_CALL_PROMPT, TOOL_CALL_PROMPT_SKILLS_LIKE_MODE, + SEND_MESSAGE_TO_USER_TOOL, retrieve_knowledge_base, ) from astrbot.core.conversation_mgr import Conversation @@ -923,6 +924,11 @@ async def build_main_agent( if config.add_cron_tools: _proactive_cron_job_tools(req) + if event.platform_meta.support_proactive_message: + if req.func_tool is None: + req.func_tool = ToolSet() + req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL) + if provider.provider_config.get("max_context_tokens", 0) <= 0: model = provider.get_model() if model_info := LLM_METADATAS.get(model): diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 779f0d0c3..ba6fc2059 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -1,4 +1,5 @@ import base64 +import os from pydantic import Field from pydantic.dataclasses import dataclass @@ -172,46 +173,123 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]): @dataclass class SendMessageToUserTool(FunctionTool[AstrAgentContext]): name: str = "send_message_to_user" - description: str = ( - "Send a short, proactive message to the user. " - "Use this to deliver scheduled/background task results or important updates without waiting for a new user prompt." - ) + description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation." + parameters: dict = Field( default_factory=lambda: { "type": "object", "properties": { - "message": { - "type": "string", - "description": "What you want to tell the user.", - }, - "image_path": { - "type": "string", - "description": "Optional. Send an image to the user by specifying the file path. Use an absolute path when possible; otherwise, ensure the path is relative to `data/`.", - }, - "session": { - "type": "string", - "description": "Optional target session in format platform_id:message_type:session_id. Defaults to current session.", + "messages": { + "type": "array", + "description": "An ordered list of message components to send. `mention_user` type can be used to mention the user.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": ( + "Component type. One of: " + "plain, image, record, file, mention_user" + ), + }, + "text": { + "type": "string", + "description": "Text content for `plain` type.", + }, + "path": { + "type": "string", + "description": "File path for `image`, `record`, or `file` types.", + }, + "url": { + "type": "string", + "description": "URL for `image`, `record`, or `file` types.", + }, + "mention_user_id": { + "type": "string", + "description": "User ID to mention for `mention_user` type.", + }, + }, + "required": ["type"], + }, }, }, + "required": ["messages"], } ) async def call( self, context: ContextWrapper[AstrAgentContext], **kwargs ) -> ToolExecResult: - message = str(kwargs.get("message", "")).strip() - image_path = kwargs.get("image_path") session = kwargs.get("session") or context.context.event.unified_msg_origin + messages = kwargs.get("messages") - if not message and not image_path: - return "error: message is empty." + if not isinstance(messages, list) or not messages: + return "error: messages parameter is empty or invalid." - comps: list[Comp.BaseMessageComponent] = [] + components: list[Comp.BaseMessageComponent] = [] - if message: - comps.append(Comp.Plain(text=message)) - if image_path: - comps.append(Comp.Image.fromFileSystem(path=image_path)) + for idx, msg in enumerate(messages): + if not isinstance(msg, dict): + return f"error: messages[{idx}] should be an object." + + msg_type = str(msg.get("type", "")).lower() + if not msg_type: + return f"error: messages[{idx}].type is required." + + try: + if msg_type == "plain": + text = str(msg.get("text", "")).strip() + if not text: + return f"error: messages[{idx}].text is required for plain component." + components.append(Comp.Plain(text=text)) + elif msg_type == "image": + path = msg.get("path") + url = msg.get("url") + if path: + components.append(Comp.Image.fromFileSystem(path=path)) + elif url: + components.append(Comp.Image.fromURL(url=url)) + else: + return f"error: messages[{idx}] must include path or url for image component." + elif msg_type == "record": + path = msg.get("path") + url = msg.get("url") + if path: + components.append(Comp.Record.fromFileSystem(path=path)) + elif url: + components.append(Comp.Record.fromURL(url=url)) + else: + return f"error: messages[{idx}] must include path or url for record component." + elif msg_type == "file": + path = msg.get("path") + url = msg.get("url") + name = ( + msg.get("text") + or (os.path.basename(path) if path else "") + or (os.path.basename(url) if url else "") + or "file" + ) + if path: + components.append(Comp.File(name=name, file=path)) + elif url: + components.append(Comp.File(name=name, url=url)) + else: + return f"error: messages[{idx}] must include path or url for file component." + elif msg_type == "mention_user": + mention_user_id = msg.get("mention_user_id") + if not mention_user_id: + return f"error: messages[{idx}].mention_user_id is required for mention_user component." + components.append( + Comp.At( + qq=mention_user_id, + ), + ) + else: + return ( + f"error: unsupported message type '{msg_type}' at index {idx}." + ) + except Exception as exc: # 捕获组件构造异常,避免直接抛出 + return f"error: failed to build messages[{idx}] component: {exc}" try: target_session = ( @@ -224,7 +302,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): await context.context.context.send_message( target_session, - MessageChain(chain=comps), + MessageChain(chain=components), ) return f"Message sent to session {target_session}" diff --git a/astrbot/core/platform/platform_metadata.py b/astrbot/core/platform/platform_metadata.py index 06455aac4..b5f11ca15 100644 --- a/astrbot/core/platform/platform_metadata.py +++ b/astrbot/core/platform/platform_metadata.py @@ -19,3 +19,5 @@ class PlatformMetadata: support_streaming_message: bool = True """平台是否支持真实流式传输""" + support_proactive_message: bool = True + """平台是否支持主动消息推送(非用户触发)""" diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index e73f724ca..8c93ab40f 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -99,6 +99,7 @@ class DingtalkPlatformAdapter(Platform): description="钉钉机器人官方 API 适配器", id=cast(str, self.config.get("id")), support_streaming_message=True, + support_proactive_message=False, ) async def create_message_card( diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 7de535fbf..6f1164faf 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -136,6 +136,7 @@ class QQOfficialPlatformAdapter(Platform): name="qq_official", description="QQ 机器人官方 API 适配器", id=cast(str, self.config.get("id")), + support_proactive_message=False, ) @staticmethod diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index 80ed34245..af160f1b5 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -118,6 +118,7 @@ class QQOfficialWebhookPlatformAdapter(Platform): name="qq_official_webhook", description="QQ 机器人官方 API 适配器", id=cast(str, self.config.get("id")), + support_proactive_message=False, ) async def run(self): diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 44ed75117..adc24578f 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -224,6 +224,7 @@ class WecomPlatformAdapter(Platform): "wecom 适配器", id=self.config.get("id", "wecom"), support_streaming_message=False, + support_proactive_message=False, ) @override diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index 70581e7ea..57da5176b 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -128,6 +128,7 @@ class WecomAIBotAdapter(Platform): name="wecom_ai_bot", description="企业微信智能机器人适配器,支持 HTTP 回调接收消息", id=self.config.get("id", "wecom_ai_bot"), + support_proactive_message=False, ) # 初始化 API 客户端 diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index 2828c0392..a38952127 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -228,6 +228,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform): "微信公众平台 适配器", id=self.config.get("id", "weixin_official_account"), support_streaming_message=False, + support_proactive_message=False, ) @override