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;
pub struct TelegramAdapter {
bot: Bot,
}
impl TelegramAdapter {
pub fn new(token: String) -> Self {
Self {
bot: Bot::new(token),
}
}
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(());
}
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)?;
let rows: Vec<Vec<InlineKeyboardButton>> = buttons
.iter()
.map(|(data, label)| {
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(())
}
}
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(())
}