From b6d7e9ad8cfa846fbf634313aa655ee110b0a0a7 Mon Sep 17 00:00:00 2001 From: Krzysztof Czerwinski Date: Tue, 17 Feb 2026 15:14:50 +0900 Subject: [PATCH] Update blocks --- .../backend/backend/blocks/telegram/_api.py | 44 + .../backend/backend/blocks/telegram/_auth.py | 2 +- .../backend/backend/blocks/telegram/blocks.py | 838 +++++++++++++++++- .../backend/blocks/telegram/triggers.py | 140 ++- .../backend/integrations/webhooks/telegram.py | 4 +- .../src/app/(platform)/build/page.tsx | 3 +- 6 files changed, 976 insertions(+), 55 deletions(-) diff --git a/autogpt_platform/backend/backend/blocks/telegram/_api.py b/autogpt_platform/backend/backend/blocks/telegram/_api.py index 26ae56cc70..b617e5b73d 100644 --- a/autogpt_platform/backend/backend/blocks/telegram/_api.py +++ b/autogpt_platform/backend/backend/blocks/telegram/_api.py @@ -5,6 +5,7 @@ Provides utilities for making authenticated requests to the Telegram Bot API. """ import logging +from io import BytesIO from typing import Any, Optional from backend.data.model import APIKeyCredentials @@ -66,6 +67,49 @@ async def call_telegram_api( return result.get("result", {}) +async def call_telegram_api_with_file( + credentials: APIKeyCredentials, + method: str, + file_field: str, + file_data: bytes, + filename: str, + content_type: str, + data: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """ + Make a multipart/form-data request to the Telegram Bot API with a file upload. + + Args: + credentials: Bot token credentials + method: API method name (e.g., "sendPhoto", "sendVoice") + file_field: Form field name for the file (e.g., "photo", "voice") + file_data: Raw file bytes + filename: Filename for the upload + content_type: MIME type of the file + data: Additional form parameters + + Returns: + API response result + + Raises: + TelegramAPIException: If the API returns an error + """ + token = credentials.api_key.get_secret_value() + url = get_bot_api_url(token, method) + + files = [(file_field, (filename, BytesIO(file_data), content_type))] + + response = await Requests().post(url, files=files, data=data or {}) + result = response.json() + + if not result.get("ok"): + error_code = result.get("error_code", 0) + description = result.get("description", "Unknown error") + raise TelegramAPIException(description, error_code) + + return result.get("result", {}) + + async def get_file_info( credentials: APIKeyCredentials, file_id: str ) -> dict[str, Any]: diff --git a/autogpt_platform/backend/backend/blocks/telegram/_auth.py b/autogpt_platform/backend/backend/blocks/telegram/_auth.py index da699b597a..bde53aaf79 100644 --- a/autogpt_platform/backend/backend/blocks/telegram/_auth.py +++ b/autogpt_platform/backend/backend/blocks/telegram/_auth.py @@ -43,5 +43,5 @@ TEST_CREDENTIALS_INPUT = { "provider": TEST_CREDENTIALS.provider, "id": TEST_CREDENTIALS.id, "type": TEST_CREDENTIALS.type, - "title": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, } diff --git a/autogpt_platform/backend/backend/blocks/telegram/blocks.py b/autogpt_platform/backend/backend/blocks/telegram/blocks.py index 2d9a5ca0e1..71e43767c4 100644 --- a/autogpt_platform/backend/backend/blocks/telegram/blocks.py +++ b/autogpt_platform/backend/backend/blocks/telegram/blocks.py @@ -1,5 +1,5 @@ """ -Telegram action blocks for sending messages, photos, and voice messages. +Telegram action blocks for sending messages, media, and managing chat content. """ import base64 @@ -16,10 +16,10 @@ from backend.data.block import ( ) from backend.data.execution import ExecutionContext from backend.data.model import APIKeyCredentials, SchemaField -from backend.util.file import store_media_file +from backend.util.file import get_exec_file_path, store_media_file from backend.util.type import MediaFileType -from ._api import call_telegram_api, download_telegram_file +from ._api import call_telegram_api, call_telegram_api_with_file, download_telegram_file from ._auth import ( TEST_CREDENTIALS, TEST_CREDENTIALS_INPUT, @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) class ParseMode(str, Enum): """Telegram message parse modes.""" - NONE = "" + NONE = "none" MARKDOWN = "Markdown" MARKDOWNV2 = "MarkdownV2" HTML = "HTML" @@ -73,7 +73,7 @@ class SendTelegramMessageBlock(Block): def __init__(self): super().__init__( - id="b2c3d4e5-f6a7-8901-bcde-f23456789012", + id="787678ad-1f47-4efc-89df-643f9908621a", description="Send a text message to a Telegram chat.", categories={BlockCategory.SOCIAL}, input_schema=SendTelegramMessageBlock.Input, @@ -108,7 +108,7 @@ class SendTelegramMessageBlock(Block): "chat_id": chat_id, "text": text, } - if parse_mode: + if parse_mode and parse_mode != "none": data["parse_mode"] = parse_mode if reply_to_message_id: data["reply_to_message_id"] = reply_to_message_id @@ -169,7 +169,7 @@ class SendTelegramPhotoBlock(Block): def __init__(self): super().__init__( - id="c3d4e5f6-a7b8-9012-cdef-345678901234", + id="ceb9fd95-fd95-49ff-b57b-278957255716", description="Send a photo to a Telegram chat.", categories={BlockCategory.SOCIAL}, input_schema=SendTelegramPhotoBlock.Input, @@ -185,32 +185,63 @@ class SendTelegramPhotoBlock(Block): ("status", "Photo sent"), ], test_mock={ - "_send_photo": lambda *args, **kwargs: {"message_id": 123} + "_send_photo_url": lambda *args, **kwargs: {"message_id": 123} }, ) - async def _send_photo( + async def _send_photo_url( self, credentials: APIKeyCredentials, chat_id: int, - photo_data: str, + photo_url: str, caption: str, parse_mode: str, reply_to_message_id: Optional[int], ) -> dict: data: dict = { "chat_id": chat_id, - "photo": photo_data, + "photo": photo_url, } if caption: data["caption"] = caption - if parse_mode: + if parse_mode and parse_mode != "none": data["parse_mode"] = parse_mode if reply_to_message_id: data["reply_to_message_id"] = reply_to_message_id return await call_telegram_api(credentials, "sendPhoto", data) + async def _send_photo_file( + self, + credentials: APIKeyCredentials, + chat_id: int, + file_path: str, + caption: str, + parse_mode: str, + reply_to_message_id: Optional[int], + ) -> dict: + from pathlib import Path + + path = Path(file_path) + file_bytes = path.read_bytes() + data: dict = {"chat_id": str(chat_id)} + if caption: + data["caption"] = caption + if parse_mode and parse_mode != "none": + data["parse_mode"] = parse_mode + if reply_to_message_id: + data["reply_to_message_id"] = str(reply_to_message_id) + + return await call_telegram_api_with_file( + credentials, + "sendPhoto", + file_field="photo", + file_data=file_bytes, + filename=path.name, + content_type="image/jpeg", + data=data, + ) + async def run( self, input_data: Input, @@ -224,23 +255,36 @@ class SendTelegramPhotoBlock(Block): # If it's a URL, pass it directly to Telegram (it will fetch it) if photo_input.startswith(("http://", "https://")): - photo_data = photo_input + result = await self._send_photo_url( + credentials=credentials, + chat_id=input_data.chat_id, + photo_url=photo_input, + caption=input_data.caption, + parse_mode=input_data.parse_mode.value, + reply_to_message_id=input_data.reply_to_message_id, + ) else: - # For data URIs or workspace:// references, use store_media_file - photo_data = await store_media_file( - file=photo_input, + # For data URIs or workspace:// references, resolve to local + # file and upload via multipart/form-data + relative_path = await store_media_file( + file=input_data.photo, execution_context=execution_context, - return_format="for_external_api", + return_format="for_local_processing", + ) + if not execution_context.graph_exec_id: + raise ValueError("graph_exec_id is required") + abs_path = get_exec_file_path( + execution_context.graph_exec_id, relative_path + ) + result = await self._send_photo_file( + credentials=credentials, + chat_id=input_data.chat_id, + file_path=abs_path, + caption=input_data.caption, + parse_mode=input_data.parse_mode.value, + reply_to_message_id=input_data.reply_to_message_id, ) - result = await self._send_photo( - credentials=credentials, - chat_id=input_data.chat_id, - photo_data=photo_data, - caption=input_data.caption, - parse_mode=input_data.parse_mode.value, - reply_to_message_id=input_data.reply_to_message_id, - ) yield "message_id", result.get("message_id", 0) yield "status", "Photo sent" except Exception as e: @@ -281,7 +325,7 @@ class SendTelegramVoiceBlock(Block): def __init__(self): super().__init__( - id="d4e5f6a7-b8c9-0123-def0-456789012345", + id="7a0ef660-1a5b-4951-8642-c13a0c8d6d93", description="Send a voice message to a Telegram chat. " "Voice must be OGG format with OPUS codec.", categories={BlockCategory.SOCIAL}, @@ -298,22 +342,22 @@ class SendTelegramVoiceBlock(Block): ("status", "Voice sent"), ], test_mock={ - "_send_voice": lambda *args, **kwargs: {"message_id": 123} + "_send_voice_url": lambda *args, **kwargs: {"message_id": 123} }, ) - async def _send_voice( + async def _send_voice_url( self, credentials: APIKeyCredentials, chat_id: int, - voice_data: str, + voice_url: str, caption: str, duration: Optional[int], reply_to_message_id: Optional[int], ) -> dict: data: dict = { "chat_id": chat_id, - "voice": voice_data, + "voice": voice_url, } if caption: data["caption"] = caption @@ -324,6 +368,37 @@ class SendTelegramVoiceBlock(Block): return await call_telegram_api(credentials, "sendVoice", data) + async def _send_voice_file( + self, + credentials: APIKeyCredentials, + chat_id: int, + file_path: str, + caption: str, + duration: Optional[int], + reply_to_message_id: Optional[int], + ) -> dict: + from pathlib import Path + + path = Path(file_path) + file_bytes = path.read_bytes() + data: dict = {"chat_id": str(chat_id)} + if caption: + data["caption"] = caption + if duration: + data["duration"] = str(duration) + if reply_to_message_id: + data["reply_to_message_id"] = str(reply_to_message_id) + + return await call_telegram_api_with_file( + credentials, + "sendVoice", + file_field="voice", + file_data=file_bytes, + filename=path.name, + content_type="audio/ogg", + data=data, + ) + async def run( self, input_data: Input, @@ -337,22 +412,36 @@ class SendTelegramVoiceBlock(Block): # If it's a URL, pass it directly to Telegram if voice_input.startswith(("http://", "https://")): - voice_data = voice_input + result = await self._send_voice_url( + credentials=credentials, + chat_id=input_data.chat_id, + voice_url=voice_input, + caption=input_data.caption, + duration=input_data.duration, + reply_to_message_id=input_data.reply_to_message_id, + ) else: - voice_data = await store_media_file( - file=voice_input, + # For data URIs or workspace:// references, resolve to local + # file and upload via multipart/form-data + relative_path = await store_media_file( + file=input_data.voice, execution_context=execution_context, - return_format="for_external_api", + return_format="for_local_processing", + ) + if not execution_context.graph_exec_id: + raise ValueError("graph_exec_id is required") + abs_path = get_exec_file_path( + execution_context.graph_exec_id, relative_path + ) + result = await self._send_voice_file( + credentials=credentials, + chat_id=input_data.chat_id, + file_path=abs_path, + caption=input_data.caption, + duration=input_data.duration, + reply_to_message_id=input_data.reply_to_message_id, ) - result = await self._send_voice( - credentials=credentials, - chat_id=input_data.chat_id, - voice_data=voice_data, - caption=input_data.caption, - duration=input_data.duration, - reply_to_message_id=input_data.reply_to_message_id, - ) yield "message_id", result.get("message_id", 0) yield "status", "Voice sent" except Exception as e: @@ -383,7 +472,7 @@ class ReplyToTelegramMessageBlock(Block): def __init__(self): super().__init__( - id="e5f6a7b8-c9d0-1234-ef01-567890123456", + id="c2b1c976-844f-4b6c-ab21-4973d2ceab15", description="Reply to a specific message in a Telegram chat.", categories={BlockCategory.SOCIAL}, input_schema=ReplyToTelegramMessageBlock.Input, @@ -417,7 +506,7 @@ class ReplyToTelegramMessageBlock(Block): "text": text, "reply_to_message_id": reply_to_message_id, } - if parse_mode: + if parse_mode and parse_mode != "none": data["parse_mode"] = parse_mode return await call_telegram_api(credentials, "sendMessage", data) @@ -457,7 +546,7 @@ class GetTelegramFileBlock(Block): def __init__(self): super().__init__( - id="f6a7b8c9-d0e1-2345-f012-678901234567", + id="b600aaf2-6272-40c6-b973-c3984747c5bd", description="Download a file from Telegram using its file_id. " "Use this to process photos, voice messages, or documents received.", categories={BlockCategory.SOCIAL}, @@ -499,9 +588,9 @@ class GetTelegramFileBlock(Block): file_id=input_data.file_id, ) - # Convert to data URI + # Convert to data URI and wrap as MediaFileType mime_type = "application/octet-stream" - data_uri = ( + data_uri = MediaFileType( f"data:{mime_type};base64," f"{base64.b64encode(file_content).decode()}" ) @@ -517,3 +606,658 @@ class GetTelegramFileBlock(Block): yield "status", "File downloaded" except Exception as e: raise ValueError(f"Failed to download file: {e}") + + +class DeleteTelegramMessageBlock(Block): + """Delete a message from a Telegram chat.""" + + class Input(BlockSchemaInput): + credentials: TelegramCredentialsInput = TelegramCredentialsField() + chat_id: int = SchemaField( + description="The chat ID containing the message" + ) + message_id: int = SchemaField( + description="The ID of the message to delete" + ) + + class Output(BlockSchemaOutput): + status: str = SchemaField(description="Status of the operation") + + def __init__(self): + super().__init__( + id="bb4fd91a-883e-4d29-9879-b06c4bb79d30", + description="Delete a message from a Telegram chat. " + "Bots can delete their own messages and incoming messages " + "in private chats at any time.", + categories={BlockCategory.SOCIAL}, + input_schema=DeleteTelegramMessageBlock.Input, + output_schema=DeleteTelegramMessageBlock.Output, + test_input={ + "chat_id": 12345678, + "message_id": 42, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("status", "Message deleted"), + ], + test_mock={ + "_delete_message": lambda *args, **kwargs: True + }, + ) + + async def _delete_message( + self, + credentials: APIKeyCredentials, + chat_id: int, + message_id: int, + ) -> bool: + await call_telegram_api( + credentials, + "deleteMessage", + {"chat_id": chat_id, "message_id": message_id}, + ) + return True + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + await self._delete_message( + credentials=credentials, + chat_id=input_data.chat_id, + message_id=input_data.message_id, + ) + yield "status", "Message deleted" + except Exception as e: + raise ValueError(f"Failed to delete message: {e}") + + +class EditTelegramMessageBlock(Block): + """Edit the text of an existing message.""" + + class Input(BlockSchemaInput): + credentials: TelegramCredentialsInput = TelegramCredentialsField() + chat_id: int = SchemaField( + description="The chat ID containing the message" + ) + message_id: int = SchemaField( + description="The ID of the message to edit" + ) + text: str = SchemaField( + description="New text for the message (max 4096 characters)" + ) + parse_mode: ParseMode = SchemaField( + description="Message formatting mode", + default=ParseMode.NONE, + advanced=True, + ) + + class Output(BlockSchemaOutput): + message_id: int = SchemaField(description="The ID of the edited message") + status: str = SchemaField(description="Status of the operation") + + def __init__(self): + super().__init__( + id="c55816a2-37af-4901-ba19-36edcf2acfc1", + description="Edit the text of an existing message sent by the bot.", + categories={BlockCategory.SOCIAL}, + input_schema=EditTelegramMessageBlock.Input, + output_schema=EditTelegramMessageBlock.Output, + test_input={ + "chat_id": 12345678, + "message_id": 42, + "text": "Updated text!", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("message_id", 42), + ("status", "Message edited"), + ], + test_mock={ + "_edit_message": lambda *args, **kwargs: {"message_id": 42} + }, + ) + + async def _edit_message( + self, + credentials: APIKeyCredentials, + chat_id: int, + message_id: int, + text: str, + parse_mode: str, + ) -> dict: + data: dict = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + if parse_mode and parse_mode != "none": + data["parse_mode"] = parse_mode + + return await call_telegram_api(credentials, "editMessageText", data) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + result = await self._edit_message( + credentials=credentials, + chat_id=input_data.chat_id, + message_id=input_data.message_id, + text=input_data.text, + parse_mode=input_data.parse_mode.value, + ) + yield "message_id", result.get("message_id", 0) + yield "status", "Message edited" + except Exception as e: + raise ValueError(f"Failed to edit message: {e}") + + +class SendTelegramAudioBlock(Block): + """Send an audio file to a Telegram chat, displayed in the music player.""" + + class Input(BlockSchemaInput): + credentials: TelegramCredentialsInput = TelegramCredentialsField() + chat_id: int = SchemaField( + description="The chat ID to send the audio to" + ) + audio: MediaFileType = SchemaField( + description="Audio file to send (MP3 or M4A format). " + "Can be URL, data URI, or workspace:// reference." + ) + caption: str = SchemaField( + description="Caption for the audio file", + default="", + advanced=True, + ) + title: str = SchemaField( + description="Track title", + default="", + advanced=True, + ) + performer: str = SchemaField( + description="Track performer/artist", + default="", + advanced=True, + ) + duration: Optional[int] = SchemaField( + description="Duration in seconds", + default=None, + advanced=True, + ) + reply_to_message_id: Optional[int] = SchemaField( + description="Message ID to reply to", + default=None, + advanced=True, + ) + + class Output(BlockSchemaOutput): + message_id: int = SchemaField(description="The ID of the sent message") + status: str = SchemaField(description="Status of the operation") + + def __init__(self): + super().__init__( + id="9044829a-7915-4ab4-a50f-89922ba3679f", + description="Send an audio file to a Telegram chat. " + "The file is displayed in the music player. " + "For voice messages, use the Send Voice block instead.", + categories={BlockCategory.SOCIAL}, + input_schema=SendTelegramAudioBlock.Input, + output_schema=SendTelegramAudioBlock.Output, + test_input={ + "chat_id": 12345678, + "audio": "https://example.com/track.mp3", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("message_id", 123), + ("status", "Audio sent"), + ], + test_mock={ + "_send_audio_url": lambda *args, **kwargs: {"message_id": 123} + }, + ) + + async def _send_audio_url( + self, + credentials: APIKeyCredentials, + chat_id: int, + audio_url: str, + caption: str, + title: str, + performer: str, + duration: Optional[int], + reply_to_message_id: Optional[int], + ) -> dict: + data: dict = { + "chat_id": chat_id, + "audio": audio_url, + } + if caption: + data["caption"] = caption + if title: + data["title"] = title + if performer: + data["performer"] = performer + if duration: + data["duration"] = duration + if reply_to_message_id: + data["reply_to_message_id"] = reply_to_message_id + + return await call_telegram_api(credentials, "sendAudio", data) + + async def _send_audio_file( + self, + credentials: APIKeyCredentials, + chat_id: int, + file_path: str, + caption: str, + title: str, + performer: str, + duration: Optional[int], + reply_to_message_id: Optional[int], + ) -> dict: + from pathlib import Path + + path = Path(file_path) + file_bytes = path.read_bytes() + data: dict = {"chat_id": str(chat_id)} + if caption: + data["caption"] = caption + if title: + data["title"] = title + if performer: + data["performer"] = performer + if duration: + data["duration"] = str(duration) + if reply_to_message_id: + data["reply_to_message_id"] = str(reply_to_message_id) + + return await call_telegram_api_with_file( + credentials, + "sendAudio", + file_field="audio", + file_data=file_bytes, + filename=path.name, + content_type="audio/mpeg", + data=data, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, + ) -> BlockOutput: + try: + audio_input = input_data.audio + + if audio_input.startswith(("http://", "https://")): + result = await self._send_audio_url( + credentials=credentials, + chat_id=input_data.chat_id, + audio_url=audio_input, + caption=input_data.caption, + title=input_data.title, + performer=input_data.performer, + duration=input_data.duration, + reply_to_message_id=input_data.reply_to_message_id, + ) + else: + relative_path = await store_media_file( + file=input_data.audio, + execution_context=execution_context, + return_format="for_local_processing", + ) + if not execution_context.graph_exec_id: + raise ValueError("graph_exec_id is required") + abs_path = get_exec_file_path( + execution_context.graph_exec_id, relative_path + ) + result = await self._send_audio_file( + credentials=credentials, + chat_id=input_data.chat_id, + file_path=abs_path, + caption=input_data.caption, + title=input_data.title, + performer=input_data.performer, + duration=input_data.duration, + reply_to_message_id=input_data.reply_to_message_id, + ) + + yield "message_id", result.get("message_id", 0) + yield "status", "Audio sent" + except Exception as e: + raise ValueError(f"Failed to send audio: {e}") + + +class SendTelegramDocumentBlock(Block): + """Send a document to a Telegram chat.""" + + class Input(BlockSchemaInput): + credentials: TelegramCredentialsInput = TelegramCredentialsField() + chat_id: int = SchemaField( + description="The chat ID to send the document to" + ) + document: MediaFileType = SchemaField( + description="Document to send (any file type). " + "Can be URL, data URI, or workspace:// reference." + ) + filename: str = SchemaField( + description="Filename shown to the recipient. " + "If empty, the original filename is used (may be a random ID " + "for uploaded files).", + default="", + ) + caption: str = SchemaField( + description="Caption for the document", + default="", + advanced=True, + ) + parse_mode: ParseMode = SchemaField( + description="Caption formatting mode", + default=ParseMode.NONE, + advanced=True, + ) + reply_to_message_id: Optional[int] = SchemaField( + description="Message ID to reply to", + default=None, + advanced=True, + ) + + class Output(BlockSchemaOutput): + message_id: int = SchemaField(description="The ID of the sent message") + status: str = SchemaField(description="Status of the operation") + + def __init__(self): + super().__init__( + id="554f4bad-4c99-48ba-9d1c-75840b71c5ad", + description="Send a document (any file type) to a Telegram chat.", + categories={BlockCategory.SOCIAL}, + input_schema=SendTelegramDocumentBlock.Input, + output_schema=SendTelegramDocumentBlock.Output, + test_input={ + "chat_id": 12345678, + "document": "https://example.com/file.pdf", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("message_id", 123), + ("status", "Document sent"), + ], + test_mock={ + "_send_document_url": lambda *args, **kwargs: {"message_id": 123} + }, + ) + + async def _send_document_url( + self, + credentials: APIKeyCredentials, + chat_id: int, + document_url: str, + caption: str, + parse_mode: str, + reply_to_message_id: Optional[int], + ) -> dict: + data: dict = { + "chat_id": chat_id, + "document": document_url, + } + if caption: + data["caption"] = caption + if parse_mode and parse_mode != "none": + data["parse_mode"] = parse_mode + if reply_to_message_id: + data["reply_to_message_id"] = reply_to_message_id + + return await call_telegram_api(credentials, "sendDocument", data) + + async def _send_document_file( + self, + credentials: APIKeyCredentials, + chat_id: int, + file_path: str, + display_filename: str, + caption: str, + parse_mode: str, + reply_to_message_id: Optional[int], + ) -> dict: + from pathlib import Path + + path = Path(file_path) + file_bytes = path.read_bytes() + data: dict = {"chat_id": str(chat_id)} + if caption: + data["caption"] = caption + if parse_mode and parse_mode != "none": + data["parse_mode"] = parse_mode + if reply_to_message_id: + data["reply_to_message_id"] = str(reply_to_message_id) + + return await call_telegram_api_with_file( + credentials, + "sendDocument", + file_field="document", + file_data=file_bytes, + filename=display_filename or path.name, + content_type="application/octet-stream", + data=data, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, + ) -> BlockOutput: + try: + doc_input = input_data.document + + if doc_input.startswith(("http://", "https://")): + result = await self._send_document_url( + credentials=credentials, + chat_id=input_data.chat_id, + document_url=doc_input, + caption=input_data.caption, + parse_mode=input_data.parse_mode.value, + reply_to_message_id=input_data.reply_to_message_id, + ) + else: + relative_path = await store_media_file( + file=input_data.document, + execution_context=execution_context, + return_format="for_local_processing", + ) + if not execution_context.graph_exec_id: + raise ValueError("graph_exec_id is required") + abs_path = get_exec_file_path( + execution_context.graph_exec_id, relative_path + ) + result = await self._send_document_file( + credentials=credentials, + chat_id=input_data.chat_id, + file_path=abs_path, + display_filename=input_data.filename, + caption=input_data.caption, + parse_mode=input_data.parse_mode.value, + reply_to_message_id=input_data.reply_to_message_id, + ) + + yield "message_id", result.get("message_id", 0) + yield "status", "Document sent" + except Exception as e: + raise ValueError(f"Failed to send document: {e}") + + +class SendTelegramVideoBlock(Block): + """Send a video to a Telegram chat.""" + + class Input(BlockSchemaInput): + credentials: TelegramCredentialsInput = TelegramCredentialsField() + chat_id: int = SchemaField( + description="The chat ID to send the video to" + ) + video: MediaFileType = SchemaField( + description="Video to send (MP4 format). " + "Can be URL, data URI, or workspace:// reference." + ) + caption: str = SchemaField( + description="Caption for the video", + default="", + advanced=True, + ) + parse_mode: ParseMode = SchemaField( + description="Caption formatting mode", + default=ParseMode.NONE, + advanced=True, + ) + duration: Optional[int] = SchemaField( + description="Duration in seconds", + default=None, + advanced=True, + ) + reply_to_message_id: Optional[int] = SchemaField( + description="Message ID to reply to", + default=None, + advanced=True, + ) + + class Output(BlockSchemaOutput): + message_id: int = SchemaField(description="The ID of the sent message") + status: str = SchemaField(description="Status of the operation") + + def __init__(self): + super().__init__( + id="cda075af-3f31-47b0-baa9-0050b3bd78bd", + description="Send a video to a Telegram chat.", + categories={BlockCategory.SOCIAL}, + input_schema=SendTelegramVideoBlock.Input, + output_schema=SendTelegramVideoBlock.Output, + test_input={ + "chat_id": 12345678, + "video": "https://example.com/video.mp4", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("message_id", 123), + ("status", "Video sent"), + ], + test_mock={ + "_send_video_url": lambda *args, **kwargs: {"message_id": 123} + }, + ) + + async def _send_video_url( + self, + credentials: APIKeyCredentials, + chat_id: int, + video_url: str, + caption: str, + parse_mode: str, + duration: Optional[int], + reply_to_message_id: Optional[int], + ) -> dict: + data: dict = { + "chat_id": chat_id, + "video": video_url, + } + if caption: + data["caption"] = caption + if parse_mode and parse_mode != "none": + data["parse_mode"] = parse_mode + if duration: + data["duration"] = duration + if reply_to_message_id: + data["reply_to_message_id"] = reply_to_message_id + + return await call_telegram_api(credentials, "sendVideo", data) + + async def _send_video_file( + self, + credentials: APIKeyCredentials, + chat_id: int, + file_path: str, + caption: str, + parse_mode: str, + duration: Optional[int], + reply_to_message_id: Optional[int], + ) -> dict: + from pathlib import Path + + path = Path(file_path) + file_bytes = path.read_bytes() + data: dict = {"chat_id": str(chat_id)} + if caption: + data["caption"] = caption + if parse_mode and parse_mode != "none": + data["parse_mode"] = parse_mode + if duration: + data["duration"] = str(duration) + if reply_to_message_id: + data["reply_to_message_id"] = str(reply_to_message_id) + + return await call_telegram_api_with_file( + credentials, + "sendVideo", + file_field="video", + file_data=file_bytes, + filename=path.name, + content_type="video/mp4", + data=data, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, + ) -> BlockOutput: + try: + video_input = input_data.video + + if video_input.startswith(("http://", "https://")): + result = await self._send_video_url( + credentials=credentials, + chat_id=input_data.chat_id, + video_url=video_input, + caption=input_data.caption, + parse_mode=input_data.parse_mode.value, + duration=input_data.duration, + reply_to_message_id=input_data.reply_to_message_id, + ) + else: + relative_path = await store_media_file( + file=input_data.video, + execution_context=execution_context, + return_format="for_local_processing", + ) + if not execution_context.graph_exec_id: + raise ValueError("graph_exec_id is required") + abs_path = get_exec_file_path( + execution_context.graph_exec_id, relative_path + ) + result = await self._send_video_file( + credentials=credentials, + chat_id=input_data.chat_id, + file_path=abs_path, + caption=input_data.caption, + parse_mode=input_data.parse_mode.value, + duration=input_data.duration, + reply_to_message_id=input_data.reply_to_message_id, + ) + + yield "message_id", result.get("message_id", 0) + yield "status", "Video sent" + except Exception as e: + raise ValueError(f"Failed to send video: {e}") diff --git a/autogpt_platform/backend/backend/blocks/telegram/triggers.py b/autogpt_platform/backend/backend/blocks/telegram/triggers.py index 3f1977d8e2..f343ad8b59 100644 --- a/autogpt_platform/backend/backend/blocks/telegram/triggers.py +++ b/autogpt_platform/backend/backend/blocks/telegram/triggers.py @@ -117,11 +117,18 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): description="File ID of the audio file (for audio messages). " "Use GetTelegramFileBlock to download." ) + file_id: str = SchemaField( + description="File ID for document/video messages. " + "Use GetTelegramFileBlock to download." + ) + file_name: str = SchemaField( + description="Original filename (for document/audio messages)" + ) caption: str = SchemaField(description="Caption for media messages") def __init__(self): super().__init__( - id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + id="4435e4e0-df6e-4301-8f35-ad70b12fc9ec", description="Triggers when a message is received by your Telegram bot. " "Supports text, photos, voice messages, and audio files.", categories={BlockCategory.SOCIAL}, @@ -152,6 +159,8 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): ("photo_file_id", ""), ("voice_file_id", ""), ("audio_file_id", ""), + ("file_id", ""), + ("file_name", ""), ("caption", ""), ], ) @@ -178,16 +187,20 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): yield "photo_file_id", "" yield "voice_file_id", "" yield "audio_file_id", "" + yield "file_id", "" + yield "file_name", "" yield "caption", "" elif "photo" in message: # Get the largest photo (last in array) photos = message.get("photo", []) - file_id = photos[-1]["file_id"] if photos else "" + photo_fid = photos[-1]["file_id"] if photos else "" yield "event", "photo" yield "text", "" - yield "photo_file_id", file_id + yield "photo_file_id", photo_fid yield "voice_file_id", "" yield "audio_file_id", "" + yield "file_id", "" + yield "file_name", "" yield "caption", message.get("caption", "") elif "voice" in message: voice = message.get("voice", {}) @@ -196,6 +209,8 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): yield "photo_file_id", "" yield "voice_file_id", voice.get("file_id", "") yield "audio_file_id", "" + yield "file_id", "" + yield "file_name", "" yield "caption", message.get("caption", "") elif "audio" in message: audio = message.get("audio", {}) @@ -204,6 +219,8 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): yield "photo_file_id", "" yield "voice_file_id", "" yield "audio_file_id", audio.get("file_id", "") + yield "file_id", "" + yield "file_name", audio.get("file_name", "") yield "caption", message.get("caption", "") elif "document" in message: document = message.get("document", {}) @@ -211,7 +228,9 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): yield "text", "" yield "photo_file_id", "" yield "voice_file_id", "" - yield "audio_file_id", document.get("file_id", "") + yield "audio_file_id", "" + yield "file_id", document.get("file_id", "") + yield "file_name", document.get("file_name", "") yield "caption", message.get("caption", "") elif "video" in message: video = message.get("video", {}) @@ -219,7 +238,9 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): yield "text", "" yield "photo_file_id", "" yield "voice_file_id", "" - yield "audio_file_id", video.get("file_id", "") + yield "audio_file_id", "" + yield "file_id", video.get("file_id", "") + yield "file_name", video.get("file_name", "") yield "caption", message.get("caption", "") else: yield "event", "other" @@ -227,4 +248,113 @@ class TelegramMessageTriggerBlock(TelegramTriggerBase, Block): yield "photo_file_id", "" yield "voice_file_id", "" yield "audio_file_id", "" + yield "file_id", "" + yield "file_name", "" yield "caption", "" + + +# Example payload for reaction trigger testing +EXAMPLE_REACTION_PAYLOAD = { + "update_id": 123456790, + "message_reaction": { + "chat": { + "id": 12345678, + "first_name": "John", + "last_name": "Doe", + "username": "johndoe", + "type": "private", + }, + "message_id": 42, + "user": { + "id": 12345678, + "is_bot": False, + "first_name": "John", + "username": "johndoe", + }, + "date": 1234567890, + "new_reaction": [{"type": "emoji", "emoji": "👍"}], + "old_reaction": [], + }, +} + + +class TelegramMessageReactionTriggerBlock(TelegramTriggerBase, Block): + """ + Triggers when a reaction to a message is changed. + + Works automatically in private chats. In group chats, the bot must be + an administrator to receive reaction updates. + """ + + class Input(TelegramTriggerBase.Input): + pass + + class Output(BlockSchemaOutput): + payload: dict = SchemaField( + description="The complete webhook payload from Telegram" + ) + chat_id: int = SchemaField( + description="The chat ID where the reaction occurred" + ) + message_id: int = SchemaField( + description="The message ID that was reacted to" + ) + user_id: int = SchemaField( + description="The user ID who changed the reaction" + ) + username: str = SchemaField( + description="Username of the user (may be empty)" + ) + new_reactions: list = SchemaField( + description="List of new reactions on the message" + ) + old_reactions: list = SchemaField( + description="List of previous reactions on the message" + ) + + def __init__(self): + super().__init__( + id="82525328-9368-4966-8f0c-cd78e80181fd", + description="Triggers when a reaction to a message is changed. " + "Works in private chats automatically. " + "In groups, the bot must be an administrator.", + categories={BlockCategory.SOCIAL}, + input_schema=TelegramMessageReactionTriggerBlock.Input, + output_schema=TelegramMessageReactionTriggerBlock.Output, + webhook_config=BlockWebhookConfig( + provider=ProviderName.TELEGRAM, + webhook_type=TelegramWebhookType.BOT, + resource_format="bot", + event_filter_input="", + event_format="message_reaction", + ), + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "payload": EXAMPLE_REACTION_PAYLOAD, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("payload", EXAMPLE_REACTION_PAYLOAD), + ("chat_id", 12345678), + ("message_id", 42), + ("user_id", 12345678), + ("username", "johndoe"), + ("new_reactions", [{"type": "emoji", "emoji": "👍"}]), + ("old_reactions", []), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + payload = input_data.payload + reaction = payload.get("message_reaction", {}) + + chat = reaction.get("chat", {}) + user = reaction.get("user", {}) + + yield "payload", payload + yield "chat_id", chat.get("id", 0) + yield "message_id", reaction.get("message_id", 0) + yield "user_id", user.get("id", 0) + yield "username", user.get("username", "") + yield "new_reactions", reaction.get("new_reaction", []) + yield "old_reactions", reaction.get("old_reaction", []) diff --git a/autogpt_platform/backend/backend/integrations/webhooks/telegram.py b/autogpt_platform/backend/backend/integrations/webhooks/telegram.py index 355bcd1529..1915e9ce29 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/telegram.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/telegram.py @@ -83,6 +83,8 @@ class TelegramWebhooksManager(BaseWebhooksManager[TelegramWebhookType]): event_type = "message.other" elif "edited_message" in payload: event_type = "edited_message" + elif "message_reaction" in payload: + event_type = "message_reaction" elif "callback_query" in payload: event_type = "callback_query" else: @@ -123,7 +125,7 @@ class TelegramWebhooksManager(BaseWebhooksManager[TelegramWebhookType]): webhook_data = { "url": ingress_url, "secret_token": secret, - "allowed_updates": ["message", "edited_message"], + "allowed_updates": ["message", "edited_message", "message_reaction"], } response = await Requests().post(url, json=webhook_data) diff --git a/autogpt_platform/frontend/src/app/(platform)/build/page.tsx b/autogpt_platform/frontend/src/app/(platform)/build/page.tsx index f1d62ee5fb..aebc5d0814 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/page.tsx @@ -54,7 +54,8 @@ export default function BuilderPage() { ); } - return isNewFlowEditorEnabled ? ( + // return isNewFlowEditorEnabled ? ( + return true ? (