use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use crate::channels::telegram::TelegramState;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use teloxide::payloads::SendMessageSetters;
use teloxide::prelude::*;
use teloxide::types::{
ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, MessageId, ReactionType,
ReplyParameters, UserId,
};
pub struct TelegramSendTool {
telegram_state: Arc<TelegramState>,
}
impl TelegramSendTool {
pub fn new(telegram_state: Arc<TelegramState>) -> Self {
Self { telegram_state }
}
}
#[allow(clippy::result_large_err)]
fn get_str<'a>(input: &'a Value, key: &str) -> std::result::Result<&'a str, ToolResult> {
match input.get(key).and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => Ok(s),
_ => Err(ToolResult::error(format!(
"Missing required parameter '{key}'."
))),
}
}
#[allow(clippy::result_large_err)]
fn get_id(input: &Value, key: &str) -> std::result::Result<i64, ToolResult> {
match input.get(key).and_then(|v| v.as_i64()) {
Some(id) => Ok(id),
None => Err(ToolResult::error(format!(
"Missing required parameter '{key}' (must be an integer)."
))),
}
}
#[allow(clippy::result_large_err)]
pub(crate) async fn resolve_input_file(
reference: &str,
label: &str,
) -> std::result::Result<InputFile, ToolResult> {
if reference.starts_with("http://") || reference.starts_with("https://") {
return reference
.parse()
.map(InputFile::url)
.map_err(|e| ToolResult::error(format!("Invalid {label}: {e}")));
}
let path = crate::brain::tools::error::expand_tilde(reference);
match tokio::fs::read(&path).await {
Ok(bytes) => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
Ok(InputFile::memory(bytes).file_name(name))
}
Err(e) => Err(ToolResult::error(format!(
"Failed to read local {label} '{}': {e}",
path.display()
))),
}
}
#[allow(clippy::result_large_err)]
pub(crate) async fn resolve_thread_id(
input: &Value,
chat_id: i64,
) -> Option<teloxide::types::ThreadId> {
if let Some(tid) = input.get("thread_id").and_then(|v| v.as_i64())
&& let Ok(tid_i32) = i32::try_from(tid)
{
return Some(teloxide::types::ThreadId(teloxide::types::MessageId(
tid_i32,
)));
}
crate::channels::telegram::send::latest_thread_id_for_chat(chat_id).await
}
async fn chat_or_err(input: &Value, state: &TelegramState) -> std::result::Result<i64, ToolResult> {
if let Some(id) = input.get("chat_id").and_then(|v| v.as_i64()) {
return Ok(id);
}
match state.owner_chat_id().await {
Some(id) => Ok(id),
None => Err(ToolResult::error(
"No owner chat ID known yet and no 'chat_id' parameter provided. \
The owner needs to send at least one message to the bot first, \
or specify a chat_id."
.to_string(),
)),
}
}
macro_rules! pget {
($expr:expr) => {
match $expr {
Ok(v) => v,
Err(e) => return Ok(e),
}
};
}
#[async_trait]
impl Tool for TelegramSendTool {
fn name(&self) -> &str {
"telegram_send"
}
fn description(&self) -> &str {
"Full Telegram control: send messages, reply, edit, delete, pin/unpin, forward, \
send photos/documents/locations/polls, inline buttons, get chat info, list admins, \
check member count/status, ban/unban users, and set emoji reactions. \
Always use telegram_send instead of http_request — credentials handled securely. \
Requires Telegram to be connected first."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"send", "reply", "edit", "delete", "pin", "unpin",
"forward", "send_photo", "send_document", "send_location",
"send_poll", "send_buttons", "get_chat",
"get_chat_administrators", "get_chat_member_count", "get_chat_member",
"ban_user", "unban_user", "set_reaction", "list_topics"
],
"description": "The Telegram action to perform. \
`list_topics` returns the (thread_id, topic_name) pairs the bot has \
observed in a forum-enabled supergroup — use this to translate a \
user-typed topic name like \"#announcements\" to the numeric thread_id \
you then pass to `send` / `reply` / `send_photo` via the `thread_id` field."
},
"message": {
"type": "string",
"description": "Message text (send, reply, edit, send_buttons)"
},
"chat_id": {
"type": "integer",
"description": "Telegram chat ID. Omit to use owner's chat."
},
"thread_id": {
"type": "integer",
"description": "Optional forum-topic ID for groups with topics enabled. Omit to auto-route to the most recent topic seen in the chat (the usual case for replies to ongoing conversations). Pass an explicit value to route to a DIFFERENT topic — e.g. post a release announcement in #announcements when the latest message came from #dev. Ignored for non-forum chats."
},
"message_id": {
"type": "integer",
"description": "Target message ID for reply/edit/delete/pin/unpin/forward/set_reaction"
},
"from_chat_id": {
"type": "integer",
"description": "Source chat ID for forward action"
},
"photo_url": {
"type": "string",
"description": "Photo for send_photo: an HTTPS URL or a local file path (e.g. /tmp/chart.png or ~/.opencrabs/out.png)"
},
"document_url": {
"type": "string",
"description": "Document for send_document: an HTTPS URL or a local file path (e.g. /tmp/report.pdf or ~/.opencrabs/data.csv)"
},
"latitude": {
"type": "number",
"description": "Latitude for send_location"
},
"longitude": {
"type": "number",
"description": "Longitude for send_location"
},
"poll_question": {
"type": "string",
"description": "Poll question text for send_poll"
},
"poll_options": {
"type": "array",
"items": {"type": "string"},
"description": "Array of poll option strings (2–10) for send_poll"
},
"buttons": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"callback_data": {"type": "string"}
}
}
},
"description": "2D array of button rows for send_buttons. Each button has 'text' and 'callback_data'."
},
"user_id": {
"type": "integer",
"description": "Telegram user ID for ban_user/unban_user"
},
"emoji": {
"type": "string",
"description": "Emoji for set_reaction (e.g. \"👍\")"
}
},
"required": ["action"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::Network]
}
async fn execute(&self, input: Value, _context: &ToolExecutionContext) -> Result<ToolResult> {
let action = match input.get("action").and_then(|v| v.as_str()) {
Some(a) if !a.is_empty() => a.to_string(),
_ => {
return Ok(ToolResult::error(
"Missing required 'action' parameter.".to_string(),
));
}
};
let bot = match self.telegram_state.bot().await {
Some(b) => b,
None => {
return Ok(ToolResult::error(
"Telegram is not connected. Ask the user to connect Telegram first \
(use the telegram_connect tool)."
.to_string(),
));
}
};
match action.as_str() {
"send" => {
let text = pget!(get_str(&input, "message")).to_string();
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let thread_id = resolve_thread_id(&input, chat_id).await;
let sent_rich = crate::channels::telegram::rich::should_send_native_rich(&text)
&& crate::channels::telegram::rich::api::try_send_rich(
&bot, chat_id, thread_id, &text,
)
.await
.inspect_err(|e| {
tracing::warn!("telegram_send: rich send failed, sending plain: {e}");
})
.is_ok();
if !sent_rich {
for chunk in crate::channels::telegram::handler::split_message(&text, 4096) {
if let Err(e) = crate::channels::telegram::send::message_in_thread(
&bot,
ChatId(chat_id),
thread_id,
chunk,
)
.await
{
return Ok(ToolResult::error(format!("Failed to send: {e}")));
}
}
}
Ok(ToolResult::success(format!(
"Message sent to chat {chat_id}."
)))
}
"reply" => {
let text = pget!(get_str(&input, "message")).to_string();
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let message_id = pget!(get_id(&input, "message_id"));
let thread_id = resolve_thread_id(&input, chat_id).await;
match crate::channels::telegram::send::message_in_thread(
&bot,
ChatId(chat_id),
thread_id,
text,
)
.reply_parameters(ReplyParameters::new(MessageId(message_id as i32)))
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"Reply sent to message {message_id}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to reply: {e}"))),
}
}
"edit" => {
let text = pget!(get_str(&input, "message")).to_string();
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let message_id = pget!(get_id(&input, "message_id"));
match bot
.edit_message_text(ChatId(chat_id), MessageId(message_id as i32), text)
.await
{
Ok(_) => Ok(ToolResult::success(format!("Message {message_id} edited."))),
Err(e) => Ok(ToolResult::error(format!("Failed to edit: {e}"))),
}
}
"delete" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let message_id = pget!(get_id(&input, "message_id"));
match bot
.delete_message(ChatId(chat_id), MessageId(message_id as i32))
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"Message {message_id} deleted."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to delete: {e}"))),
}
}
"pin" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let message_id = pget!(get_id(&input, "message_id"));
match bot
.pin_chat_message(ChatId(chat_id), MessageId(message_id as i32))
.await
{
Ok(_) => Ok(ToolResult::success(format!("Message {message_id} pinned."))),
Err(e) => Ok(ToolResult::error(format!("Failed to pin: {e}"))),
}
}
"unpin" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
match bot.unpin_chat_message(ChatId(chat_id)).await {
Ok(_) => Ok(ToolResult::success(
"Latest pinned message unpinned.".to_string(),
)),
Err(e) => Ok(ToolResult::error(format!("Failed to unpin: {e}"))),
}
}
"forward" => {
let to_chat = pget!(chat_or_err(&input, &self.telegram_state).await);
let from_chat = pget!(get_id(&input, "from_chat_id"));
let message_id = pget!(get_id(&input, "message_id"));
match bot
.forward_message(
ChatId(to_chat),
ChatId(from_chat),
MessageId(message_id as i32),
)
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"Message {message_id} forwarded from chat {from_chat} to {to_chat}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to forward: {e}"))),
}
}
"send_photo" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let reference = pget!(get_str(&input, "photo_url")).to_string();
let file = pget!(resolve_input_file(&reference, "photo_url").await);
match bot.send_photo(ChatId(chat_id), file).await {
Ok(_) => Ok(ToolResult::success(format!(
"Photo sent to chat {chat_id}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send photo: {e}"))),
}
}
"send_document" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let reference = pget!(get_str(&input, "document_url")).to_string();
let file = pget!(resolve_input_file(&reference, "document_url").await);
match bot.send_document(ChatId(chat_id), file).await {
Ok(_) => Ok(ToolResult::success(format!(
"Document sent to chat {chat_id}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send document: {e}"))),
}
}
"send_location" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let lat = match input.get("latitude").and_then(|v| v.as_f64()) {
Some(v) => v,
None => {
return Ok(ToolResult::error(
"Missing required 'latitude' parameter.".to_string(),
));
}
};
let lng = match input.get("longitude").and_then(|v| v.as_f64()) {
Some(v) => v,
None => {
return Ok(ToolResult::error(
"Missing required 'longitude' parameter.".to_string(),
));
}
};
match bot.send_location(ChatId(chat_id), lat, lng).await {
Ok(_) => Ok(ToolResult::success(format!(
"Location ({lat}, {lng}) sent to chat {chat_id}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send location: {e}"))),
}
}
"send_poll" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let question = pget!(get_str(&input, "poll_question")).to_string();
let opts: Vec<String> = match input.get("poll_options").and_then(|v| v.as_array()) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
None => {
return Ok(ToolResult::error(
"Missing required 'poll_options' parameter.".to_string(),
));
}
};
if opts.len() < 2 {
return Ok(ToolResult::error(
"'poll_options' must have at least 2 options.".to_string(),
));
}
let poll_opts: Vec<teloxide::types::InputPollOption> =
opts.into_iter().map(|s| s.into()).collect();
match bot.send_poll(ChatId(chat_id), question, poll_opts).await {
Ok(_) => Ok(ToolResult::success(format!("Poll sent to chat {chat_id}."))),
Err(e) => Ok(ToolResult::error(format!("Failed to send poll: {e}"))),
}
}
"send_buttons" => {
let text = pget!(get_str(&input, "message")).to_string();
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let rows: Vec<Vec<InlineKeyboardButton>> =
match input.get("buttons").and_then(|v| v.as_array()) {
Some(outer) => outer
.iter()
.filter_map(|row| row.as_array())
.map(|row| {
row.iter()
.filter_map(|btn| {
let text =
btn.get("text").and_then(|v| v.as_str())?.to_string();
let data = btn
.get("callback_data")
.and_then(|v| v.as_str())?
.to_string();
Some(InlineKeyboardButton::callback(text, data))
})
.collect()
})
.collect(),
None => {
return Ok(ToolResult::error(
"Missing required 'buttons' parameter.".to_string(),
));
}
};
let keyboard = InlineKeyboardMarkup::new(rows);
match bot
.send_message(ChatId(chat_id), text)
.reply_markup(keyboard)
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"Message with buttons sent to chat {chat_id}."
))),
Err(e) => Ok(ToolResult::error(format!(
"Failed to send message with buttons: {e}"
))),
}
}
"get_chat" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
match bot.get_chat(ChatId(chat_id)).await {
Ok(chat) => {
let info = format!(
"Chat {}: type={:?}, title={:?}",
chat.id,
chat.kind,
chat.title()
);
Ok(ToolResult::success(info))
}
Err(e) => Ok(ToolResult::error(format!("Failed to get chat: {e}"))),
}
}
"get_chat_administrators" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
match bot.get_chat_administrators(ChatId(chat_id)).await {
Ok(admins) => {
let lines: Vec<String> = admins
.iter()
.map(|m| {
let u = &m.user;
let role = match m.kind {
teloxide::types::ChatMemberKind::Owner { .. } => "owner",
teloxide::types::ChatMemberKind::Administrator { .. } => {
"admin"
}
_ => "member",
};
let handle = u
.username
.as_ref()
.map(|h| format!(" @{h}"))
.unwrap_or_default();
format!("- {} (id={}){} [{}]", u.first_name, u.id, handle, role)
})
.collect();
Ok(ToolResult::success(format!(
"Chat {} administrators ({}):\n{}",
chat_id,
admins.len(),
lines.join("\n")
)))
}
Err(e) => Ok(ToolResult::error(format!(
"Failed to get administrators: {e}"
))),
}
}
"get_chat_member_count" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
match bot.get_chat_member_count(ChatId(chat_id)).await {
Ok(count) => Ok(ToolResult::success(format!(
"Chat {chat_id} has {count} members."
))),
Err(e) => Ok(ToolResult::error(format!(
"Failed to get member count: {e}"
))),
}
}
"get_chat_member" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let uid = pget!(get_id(&input, "user_id"));
match bot
.get_chat_member(ChatId(chat_id), UserId(uid as u64))
.await
{
Ok(member) => {
let u = &member.user;
let status = match member.kind {
teloxide::types::ChatMemberKind::Owner { .. } => "owner",
teloxide::types::ChatMemberKind::Administrator { .. } => {
"administrator"
}
teloxide::types::ChatMemberKind::Member(_) => "member",
teloxide::types::ChatMemberKind::Restricted { .. } => "restricted",
teloxide::types::ChatMemberKind::Left => "left",
teloxide::types::ChatMemberKind::Banned { .. } => "banned",
};
let handle = u
.username
.as_ref()
.map(|h| format!(" @{h}"))
.unwrap_or_default();
Ok(ToolResult::success(format!(
"User {} (id={}){}: status={}",
u.first_name, u.id, handle, status
)))
}
Err(e) => Ok(ToolResult::error(format!("Failed to get chat member: {e}"))),
}
}
"ban_user" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let user_id = pget!(get_id(&input, "user_id"));
match bot
.ban_chat_member(ChatId(chat_id), UserId(user_id as u64))
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"User {user_id} banned from chat {chat_id}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to ban user: {e}"))),
}
}
"unban_user" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let user_id = pget!(get_id(&input, "user_id"));
match bot
.unban_chat_member(ChatId(chat_id), UserId(user_id as u64))
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"User {user_id} unbanned from chat {chat_id}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to unban user: {e}"))),
}
}
"set_reaction" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let message_id = pget!(get_id(&input, "message_id"));
let emoji = pget!(get_str(&input, "emoji")).to_string();
let reactions = vec![ReactionType::Emoji {
emoji: emoji.clone(),
}];
match bot
.set_message_reaction(ChatId(chat_id), MessageId(message_id as i32))
.reaction(reactions)
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"Reaction {emoji} set on message {message_id}."
))),
Err(e) => Ok(ToolResult::error(format!("Failed to set reaction: {e}"))),
}
}
"list_topics" => {
let chat_id = pget!(chat_or_err(&input, &self.telegram_state).await);
let Some(pool) = crate::db::global_pool() else {
return Ok(ToolResult::error(
"Channel message store unavailable (DB not initialised).".to_string(),
));
};
let repo = crate::db::ChannelMessageRepository::new(pool.clone());
let chat_id_str = chat_id.to_string();
let topics = match repo.topics_for_chat("telegram", &chat_id_str).await {
Ok(t) => t,
Err(e) => {
return Ok(ToolResult::error(format!("Failed to list topics: {e}")));
}
};
if topics.is_empty() {
return Ok(ToolResult::success(format!(
"No forum topics observed yet for chat {chat_id}. \
Telegram's Bot API has no listForumTopics endpoint — the bot only \
learns topic names from messages it sees. Ask a user to post once in \
each topic so the bot can capture their names, then retry."
)));
}
let mut out = format!(
"Topics in chat {chat_id} (only those the bot has seen activity in):\n"
);
out.push_str(" thread_id | topic_name | messages | last_seen\n");
for t in &topics {
let name = t.topic_name.as_deref().unwrap_or("(unknown)");
let last_seen = chrono::DateTime::from_timestamp(t.last_message_at, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| t.last_message_at.to_string());
out.push_str(&format!(
" {:<9} | {:<23} | {:>8} | {}\n",
t.thread_id,
name.chars().take(23).collect::<String>(),
t.message_count,
last_seen,
));
}
out.push_str(
"\nPass the thread_id back into `send` / `reply` / `send_photo` etc. \
via the optional `thread_id` field to route a message into a specific topic.",
);
Ok(ToolResult::success(out))
}
unknown => Ok(ToolResult::error(format!(
"Unknown action '{unknown}'. Valid actions: send, reply, edit, delete, pin, \
unpin, forward, send_photo, send_document, send_location, send_poll, \
send_buttons, get_chat, get_chat_administrators, get_chat_member_count, \
get_chat_member, ban_user, unban_user, set_reaction, list_topics"
))),
}
}
}