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
+11 -27
View File
@@ -1,6 +1,6 @@
"""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.bus.events import OutboundMessage
@@ -15,30 +15,22 @@ class MessageTool(Tool):
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
sessions: SessionManager | None = None,
default_channel: str = "",
default_chat_id: str = "",
default_message_id: str | None = None,
default_chat_id: str = ""
):
self._send_callback = send_callback
self._sessions = sessions
self._default_channel = default_channel
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, message_id: str | None = None) -> None:
def set_context(self, channel: str, chat_id: str) -> None:
"""Set the current message context."""
self._default_channel = channel
self._default_chat_id = chat_id
self._default_message_id = 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
@property
def name(self) -> str:
return "message"
@@ -56,6 +48,11 @@ class MessageTool(Tool):
"type": "string",
"description": "The message content to send"
},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of media file paths or URLs to attach"
},
"channel": {
"type": "string",
"description": "Optional: target channel (telegram, discord, etc.)"
@@ -63,11 +60,6 @@ class MessageTool(Tool):
"chat_id": {
"type": "string",
"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"]
@@ -76,15 +68,13 @@ class MessageTool(Tool):
async def execute(
self,
content: str,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
**kwargs: Any
) -> str:
channel = channel or self._default_channel
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:
return "Error: No target channel/chat specified"
@@ -96,10 +86,7 @@ class MessageTool(Tool):
channel=channel,
chat_id=chat_id,
content=content,
media=media or [],
metadata={
"message_id": message_id,
}
media=media or []
)
try:
@@ -111,9 +98,6 @@ class MessageTool(Tool):
session.add_message("assistant", content)
self._sessions.save(session)
if channel == self._default_channel and chat_id == self._default_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}"
return f"Message sent to {channel}:{chat_id}"
except Exception as e:
return f"Error sending message: {str(e)}"
+160
View File
@@ -199,6 +199,12 @@ class TelegramChannel(BaseChannel):
chat_id = int(msg.chat_id)
# Convert markdown to Telegram HTML
html_content = _markdown_to_telegram_html(msg.content)
# Check if message has media attachments
if msg.media:
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,
@@ -217,6 +223,160 @@ class TelegramChannel(BaseChannel):
except Exception as 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:
"""Handle /start command."""
if not update.message or not update.effective_user: