CoACT initialize (#292)
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .discord import DiscordRetrieveTool, DiscordSendTool
|
||||
from .slack import SlackRetrieveRepliesTool, SlackRetrieveTool, SlackSendTool
|
||||
from .telegram import TelegramRetrieveTool, TelegramSendTool
|
||||
|
||||
__all__ = [
|
||||
"DiscordRetrieveTool",
|
||||
"DiscordSendTool",
|
||||
"SlackRetrieveRepliesTool",
|
||||
"SlackRetrieveTool",
|
||||
"SlackSendTool",
|
||||
"TelegramRetrieveTool",
|
||||
"TelegramSendTool",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .discord import DiscordRetrieveTool, DiscordSendTool
|
||||
|
||||
__all__ = ["DiscordRetrieveTool", "DiscordSendTool"]
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Union
|
||||
|
||||
from .....doc_utils import export_module
|
||||
from .....import_utils import optional_import_block, require_optional_import
|
||||
from .... import Tool
|
||||
from ....dependency_injection import Depends, on
|
||||
|
||||
__all__ = ["DiscordRetrieveTool", "DiscordSendTool"]
|
||||
|
||||
with optional_import_block():
|
||||
from discord import Client, Intents, utils
|
||||
|
||||
MAX_MESSAGE_LENGTH = 2000
|
||||
MAX_BATCH_RETRIEVE_MESSAGES = 100 # Discord's max per request
|
||||
|
||||
|
||||
@require_optional_import(["discord"], "commsagent-discord")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class DiscordSendTool(Tool):
|
||||
"""Sends a message to a Discord channel."""
|
||||
|
||||
def __init__(self, *, bot_token: str, channel_name: str, guild_name: str) -> None:
|
||||
"""
|
||||
Initialize the DiscordSendTool.
|
||||
|
||||
Args:
|
||||
bot_token: The bot token to use for sending messages.
|
||||
channel_name: The name of the channel to send messages to.
|
||||
guild_name: The name of the guild for the channel.
|
||||
"""
|
||||
|
||||
# Function that sends the message, uses dependency injection for bot token / channel / guild
|
||||
async def discord_send_message(
|
||||
message: Annotated[str, "Message to send to the channel."],
|
||||
bot_token: Annotated[str, Depends(on(bot_token))],
|
||||
guild_name: Annotated[str, Depends(on(guild_name))],
|
||||
channel_name: Annotated[str, Depends(on(channel_name))],
|
||||
) -> Any:
|
||||
"""
|
||||
Sends a message to a Discord channel.
|
||||
|
||||
Args:
|
||||
message: The message to send to the channel.
|
||||
bot_token: The bot token to use for Discord. (uses dependency injection)
|
||||
guild_name: The name of the server. (uses dependency injection)
|
||||
channel_name: The name of the channel. (uses dependency injection)
|
||||
"""
|
||||
intents = Intents.default()
|
||||
intents.message_content = True
|
||||
intents.guilds = True
|
||||
intents.guild_messages = True
|
||||
|
||||
client = Client(intents=intents)
|
||||
result_future: asyncio.Future[str] = asyncio.Future() # Stores the result of the send
|
||||
|
||||
# When the client is ready, we'll send the message
|
||||
@client.event # type: ignore[misc]
|
||||
async def on_ready() -> None:
|
||||
try:
|
||||
# Server
|
||||
guild = utils.get(client.guilds, name=guild_name)
|
||||
if guild:
|
||||
# Channel
|
||||
channel = utils.get(guild.text_channels, name=channel_name)
|
||||
if channel:
|
||||
# Send the message
|
||||
if len(message) > MAX_MESSAGE_LENGTH:
|
||||
chunks = [
|
||||
message[i : i + (MAX_MESSAGE_LENGTH - 1)]
|
||||
for i in range(0, len(message), (MAX_MESSAGE_LENGTH - 1))
|
||||
]
|
||||
for i, chunk in enumerate(chunks):
|
||||
sent = await channel.send(chunk)
|
||||
|
||||
# Store ID for the first chunk
|
||||
if i == 0:
|
||||
sent_message_id = str(sent.id)
|
||||
|
||||
result_future.set_result(
|
||||
f"Message sent successfully ({len(chunks)} chunks, first ID: {sent_message_id}):\n{message}"
|
||||
)
|
||||
else:
|
||||
sent = await channel.send(message)
|
||||
result_future.set_result(f"Message sent successfully (ID: {sent.id}):\n{message}")
|
||||
else:
|
||||
result_future.set_result(f"Message send failed, could not find channel: {channel_name}")
|
||||
else:
|
||||
result_future.set_result(f"Message send failed, could not find guild: {guild_name}")
|
||||
|
||||
except Exception as e:
|
||||
result_future.set_exception(e)
|
||||
finally:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception as e:
|
||||
raise Exception(f"Unable to close Discord client: {e}")
|
||||
|
||||
# Start the client and when it's ready it'll send the message in on_ready
|
||||
try:
|
||||
await client.start(bot_token)
|
||||
|
||||
# Capture the result of the send
|
||||
return await result_future
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to start Discord client: {e}")
|
||||
|
||||
super().__init__(
|
||||
name="discord_send",
|
||||
description="Sends a message to a Discord channel.",
|
||||
func_or_tool=discord_send_message,
|
||||
)
|
||||
|
||||
|
||||
@require_optional_import(["discord"], "commsagent-discord")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class DiscordRetrieveTool(Tool):
|
||||
"""Retrieves messages from a Discord channel."""
|
||||
|
||||
def __init__(self, *, bot_token: str, channel_name: str, guild_name: str) -> None:
|
||||
"""
|
||||
Initialize the DiscordRetrieveTool.
|
||||
|
||||
Args:
|
||||
bot_token: The bot token to use for retrieving messages.
|
||||
channel_name: The name of the channel to retrieve messages from.
|
||||
guild_name: The name of the guild for the channel.
|
||||
"""
|
||||
|
||||
async def discord_retrieve_messages(
|
||||
bot_token: Annotated[str, Depends(on(bot_token))],
|
||||
guild_name: Annotated[str, Depends(on(guild_name))],
|
||||
channel_name: Annotated[str, Depends(on(channel_name))],
|
||||
messages_since: Annotated[
|
||||
Union[str, None],
|
||||
"Date to retrieve messages from (ISO format) OR Discord snowflake ID. If None, retrieves latest messages.",
|
||||
] = None,
|
||||
maximum_messages: Annotated[
|
||||
Union[int, None], "Maximum number of messages to retrieve. If None, retrieves all messages since date."
|
||||
] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieves messages from a Discord channel.
|
||||
|
||||
Args:
|
||||
bot_token: The bot token to use for Discord. (uses dependency injection)
|
||||
guild_name: The name of the server. (uses dependency injection)
|
||||
channel_name: The name of the channel. (uses dependency injection)
|
||||
messages_since: ISO format date string OR Discord snowflake ID, to retrieve messages from. If None, retrieves latest messages.
|
||||
maximum_messages: Maximum number of messages to retrieve. If None, retrieves all messages since date.
|
||||
"""
|
||||
intents = Intents.default()
|
||||
intents.message_content = True
|
||||
intents.guilds = True
|
||||
intents.guild_messages = True
|
||||
|
||||
client = Client(intents=intents)
|
||||
result_future: asyncio.Future[list[dict[str, Any]]] = asyncio.Future()
|
||||
|
||||
messages_since_date: Union[str, None] = None
|
||||
if messages_since is not None:
|
||||
if DiscordRetrieveTool._is_snowflake(messages_since):
|
||||
messages_since_date = DiscordRetrieveTool._snowflake_to_iso(messages_since)
|
||||
else:
|
||||
messages_since_date = messages_since
|
||||
|
||||
@client.event # type: ignore[misc]
|
||||
async def on_ready() -> None:
|
||||
try:
|
||||
messages = []
|
||||
|
||||
# Get guild and channel
|
||||
guild = utils.get(client.guilds, name=guild_name)
|
||||
if not guild:
|
||||
result_future.set_result([{"error": f"Could not find guild: {guild_name}"}])
|
||||
return
|
||||
|
||||
channel = utils.get(guild.text_channels, name=channel_name)
|
||||
if not channel:
|
||||
result_future.set_result([{"error": f"Could not find channel: {channel_name}"}])
|
||||
return
|
||||
|
||||
# Setup retrieval parameters
|
||||
last_message_id = None
|
||||
messages_retrieved = 0
|
||||
|
||||
# Convert to ISO format
|
||||
after_date = None
|
||||
if messages_since_date:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
after_date = datetime.fromisoformat(messages_since_date)
|
||||
except ValueError:
|
||||
result_future.set_result([
|
||||
{"error": f"Invalid date format: {messages_since_date}. Use ISO format."}
|
||||
])
|
||||
return
|
||||
|
||||
while True:
|
||||
# Setup fetch options
|
||||
fetch_options = {
|
||||
"limit": MAX_BATCH_RETRIEVE_MESSAGES,
|
||||
"before": last_message_id if last_message_id else None,
|
||||
"after": after_date if after_date else None,
|
||||
}
|
||||
|
||||
# Fetch batch of messages
|
||||
message_batch = []
|
||||
async for message in channel.history(**fetch_options): # type: ignore[arg-type]
|
||||
message_batch.append(message)
|
||||
messages_retrieved += 1
|
||||
|
||||
# Check if we've reached the maximum
|
||||
if maximum_messages and messages_retrieved >= maximum_messages:
|
||||
break
|
||||
|
||||
if not message_batch:
|
||||
break
|
||||
|
||||
# Process messages
|
||||
for msg in message_batch:
|
||||
messages.append({
|
||||
"id": str(msg.id),
|
||||
"content": msg.content,
|
||||
"author": str(msg.author),
|
||||
"timestamp": msg.created_at.isoformat(),
|
||||
})
|
||||
|
||||
# Update last message ID for pagination
|
||||
last_message_id = message_batch[-1] # Use message object directly as 'before' parameter
|
||||
|
||||
# Break if we've reached the maximum
|
||||
if maximum_messages and messages_retrieved >= maximum_messages:
|
||||
break
|
||||
|
||||
result_future.set_result(messages)
|
||||
|
||||
except Exception as e:
|
||||
result_future.set_exception(e)
|
||||
finally:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception as e:
|
||||
raise Exception(f"Unable to close Discord client: {e}")
|
||||
|
||||
try:
|
||||
await client.start(bot_token)
|
||||
return await result_future
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to start Discord client: {e}")
|
||||
|
||||
super().__init__(
|
||||
name="discord_retrieve",
|
||||
description="Retrieves messages from a Discord channel based datetime/message ID and/or number of latest messages.",
|
||||
func_or_tool=discord_retrieve_messages,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_snowflake(value: str) -> bool:
|
||||
"""Check if a string is a valid Discord snowflake ID."""
|
||||
# Must be numeric and 17-20 digits
|
||||
if not value.isdigit():
|
||||
return False
|
||||
|
||||
digit_count = len(value)
|
||||
return 17 <= digit_count <= 20
|
||||
|
||||
@staticmethod
|
||||
def _snowflake_to_iso(snowflake: str) -> str:
|
||||
"""Convert a Discord snowflake ID to ISO timestamp string."""
|
||||
if not DiscordRetrieveTool._is_snowflake(snowflake):
|
||||
raise ValueError(f"Invalid snowflake ID: {snowflake}")
|
||||
|
||||
# Discord epoch (2015-01-01)
|
||||
discord_epoch = 1420070400000
|
||||
|
||||
# Convert ID to int and shift right 22 bits to get timestamp
|
||||
timestamp_ms = (int(snowflake) >> 22) + discord_epoch
|
||||
|
||||
# Convert to datetime and format as ISO string
|
||||
dt = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc)
|
||||
return dt.isoformat()
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .slack import SlackRetrieveRepliesTool, SlackRetrieveTool, SlackSendTool
|
||||
|
||||
__all__ = ["SlackRetrieveRepliesTool", "SlackRetrieveTool", "SlackSendTool"]
|
||||
@@ -0,0 +1,391 @@
|
||||
# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Any, Optional, Tuple, Union
|
||||
|
||||
from .....doc_utils import export_module
|
||||
from .....import_utils import optional_import_block, require_optional_import
|
||||
from .... import Tool
|
||||
from ....dependency_injection import Depends, on
|
||||
|
||||
__all__ = ["SlackSendTool"]
|
||||
|
||||
with optional_import_block():
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
MAX_MESSAGE_LENGTH = 40000
|
||||
|
||||
|
||||
@require_optional_import(["slack_sdk"], "commsagent-slack")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class SlackSendTool(Tool):
|
||||
"""Sends a message to a Slack channel."""
|
||||
|
||||
def __init__(self, *, bot_token: str, channel_id: str) -> None:
|
||||
"""
|
||||
Initialize the SlackSendTool.
|
||||
|
||||
Args:
|
||||
bot_token: Bot User OAuth Token starting with "xoxb-".
|
||||
channel_id: Channel ID where messages will be sent.
|
||||
"""
|
||||
|
||||
# Function that sends the message, uses dependency injection for bot token / channel / guild
|
||||
async def slack_send_message(
|
||||
message: Annotated[str, "Message to send to the channel."],
|
||||
bot_token: Annotated[str, Depends(on(bot_token))],
|
||||
channel_id: Annotated[str, Depends(on(channel_id))],
|
||||
) -> Any:
|
||||
"""
|
||||
Sends a message to a Slack channel.
|
||||
|
||||
Args:
|
||||
message: The message to send to the channel.
|
||||
bot_token: The bot token to use for Slack. (uses dependency injection)
|
||||
channel_id: The ID of the channel. (uses dependency injection)
|
||||
"""
|
||||
try:
|
||||
web_client = WebClient(token=bot_token)
|
||||
|
||||
# Send the message
|
||||
if len(message) > MAX_MESSAGE_LENGTH:
|
||||
chunks = [
|
||||
message[i : i + (MAX_MESSAGE_LENGTH - 1)]
|
||||
for i in range(0, len(message), (MAX_MESSAGE_LENGTH - 1))
|
||||
]
|
||||
for i, chunk in enumerate(chunks):
|
||||
response = web_client.chat_postMessage(channel=channel_id, text=chunk)
|
||||
|
||||
if not response["ok"]:
|
||||
return f"Message send failed on chunk {i + 1}, Slack response error: {response['error']}"
|
||||
|
||||
# Store ID for the first chunk
|
||||
if i == 0:
|
||||
sent_message_id = response["ts"]
|
||||
|
||||
return f"Message sent successfully ({len(chunks)} chunks, first ID: {sent_message_id}):\n{message}"
|
||||
else:
|
||||
response = web_client.chat_postMessage(channel=channel_id, text=message)
|
||||
|
||||
if not response["ok"]:
|
||||
return f"Message send failed, Slack response error: {response['error']}"
|
||||
|
||||
return f"Message sent successfully (ID: {response['ts']}):\n{message}"
|
||||
except SlackApiError as e:
|
||||
return f"Message send failed, Slack API exception: {e.response['error']} (See https://api.slack.com/automation/cli/errors#{e.response['error']})"
|
||||
except Exception as e:
|
||||
return f"Message send failed, exception: {e}"
|
||||
|
||||
super().__init__(
|
||||
name="slack_send",
|
||||
description="Sends a message to a Slack channel.",
|
||||
func_or_tool=slack_send_message,
|
||||
)
|
||||
|
||||
|
||||
@require_optional_import(["slack_sdk"], "commsagent-slack")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class SlackRetrieveTool(Tool):
|
||||
"""Retrieves messages from a Slack channel."""
|
||||
|
||||
def __init__(self, *, bot_token: str, channel_id: str) -> None:
|
||||
"""
|
||||
Initialize the SlackRetrieveTool.
|
||||
|
||||
Args:
|
||||
bot_token: Bot User OAuth Token starting with "xoxb-".
|
||||
channel_id: Channel ID where messages will be sent.
|
||||
"""
|
||||
|
||||
async def slack_retrieve_messages(
|
||||
bot_token: Annotated[str, Depends(on(bot_token))],
|
||||
channel_id: Annotated[str, Depends(on(channel_id))],
|
||||
messages_since: Annotated[
|
||||
Union[str, None],
|
||||
"Date to retrieve messages from (ISO format) OR Slack message ID. If None, retrieves latest messages.",
|
||||
] = None,
|
||||
maximum_messages: Annotated[
|
||||
Union[int, None], "Maximum number of messages to retrieve. If None, retrieves all messages since date."
|
||||
] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieves messages from a Discord channel.
|
||||
|
||||
Args:
|
||||
bot_token: The bot token to use for Discord. (uses dependency injection)
|
||||
channel_id: The ID of the channel. (uses dependency injection)
|
||||
messages_since: ISO format date string OR Slack message ID, to retrieve messages from. If None, retrieves latest messages.
|
||||
maximum_messages: Maximum number of messages to retrieve. If None, retrieves all messages since date.
|
||||
"""
|
||||
try:
|
||||
web_client = WebClient(token=bot_token)
|
||||
|
||||
# Convert ISO datetime to Unix timestamp if needed
|
||||
oldest = None
|
||||
if messages_since:
|
||||
if "." in messages_since: # Likely a Slack message ID
|
||||
oldest = messages_since
|
||||
else: # Assume ISO format
|
||||
try:
|
||||
dt = datetime.fromisoformat(messages_since.replace("Z", "+00:00"))
|
||||
oldest = str(dt.timestamp())
|
||||
except ValueError as e:
|
||||
return f"Invalid date format. Please provide either a Slack message ID or ISO format date (e.g., '2025-01-25T00:00:00Z'). Error: {e}"
|
||||
|
||||
messages = []
|
||||
cursor = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Prepare API call parameters
|
||||
params = {
|
||||
"channel": channel_id,
|
||||
"limit": min(1000, maximum_messages) if maximum_messages else 1000,
|
||||
}
|
||||
if oldest:
|
||||
params["oldest"] = oldest
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
# Make API call
|
||||
response = web_client.conversations_history(**params) # type: ignore[arg-type]
|
||||
|
||||
if not response["ok"]:
|
||||
return f"Message retrieval failed, Slack response error: {response['error']}"
|
||||
|
||||
# Add messages to our list
|
||||
messages.extend(response["messages"])
|
||||
|
||||
# Check if we've hit our maximum
|
||||
if maximum_messages and len(messages) >= maximum_messages:
|
||||
messages = messages[:maximum_messages]
|
||||
break
|
||||
|
||||
# Check if there are more messages
|
||||
if not response["has_more"]:
|
||||
break
|
||||
|
||||
cursor = response["response_metadata"]["next_cursor"]
|
||||
|
||||
except SlackApiError as e:
|
||||
return f"Message retrieval failed on pagination, Slack API error: {e.response['error']}"
|
||||
|
||||
return {
|
||||
"message_count": len(messages),
|
||||
"messages": messages,
|
||||
"start_time": oldest or "latest",
|
||||
}
|
||||
|
||||
except SlackApiError as e:
|
||||
return f"Message retrieval failed, Slack API exception: {e.response['error']} (See https://api.slack.com/automation/cli/errors#{e.response['error']})"
|
||||
except Exception as e:
|
||||
return f"Message retrieval failed, exception: {e}"
|
||||
|
||||
super().__init__(
|
||||
name="slack_retrieve",
|
||||
description="Retrieves messages from a Slack channel based datetime/message ID and/or number of latest messages.",
|
||||
func_or_tool=slack_retrieve_messages,
|
||||
)
|
||||
|
||||
|
||||
@require_optional_import(["slack_sdk"], "commsagent-slack")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class SlackRetrieveRepliesTool(Tool):
|
||||
"""Retrieves replies to a specific Slack message from both threads and the channel."""
|
||||
|
||||
def __init__(self, *, bot_token: str, channel_id: str) -> None:
|
||||
"""
|
||||
Initialize the SlackRetrieveRepliesTool.
|
||||
|
||||
Args:
|
||||
bot_token: Bot User OAuth Token starting with "xoxb-".
|
||||
channel_id: Channel ID where the parent message exists.
|
||||
"""
|
||||
|
||||
async def slack_retrieve_replies(
|
||||
message_ts: Annotated[str, "Timestamp (ts) of the parent message to retrieve replies for."],
|
||||
bot_token: Annotated[str, Depends(on(bot_token))],
|
||||
channel_id: Annotated[str, Depends(on(channel_id))],
|
||||
min_replies: Annotated[
|
||||
Optional[int],
|
||||
"Minimum number of replies to wait for before returning (thread + channel). If None, returns immediately.",
|
||||
] = None,
|
||||
timeout_seconds: Annotated[
|
||||
int, "Maximum time in seconds to wait for the requested number of replies."
|
||||
] = 60,
|
||||
poll_interval: Annotated[int, "Time in seconds between polling attempts when waiting for replies."] = 5,
|
||||
include_channel_messages: Annotated[
|
||||
bool, "Whether to include messages in the channel after the original message."
|
||||
] = True,
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieves replies to a specific Slack message, from both threads and the main channel.
|
||||
|
||||
Args:
|
||||
message_ts: The timestamp (ts) identifier of the parent message.
|
||||
bot_token: The bot token to use for Slack. (uses dependency injection)
|
||||
channel_id: The ID of the channel. (uses dependency injection)
|
||||
min_replies: Minimum number of combined replies to wait for before returning. If None, returns immediately.
|
||||
timeout_seconds: Maximum time in seconds to wait for the requested number of replies.
|
||||
poll_interval: Time in seconds between polling attempts when waiting for replies.
|
||||
include_channel_messages: Whether to include messages posted in the channel after the original message.
|
||||
"""
|
||||
try:
|
||||
web_client = WebClient(token=bot_token)
|
||||
|
||||
# Function to get current thread replies
|
||||
async def get_thread_replies() -> tuple[Optional[list[dict[str, Any]]], Optional[str]]:
|
||||
try:
|
||||
response = web_client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=message_ts,
|
||||
)
|
||||
|
||||
if not response["ok"]:
|
||||
return None, f"Thread reply retrieval failed, Slack response error: {response['error']}"
|
||||
|
||||
# The first message is the parent message itself, so exclude it when counting replies
|
||||
replies = response["messages"][1:] if len(response["messages"]) > 0 else []
|
||||
return replies, None
|
||||
|
||||
except SlackApiError as e:
|
||||
return None, f"Thread reply retrieval failed, Slack API exception: {e.response['error']}"
|
||||
except Exception as e:
|
||||
return None, f"Thread reply retrieval failed, exception: {e}"
|
||||
|
||||
# Function to get messages in the channel after the original message
|
||||
async def get_channel_messages() -> Tuple[Optional[list[dict[str, Any]]], Optional[str]]:
|
||||
try:
|
||||
response = web_client.conversations_history(
|
||||
channel=channel_id,
|
||||
oldest=message_ts, # Start from the original message timestamp
|
||||
inclusive=False, # Don't include the original message
|
||||
)
|
||||
|
||||
if not response["ok"]:
|
||||
return None, f"Channel message retrieval failed, Slack response error: {response['error']}"
|
||||
|
||||
# Return all messages in the channel after the original message
|
||||
# We need to filter out any that are part of the thread we're already getting
|
||||
messages = []
|
||||
for msg in response["messages"]:
|
||||
# Skip if the message is part of the thread we're already retrieving
|
||||
if "thread_ts" in msg and msg["thread_ts"] == message_ts:
|
||||
continue
|
||||
messages.append(msg)
|
||||
|
||||
return messages, None
|
||||
|
||||
except SlackApiError as e:
|
||||
return None, f"Channel message retrieval failed, Slack API exception: {e.response['error']}"
|
||||
except Exception as e:
|
||||
return None, f"Channel message retrieval failed, exception: {e}"
|
||||
|
||||
# Function to get all replies (both thread and channel)
|
||||
async def get_all_replies() -> Tuple[
|
||||
Optional[list[dict[str, Any]]], Optional[list[dict[str, Any]]], Optional[str]
|
||||
]:
|
||||
thread_replies, thread_error = await get_thread_replies()
|
||||
if thread_error:
|
||||
return None, None, thread_error
|
||||
|
||||
channel_messages: list[dict[str, Any]] = []
|
||||
channel_error = None
|
||||
|
||||
if include_channel_messages:
|
||||
channel_results, channel_error = await get_channel_messages()
|
||||
if channel_error:
|
||||
return thread_replies, None, channel_error
|
||||
channel_messages = channel_results if channel_results is not None else []
|
||||
|
||||
return thread_replies, channel_messages, None
|
||||
|
||||
# If no waiting is required, just get replies and return
|
||||
if min_replies is None:
|
||||
thread_replies, channel_messages, error = await get_all_replies()
|
||||
if error:
|
||||
return error
|
||||
|
||||
thread_replies_list: list[dict[str, Any]] = [] if thread_replies is None else thread_replies
|
||||
channel_messages_list: list[dict[str, Any]] = [] if channel_messages is None else channel_messages
|
||||
|
||||
# Combine replies for counting but keep them separate in the result
|
||||
total_reply_count = len(thread_replies_list) + len(channel_messages_list)
|
||||
|
||||
return {
|
||||
"parent_message_ts": message_ts,
|
||||
"total_reply_count": total_reply_count,
|
||||
"thread_replies": thread_replies_list,
|
||||
"thread_reply_count": len(thread_replies_list),
|
||||
"channel_messages": channel_messages_list if include_channel_messages else None,
|
||||
"channel_message_count": len(channel_messages_list) if include_channel_messages else None,
|
||||
}
|
||||
|
||||
# Wait for the required number of replies with timeout
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(seconds=timeout_seconds)
|
||||
|
||||
while datetime.now() < end_time:
|
||||
thread_replies, channel_messages, error = await get_all_replies()
|
||||
if error:
|
||||
return error
|
||||
|
||||
thread_replies_current: list[dict[str, Any]] = [] if thread_replies is None else thread_replies
|
||||
channel_messages_current: list[dict[str, Any]] = (
|
||||
[] if channel_messages is None else channel_messages
|
||||
)
|
||||
|
||||
# Combine replies for counting
|
||||
total_reply_count = len(thread_replies_current) + len(channel_messages_current)
|
||||
|
||||
# If we have enough total replies, return them
|
||||
if total_reply_count >= min_replies:
|
||||
return {
|
||||
"parent_message_ts": message_ts,
|
||||
"total_reply_count": total_reply_count,
|
||||
"thread_replies": thread_replies_current,
|
||||
"thread_reply_count": len(thread_replies_current),
|
||||
"channel_messages": channel_messages_current if include_channel_messages else None,
|
||||
"channel_message_count": len(channel_messages_current)
|
||||
if include_channel_messages
|
||||
else None,
|
||||
"waited_seconds": (datetime.now() - start_time).total_seconds(),
|
||||
}
|
||||
|
||||
# Wait before checking again
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
# If we reach here, we timed out waiting for replies
|
||||
thread_replies, channel_messages, error = await get_all_replies()
|
||||
if error:
|
||||
return error
|
||||
|
||||
# Combine replies for counting
|
||||
total_reply_count = len(thread_replies or []) + len(channel_messages or [])
|
||||
|
||||
return {
|
||||
"parent_message_ts": message_ts,
|
||||
"total_reply_count": total_reply_count,
|
||||
"thread_replies": thread_replies or [],
|
||||
"thread_reply_count": len(thread_replies or []),
|
||||
"channel_messages": channel_messages or [] if include_channel_messages else None,
|
||||
"channel_message_count": len(channel_messages or []) if include_channel_messages else None,
|
||||
"timed_out": True,
|
||||
"waited_seconds": timeout_seconds,
|
||||
"requested_replies": min_replies,
|
||||
}
|
||||
|
||||
except SlackApiError as e:
|
||||
return f"Reply retrieval failed, Slack API exception: {e.response['error']} (See https://api.slack.com/automation/cli/errors#{e.response['error']})"
|
||||
except Exception as e:
|
||||
return f"Reply retrieval failed, exception: {e}"
|
||||
|
||||
super().__init__(
|
||||
name="slack_retrieve_replies",
|
||||
description="Retrieves replies to a specific Slack message, checking both thread replies and messages in the channel after the original message.",
|
||||
func_or_tool=slack_retrieve_replies,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .telegram import TelegramRetrieveTool, TelegramSendTool
|
||||
|
||||
__all__ = ["TelegramRetrieveTool", "TelegramSendTool"]
|
||||
@@ -0,0 +1,275 @@
|
||||
# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Union
|
||||
|
||||
from .....doc_utils import export_module
|
||||
from .....import_utils import optional_import_block, require_optional_import
|
||||
from .... import Tool
|
||||
from ....dependency_injection import Depends, on
|
||||
|
||||
__all__ = ["TelegramRetrieveTool", "TelegramSendTool"]
|
||||
|
||||
with optional_import_block():
|
||||
from telethon import TelegramClient
|
||||
from telethon.tl.types import InputMessagesFilterEmpty, Message, PeerChannel, PeerChat, PeerUser
|
||||
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
|
||||
@require_optional_import(["telethon", "telethon.tl.types"], "commsagent-telegram")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class BaseTelegramTool:
|
||||
"""Base class for Telegram tools containing shared functionality."""
|
||||
|
||||
def __init__(self, api_id: str, api_hash: str, session_name: str) -> None:
|
||||
self._api_id = api_id
|
||||
self._api_hash = api_hash
|
||||
self._session_name = session_name
|
||||
|
||||
def _get_client(self) -> "TelegramClient": # type: ignore[no-any-unimported]
|
||||
"""Get a fresh TelegramClient instance."""
|
||||
return TelegramClient(self._session_name, self._api_id, self._api_hash)
|
||||
|
||||
@staticmethod
|
||||
def _get_peer_from_id(chat_id: str) -> Union["PeerChat", "PeerChannel", "PeerUser"]: # type: ignore[no-any-unimported]
|
||||
"""Convert a chat ID string to appropriate Peer type."""
|
||||
try:
|
||||
# Convert string to integer
|
||||
id_int = int(chat_id)
|
||||
|
||||
# Channel/Supergroup: -100 prefix
|
||||
if str(chat_id).startswith("-100"):
|
||||
channel_id = int(str(chat_id)[4:]) # Remove -100 prefix
|
||||
return PeerChannel(channel_id)
|
||||
|
||||
# Group: negative number without -100 prefix
|
||||
elif id_int < 0:
|
||||
group_id = -id_int # Remove the negative sign
|
||||
return PeerChat(group_id)
|
||||
|
||||
# User/Bot: positive number
|
||||
else:
|
||||
return PeerUser(id_int)
|
||||
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid chat_id format: {chat_id}. Error: {str(e)}")
|
||||
|
||||
async def _initialize_entity(self, client: "TelegramClient", chat_id: str) -> Any: # type: ignore[no-any-unimported]
|
||||
"""Initialize and cache the entity by trying different methods."""
|
||||
peer = self._get_peer_from_id(chat_id)
|
||||
|
||||
try:
|
||||
# Try direct entity resolution first
|
||||
entity = await client.get_entity(peer)
|
||||
return entity
|
||||
except ValueError:
|
||||
try:
|
||||
# Get all dialogs (conversations)
|
||||
async for dialog in client.iter_dialogs():
|
||||
# For users/bots, we need to find the dialog with the user
|
||||
if (
|
||||
isinstance(peer, PeerUser)
|
||||
and dialog.entity.id == peer.user_id
|
||||
or dialog.entity.id == getattr(peer, "channel_id", getattr(peer, "chat_id", None))
|
||||
):
|
||||
return dialog.entity
|
||||
|
||||
# If we get here, we didn't find the entity in dialogs
|
||||
raise ValueError(f"Could not find entity {chat_id} in dialogs")
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Could not initialize entity for {chat_id}. "
|
||||
f"Make sure you have access to this chat. Error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@require_optional_import(["telethon"], "commsagent-telegram")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class TelegramSendTool(BaseTelegramTool, Tool):
|
||||
"""Sends a message to a Telegram channel, group, or user."""
|
||||
|
||||
def __init__(self, *, api_id: str, api_hash: str, chat_id: str) -> None:
|
||||
"""
|
||||
Initialize the TelegramSendTool.
|
||||
|
||||
Args:
|
||||
api_id: Telegram API ID from https://my.telegram.org/apps.
|
||||
api_hash: Telegram API hash from https://my.telegram.org/apps.
|
||||
chat_id: The ID of the destination (Channel, Group, or User ID).
|
||||
"""
|
||||
BaseTelegramTool.__init__(self, api_id, api_hash, "telegram_send_session")
|
||||
|
||||
async def telegram_send_message(
|
||||
message: Annotated[str, "Message to send to the chat."],
|
||||
chat_id: Annotated[str, Depends(on(chat_id))],
|
||||
) -> Any:
|
||||
"""
|
||||
Sends a message to a Telegram chat.
|
||||
|
||||
Args:
|
||||
message: The message to send.
|
||||
chat_id: The ID of the destination. (uses dependency injection)
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
async with client:
|
||||
# Initialize and cache the entity
|
||||
entity = await self._initialize_entity(client, chat_id)
|
||||
|
||||
if len(message) > MAX_MESSAGE_LENGTH:
|
||||
chunks = [
|
||||
message[i : i + (MAX_MESSAGE_LENGTH - 1)]
|
||||
for i in range(0, len(message), (MAX_MESSAGE_LENGTH - 1))
|
||||
]
|
||||
first_message: Union[Message, None] = None # type: ignore[no-any-unimported]
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
sent = await client.send_message(
|
||||
entity=entity,
|
||||
message=chunk,
|
||||
parse_mode="html",
|
||||
reply_to=first_message.id if first_message else None,
|
||||
)
|
||||
|
||||
# Store the first message to chain replies
|
||||
if i == 0:
|
||||
first_message = sent
|
||||
sent_message_id = str(sent.id)
|
||||
|
||||
return (
|
||||
f"Message sent successfully ({len(chunks)} chunks, first ID: {sent_message_id}):\n{message}"
|
||||
)
|
||||
else:
|
||||
sent = await client.send_message(entity=entity, message=message, parse_mode="html")
|
||||
return f"Message sent successfully (ID: {sent.id}):\n{message}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Message send failed, exception: {str(e)}"
|
||||
|
||||
Tool.__init__(
|
||||
self,
|
||||
name="telegram_send",
|
||||
description="Sends a message to a personal channel, bot channel, group, or channel.",
|
||||
func_or_tool=telegram_send_message,
|
||||
)
|
||||
|
||||
|
||||
@require_optional_import(["telethon"], "commsagent-telegram")
|
||||
@export_module("autogen.tools.experimental")
|
||||
class TelegramRetrieveTool(BaseTelegramTool, Tool):
|
||||
"""Retrieves messages from a Telegram channel."""
|
||||
|
||||
def __init__(self, *, api_id: str, api_hash: str, chat_id: str) -> None:
|
||||
"""
|
||||
Initialize the TelegramRetrieveTool.
|
||||
|
||||
Args:
|
||||
api_id: Telegram API ID from https://my.telegram.org/apps.
|
||||
api_hash: Telegram API hash from https://my.telegram.org/apps.
|
||||
chat_id: The ID of the chat to retrieve messages from (Channel, Group, Bot Chat ID).
|
||||
"""
|
||||
BaseTelegramTool.__init__(self, api_id, api_hash, "telegram_retrieve_session")
|
||||
self._chat_id = chat_id
|
||||
|
||||
async def telegram_retrieve_messages(
|
||||
chat_id: Annotated[str, Depends(on(chat_id))],
|
||||
messages_since: Annotated[
|
||||
Union[str, None],
|
||||
"Date to retrieve messages from (ISO format) OR message ID. If None, retrieves latest messages.",
|
||||
] = None,
|
||||
maximum_messages: Annotated[
|
||||
Union[int, None], "Maximum number of messages to retrieve. If None, retrieves all messages since date."
|
||||
] = None,
|
||||
search: Annotated[Union[str, None], "Optional string to search for in messages."] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieves messages from a Telegram chat.
|
||||
|
||||
Args:
|
||||
chat_id: The ID of the chat. (uses dependency injection)
|
||||
messages_since: ISO format date string OR message ID to retrieve messages from.
|
||||
maximum_messages: Maximum number of messages to retrieve.
|
||||
search: Optional string to search for in messages.
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
async with client:
|
||||
# Initialize and cache the entity
|
||||
entity = await self._initialize_entity(client, chat_id)
|
||||
|
||||
# Setup retrieval parameters
|
||||
params = {
|
||||
"entity": entity,
|
||||
"limit": maximum_messages if maximum_messages else None,
|
||||
"search": search if search else None,
|
||||
"filter": InputMessagesFilterEmpty(),
|
||||
"wait_time": None, # No wait time between requests
|
||||
}
|
||||
|
||||
# Handle messages_since parameter
|
||||
if messages_since:
|
||||
try:
|
||||
# Try to parse as message ID first
|
||||
msg_id = int(messages_since)
|
||||
params["min_id"] = msg_id
|
||||
except ValueError:
|
||||
# Not a message ID, try as ISO date
|
||||
try:
|
||||
date = datetime.fromisoformat(messages_since.replace("Z", "+00:00"))
|
||||
params["offset_date"] = date
|
||||
params["reverse"] = (
|
||||
True # Need this because the date gets messages before a certain date by default
|
||||
)
|
||||
except ValueError:
|
||||
return {
|
||||
"error": "Invalid messages_since format. Please provide either a message ID or ISO format date (e.g., '2025-01-25T00:00:00Z')"
|
||||
}
|
||||
|
||||
# Retrieve messages
|
||||
messages = []
|
||||
count = 0
|
||||
# For bot users, we need to get both sent and received messages
|
||||
if isinstance(self._get_peer_from_id(chat_id), PeerUser):
|
||||
print(f"Retrieving messages for bot chat {chat_id}")
|
||||
|
||||
async for message in client.iter_messages(**params):
|
||||
count += 1
|
||||
messages.append({
|
||||
"id": str(message.id),
|
||||
"date": message.date.isoformat(),
|
||||
"from_id": str(message.from_id) if message.from_id else None,
|
||||
"text": message.text,
|
||||
"reply_to_msg_id": str(message.reply_to_msg_id) if message.reply_to_msg_id else None,
|
||||
"forward_from": str(message.forward.from_id) if message.forward else None,
|
||||
"edit_date": message.edit_date.isoformat() if message.edit_date else None,
|
||||
"media": bool(message.media),
|
||||
"entities": [
|
||||
{"type": e.__class__.__name__, "offset": e.offset, "length": e.length}
|
||||
for e in message.entities
|
||||
]
|
||||
if message.entities
|
||||
else None,
|
||||
})
|
||||
|
||||
# Check if we've hit the maximum
|
||||
if maximum_messages and len(messages) >= maximum_messages:
|
||||
break
|
||||
|
||||
return {
|
||||
"message_count": len(messages),
|
||||
"messages": messages,
|
||||
"start_time": messages_since or "latest",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return f"Message retrieval failed, exception: {str(e)}"
|
||||
|
||||
Tool.__init__(
|
||||
self,
|
||||
name="telegram_retrieve",
|
||||
description="Retrieves messages from a Telegram chat based on datetime/message ID and/or number of latest messages.",
|
||||
func_or_tool=telegram_retrieve_messages,
|
||||
)
|
||||
Reference in New Issue
Block a user