Rebase onto upstream (a4d95fd) #12

Closed
wylab wants to merge 144 commits from rebase-onto-upstream into main
2 changed files with 185 additions and 41 deletions
Showing only changes of commit 49a7adc03b - Show all commits
+19 -35
View File
@@ -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)}"
+166 -6
View File
@@ -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: