feat: add proactive messaging support and enhance message handling in SendMessageToUserTool

This commit is contained in:
Soulter
2026-02-01 16:49:10 +08:00
parent 4c8c87d3fd
commit bddf7b8623
9 changed files with 117 additions and 25 deletions
+6
View File
@@ -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):
+103 -25
View File
@@ -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}"
@@ -19,3 +19,5 @@ class PlatformMetadata:
support_streaming_message: bool = True
"""平台是否支持真实流式传输"""
support_proactive_message: bool = True
"""平台是否支持主动消息推送(非用户触发)"""
@@ -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(
@@ -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
@@ -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):
@@ -224,6 +224,7 @@ class WecomPlatformAdapter(Platform):
"wecom 适配器",
id=self.config.get("id", "wecom"),
support_streaming_message=False,
support_proactive_message=False,
)
@override
@@ -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 客户端
@@ -228,6 +228,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
"微信公众平台 适配器",
id=self.config.get("id", "weixin_official_account"),
support_streaming_message=False,
support_proactive_message=False,
)
@override