Rebase onto upstream (a4d95fd)
#12
@@ -1,6 +1,6 @@
|
|||||||
"""Message tool for sending messages to users."""
|
"""Message tool for sending messages to users."""
|
||||||
|
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Callable, Awaitable
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -9,44 +9,36 @@ from nanobot.session import SessionManager
|
|||||||
|
|
||||||
class MessageTool(Tool):
|
class MessageTool(Tool):
|
||||||
"""Tool to send messages to users on chat channels."""
|
"""Tool to send messages to users on chat channels."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
||||||
sessions: SessionManager | None = None,
|
sessions: SessionManager | None = None,
|
||||||
default_channel: str = "",
|
default_channel: str = "",
|
||||||
default_chat_id: str = "",
|
default_chat_id: str = ""
|
||||||
default_message_id: str | None = None,
|
|
||||||
):
|
):
|
||||||
self._send_callback = send_callback
|
self._send_callback = send_callback
|
||||||
self._sessions = sessions
|
self._sessions = sessions
|
||||||
self._default_channel = default_channel
|
self._default_channel = default_channel
|
||||||
self._default_chat_id = default_chat_id
|
self._default_chat_id = default_chat_id
|
||||||
self._default_message_id = default_message_id
|
|
||||||
self._sent_in_turn: bool = False
|
def set_context(self, channel: str, chat_id: str) -> None:
|
||||||
|
|
||||||
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
|
|
||||||
"""Set the current message context."""
|
"""Set the current message context."""
|
||||||
self._default_channel = channel
|
self._default_channel = channel
|
||||||
self._default_chat_id = chat_id
|
self._default_chat_id = chat_id
|
||||||
self._default_message_id = message_id
|
|
||||||
|
|
||||||
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
||||||
"""Set the callback for sending messages."""
|
"""Set the callback for sending messages."""
|
||||||
self._send_callback = callback
|
self._send_callback = callback
|
||||||
|
|
||||||
def start_turn(self) -> None:
|
|
||||||
"""Reset per-turn send tracking."""
|
|
||||||
self._sent_in_turn = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "message"
|
return "message"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return "Send a message to the user. Use this when you want to communicate something."
|
return "Send a message to the user. Use this when you want to communicate something."
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -56,6 +48,11 @@ class MessageTool(Tool):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The message content to send"
|
"description": "The message content to send"
|
||||||
},
|
},
|
||||||
|
"media": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional: list of media file paths or URLs to attach"
|
||||||
|
},
|
||||||
"channel": {
|
"channel": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional: target channel (telegram, discord, etc.)"
|
"description": "Optional: target channel (telegram, discord, etc.)"
|
||||||
@@ -63,28 +60,21 @@ class MessageTool(Tool):
|
|||||||
"chat_id": {
|
"chat_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional: target chat/user ID"
|
"description": "Optional: target chat/user ID"
|
||||||
},
|
|
||||||
"media": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"type": "string"},
|
|
||||||
"description": "Optional: list of file paths to attach (images, audio, documents)"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["content"]
|
"required": ["content"]
|
||||||
}
|
}
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
content: str,
|
content: str,
|
||||||
|
media: list[str] | None = None,
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
message_id: str | None = None,
|
|
||||||
media: list[str] | None = None,
|
|
||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> str:
|
) -> str:
|
||||||
channel = channel or self._default_channel
|
channel = channel or self._default_channel
|
||||||
chat_id = chat_id or self._default_chat_id
|
chat_id = chat_id or self._default_chat_id
|
||||||
message_id = message_id or self._default_message_id
|
|
||||||
|
|
||||||
if not channel or not chat_id:
|
if not channel or not chat_id:
|
||||||
return "Error: No target channel/chat specified"
|
return "Error: No target channel/chat specified"
|
||||||
@@ -96,12 +86,9 @@ class MessageTool(Tool):
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
content=content,
|
content=content,
|
||||||
media=media or [],
|
media=media or []
|
||||||
metadata={
|
|
||||||
"message_id": message_id,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._send_callback(msg)
|
await self._send_callback(msg)
|
||||||
|
|
||||||
@@ -111,9 +98,6 @@ class MessageTool(Tool):
|
|||||||
session.add_message("assistant", content)
|
session.add_message("assistant", content)
|
||||||
self._sessions.save(session)
|
self._sessions.save(session)
|
||||||
|
|
||||||
if channel == self._default_channel and chat_id == self._default_chat_id:
|
return f"Message sent to {channel}:{chat_id}"
|
||||||
self._sent_in_turn = True
|
|
||||||
media_info = f" with {len(media)} attachments" if media else ""
|
|
||||||
return f"Message sent to {channel}:{chat_id}{media_info}"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error sending message: {str(e)}"
|
return f"Error sending message: {str(e)}"
|
||||||
|
|||||||
@@ -199,11 +199,17 @@ class TelegramChannel(BaseChannel):
|
|||||||
chat_id = int(msg.chat_id)
|
chat_id = int(msg.chat_id)
|
||||||
# Convert markdown to Telegram HTML
|
# Convert markdown to Telegram HTML
|
||||||
html_content = _markdown_to_telegram_html(msg.content)
|
html_content = _markdown_to_telegram_html(msg.content)
|
||||||
await self._app.bot.send_message(
|
|
||||||
chat_id=chat_id,
|
# Check if message has media attachments
|
||||||
text=html_content,
|
if msg.media:
|
||||||
parse_mode="HTML"
|
await self._send_with_media(chat_id, html_content, msg.media)
|
||||||
)
|
else:
|
||||||
|
# Text-only message
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=html_content,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -216,7 +222,161 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"Error sending Telegram message: {e2}")
|
logger.error(f"Error sending Telegram message: {e2}")
|
||||||
|
|
||||||
|
async def _send_with_media(self, chat_id: int, caption: str, media_paths: list[str]) -> None:
|
||||||
|
"""
|
||||||
|
Send message with media attachments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_id: Telegram chat ID
|
||||||
|
caption: Message caption
|
||||||
|
media_paths: List of file paths or URLs
|
||||||
|
"""
|
||||||
|
from telegram import InputMediaPhoto, InputMediaVideo
|
||||||
|
|
||||||
|
from nanobot.channels.telegram_media import (
|
||||||
|
MediaKind,
|
||||||
|
classify_media,
|
||||||
|
detect_mime,
|
||||||
|
fetch_media,
|
||||||
|
group_media_for_album,
|
||||||
|
optimize_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process each media item
|
||||||
|
processed_media: list[tuple[str, MediaKind, bytes]] = []
|
||||||
|
|
||||||
|
for path in media_paths:
|
||||||
|
try:
|
||||||
|
# Fetch remote URLs
|
||||||
|
if path.startswith(("http://", "https://")):
|
||||||
|
content, mime = await fetch_media(path, max_bytes=100_000_000)
|
||||||
|
kind = classify_media(mime)
|
||||||
|
else:
|
||||||
|
# Local file
|
||||||
|
file_path = Path(path)
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.warning(f"Media file not found: {path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
mime = detect_mime(path, content)
|
||||||
|
kind = classify_media(mime)
|
||||||
|
|
||||||
|
# Optimize images
|
||||||
|
if kind == MediaKind.IMAGE:
|
||||||
|
try:
|
||||||
|
content = optimize_image(path, max_bytes=6_000_000)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Image optimization failed: {e}, sending original")
|
||||||
|
|
||||||
|
processed_media.append((path, kind, content))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to process media {path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not processed_media:
|
||||||
|
# No media could be processed, send text only
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Group media for album sending
|
||||||
|
media_items = [(path, kind) for path, kind, _ in processed_media]
|
||||||
|
grouping = group_media_for_album(media_items)
|
||||||
|
|
||||||
|
# Handle caption length (Telegram limit: 1024 chars)
|
||||||
|
if len(caption) > 1024:
|
||||||
|
# Send media without caption, then follow-up text
|
||||||
|
media_caption = None
|
||||||
|
followup_text = caption
|
||||||
|
else:
|
||||||
|
media_caption = caption
|
||||||
|
followup_text = None
|
||||||
|
|
||||||
|
# Send album if grouped
|
||||||
|
if grouping["album"]:
|
||||||
|
album_paths = grouping["album"]
|
||||||
|
album_media = []
|
||||||
|
|
||||||
|
for path, kind, content in processed_media:
|
||||||
|
if path not in album_paths:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if kind == MediaKind.IMAGE:
|
||||||
|
media_obj = InputMediaPhoto(
|
||||||
|
media=content,
|
||||||
|
caption=media_caption if len(album_media) == 0 else None,
|
||||||
|
parse_mode="HTML" if media_caption else None
|
||||||
|
)
|
||||||
|
elif kind == MediaKind.VIDEO:
|
||||||
|
media_obj = InputMediaVideo(
|
||||||
|
media=content,
|
||||||
|
caption=media_caption if len(album_media) == 0 else None,
|
||||||
|
parse_mode="HTML" if media_caption else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue # Skip non-album types
|
||||||
|
|
||||||
|
album_media.append(media_obj)
|
||||||
|
|
||||||
|
if album_media:
|
||||||
|
await self._app.bot.send_media_group(
|
||||||
|
chat_id=chat_id,
|
||||||
|
media=album_media
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send separate media
|
||||||
|
for i, (path, kind, content) in enumerate(processed_media):
|
||||||
|
if path in grouping["album"]:
|
||||||
|
continue # Already sent in album
|
||||||
|
|
||||||
|
# Only first separate item gets caption
|
||||||
|
item_caption = media_caption if i == 0 else None
|
||||||
|
|
||||||
|
if kind == MediaKind.IMAGE:
|
||||||
|
await self._app.bot.send_photo(
|
||||||
|
chat_id=chat_id,
|
||||||
|
photo=content,
|
||||||
|
caption=item_caption,
|
||||||
|
parse_mode="HTML" if item_caption else None
|
||||||
|
)
|
||||||
|
elif kind == MediaKind.VIDEO:
|
||||||
|
await self._app.bot.send_video(
|
||||||
|
chat_id=chat_id,
|
||||||
|
video=content,
|
||||||
|
caption=item_caption,
|
||||||
|
parse_mode="HTML" if item_caption else None
|
||||||
|
)
|
||||||
|
elif kind == MediaKind.AUDIO:
|
||||||
|
await self._app.bot.send_audio(
|
||||||
|
chat_id=chat_id,
|
||||||
|
audio=content,
|
||||||
|
caption=item_caption,
|
||||||
|
parse_mode="HTML" if item_caption else None
|
||||||
|
)
|
||||||
|
elif kind == MediaKind.DOCUMENT:
|
||||||
|
await self._app.bot.send_document(
|
||||||
|
chat_id=chat_id,
|
||||||
|
document=content,
|
||||||
|
caption=item_caption,
|
||||||
|
parse_mode="HTML" if item_caption else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send follow-up text if caption was too long
|
||||||
|
if followup_text:
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=followup_text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Handle /start command."""
|
"""Handle /start command."""
|
||||||
if not update.message or not update.effective_user:
|
if not update.message or not update.effective_user:
|
||||||
|
|||||||
Reference in New Issue
Block a user