返回 JoyAI-Echo
message.py
1 """Message tool for sending messages to users."""
2
3 from contextvars import ContextVar
4 from typing import Any, Awaitable, Callable
5
6 from nanobot.agent.tools.base import Tool, tool_parameters
7 from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
8 from nanobot.bus.events import OutboundMessage
9
10
11 @tool_parameters(
12 tool_parameters_schema(
13 content=StringSchema("The message content to send"),
14 channel=StringSchema("Optional: target channel (telegram, discord, etc.)"),
15 chat_id=StringSchema("Optional: target chat/user ID"),
16 media=ArraySchema(
17 StringSchema(""),
18 description="Optional: list of file paths to attach (images, audio, documents)",
19 ),
20 buttons=ArraySchema(
21 ArraySchema(StringSchema("Button label")),
22 description="Optional: inline keyboard buttons as list of rows, each row is list of button labels.",
23 ),
24 required=["content"],
25 )
26 )
27 class MessageTool(Tool):
28 """Tool to send messages to users on chat channels."""
29
30 def __init__(
31 self,
32 send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
33 default_channel: str = "",
34 default_chat_id: str = "",
35 default_message_id: str | None = None,
36 ):
37 self._send_callback = send_callback
38 self._default_channel: ContextVar[str] = ContextVar("message_default_channel", default=default_channel)
39 self._default_chat_id: ContextVar[str] = ContextVar("message_default_chat_id", default=default_chat_id)
40 self._default_message_id: ContextVar[str | None] = ContextVar(
41 "message_default_message_id",
42 default=default_message_id,
43 )
44 self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
45
46 def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
47 """Set the current message context."""
48 self._default_channel.set(channel)
49 self._default_chat_id.set(chat_id)
50 self._default_message_id.set(message_id)
51
52 def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
53 """Set the callback for sending messages."""
54 self._send_callback = callback
55
56 def start_turn(self) -> None:
57 """Reset per-turn send tracking."""
58 self._sent_in_turn = False
59
60 @property
61 def _sent_in_turn(self) -> bool:
62 return self._sent_in_turn_var.get()
63
64 @_sent_in_turn.setter
65 def _sent_in_turn(self, value: bool) -> None:
66 self._sent_in_turn_var.set(value)
67
68 @property
69 def name(self) -> str:
70 return "message"
71
72 @property
73 def description(self) -> str:
74 return (
75 "Send a message to the user, optionally with file attachments. "
76 "This is the ONLY way to deliver files (images, documents, audio, video) to the user. "
77 "Use the 'media' parameter with file paths to attach files. "
78 "Do NOT use read_file to send files — that only reads content for your own analysis."
79 )
80
81 async def execute(
82 self,
83 content: str,
84 channel: str | None = None,
85 chat_id: str | None = None,
86 message_id: str | None = None,
87 media: list[str] | None = None,
88 buttons: list[list[str]] | None = None,
89 **kwargs: Any
90 ) -> str:
91 from nanobot.utils.helpers import strip_think
92 content = strip_think(content)
93
94 if buttons is not None:
95 if not isinstance(buttons, list) or any(
96 not isinstance(row, list) or any(not isinstance(label, str) for label in row)
97 for row in buttons
98 ):
99 return "Error: buttons must be a list of list of strings"
100 default_channel = self._default_channel.get()
101 default_chat_id = self._default_chat_id.get()
102 channel = channel or default_channel
103 chat_id = chat_id or default_chat_id
104 # Only inherit default message_id when targeting the same channel+chat.
105 # Cross-chat sends must not carry the original message_id, because
106 # some channels (e.g. Feishu) use it to determine the target
107 # conversation via their Reply API, which would route the message
108 # to the wrong chat entirely.
109 if channel == default_channel and chat_id == default_chat_id:
110 message_id = message_id or self._default_message_id.get()
111 else:
112 message_id = None
113
114 if not channel or not chat_id:
115 return "Error: No target channel/chat specified"
116
117 if not self._send_callback:
118 return "Error: Message sending not configured"
119
120 msg = OutboundMessage(
121 channel=channel,
122 chat_id=chat_id,
123 content=content,
124 media=media or [],
125 buttons=buttons or [],
126 metadata={
127 "message_id": message_id,
128 } if message_id else {},
129 )
130
131 try:
132 await self._send_callback(msg)
133 if channel == default_channel and chat_id == default_chat_id:
134 self._sent_in_turn = True
135 media_info = f" with {len(media)} attachments" if media else ""
136 button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
137 return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
138 except Exception as e:
139 return f"Error sending message: {str(e)}"
140
140 lines PYTHON