| 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 |