From a58ac2150f41fe223ebaa30d904d60af25993c3f Mon Sep 17 00:00:00 2001 From: Bently Date: Mon, 1 Dec 2025 12:01:27 -0800 Subject: [PATCH] feat(backend/blocks): add Discord create thread block (#11131) Adds a new Discord block that allows users to create threads in Discord channels. This addresses issue OPEN-2666 which requested the ability to create Discord threads from workflows. ## Solution Implemented `CreateDiscordThreadBlock` in `autogpt_platform/backend/backend/blocks/discord/bot_blocks.py` with the following features: - Create public or private threads in Discord channels via bot token - Support for both channel ID and channel name lookup (with optional server name) - Configurable thread type (public/private toggle) - Configurable auto-archive duration (60, 1440, 4320, or 10080 minutes) - Optional initial message to send in the newly created thread - Outputs: status, thread_id, and thread_name for workflow chaining The block follows the existing Discord block patterns and includes proper error handling for permissions, channel not found, and login failures. #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verify block appears in the workflow builder UI - [x] Test creating a public thread with valid bot token and channel ID - [x] Test creating a private thread with valid bot token and channel name - [x] Test with invalid channel ID/name to verify error handling - [x] Test with bot lacking thread creation permissions - [x] Verify thread_id output can be chained to subsequent blocks - [x] Test auto-archive duration options (60, 1440, 4320, 10080 minutes) - [x] Test sending initial message in newly created thread Video to show the blocks working! https://github.com/user-attachments/assets/f248f315-05b3-47e2-bd6b-4c64d53c35fc --- .../backend/blocks/discord/bot_blocks.py | 224 +++++++++++++++++- 1 file changed, 223 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py b/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py index 21af3507d8..5ecd730f47 100644 --- a/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py +++ b/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py @@ -1,8 +1,9 @@ import base64 import io import mimetypes +from enum import Enum from pathlib import Path -from typing import Any +from typing import Any, Literal, cast import discord from pydantic import SecretStr @@ -33,6 +34,19 @@ TEST_CREDENTIALS = TEST_BOT_CREDENTIALS TEST_CREDENTIALS_INPUT = TEST_BOT_CREDENTIALS_INPUT +class ThreadArchiveDuration(str, Enum): + """Discord thread auto-archive duration options""" + + ONE_HOUR = "60" + ONE_DAY = "1440" + THREE_DAYS = "4320" + ONE_WEEK = "10080" + + def to_minutes(self) -> int: + """Convert the duration string to minutes for Discord API""" + return int(self.value) + + class ReadDiscordMessagesBlock(Block): class Input(BlockSchemaInput): credentials: DiscordCredentials = DiscordCredentialsField() @@ -1166,3 +1180,211 @@ class DiscordChannelInfoBlock(Block): raise ValueError(f"Login error occurred: {login_err}") except Exception as e: raise ValueError(f"An error occurred: {e}") + + +class CreateDiscordThreadBlock(Block): + class Input(BlockSchemaInput): + credentials: DiscordCredentials = DiscordCredentialsField() + channel_name: str = SchemaField( + description="Channel ID or channel name to create the thread in" + ) + server_name: str = SchemaField( + description="Server name (only needed if using channel name)", + advanced=True, + default="", + ) + thread_name: str = SchemaField(description="The name of the thread to create") + is_private: bool = SchemaField( + description="Whether to create a private thread (requires Boost Level 2+) or public thread", + default=False, + ) + auto_archive_duration: ThreadArchiveDuration = SchemaField( + description="Duration before the thread is automatically archived", + advanced=True, + default=ThreadArchiveDuration.ONE_WEEK, + ) + message_content: str = SchemaField( + description="Optional initial message to send in the thread", + advanced=True, + default="", + ) + + class Output(BlockSchemaOutput): + status: str = SchemaField(description="Operation status") + thread_id: str = SchemaField(description="ID of the created thread") + thread_name: str = SchemaField(description="Name of the created thread") + + def __init__(self): + super().__init__( + id="e8f3c9a2-7b5d-4f1e-9c6a-3d8e2b4f7a1c", + input_schema=CreateDiscordThreadBlock.Input, + output_schema=CreateDiscordThreadBlock.Output, + description="Creates a new thread in a Discord channel.", + categories={BlockCategory.SOCIAL}, + test_input={ + "channel_name": "general", + "thread_name": "Test Thread", + "is_private": False, + "auto_archive_duration": ThreadArchiveDuration.ONE_HOUR, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("status", "Thread created successfully"), + ("thread_id", "123456789012345678"), + ("thread_name", "Test Thread"), + ], + test_mock={ + "create_thread": lambda *args, **kwargs: { + "status": "Thread created successfully", + "thread_id": "123456789012345678", + "thread_name": "Test Thread", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def create_thread( + self, + token: str, + channel_name: str, + server_name: str | None, + thread_name: str, + is_private: bool, + auto_archive_duration: ThreadArchiveDuration, + message_content: str, + ) -> dict: + intents = discord.Intents.default() + intents.guilds = True + intents.message_content = True # Required for sending messages in threads + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + channel = None + + # Try to parse as channel ID first + try: + channel_id = int(channel_name) + try: + channel = await client.fetch_channel(channel_id) + except discord.errors.NotFound: + result["status"] = f"Channel with ID {channel_id} not found" + await client.close() + return + except discord.errors.Forbidden: + result["status"] = ( + f"Bot does not have permission to view channel {channel_id}" + ) + await client.close() + return + except ValueError: + # Not an ID, treat as channel name + # Collect all matching channels to detect duplicates + matching_channels = [] + for guild in client.guilds: + # Skip guilds if server_name is provided and doesn't match + if ( + server_name + and server_name.strip() + and guild.name != server_name + ): + continue + for ch in guild.text_channels: + if ch.name == channel_name: + matching_channels.append(ch) + + if not matching_channels: + result["status"] = f"Channel not found: {channel_name}" + await client.close() + return + elif len(matching_channels) > 1: + result["status"] = ( + f"Multiple channels named '{channel_name}' found. " + "Please specify server_name to disambiguate." + ) + await client.close() + return + else: + channel = matching_channels[0] + + if not channel: + result["status"] = "Failed to resolve channel" + await client.close() + return + + # Type check - ensure it's a text channel that can create threads + if not hasattr(channel, "create_thread"): + result["status"] = ( + f"Channel {channel_name} cannot create threads (not a text channel)" + ) + await client.close() + return + + # After the hasattr check, we know channel is a TextChannel + channel = cast(discord.TextChannel, channel) + + try: + # Create the thread using discord.py 2.0+ API + thread_type = ( + discord.ChannelType.private_thread + if is_private + else discord.ChannelType.public_thread + ) + + # Cast to the specific Literal type that discord.py expects + duration_minutes = cast( + Literal[60, 1440, 4320, 10080], auto_archive_duration.to_minutes() + ) + + # The 'type' parameter exists in discord.py 2.0+ but isn't in type stubs yet + # pyright: ignore[reportCallIssue] + thread = await channel.create_thread( + name=thread_name, + type=thread_type, + auto_archive_duration=duration_minutes, + ) + + # Send initial message if provided + if message_content: + await thread.send(message_content) + + result["status"] = "Thread created successfully" + result["thread_id"] = str(thread.id) + result["thread_name"] = thread.name + + except discord.errors.Forbidden as e: + result["status"] = ( + f"Bot does not have permission to create threads in this channel. {str(e)}" + ) + except Exception as e: + result["status"] = f"Error creating thread: {str(e)}" + finally: + await client.close() + + await client.start(token) + return result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + result = await self.create_thread( + token=credentials.api_key.get_secret_value(), + channel_name=input_data.channel_name, + server_name=input_data.server_name or None, + thread_name=input_data.thread_name, + is_private=input_data.is_private, + auto_archive_duration=input_data.auto_archive_duration, + message_content=input_data.message_content, + ) + + yield "status", result.get("status", "Unknown error") + if "thread_id" in result: + yield "thread_id", result["thread_id"] + if "thread_name" in result: + yield "thread_name", result["thread_name"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}")