collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Telegram adapter — teloxide 0.13 (Long Polling).
//!
//! Feature-gated behind `telegram`. Max message: 4096 chars.

use anyhow::Result;
use async_trait::async_trait;
use teloxide::prelude::*;
use teloxide::types::{
    BotCommand, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, MessageId,
};
use tokio::sync::mpsc;

use super::adapter::{ChannelId, IncomingCommand, PlatformAdapter};
use super::commands::parse_remote_command;

// ---------------------------------------------------------------------------
// Adapter
// ---------------------------------------------------------------------------

pub struct TelegramAdapter {
    bot: Bot,
}

impl TelegramAdapter {
    pub fn new(token: String) -> Self {
        Self {
            bot: Bot::new(token),
        }
    }

    /// Parse a channel string into a Telegram `ChatId`.
    fn parse_chat_id(channel: &ChannelId) -> Result<ChatId> {
        let id: i64 = channel
            .channel
            .parse()
            .map_err(|_| anyhow::anyhow!("invalid telegram chat id: {}", channel.channel))?;
        Ok(ChatId(id))
    }
}

#[async_trait]
impl PlatformAdapter for TelegramAdapter {
    fn platform_name(&self) -> &str {
        "telegram"
    }

    fn max_message_length(&self) -> usize {
        4096
    }

    async fn register_commands(&self, commands: &[(&str, &str)]) -> Result<()> {
        if commands.is_empty() {
            return Ok(());
        }
        // Telegram command names must match ^[a-z0-9_]{1,32}$ and
        // descriptions must be 3..=256 chars. Sanitize to avoid
        // BOT_COMMAND_INVALID from setMyCommands.
        let bot_cmds: Vec<BotCommand> = commands
            .iter()
            .filter_map(|(name, desc)| {
                let mut sanitized: String = name
                    .chars()
                    .map(|c| {
                        if c.is_ascii_alphanumeric() || c == '_' {
                            c.to_ascii_lowercase()
                        } else {
                            '_'
                        }
                    })
                    .collect();
                sanitized.truncate(32);
                let sanitized = sanitized.trim_matches('_').to_string();
                if sanitized.is_empty() {
                    return None;
                }
                let mut d = desc.trim().to_string();
                if d.len() < 3 {
                    d = format!("{d} command");
                }
                if d.chars().count() > 256 {
                    d = d.chars().take(256).collect();
                }
                Some(BotCommand::new(sanitized, d))
            })
            .collect();
        if bot_cmds.is_empty() {
            return Ok(());
        }
        self.bot.set_my_commands(bot_cmds).await?;
        tracing::info!("[telegram] registered {} skill command(s)", commands.len());
        Ok(())
    }

    async fn send_typing(&self, channel: &ChannelId) -> Result<()> {
        let chat_id = Self::parse_chat_id(channel)?;
        self.bot
            .send_chat_action(chat_id, teloxide::types::ChatAction::Typing)
            .await?;
        Ok(())
    }

    async fn send_message(&self, channel: &ChannelId, text: &str) -> Result<()> {
        let chat_id = Self::parse_chat_id(channel)?;
        self.bot.send_message(chat_id, text).await?;
        Ok(())
    }

    async fn send_long_message(
        &self,
        channel: &ChannelId,
        text: &str,
        filename: Option<&str>,
    ) -> Result<()> {
        if text.len() <= self.max_message_length() {
            return self.send_message(channel, text).await;
        }

        let chat_id = Self::parse_chat_id(channel)?;
        let name = filename.unwrap_or("response.txt").to_string();
        let file = InputFile::memory(text.as_bytes().to_vec()).file_name(name);
        self.bot.send_document(chat_id, file).await?;
        Ok(())
    }

    async fn send_buttons(
        &self,
        channel: &ChannelId,
        text: &str,
        buttons: &[(String, String)],
    ) -> Result<()> {
        let chat_id = Self::parse_chat_id(channel)?;
        // Each button on its own row for readability.
        // buttons: (callback_data, display_label)
        let rows: Vec<Vec<InlineKeyboardButton>> = buttons
            .iter()
            .map(|(data, label)| {
                // Telegram callback_data max 64 bytes — truncate if needed
                let cb = if data.len() > 64 {
                    crate::util::truncate_bytes(data, 64)
                } else {
                    data.as_str()
                };
                vec![InlineKeyboardButton::callback(
                    label.clone(),
                    cb.to_string(),
                )]
            })
            .collect();
        let keyboard = InlineKeyboardMarkup::new(rows);
        self.bot
            .send_message(chat_id, text)
            .reply_markup(keyboard)
            .await?;
        Ok(())
    }

    async fn edit_message(
        &self,
        channel: &ChannelId,
        message_id: &str,
        new_text: &str,
    ) -> Result<bool> {
        let chat_id = Self::parse_chat_id(channel)?;
        let msg_id: i32 = message_id
            .parse()
            .map_err(|_| anyhow::anyhow!("invalid telegram message id: {}", message_id))?;
        self.bot
            .edit_message_text(chat_id, MessageId(msg_id), new_text)
            .await?;
        Ok(true)
    }

    async fn run(&self, command_tx: mpsc::UnboundedSender<IncomingCommand>) -> Result<()> {
        tracing::info!("[telegram] adapter starting with teloxide long-polling");

        let bot = self.bot.clone();

        let message_handler = Update::filter_message().endpoint(handle_message);
        let callback_handler = Update::filter_callback_query().endpoint(handle_callback);

        let handler = dptree::entry()
            .branch(message_handler)
            .branch(callback_handler);

        Dispatcher::builder(bot, handler)
            .dependencies(dptree::deps![command_tx])
            .enable_ctrlc_handler()
            .build()
            .dispatch()
            .await;

        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Handler functions
// ---------------------------------------------------------------------------

async fn handle_message(
    msg: Message,
    command_tx: mpsc::UnboundedSender<IncomingCommand>,
) -> ResponseResult<()> {
    let text = match msg.text() {
        Some(t) if !t.is_empty() => t,
        _ => return teloxide::respond(()),
    };

    let channel = ChannelId::new("telegram", msg.chat.id.0.to_string());
    let user_id = msg
        .from
        .as_ref()
        .map(|u| u.id.0.to_string())
        .unwrap_or_default();
    let command = parse_remote_command(text);

    let _ = command_tx.send(IncomingCommand {
        channel,
        user_id,
        command,
    });

    teloxide::respond(())
}

async fn handle_callback(
    query: CallbackQuery,
    command_tx: mpsc::UnboundedSender<IncomingCommand>,
) -> ResponseResult<()> {
    let data = match &query.data {
        Some(d) if !d.is_empty() => d.as_str(),
        _ => return teloxide::respond(()),
    };

    let channel = match &query.message {
        Some(msg) => ChannelId::new("telegram", msg.chat().id.0.to_string()),
        None => return teloxide::respond(()),
    };

    let user_id = query.from.id.0.to_string();
    let command = parse_remote_command(data);

    let _ = command_tx.send(IncomingCommand {
        channel,
        user_id,
        command,
    });

    teloxide::respond(())
}