Only mark message-tool deliveries for channel-session recording while cron jobs are running, avoiding duplicate session writes during normal user turns. Made-with: Cursor
156 lines
6.1 KiB
Python
156 lines
6.1 KiB
Python
"""Message tool for sending messages to users."""
|
|
|
|
from contextvars import ContextVar
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
|
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
|
from nanobot.bus.events import OutboundMessage
|
|
|
|
|
|
@tool_parameters(
|
|
tool_parameters_schema(
|
|
content=StringSchema("The message content to send"),
|
|
channel=StringSchema("Optional: target channel (telegram, discord, etc.)"),
|
|
chat_id=StringSchema("Optional: target chat/user ID"),
|
|
media=ArraySchema(
|
|
StringSchema(""),
|
|
description="Optional: list of file paths to attach (images, video, audio, documents)",
|
|
),
|
|
buttons=ArraySchema(
|
|
ArraySchema(StringSchema("Button label")),
|
|
description="Optional: inline keyboard buttons as list of rows, each row is list of button labels.",
|
|
),
|
|
required=["content"],
|
|
)
|
|
)
|
|
class MessageTool(Tool):
|
|
"""Tool to send messages to users on chat channels."""
|
|
|
|
def __init__(
|
|
self,
|
|
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
|
default_channel: str = "",
|
|
default_chat_id: str = "",
|
|
default_message_id: str | None = None,
|
|
):
|
|
self._send_callback = send_callback
|
|
self._default_channel: ContextVar[str] = ContextVar("message_default_channel", default=default_channel)
|
|
self._default_chat_id: ContextVar[str] = ContextVar("message_default_chat_id", default=default_chat_id)
|
|
self._default_message_id: ContextVar[str | None] = ContextVar(
|
|
"message_default_message_id",
|
|
default=default_message_id,
|
|
)
|
|
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
|
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
|
|
"message_record_channel_delivery",
|
|
default=False,
|
|
)
|
|
|
|
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
|
|
"""Set the current message context."""
|
|
self._default_channel.set(channel)
|
|
self._default_chat_id.set(chat_id)
|
|
self._default_message_id.set(message_id)
|
|
|
|
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
|
"""Set the callback for sending messages."""
|
|
self._send_callback = callback
|
|
|
|
def start_turn(self) -> None:
|
|
"""Reset per-turn send tracking."""
|
|
self._sent_in_turn = False
|
|
|
|
def set_record_channel_delivery(self, active: bool):
|
|
"""Mark tool-sent messages as proactive channel deliveries."""
|
|
return self._record_channel_delivery_var.set(active)
|
|
|
|
def reset_record_channel_delivery(self, token) -> None:
|
|
"""Restore previous proactive delivery recording state."""
|
|
self._record_channel_delivery_var.reset(token)
|
|
|
|
@property
|
|
def _sent_in_turn(self) -> bool:
|
|
return self._sent_in_turn_var.get()
|
|
|
|
@_sent_in_turn.setter
|
|
def _sent_in_turn(self, value: bool) -> None:
|
|
self._sent_in_turn_var.set(value)
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "message"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return (
|
|
"Send a message to the user, optionally with file attachments. "
|
|
"This is the ONLY way to deliver files (images, documents, audio, video) to the user. "
|
|
"Use the 'media' parameter with file paths to attach files. "
|
|
"Do NOT use read_file to send files — that only reads content for your own analysis."
|
|
)
|
|
|
|
async def execute(
|
|
self,
|
|
content: str,
|
|
channel: str | None = None,
|
|
chat_id: str | None = None,
|
|
message_id: str | None = None,
|
|
media: list[str] | None = None,
|
|
buttons: list[list[str]] | None = None,
|
|
**kwargs: Any
|
|
) -> str:
|
|
from nanobot.utils.helpers import strip_think
|
|
content = strip_think(content)
|
|
|
|
if buttons is not None:
|
|
if not isinstance(buttons, list) or any(
|
|
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
|
|
for row in buttons
|
|
):
|
|
return "Error: buttons must be a list of list of strings"
|
|
default_channel = self._default_channel.get()
|
|
default_chat_id = self._default_chat_id.get()
|
|
channel = channel or default_channel
|
|
chat_id = chat_id or default_chat_id
|
|
# Only inherit default message_id when targeting the same channel+chat.
|
|
# Cross-chat sends must not carry the original message_id, because
|
|
# some channels (e.g. Feishu) use it to determine the target
|
|
# conversation via their Reply API, which would route the message
|
|
# to the wrong chat entirely.
|
|
if channel == default_channel and chat_id == default_chat_id:
|
|
message_id = message_id or self._default_message_id.get()
|
|
else:
|
|
message_id = None
|
|
|
|
if not channel or not chat_id:
|
|
return "Error: No target channel/chat specified"
|
|
|
|
if not self._send_callback:
|
|
return "Error: Message sending not configured"
|
|
|
|
metadata = {
|
|
"message_id": message_id,
|
|
} if message_id else {}
|
|
if self._record_channel_delivery_var.get():
|
|
metadata["_record_channel_delivery"] = True
|
|
|
|
msg = OutboundMessage(
|
|
channel=channel,
|
|
chat_id=chat_id,
|
|
content=content,
|
|
media=media or [],
|
|
buttons=buttons or [],
|
|
metadata=metadata,
|
|
)
|
|
|
|
try:
|
|
await self._send_callback(msg)
|
|
if channel == default_channel and chat_id == default_chat_id:
|
|
self._sent_in_turn = True
|
|
media_info = f" with {len(media)} attachments" if media else ""
|
|
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
|
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
|
except Exception as e:
|
|
return f"Error sending message: {str(e)}"
|