First Version
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "plugin-telegram-bot"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
core-api = { path = "../core-api" }
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
tracing = "0.1"
|
||||
teloxide = { version = "0.17.0", default-features = false, features = ["macros", "rustls", "ctrlc_handler"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
rand = "0.10.1"
|
||||
regex = "1"
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use core_api::message_meta::Attachment;
|
||||
use teloxide::net::Download;
|
||||
use teloxide::prelude::*;
|
||||
|
||||
/// A media item sent by the user via Telegram.
|
||||
///
|
||||
/// # Extending
|
||||
/// Add a new variant here, then handle it in:
|
||||
/// 1. `handlers::classify_message` — detect the message type and build the variant
|
||||
/// 2. `TelegramAttachment::download_and_save` — fetch bytes and persist to disk,
|
||||
/// returning an [`Attachment`]
|
||||
/// (return `Ok(None)` if no file is involved)
|
||||
/// 3. `TelegramAttachment::system_info_message` — describe a file-less variant
|
||||
/// (Location) for the LLM
|
||||
pub(crate) enum TelegramAttachment {
|
||||
Document {
|
||||
file_id: String,
|
||||
file_name: String,
|
||||
mime_type: Option<String>,
|
||||
caption: Option<String>,
|
||||
},
|
||||
Photo {
|
||||
file_id: String,
|
||||
caption: Option<String>,
|
||||
},
|
||||
Location {
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
accuracy: Option<f64>,
|
||||
/// True when the user shared a live location (continuously updated).
|
||||
is_live: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelegramAttachment {
|
||||
/// Downloads the attachment from Telegram, writes it to `base_dir/<chat_id>/<name>`,
|
||||
/// and returns the saved [`Attachment`] (shared with the web/mobile path so the
|
||||
/// copilot UI renders it identically). Returns `None` for attachment types that
|
||||
/// carry no binary content (e.g. Location).
|
||||
///
|
||||
/// The returned `path` is made relative to the process working directory (the
|
||||
/// project root) when possible, so it is both servable under `/data/…` and
|
||||
/// resolvable by the filesystem tools — matching web uploads.
|
||||
pub(crate) async fn download_and_save(
|
||||
&self,
|
||||
bot: &Bot,
|
||||
base_dir: &Path,
|
||||
chat_id: i64,
|
||||
) -> Result<Option<Attachment>> {
|
||||
let (file_id, file_name, mimetype): (&str, String, Option<String>) = match self {
|
||||
Self::Document { file_id, file_name, mime_type, .. } =>
|
||||
(file_id, file_name.clone(), mime_type.clone()),
|
||||
Self::Photo { file_id, .. } =>
|
||||
(file_id, format!("{file_id}.jpg"), Some("image/jpeg".to_string())),
|
||||
Self::Location { .. } => return Ok(None),
|
||||
};
|
||||
|
||||
let dir = base_dir.join(chat_id.to_string());
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
|
||||
let tg_file = bot.get_file(teloxide::types::FileId(file_id.to_string())).await?;
|
||||
let mut bytes = Vec::new();
|
||||
bot.download_file(&tg_file.path, &mut bytes).await?;
|
||||
|
||||
let path = dir.join(&file_name);
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
|
||||
// Prefer a project-root-relative path so `/data/…` serving works.
|
||||
let rel = std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|cwd| path.strip_prefix(&cwd).ok().map(Path::to_path_buf))
|
||||
.unwrap_or_else(|| path.clone());
|
||||
|
||||
Ok(Some(Attachment {
|
||||
path: rel.to_string_lossy().to_string(),
|
||||
name: file_name,
|
||||
mimetype,
|
||||
filesize: Some(bytes.len() as u64),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Builds the `[TELEGRAM SYSTEM INFO]` message injected into the conversation history.
|
||||
/// `saved_path` is `None` for attachment types that produce no file on disk.
|
||||
pub(crate) fn system_info_message(&self, saved_path: Option<&Path>) -> String {
|
||||
match self {
|
||||
Self::Document { file_name, mime_type, caption, .. } => {
|
||||
let mime = mime_type.as_deref().unwrap_or("application/octet-stream");
|
||||
let path = saved_path.map(|p| p.display().to_string()).unwrap_or_default();
|
||||
format!(
|
||||
"[TELEGRAM SYSTEM INFO]\n\
|
||||
The user has sent a file attachment.\n\
|
||||
File name: {file_name}\n\
|
||||
MIME type: {mime}\n\
|
||||
Saved at: {path}{}",
|
||||
caption_line(caption.as_deref()),
|
||||
)
|
||||
}
|
||||
Self::Photo { caption, .. } => {
|
||||
let path = saved_path.map(|p| p.display().to_string()).unwrap_or_default();
|
||||
format!(
|
||||
"[TELEGRAM SYSTEM INFO]\n\
|
||||
The user has sent a photo.\n\
|
||||
Saved at: {path}{}",
|
||||
caption_line(caption.as_deref()),
|
||||
)
|
||||
}
|
||||
Self::Location { latitude, longitude, accuracy, is_live } => {
|
||||
let maps_url = format!("https://maps.google.com/?q={latitude},{longitude}");
|
||||
let accuracy_line = accuracy
|
||||
.map(|a| format!("\nAccuracy: ±{a:.0} m"))
|
||||
.unwrap_or_default();
|
||||
let kind = if *is_live { "live location (snapshot at time of receipt)" } else { "location" };
|
||||
format!(
|
||||
"[TELEGRAM SYSTEM INFO]\n\
|
||||
The user has shared a {kind}.\n\
|
||||
Latitude: {latitude}\n\
|
||||
Longitude: {longitude}{accuracy_line}\n\
|
||||
Maps URL: {maps_url}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn caption_line(caption: Option<&str>) -> String {
|
||||
caption
|
||||
.map(|c| format!("\nCaption: {c}"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use rand::RngExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::ParseMode;
|
||||
use tokio::time::Duration;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::TgShared;
|
||||
|
||||
// ── Whitelist file schema ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Written to secrets/telegram_whitelist.json.
|
||||
// The main agent edits this file directly to authorise users.
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct WhitelistFile {
|
||||
#[serde(default)]
|
||||
pub whitelist: Vec<i64>,
|
||||
#[serde(default)]
|
||||
pub pending_pairings: Vec<PairingEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PairingEntry {
|
||||
pub code: String,
|
||||
pub chat_id: i64,
|
||||
pub issued_at: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_wl(secrets_dir: &Path) -> WhitelistFile {
|
||||
let path = secrets_dir.join("telegram_whitelist.json");
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => WhitelistFile::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn save_wl(secrets_dir: &Path, wl: &WhitelistFile) -> Result<()> {
|
||||
tokio::fs::create_dir_all(secrets_dir).await?;
|
||||
let path = secrets_dir.join("telegram_whitelist.json");
|
||||
tokio::fs::write(&path, serde_json::to_string_pretty(wl)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Pairing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Pairing codes older than this are considered abandoned and pruned, so the
|
||||
/// whitelist file does not accumulate stale `pending_pairings` entries.
|
||||
const PAIRING_TTL_HOURS: i64 = 24;
|
||||
|
||||
pub(crate) async fn handle_pairing(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
let mut wl = load_wl(&shared.secrets_dir).await;
|
||||
|
||||
// Drop pairing codes past their TTL. Entries with an unparseable timestamp
|
||||
// are kept (don't silently lose data on a format change).
|
||||
let cutoff = Utc::now() - chrono::Duration::hours(PAIRING_TTL_HOURS);
|
||||
let before = wl.pending_pairings.len();
|
||||
wl.pending_pairings.retain(|e| match DateTime::parse_from_rfc3339(&e.issued_at) {
|
||||
Ok(ts) => ts.with_timezone(&Utc) > cutoff,
|
||||
Err(_) => true,
|
||||
});
|
||||
let pruned = wl.pending_pairings.len() != before;
|
||||
|
||||
// Re-use an existing (non-expired) code if one is already pending for this chat.
|
||||
let (code, added) = if let Some(entry) = wl.pending_pairings.iter().find(|e| e.chat_id == chat_id.0) {
|
||||
(entry.code.clone(), false)
|
||||
} else {
|
||||
let code = generate_code();
|
||||
wl.pending_pairings.push(PairingEntry {
|
||||
code: code.clone(),
|
||||
chat_id: chat_id.0,
|
||||
issued_at: Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string(),
|
||||
});
|
||||
(code, true)
|
||||
};
|
||||
|
||||
// Persist if we added a new code or pruned expired ones.
|
||||
if added || pruned {
|
||||
if let Err(e) = save_wl(&shared.secrets_dir, &wl).await {
|
||||
error!(error = %e, "telegram: failed to write whitelist file");
|
||||
}
|
||||
}
|
||||
if added {
|
||||
info!(chat_id = chat_id.0, code = %code, "TELEGRAM PAIRING: code written to telegram_whitelist.json");
|
||||
}
|
||||
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
format!(
|
||||
"🔐 <b>Pairing required.</b>\n\n\
|
||||
Code: <code>{code}</code>\n\n\
|
||||
Provide this code to the web agent to authorize access.",
|
||||
),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub(crate) fn generate_code() -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let mut rng = rand::rng();
|
||||
(0..6).map(|_| CHARS[rng.random_range(0..CHARS.len())] as char).collect()
|
||||
}
|
||||
|
||||
// ── Whitelist watchdog ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Polls telegram_whitelist.json every 10 s for mtime changes.
|
||||
// When a new chat_id appears in `whitelist` (agent moved it from pending),
|
||||
// sends a welcome message so the user knows they are authorized.
|
||||
|
||||
pub(crate) async fn whitelist_watchdog(bot: Bot, secrets_dir: PathBuf, cancel: CancellationToken) {
|
||||
let path = secrets_dir.join("telegram_whitelist.json");
|
||||
|
||||
let mut last_mtime: Option<SystemTime> = tokio::fs::metadata(&path).await.ok()
|
||||
.and_then(|m| m.modified().ok());
|
||||
let mut known_wl = load_wl(&secrets_dir).await.whitelist;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||
interval.tick().await; // skip the immediate first tick
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = interval.tick() => {
|
||||
let new_mtime = tokio::fs::metadata(&path).await.ok()
|
||||
.and_then(|m| m.modified().ok());
|
||||
if new_mtime.is_none() || new_mtime == last_mtime {
|
||||
continue;
|
||||
}
|
||||
last_mtime = new_mtime;
|
||||
|
||||
let wl = load_wl(&secrets_dir).await;
|
||||
let newly_authorized: Vec<i64> = wl.whitelist.iter()
|
||||
.filter(|id| !known_wl.contains(id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !newly_authorized.is_empty() {
|
||||
info!(users = ?newly_authorized, "telegram: new users authorized — sending welcome");
|
||||
for &chat_id in &newly_authorized {
|
||||
bot.send_message(
|
||||
ChatId(chat_id),
|
||||
"✅ <b>Access granted!</b>\n\
|
||||
You can now talk to your agent.\n\n\
|
||||
/help for available commands.",
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
known_wl = wl.whitelist;
|
||||
info!(
|
||||
whitelist = known_wl.len(),
|
||||
pending = wl.pending_pairings.len(),
|
||||
"telegram: whitelist file reloaded"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
use std::sync::Arc;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, ParseMode};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use core_api::events::{GlobalEvent, ServerEvent};
|
||||
|
||||
use super::TgShared;
|
||||
use super::auth::load_wl;
|
||||
use super::helpers::{escape_html, label_to_html, send_long};
|
||||
|
||||
|
||||
/// Sends an inline keyboard for an approval request and records the request_id.
|
||||
async fn send_approval_keyboard(
|
||||
bot: &Bot,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
request_id: i64,
|
||||
shared: &Arc<TgShared>,
|
||||
) {
|
||||
let keyboard = InlineKeyboardMarkup::new(vec![
|
||||
vec![
|
||||
InlineKeyboardButton::callback("✅ Approve", format!("approve:{request_id}")),
|
||||
InlineKeyboardButton::callback("❌ Reject", format!("reject:{request_id}")),
|
||||
],
|
||||
vec![
|
||||
InlineKeyboardButton::callback("⏱ 15 min", format!("bypass_time:900:{request_id}")),
|
||||
InlineKeyboardButton::callback("🔄 Session", format!("bypass_session:{request_id}")),
|
||||
],
|
||||
]);
|
||||
|
||||
match bot
|
||||
.send_message(chat_id, text)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.reply_markup(keyboard)
|
||||
.await
|
||||
{
|
||||
Ok(m) => { shared.pending_approvals.lock().await.insert(m.id, request_id); }
|
||||
Err(e) => error!(error = %e, "telegram: failed to send approval message"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persistent background forwarder ──────────────────────────────────────────
|
||||
|
||||
/// Spawned once when the plugin starts.
|
||||
/// Stays subscribed to the "telegram" broadcast channel forever, forwarding
|
||||
/// events to the home chat_id. This is the only subscriber — per-message
|
||||
/// subscriptions are not used — so it also catches background notifications
|
||||
/// that arrive without a user message triggering them.
|
||||
///
|
||||
/// Re-subscribes immediately after each `Done`/`Error` so no events from the
|
||||
/// next turn are missed. Safe because Tokio's cooperative scheduler guarantees
|
||||
/// no other task runs between the re-subscription point and the next `await`,
|
||||
/// and the processing mutex in `ChatSessionHandler` serialises turns.
|
||||
pub(crate) async fn persistent_forwarder(
|
||||
bot: Bot,
|
||||
shared: Arc<TgShared>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
info!("telegram: persistent forwarder started");
|
||||
|
||||
let mut rx = shared.chat_hub.events("telegram");
|
||||
|
||||
// Single loop: rx is updated in-place on Done/Error so we never miss events
|
||||
// from the next turn (re-subscription happens before the async send).
|
||||
loop {
|
||||
let ge: GlobalEvent = tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
info!("telegram: persistent forwarder stopped");
|
||||
return;
|
||||
}
|
||||
result = rx.recv() => match result {
|
||||
Ok(e) => e,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(skipped = n, "telegram: persistent forwarder lagged");
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
},
|
||||
};
|
||||
|
||||
// ApprovalResolved is handled regardless of source so Telegram removes its
|
||||
// keyboard even when the approval was resolved via web or REST.
|
||||
if let ServerEvent::ApprovalResolved { request_id, approved, .. } = ge.event {
|
||||
let label = if approved { "✅ Approved" } else { "❌ Rejected" };
|
||||
let mut pending = shared.pending_approvals.lock().await;
|
||||
if let Some((&msg_id, _)) = pending.iter().find(|(_, rid)| **rid == request_id) {
|
||||
let msg_id = msg_id;
|
||||
pending.remove(&msg_id);
|
||||
drop(pending);
|
||||
if let Some(cid) = resolve_chat_id(&shared).await {
|
||||
bot.delete_message(cid, msg_id).await.ok();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// All other events: only process if they belong to the "telegram" source.
|
||||
if ge.source.as_deref() != Some("telegram") {
|
||||
tracing::debug!(event_type = ge.event.type_name(), source = ?ge.source, "persistent_forwarder: skipping non-telegram event");
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = ge.event;
|
||||
tracing::debug!(event_type = event.type_name(), "persistent_forwarder: processing telegram event");
|
||||
|
||||
// Resolve the destination chat_id (last known user, or first in whitelist).
|
||||
// For terminal events (Done/Error) with no known chat, still re-subscribe.
|
||||
let chat_id = match resolve_chat_id(&shared).await {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
warn!(event_type = %event.type_name(), "telegram: persistent_forwarder — no chat_id resolved, dropping event");
|
||||
if matches!(event, ServerEvent::Done { .. } | ServerEvent::Error { .. }) {
|
||||
rx = shared.chat_hub.events("telegram");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
ServerEvent::Done { content, .. } => {
|
||||
// Re-subscribe BEFORE any await so we don't miss the next turn.
|
||||
rx = shared.chat_hub.events("telegram");
|
||||
if !content.trim().is_empty() {
|
||||
send_long(&bot, chat_id, &content, Some(ParseMode::Html)).await;
|
||||
}
|
||||
}
|
||||
|
||||
ServerEvent::Error { message } => {
|
||||
rx = shared.chat_hub.events("telegram");
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
format!("⚠️ <b>Error:</b> {}", escape_html(&message)),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
ServerEvent::ToolStart { label_short, .. } => {
|
||||
bot.send_message(chat_id, format!("🔧 <i>{}</i>…", label_to_html(&label_short)))
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
ServerEvent::Thinking { content, .. } => {
|
||||
if !content.trim().is_empty() {
|
||||
send_long(&bot, chat_id, &content, Some(ParseMode::Html)).await;
|
||||
}
|
||||
}
|
||||
|
||||
ServerEvent::AgentStart { agent_id, parent_agent_id, prompt_preview, .. } => {
|
||||
let preview = prompt_preview.chars().take(300).collect::<String>();
|
||||
let ellipsis = if prompt_preview.len() > 300 { "…" } else { "" };
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
format!(
|
||||
"🤖 <b>{}</b> → <b>{}</b>\n<blockquote>{}{ellipsis}</blockquote>",
|
||||
escape_html(&parent_agent_id),
|
||||
escape_html(&agent_id),
|
||||
escape_html(&preview),
|
||||
),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
ServerEvent::AgentDone { agent_id, parent_agent_id, result_preview, .. } => {
|
||||
let preview = result_preview.chars().take(300).collect::<String>();
|
||||
let ellipsis = if result_preview.len() > 300 { "…" } else { "" };
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
format!(
|
||||
"✅ <b>{}</b> finished → <b>{}</b>\n<blockquote>{}{ellipsis}</blockquote>",
|
||||
escape_html(&agent_id),
|
||||
escape_html(&parent_agent_id),
|
||||
escape_html(&preview),
|
||||
),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
ServerEvent::PendingWrite { request_id, path, new_content, .. } => {
|
||||
let preview = truncate_chars(&new_content, 800);
|
||||
let text = format!(
|
||||
"🔐 <b>Approval required</b>\n\
|
||||
<b>Operation:</b> <code>{}</code>\n\n\
|
||||
<b>Content:</b>\n<pre>{}</pre>",
|
||||
escape_html(&path),
|
||||
escape_html(&preview),
|
||||
);
|
||||
send_approval_keyboard(&bot, chat_id, text, request_id, &shared).await;
|
||||
}
|
||||
|
||||
ServerEvent::ApprovalRequired { request_id, tool_name, arguments, .. } => {
|
||||
let args_str = serde_json::to_string_pretty(&arguments)
|
||||
.unwrap_or_else(|_| arguments.to_string());
|
||||
let args_preview = truncate_chars(&args_str, 600);
|
||||
let text = format!(
|
||||
"🔐 <b>Approval required</b>\n\
|
||||
<b>Tool:</b> <code>{}</code>\n\n\
|
||||
<b>Arguments:</b>\n<pre>{}</pre>",
|
||||
escape_html(&tool_name),
|
||||
escape_html(&args_preview),
|
||||
);
|
||||
send_approval_keyboard(&bot, chat_id, text, request_id, &shared).await;
|
||||
}
|
||||
|
||||
ServerEvent::AgentQuestion { request_id, tool_call_id, title, question, suggested_answers, .. } => {
|
||||
info!(request_id, tool_call_id, %question, "telegram: persistent_forwarder received AgentQuestion");
|
||||
|
||||
// If a previous question is still pending, disable its (now-dead)
|
||||
// buttons so tapping them doesn't silently no-op.
|
||||
if let Some(prev) = shared.pending_question.lock().await.take() {
|
||||
bot.edit_message_reply_markup(chat_id, prev.message_id)
|
||||
.reply_markup(InlineKeyboardMarkup::new(vec![vec![
|
||||
InlineKeyboardButton::callback("⏭ Superseded by a newer question", "noop"),
|
||||
]]))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
let header = format!("❓ <b>{}</b>\n{}", escape_html(&title), escape_html(&question));
|
||||
let keyboard = if suggested_answers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let buttons: Vec<Vec<InlineKeyboardButton>> = suggested_answers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| vec![InlineKeyboardButton::callback(
|
||||
s.clone(),
|
||||
format!("ansidx:{request_id}:{i}"),
|
||||
)])
|
||||
.collect();
|
||||
Some(InlineKeyboardMarkup::new(buttons))
|
||||
};
|
||||
let mut req = bot.send_message(chat_id, header).parse_mode(ParseMode::Html);
|
||||
if let Some(kb) = keyboard {
|
||||
req = req.reply_markup(kb);
|
||||
}
|
||||
match req.await {
|
||||
Ok(m) => {
|
||||
info!(request_id, msg_id = m.id.0, "telegram: AgentQuestion sent to user, pending_question set");
|
||||
*shared.pending_question.lock().await = Some(super::PendingQuestion {
|
||||
request_id,
|
||||
message_id: m.id,
|
||||
suggested_answers,
|
||||
});
|
||||
}
|
||||
Err(e) => error!(error = %e, request_id, "telegram: failed to send AgentQuestion to user"),
|
||||
}
|
||||
}
|
||||
|
||||
ServerEvent::LlmFailed { tried, last_error } => {
|
||||
let models = tried.join(", ");
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
format!(
|
||||
"⚠️ <b>LLM unavailable</b>\nTried: <code>{}</code>\n{}",
|
||||
escape_html(&models),
|
||||
escape_html(&last_error),
|
||||
),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
// ToolDone, ToolError, FileChanged, Truncated, ModelFallback,
|
||||
// NewSession, ApprovalResolved (handled above) — silenced.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the Telegram chat_id to use for outbound messages.
|
||||
/// Prefers the last chat_id that sent a message; falls back to the first
|
||||
/// whitelisted user.
|
||||
async fn resolve_chat_id(shared: &TgShared) -> Option<ChatId> {
|
||||
if let Some(id) = *shared.home_chat_id.lock().await {
|
||||
return Some(id);
|
||||
}
|
||||
let wl = load_wl(&shared.secrets_dir).await;
|
||||
wl.whitelist.first().map(|&id| ChatId(id))
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Truncates `s` to at most `max_chars` Unicode scalar values.
|
||||
/// Appends `…` if truncated. Never panics on multibyte UTF-8 content.
|
||||
fn truncate_chars(s: &str, max_chars: usize) -> String {
|
||||
let mut chars = s.chars();
|
||||
let truncated: String = chars.by_ref().take(max_chars).collect();
|
||||
if chars.next().is_some() {
|
||||
format!("{truncated}…")
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
// ── Callback query handler (button presses) ───────────────────────────────────
|
||||
|
||||
pub(crate) async fn callback_handler(
|
||||
bot: Bot,
|
||||
q: CallbackQuery,
|
||||
shared: Arc<TgShared>,
|
||||
) -> ResponseResult<()> {
|
||||
let approval_msg = q
|
||||
.message
|
||||
.as_ref()
|
||||
.and_then(|m| m.regular_message())
|
||||
.map(|m| (m.chat.id, m.id));
|
||||
|
||||
let Some((msg_chat_id, msg_id)) = approval_msg else {
|
||||
bot.answer_callback_query(q.id.clone()).await.ok();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(data) = q.data.as_deref() else {
|
||||
bot.answer_callback_query(q.id.clone()).await.ok();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// ── Suggested-answer button (ask_user_clarification) ─────────────────────
|
||||
if let Some(rest) = data.strip_prefix("ansidx:") {
|
||||
let mut parts = rest.splitn(2, ':');
|
||||
let req_id = parts.next().and_then(|s| s.parse::<i64>().ok());
|
||||
let idx_str = parts.next().and_then(|s| s.parse::<usize>().ok());
|
||||
if let (Some(request_id), Some(idx)) = (req_id, idx_str) {
|
||||
let mut pq = shared.pending_question.lock().await;
|
||||
if let Some(pq_inner) = pq.as_ref() {
|
||||
if pq_inner.request_id == request_id {
|
||||
let answer = pq_inner.suggested_answers.get(idx).cloned().unwrap_or_default();
|
||||
drop(pq);
|
||||
*shared.pending_question.lock().await = None;
|
||||
shared.chat_hub.resolve_question("telegram", request_id, answer.clone()).await;
|
||||
info!(request_id, %answer, "telegram: clarification answered via button");
|
||||
bot.edit_message_reply_markup(msg_chat_id, msg_id)
|
||||
.reply_markup(InlineKeyboardMarkup::new(vec![vec![
|
||||
InlineKeyboardButton::callback(format!("✅ {answer}"), "noop"),
|
||||
]]))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
bot.answer_callback_query(q.id.clone()).await.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Approval buttons ──────────────────────────────────────────────────────
|
||||
enum ApprovalAction {
|
||||
Approve,
|
||||
Reject,
|
||||
BypassTime(u64),
|
||||
BypassSession,
|
||||
}
|
||||
|
||||
let parsed: Option<(i64, ApprovalAction, &str)> =
|
||||
if let Some(id_str) = data.strip_prefix("approve:") {
|
||||
id_str.parse::<i64>().ok().map(|id| (id, ApprovalAction::Approve, "✅ Approved"))
|
||||
} else if let Some(id_str) = data.strip_prefix("reject:") {
|
||||
id_str.parse::<i64>().ok().map(|id| (id, ApprovalAction::Reject, "❌ Rejected"))
|
||||
} else if let Some(rest) = data.strip_prefix("bypass_time:") {
|
||||
let mut parts = rest.splitn(2, ':');
|
||||
let secs = parts.next().and_then(|s| s.parse::<u64>().ok());
|
||||
let id = parts.next().and_then(|s| s.parse::<i64>().ok());
|
||||
secs.zip(id).map(|(s, id)| (id, ApprovalAction::BypassTime(s), "⏱ Bypass (timed)"))
|
||||
} else if let Some(id_str) = data.strip_prefix("bypass_session:") {
|
||||
id_str.parse::<i64>().ok().map(|id| (id, ApprovalAction::BypassSession, "🔄 Bypass (session)"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((request_id, action, label)) = parsed {
|
||||
let stored = shared.pending_approvals.lock().await.remove(&msg_id);
|
||||
if let Some(stored_id) = stored {
|
||||
if stored_id == request_id {
|
||||
match action {
|
||||
ApprovalAction::Approve =>
|
||||
shared.approval.approve(request_id).await,
|
||||
ApprovalAction::Reject =>
|
||||
shared.approval.reject(request_id, String::new()).await,
|
||||
ApprovalAction::BypassTime(secs) =>
|
||||
shared.approval.approve_with_bypass(request_id, Some(secs)).await,
|
||||
ApprovalAction::BypassSession =>
|
||||
shared.approval.approve_with_bypass(request_id, None).await,
|
||||
}
|
||||
info!(request_id, label, "telegram: approval resolved");
|
||||
bot.delete_message(msg_chat_id, msg_id).await.ok();
|
||||
}
|
||||
} else {
|
||||
warn!(request_id, "telegram: approval not found (already resolved?)");
|
||||
}
|
||||
}
|
||||
|
||||
bot.answer_callback_query(q.id.clone()).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{ChatAction, ParseMode};
|
||||
use tracing::{error, info};
|
||||
|
||||
use core_api::chat_hub::{ModelCommandOutcome, SendMessageOptions};
|
||||
use core_api::command::expand_template;
|
||||
use core_api::location::GpsCoord;
|
||||
use core_api::message_meta::{CommandRef, MessageMetadata};
|
||||
|
||||
use super::TELEGRAM_FORMAT_CONTEXT;
|
||||
use super::TgShared;
|
||||
use super::attachments::TelegramAttachment;
|
||||
use super::auth::{handle_pairing, load_wl};
|
||||
|
||||
// ── Available commands help text (shared by /help and unknown-command replies) ──
|
||||
const HELP_TEXT: &str = "<b>Available commands</b>\n\n\
|
||||
/clear — start a new conversation\n\
|
||||
/new — alias for /clear\n\
|
||||
/stop — interrupt the agent mid-turn\n\
|
||||
/models — list available LLM models, ordered by priority\n\
|
||||
/model <N|name|auto> — select the model for this chat\n\
|
||||
/context — show last turn's token usage\n\
|
||||
/cost — show total spend for this session (USD)\n\
|
||||
/compact — force context compaction\n\
|
||||
/resettools — remove all activated tool groups (MCP + config) from the session\n\
|
||||
/sethome — receive agent notifications here\n\
|
||||
/help — this message";
|
||||
|
||||
/// Builds the `/help` text: the static system-command list plus a dynamically
|
||||
/// discovered "Custom commands" section (`commands/<name>/`). Descriptions are
|
||||
/// HTML-escaped since the message is sent with `ParseMode::Html`.
|
||||
fn help_text(command: &dyn core_api::command::CommandApi) -> String {
|
||||
let mut out = String::from(HELP_TEXT);
|
||||
let cmds = command.list_enabled();
|
||||
if !cmds.is_empty() {
|
||||
out.push_str("\n\n<b>Custom commands</b>");
|
||||
for c in cmds {
|
||||
out.push_str(&format!(
|
||||
"\n/{} — {}",
|
||||
c.name,
|
||||
super::helpers::escape_html(&c.description)
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Incoming message classification ───────────────────────────────────────────
|
||||
//
|
||||
// To add a new media type: add a variant to IncomingEvent, handle it in
|
||||
// classify_message, then dispatch it in message_handler.
|
||||
|
||||
pub(crate) enum IncomingEvent {
|
||||
Text(String),
|
||||
Command { name: String, args: Vec<String> },
|
||||
Voice { file_id: String },
|
||||
Attachment(TelegramAttachment),
|
||||
}
|
||||
|
||||
pub(crate) fn classify_message(msg: &Message) -> Option<IncomingEvent> {
|
||||
if let Some(voice) = msg.voice() {
|
||||
return Some(IncomingEvent::Voice { file_id: voice.file.id.to_string() });
|
||||
}
|
||||
|
||||
if let Some(doc) = msg.document() {
|
||||
return Some(IncomingEvent::Attachment(TelegramAttachment::Document {
|
||||
file_id: doc.file.id.to_string(),
|
||||
file_name: doc.file_name.clone().unwrap_or_else(|| "attachment".to_string()),
|
||||
mime_type: doc.mime_type.as_ref().map(|m| m.to_string()),
|
||||
caption: msg.caption().map(str::to_string),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(photos) = msg.photo() {
|
||||
if let Some(largest) = photos.last() {
|
||||
return Some(IncomingEvent::Attachment(TelegramAttachment::Photo {
|
||||
file_id: largest.file.id.to_string(),
|
||||
caption: msg.caption().map(str::to_string),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(loc) = msg.location() {
|
||||
return Some(IncomingEvent::Attachment(TelegramAttachment::Location {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
accuracy: loc.horizontal_accuracy,
|
||||
is_live: loc.live_period.is_some(),
|
||||
}));
|
||||
}
|
||||
|
||||
let text = msg.text()?;
|
||||
|
||||
// A command is any message that *starts* with '/'. We deliberately do NOT
|
||||
// rely on teloxide's BotCommand entities: those are emitted for every
|
||||
// "/token" anywhere in the text, so a normal sentence containing a "/path"
|
||||
// (e.g. "stop /usr/bin/foo") would be misclassified as a command. A leading
|
||||
// slash is the only signal. Arguments are parsed from the message `text`
|
||||
// (not `entity.text()`, which spans only "/model" and would drop the arg).
|
||||
// An unknown command is handled by the dispatcher, which replies with help.
|
||||
if text.starts_with('/') {
|
||||
let full = text.trim_start_matches('/');
|
||||
let mut parts = full.splitn(2, ' ');
|
||||
let name = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let name = name.split('@').next().unwrap_or(&name).to_string();
|
||||
let rest = parts.next().unwrap_or("").trim().to_string();
|
||||
let args: Vec<String> = if rest.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
rest.split_whitespace().map(str::to_string).collect()
|
||||
};
|
||||
return Some(IncomingEvent::Command { name, args });
|
||||
}
|
||||
|
||||
Some(IncomingEvent::Text(text.to_string()))
|
||||
}
|
||||
|
||||
// ── Message handler ───────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) async fn message_handler(
|
||||
bot: Bot,
|
||||
msg: Message,
|
||||
shared: Arc<TgShared>,
|
||||
) -> ResponseResult<()> {
|
||||
let chat_id = msg.chat.id;
|
||||
|
||||
// Whitelist check — re-read the file on every message so agent edits are
|
||||
// picked up without a plugin restart.
|
||||
let wl = load_wl(&shared.secrets_dir).await;
|
||||
if !wl.whitelist.contains(&chat_id.0) {
|
||||
handle_pairing(&bot, chat_id, &shared).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Track the last active chat_id so the persistent forwarder knows
|
||||
// where to send background notifications.
|
||||
*shared.home_chat_id.lock().await = Some(chat_id);
|
||||
|
||||
let Some(incoming) = classify_message(&msg) else {
|
||||
bot.send_message(chat_id, "Unsupported message format.").await.ok();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match incoming {
|
||||
IncomingEvent::Command { ref name, .. } if name == "clear" || name == "new" => {
|
||||
handle_clear(&bot, chat_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "sethome" => {
|
||||
match shared.chat_hub.set_home("telegram").await {
|
||||
Ok(_) => {
|
||||
info!("telegram: set as home source");
|
||||
bot.send_message(chat_id, "🏠 Telegram set as <b>home</b>. Agent notifications will be delivered here.")
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "help" => {
|
||||
bot.send_message(chat_id, help_text(&*shared.command))
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "stop" => {
|
||||
handle_stop(&bot, chat_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "context" => {
|
||||
handle_context(&bot, chat_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "cost" => {
|
||||
handle_cost(&bot, chat_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "compact" => {
|
||||
handle_compact(&bot, chat_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "resettools" => {
|
||||
handle_reset_mcp(&bot, chat_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Command { ref name, .. } if name == "models" => {
|
||||
handle_list_models(&bot, chat_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Command { ref name, ref args, .. } if name == "model" => {
|
||||
handle_set_model(&bot, chat_id, args, &shared).await;
|
||||
}
|
||||
// A recognised custom `/command` expands its `COMMAND.md` template into a
|
||||
// normal user message (fully interactive: the model can then ask questions,
|
||||
// iterate, dispatch sub-agents). Any other `/...` is an unknown command and
|
||||
// is never forwarded to the LLM — reply with a not-found notice + help.
|
||||
IncomingEvent::Command { ref name, ref args, .. } => {
|
||||
if let Some(resolved) = shared.command.resolve(name) {
|
||||
let args_str = args.join(" ");
|
||||
let display = if args_str.is_empty() {
|
||||
format!("/{name}")
|
||||
} else {
|
||||
format!("/{name} {args_str}")
|
||||
};
|
||||
let content = expand_template(&resolved.template, &args_str);
|
||||
let metadata = MessageMetadata {
|
||||
command: Some(CommandRef {
|
||||
name: resolved.name,
|
||||
display,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
handle_llm_message(bot, chat_id, content, Some(metadata), shared).await;
|
||||
} else {
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
format!("Unknown command: /{name}\n\n{}", help_text(&*shared.command)),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
IncomingEvent::Voice { file_id } => {
|
||||
handle_voice(&bot, chat_id, file_id, &shared).await;
|
||||
}
|
||||
IncomingEvent::Attachment(attachment) => {
|
||||
handle_attachment(bot, chat_id, attachment, shared).await;
|
||||
}
|
||||
_ => {
|
||||
let text = match &incoming {
|
||||
IncomingEvent::Text(t) => t.clone(),
|
||||
IncomingEvent::Command { .. }
|
||||
| IncomingEvent::Voice { .. }
|
||||
| IncomingEvent::Attachment(_) => unreachable!(),
|
||||
};
|
||||
|
||||
// If a clarification question is pending, treat any text as the answer.
|
||||
{
|
||||
let mut pq = shared.pending_question.lock().await;
|
||||
if let Some(pq_inner) = pq.take() {
|
||||
let request_id = pq_inner.request_id;
|
||||
let question_msg_id = pq_inner.message_id;
|
||||
drop(pq);
|
||||
shared.chat_hub.resolve_question("telegram", request_id, text.clone()).await;
|
||||
tracing::info!(request_id, %text, "telegram: clarification answered via text");
|
||||
bot.edit_message_reply_markup(chat_id, question_msg_id)
|
||||
.reply_markup(teloxide::types::InlineKeyboardMarkup::new(vec![vec![
|
||||
teloxide::types::InlineKeyboardButton::callback(
|
||||
format!("✅ {}", super::helpers::escape_html(&text)),
|
||||
"noop",
|
||||
),
|
||||
]]))
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
handle_llm_message(bot, chat_id, text, None, shared).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── /clear command ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_clear(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
match shared.chat_hub.clear("telegram").await {
|
||||
Ok(_) => {
|
||||
info!("telegram: session cleared via /clear");
|
||||
bot.send_message(chat_id, "🆕 New conversation started.").await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "telegram: failed to clear session");
|
||||
bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── /context command ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_context(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
match shared.chat_hub.context_info("telegram").await {
|
||||
Ok((input, output)) => {
|
||||
let input_str = input.map_or("?".to_string(), |t| t.to_string());
|
||||
let output_str = output.map_or("?".to_string(), |t| t.to_string());
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
format!("<i>↑{input_str} tok · ↓{output_str} tok</i>"),
|
||||
)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── /cost command ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_cost(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
match shared.chat_hub.cost_info("telegram").await {
|
||||
Ok(Some(c)) => {
|
||||
bot.send_message(chat_id, format!("💰 Session cost: ${c:.4}")).await.ok();
|
||||
}
|
||||
Ok(None) => {
|
||||
bot.send_message(chat_id, "💰 No cost recorded for this session.").await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── /compact command ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_compact(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
match shared.chat_hub.force_compact("telegram").await {
|
||||
Ok(true) => {
|
||||
info!("telegram: manual compaction succeeded");
|
||||
bot.send_message(chat_id, "✅ Context compacted.").await.ok();
|
||||
}
|
||||
Ok(false) => {
|
||||
bot.send_message(chat_id, "⏩ Compaction skipped (no messages to summarise or compaction disabled).").await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "telegram: manual compaction failed");
|
||||
bot.send_message(chat_id, format!("⚠️ Compaction failed: {e}")).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── /resettools command ───────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
match shared.chat_hub.reset_mcp("telegram").await {
|
||||
Ok(()) => {
|
||||
info!("telegram: tool-group grants reset via /resettools");
|
||||
bot.send_message(chat_id, "✅ Activated tool groups removed from the session.").await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "telegram: /resettools failed");
|
||||
bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── /stop command ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_stop(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
shared.chat_hub.cancel("telegram").await;
|
||||
info!("telegram: agent cancelled via /stop");
|
||||
bot.send_message(chat_id, "⏹ Agent stopped.").await.ok();
|
||||
}
|
||||
|
||||
// ── /models and /model commands ──────────────────────────────────────────────
|
||||
//
|
||||
// Business logic (resolve arg, mutate pin, broadcast) lives in
|
||||
// `ChatHub::apply_model_command` / `ChatHub::list_clients_marked`. Here we only
|
||||
// format for Telegram (HTML) and send via the bot — same pattern the web WS
|
||||
// handler uses with Markdown.
|
||||
|
||||
async fn handle_list_models(bot: &Bot, chat_id: ChatId, shared: &Arc<TgShared>) {
|
||||
let items = shared.chat_hub.list_clients_marked("telegram").await;
|
||||
let mut text = String::from("<b>Available models</b>\n\n");
|
||||
for (i, name, is_current) in &items {
|
||||
let marker = if *is_current { "●" } else { "○" };
|
||||
text.push_str(&format!(
|
||||
"{} <code>{:2}</code> {}\n",
|
||||
marker,
|
||||
i,
|
||||
super::helpers::escape_html(name)
|
||||
));
|
||||
}
|
||||
text.push_str("\nUse <code>/model N</code>, <code>/model name</code>, or <code>/model auto</code>.");
|
||||
bot.send_message(chat_id, text)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
async fn handle_set_model(bot: &Bot, chat_id: ChatId, args: &[String], shared: &Arc<TgShared>) {
|
||||
let arg = args.first().cloned().unwrap_or_default();
|
||||
let outcome = shared.chat_hub.apply_model_command("telegram", &arg).await;
|
||||
let text = match outcome {
|
||||
ModelCommandOutcome::Set(name) => format!("✅ Model set: <b>{}</b>", super::helpers::escape_html(&name)),
|
||||
ModelCommandOutcome::Cleared => "✅ Model reset to <b>auto</b>.".to_string(),
|
||||
ModelCommandOutcome::Error(msg) => format!("⚠️ {}", super::helpers::escape_html(&msg)),
|
||||
};
|
||||
bot.send_message(chat_id, text)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
// ── LLM dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_llm_message(
|
||||
bot: Bot,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
metadata: Option<MessageMetadata>,
|
||||
shared: Arc<TgShared>,
|
||||
) {
|
||||
bot.send_chat_action(chat_id, ChatAction::Typing).await.ok();
|
||||
|
||||
// The persistent_forwarder (spawned once in start()) is always subscribed
|
||||
// to the "telegram" broadcast channel and will pick up all events for this
|
||||
// turn — including Done → send to Telegram. No per-message subscription needed.
|
||||
let client_name = shared.chat_hub.get_selected_client("telegram").await;
|
||||
let opts = SendMessageOptions {
|
||||
client_name,
|
||||
extra_system_context: Some(TELEGRAM_FORMAT_CONTEXT.to_string()),
|
||||
tail_reminder: Some(super::TELEGRAM_FORMAT_REMINDER.to_string()),
|
||||
interface_tools: super::tools::interface_tools(bot, chat_id, &*shared.tts).await,
|
||||
metadata,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// send_message only enqueues — the turn runs on ChatHub's per-source consumer —
|
||||
// so awaiting inline keeps this message handler responsive.
|
||||
if let Err(e) = shared.chat_hub.send_message("telegram", &text, opts).await {
|
||||
error!(error = %e, "telegram: enqueue error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Voice message → transcribe → LLM ─────────────────────────────────────────
|
||||
|
||||
async fn handle_voice(
|
||||
bot: &Bot,
|
||||
chat_id: ChatId,
|
||||
file_id: String,
|
||||
shared: &Arc<TgShared>,
|
||||
) {
|
||||
use teloxide::net::Download;
|
||||
|
||||
let transcriber = match shared.transcriber().await {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
bot.send_message(chat_id, "⚠️ Transcription not available (no transcription provider configured).").await.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let file = match bot.get_file(teloxide::types::FileId(file_id)).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
error!(error = %e, "telegram: get_file failed");
|
||||
bot.send_message(chat_id, "⚠️ Could not download audio file.").await.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut audio_bytes = Vec::new();
|
||||
if let Err(e) = bot.download_file(&file.path, &mut audio_bytes).await {
|
||||
error!(error = %e, "telegram: download_file failed");
|
||||
bot.send_message(chat_id, "⚠️ Audio download failed.").await.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
bot.send_chat_action(chat_id, ChatAction::Typing).await.ok();
|
||||
|
||||
let text = match transcriber.transcribe(audio_bytes, "ogg").await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!(error = %e, "telegram: transcription failed");
|
||||
bot.send_message(chat_id, format!("⚠️ Transcription failed: {e}")).await.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!(chat_id = chat_id.0, "telegram: voice transcribed, forwarding to LLM");
|
||||
let message = format!(
|
||||
"[TELEGRAM SYSTEM INFO]\n\
|
||||
The user sent a voice message. The following is the audio transcript:\n\n\
|
||||
{text}"
|
||||
);
|
||||
handle_llm_message(bot.clone(), chat_id, message, None, Arc::clone(shared)).await;
|
||||
}
|
||||
|
||||
// ── Edited message (live location updates) ────────────────────────────────────
|
||||
|
||||
pub(crate) async fn edited_message_handler(
|
||||
msg: Message,
|
||||
shared: Arc<TgShared>,
|
||||
) -> ResponseResult<()> {
|
||||
if let Some(loc) = msg.location() {
|
||||
let coord = GpsCoord { latitude: loc.latitude, longitude: loc.longitude };
|
||||
shared.location.update("telegram", coord, loc.horizontal_accuracy, true);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── File / media attachment ───────────────────────────────────────────────────
|
||||
|
||||
async fn handle_attachment(
|
||||
bot: Bot,
|
||||
chat_id: ChatId,
|
||||
attachment: TelegramAttachment,
|
||||
shared: Arc<TgShared>,
|
||||
) {
|
||||
// Update LocationManager immediately, before any LLM dispatch.
|
||||
if let TelegramAttachment::Location { latitude, longitude, accuracy, is_live } = &attachment {
|
||||
let coord = GpsCoord { latitude: *latitude, longitude: *longitude };
|
||||
shared.location.update("telegram", coord, *accuracy, *is_live);
|
||||
}
|
||||
|
||||
bot.send_chat_action(chat_id, ChatAction::UploadDocument).await.ok();
|
||||
|
||||
let saved = match attachment.download_and_save(&bot, &shared.uploads_dir, chat_id.0).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!(error = %e, "telegram: failed to save attachment");
|
||||
bot.send_message(chat_id, "⚠️ Could not save the attachment.").await.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match saved {
|
||||
// Document / Photo: carry the file as structured metadata (rendered as a
|
||||
// chip in the copilot UI; the LLM gets the shared [SYSTEM INFO] block).
|
||||
// The caption, if any, becomes the user's text for this turn.
|
||||
Some(att) => {
|
||||
info!(chat_id = chat_id.0, path = %att.path, "telegram: attachment saved, forwarding to LLM");
|
||||
let caption = match &attachment {
|
||||
TelegramAttachment::Document { caption, .. } => caption.clone(),
|
||||
TelegramAttachment::Photo { caption, .. } => caption.clone(),
|
||||
TelegramAttachment::Location { .. } => None,
|
||||
}.unwrap_or_default();
|
||||
let metadata = MessageMetadata { attachments: vec![att], ..Default::default() };
|
||||
handle_llm_message(bot, chat_id, caption, Some(metadata), shared).await;
|
||||
}
|
||||
// Location (no file): keep the textual system-info block.
|
||||
None => {
|
||||
let message = attachment.system_info_message(None);
|
||||
handle_llm_message(bot, chat_id, message, None, shared).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::ParseMode;
|
||||
|
||||
pub(crate) fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// Strip HTML tags for plain-text fallback.
|
||||
fn strip_html(s: &str) -> String {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
let re = RE.get_or_init(|| Regex::new(r"<[^>]+>").unwrap());
|
||||
re.replace_all(s, "").into_owned()
|
||||
}
|
||||
|
||||
// ── Markdown → Telegram HTML sanitizer ───────────────────────────────────────
|
||||
|
||||
/// Convert a Markdown table block (slice of raw lines) into bullet list lines.
|
||||
fn table_to_bullets(rows: &[&str]) -> String {
|
||||
let mut out = String::new();
|
||||
let mut header_seen = false;
|
||||
for &line in rows {
|
||||
let trimmed = line.trim();
|
||||
// Separator row (e.g. |---|---|) → skip
|
||||
if trimmed.starts_with('|')
|
||||
&& trimmed.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
|
||||
{
|
||||
header_seen = true; // next rows are data
|
||||
continue;
|
||||
}
|
||||
// Table row: split on '|', drop empty outer segments
|
||||
if trimmed.starts_with('|') && trimmed.ends_with('|') {
|
||||
let cells: Vec<&str> = trimmed
|
||||
.trim_matches('|')
|
||||
.split('|')
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
if cells.is_empty() { continue; }
|
||||
// First row before separator = header → emit as bold label, not bullet
|
||||
if !header_seen {
|
||||
out.push_str(&format!("<b>{}</b>\n", cells.join(" — ")));
|
||||
} else {
|
||||
out.push_str(&format!("• {}\n", cells.join(" — ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Safety-net: convert residual Markdown bold (**text**) to <b>text</b>.
|
||||
fn md_bold_to_html(text: &str) -> String {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
let re = RE.get_or_init(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||
re.replace_all(text, "<b>$1</b>").into_owned()
|
||||
}
|
||||
|
||||
/// Safety-net: convert residual Markdown headers (## text) to <b>text</b>.
|
||||
fn md_headers_to_html(text: &str) -> String {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
let re = RE.get_or_init(|| Regex::new(r"(?m)^#{1,6} +(.+)$").unwrap());
|
||||
re.replace_all(text, "<b>$1</b>").into_owned()
|
||||
}
|
||||
|
||||
/// Safety-net: convert residual inline `` `code` `` to <code>code</code>,
|
||||
/// HTML-escaping the inner text so it renders verbatim.
|
||||
fn md_inline_code_to_html(text: &str) -> String {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
let re = RE.get_or_init(|| Regex::new(r"`([^`\n]+?)`").unwrap());
|
||||
re.replace_all(text, |caps: ®ex::Captures| {
|
||||
format!("<code>{}</code>", escape_html(&caps[1]))
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Sanitize LLM output for Telegram HTML rendering:
|
||||
/// 1. Convert fenced code blocks (```) → `<pre>…</pre>` (inner text escaped).
|
||||
/// 2. Convert Markdown tables → bullet lists (Telegram has no `<table>` support).
|
||||
/// 3. Convert residual `**bold**` → `<b>bold</b>`.
|
||||
/// 4. Convert residual `## headers` → `<b>text</b>`.
|
||||
/// 5. Convert residual inline `` `code` `` → `<code>code</code>`.
|
||||
fn sanitize_for_telegram(text: &str) -> String {
|
||||
// Pass 1: block conversion (line-by-line state machine for fences & tables)
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Fenced code block: ``` … ``` → <pre>escaped</pre>
|
||||
if trimmed.starts_with("```") {
|
||||
i += 1; // skip the opening fence (and any language tag)
|
||||
let start = i;
|
||||
while i < lines.len() && !lines[i].trim().starts_with("```") {
|
||||
i += 1;
|
||||
}
|
||||
let inner = lines[start..i].join("\n");
|
||||
if i < lines.len() { i += 1; } // skip the closing fence
|
||||
out.push_str("<pre>");
|
||||
out.push_str(&escape_html(&inner));
|
||||
out.push_str("</pre>\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown table block
|
||||
if trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.len() > 1 {
|
||||
let start = i;
|
||||
while i < lines.len() && {
|
||||
let t = lines[i].trim();
|
||||
(t.starts_with('|') && t.ends_with('|') && t.len() > 1)
|
||||
|| (t.starts_with('|')
|
||||
&& t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')))
|
||||
} {
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&table_to_bullets(&lines[start..i]));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Passes 2-4: residual Markdown
|
||||
let out = md_bold_to_html(&out);
|
||||
let out = md_headers_to_html(&out);
|
||||
md_inline_code_to_html(&out)
|
||||
}
|
||||
|
||||
/// Convert a tool label (our internal format with backtick-wrapped args) to
|
||||
/// Telegram HTML: plain text is HTML-escaped, `` `code` `` becomes `<code>code</code>`.
|
||||
pub(crate) fn label_to_html(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 16);
|
||||
let mut rest = s;
|
||||
while let Some(open) = rest.find('`') {
|
||||
out.push_str(&escape_html(&rest[..open]));
|
||||
rest = &rest[open + 1..];
|
||||
if let Some(close) = rest.find('`') {
|
||||
out.push_str("<code>");
|
||||
out.push_str(&escape_html(&rest[..close]));
|
||||
out.push_str("</code>");
|
||||
rest = &rest[close + 1..];
|
||||
} else {
|
||||
// Unmatched backtick — emit as-is and stop.
|
||||
out.push('`');
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str(&escape_html(rest));
|
||||
out
|
||||
}
|
||||
|
||||
// ── send_long ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) async fn send_long(bot: &Bot, chat_id: ChatId, text: &str, parse_mode: Option<ParseMode>) {
|
||||
const MAX: usize = 4000;
|
||||
if text.is_empty() { return; }
|
||||
// Sanitize before chunking so table blocks are never split mid-row.
|
||||
let sanitized;
|
||||
let text = if parse_mode == Some(ParseMode::Html) {
|
||||
sanitized = sanitize_for_telegram(text);
|
||||
&sanitized
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let end = (start + MAX).min(chars.len());
|
||||
let chunk: String = chars[start..end].iter().collect();
|
||||
let mut req = bot.send_message(chat_id, &chunk);
|
||||
if let Some(pm) = parse_mode { req = req.parse_mode(pm); }
|
||||
if req.await.is_err() {
|
||||
// Retry without parse_mode so the text reaches the user even if
|
||||
// the markup was malformed. Strip HTML tags first so we don't
|
||||
// display raw `<b>…</b>` to the user.
|
||||
let plain = strip_html(&chunk);
|
||||
bot.send_message(chat_id, plain).await.ok();
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/// Telegram plugin — connects the Skald LLM to a private Telegram bot.
|
||||
///
|
||||
/// # Pairing
|
||||
/// Unknown users receive a pairing code in chat. The code is also written to
|
||||
/// `secrets/telegram_whitelist.json` under `pending_pairings`. The main agent
|
||||
/// (via `read_file` / `write_file`) can inspect that file and move the
|
||||
/// `chat_id` into the `whitelist` array to complete the authorisation — no
|
||||
/// code changes required, just a file edit.
|
||||
///
|
||||
/// # Human-in-the-loop approvals
|
||||
/// Tool calls requiring approval emit a `PendingWrite` event; the plugin
|
||||
/// forwards it to Telegram as an inline-keyboard message with
|
||||
/// [✅ Approve] [❌ Reject] / [⏱ 15 min] [🔄 Session] buttons.
|
||||
///
|
||||
/// # Adding new message types
|
||||
/// 1. Add a variant to `IncomingEvent` in `handlers.rs`.
|
||||
/// 2. Handle it in `classify_message` (same file).
|
||||
/// 3. Dispatch it in `message_handler` (same file).
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::MessageId;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use core_api::approval::ApprovalApi;
|
||||
use core_api::chat_hub::ChatHubApi;
|
||||
use core_api::command::CommandApi;
|
||||
use core_api::location::LocationUpdater;
|
||||
use core_api::plugin::{Plugin, PluginContext};
|
||||
use core_api::transcribe::{Transcribe, TranscribeProvider};
|
||||
use core_api::tts::TtsProvider;
|
||||
|
||||
mod attachments;
|
||||
mod auth;
|
||||
mod events;
|
||||
mod handlers;
|
||||
mod helpers;
|
||||
mod tools;
|
||||
|
||||
/// Injected as extra system context for every Telegram turn.
|
||||
/// Kept compact to minimise token overhead.
|
||||
pub(crate) const TELEGRAM_FORMAT_CONTEXT: &str = "\
|
||||
OUTPUT FORMAT — TELEGRAM HTML ONLY.\n\
|
||||
Allowed tags: <b> <i> <u> <s> <code> <pre> <a> <blockquote>. \
|
||||
Telegram supports NO other HTML and NO Markdown.\n\
|
||||
FORBIDDEN (will appear as raw symbols): ** * _ ` # | and Markdown tables.\n\
|
||||
• Headers → <b>text</b>\n\
|
||||
• Structured data → bullet lists with •, never | tables\n\
|
||||
• Escape & < > as & < >";
|
||||
|
||||
/// Short reminder injected near the end of the message list to counter
|
||||
/// instruction drift in long conversations.
|
||||
pub(crate) const TELEGRAM_FORMAT_REMINDER: &str = "\
|
||||
[FORMAT] Telegram HTML only: <b> <i> <code> <pre>. \
|
||||
No Markdown: no ** * _ ` # |. No tables — use bullet lists.";
|
||||
|
||||
// ── Shared state injected into every teloxide handler ─────────────────────────
|
||||
|
||||
/// A pending `ask_user_clarification` question waiting for the user's reply.
|
||||
pub(crate) struct PendingQuestion {
|
||||
pub(crate) request_id: i64,
|
||||
pub(crate) message_id: MessageId,
|
||||
/// Suggested answers (used to resolve the selection when the user taps a button).
|
||||
pub(crate) suggested_answers: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct TgShared {
|
||||
pub(crate) chat_hub: Arc<dyn ChatHubApi>,
|
||||
/// Custom slash-command resolver (`commands/<name>/`). Read-only: lets the
|
||||
/// bot expand a recognised `/command` into a template before forwarding it to
|
||||
/// the LLM, mirroring the WS handler.
|
||||
pub(crate) command: Arc<dyn CommandApi>,
|
||||
pub(crate) approval: Arc<dyn ApprovalApi>,
|
||||
pub(crate) transcribe: Arc<dyn TranscribeProvider>,
|
||||
pub(crate) tts: Arc<dyn TtsProvider>,
|
||||
pub(crate) location: Arc<dyn LocationUpdater>,
|
||||
/// MessageId of the approval message → request_id.
|
||||
pub(crate) pending_approvals: Mutex<HashMap<MessageId, i64>>,
|
||||
/// Currently active clarification question (at most one at a time per session).
|
||||
pub(crate) pending_question: Mutex<Option<PendingQuestion>>,
|
||||
pub(crate) secrets_dir: PathBuf,
|
||||
/// Base directory for file attachments: `<data_root>/uploads/telegram/`.
|
||||
pub(crate) uploads_dir: PathBuf,
|
||||
/// Last chat_id that sent a message — used as the target for background notifications.
|
||||
/// Set on every incoming message; read by the persistent event forwarder.
|
||||
pub(crate) home_chat_id: Mutex<Option<ChatId>>,
|
||||
}
|
||||
|
||||
impl TgShared {
|
||||
pub(crate) async fn transcriber(&self) -> Option<Arc<dyn Transcribe>> {
|
||||
self.transcribe.get().await
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin struct ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct TelegramPlugin {
|
||||
secrets_dir: PathBuf,
|
||||
/// Bot token — set by reload() before start() is called.
|
||||
token: Mutex<String>,
|
||||
running: Arc<AtomicBool>,
|
||||
cancel: Mutex<Option<CancellationToken>>,
|
||||
handle: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl TelegramPlugin {
|
||||
pub fn new(secrets_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
secrets_dir: secrets_dir.into(),
|
||||
token: Mutex::new(String::new()),
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
cancel: Mutex::new(None),
|
||||
handle: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for TelegramPlugin {
|
||||
fn id(&self) -> &str { "telegram" }
|
||||
fn name(&self) -> &str { "Telegram Bot" }
|
||||
fn description(&self) -> &str {
|
||||
"Private Telegram bot. Forwards messages to the LLM; supports HITL approval via inline keyboards."
|
||||
}
|
||||
fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) }
|
||||
|
||||
fn config_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"title": "Bot Token",
|
||||
"description": "Telegram bot token from @BotFather",
|
||||
"sensitive": true
|
||||
}
|
||||
},
|
||||
"required": ["token"]
|
||||
})
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
|
||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
||||
|
||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> {
|
||||
let new_token = config["token"].as_str().unwrap_or("").to_string();
|
||||
let old_token = self.token.lock().await.clone();
|
||||
let is_running = self.is_running();
|
||||
|
||||
match (enabled, is_running) {
|
||||
(true, false) => {
|
||||
anyhow::ensure!(!new_token.is_empty(),
|
||||
"telegram: cannot start — `token` is missing from config");
|
||||
*self.token.lock().await = new_token;
|
||||
self.start(ctx).await?;
|
||||
}
|
||||
(false, true) => {
|
||||
self.stop().await?;
|
||||
}
|
||||
(true, true) => {
|
||||
if new_token != old_token {
|
||||
info!("telegram: token changed — restarting");
|
||||
self.stop().await?;
|
||||
*self.token.lock().await = new_token;
|
||||
self.start(ctx).await?;
|
||||
}
|
||||
}
|
||||
(false, false) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&self, ctx: PluginContext) -> Result<()> {
|
||||
if self.running.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
let token = self.token.lock().await.clone();
|
||||
if token.is_empty() {
|
||||
anyhow::bail!("telegram: token is empty — set it via the plugins API");
|
||||
}
|
||||
|
||||
let uploads_dir = self.secrets_dir
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join("uploads")
|
||||
.join("telegram");
|
||||
|
||||
// Register "telegram" source with ChatHub (idempotent).
|
||||
// ChatHub restores the active session from the sources table automatically.
|
||||
ctx.chat_hub.register("telegram").await;
|
||||
info!("telegram: registered with ChatHub");
|
||||
|
||||
let shared = Arc::new(TgShared {
|
||||
chat_hub: Arc::clone(&ctx.chat_hub),
|
||||
command: Arc::clone(&ctx.command),
|
||||
approval: Arc::clone(&ctx.approval),
|
||||
transcribe: Arc::clone(&ctx.transcribe),
|
||||
tts: Arc::clone(&ctx.tts_provider),
|
||||
location: Arc::clone(&ctx.location),
|
||||
pending_approvals: Mutex::new(HashMap::new()),
|
||||
pending_question: Mutex::new(None),
|
||||
secrets_dir: self.secrets_dir.clone(),
|
||||
uploads_dir,
|
||||
home_chat_id: Mutex::new(None),
|
||||
});
|
||||
|
||||
let bot = Bot::new(&token);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
tokio::spawn(events::persistent_forwarder(
|
||||
bot.clone(),
|
||||
Arc::clone(&shared),
|
||||
cancel.clone(),
|
||||
));
|
||||
|
||||
let hub_clone = Arc::clone(&ctx.chat_hub);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = hub_clone.resume("telegram").await {
|
||||
tracing::warn!(error = %e, "telegram: startup resume failed");
|
||||
}
|
||||
});
|
||||
|
||||
let cancel_clone = cancel.clone();
|
||||
let cancel_wdg = cancel.clone();
|
||||
let running_clone = Arc::clone(&self.running);
|
||||
self.running.store(true, Ordering::Relaxed);
|
||||
|
||||
let handler = dptree::entry()
|
||||
.branch(Update::filter_message().endpoint(handlers::message_handler))
|
||||
.branch(Update::filter_edited_message().endpoint(handlers::edited_message_handler))
|
||||
.branch(Update::filter_callback_query().endpoint(events::callback_handler));
|
||||
|
||||
let secrets_dir_wdg = self.secrets_dir.clone();
|
||||
let bot_wdg = bot.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let mut dispatcher = Dispatcher::builder(bot, handler)
|
||||
.dependencies(dptree::deps![shared])
|
||||
.build();
|
||||
|
||||
info!("telegram plugin: dispatcher starting");
|
||||
tokio::select! {
|
||||
_ = cancel_clone.cancelled() => info!("telegram plugin: cancellation received"),
|
||||
_ = dispatcher.dispatch() => warn!("telegram plugin: dispatcher exited unexpectedly"),
|
||||
_ = auth::whitelist_watchdog(bot_wdg, secrets_dir_wdg, cancel_wdg) => {}
|
||||
}
|
||||
running_clone.store(false, Ordering::Relaxed);
|
||||
info!("telegram plugin: stopped");
|
||||
});
|
||||
|
||||
*self.cancel.lock().await = Some(cancel);
|
||||
*self.handle.lock().await = Some(task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<()> {
|
||||
if let Some(token) = self.cancel.lock().await.take() {
|
||||
token.cancel();
|
||||
}
|
||||
if let Some(h) = self.handle.lock().await.take() {
|
||||
let _ = h.await;
|
||||
}
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::InputFile;
|
||||
|
||||
use core_api::interface_tool::InterfaceTool;
|
||||
use core_api::tts::{TextToSpeech, TtsProvider};
|
||||
|
||||
/// Returns all LLM-callable tools available in a Telegram session.
|
||||
///
|
||||
/// Each tool captures `bot` and `chat_id` so its handler can send content
|
||||
/// back to the user without any additional context.
|
||||
///
|
||||
/// `send_voice_message` is included only when at least one TTS provider is active.
|
||||
///
|
||||
/// # Adding a new tool
|
||||
/// Implement a private `fn <name>_tool(bot: Bot, chat_id: ChatId, ...) -> InterfaceTool`
|
||||
/// and push it into the vec returned by this function.
|
||||
pub(crate) async fn interface_tools(
|
||||
bot: Bot,
|
||||
chat_id: ChatId,
|
||||
tts: &dyn TtsProvider,
|
||||
) -> Vec<InterfaceTool> {
|
||||
let mut tools = vec![send_attachment_tool(bot.clone(), chat_id)];
|
||||
|
||||
if let Some(synth) = tts.get().await {
|
||||
tools.push(send_voice_tool(bot, chat_id, synth));
|
||||
}
|
||||
|
||||
tools
|
||||
}
|
||||
|
||||
// ── send_attachment ───────────────────────────────────────────────────────────
|
||||
|
||||
fn send_attachment_tool(bot: Bot, chat_id: ChatId) -> InterfaceTool {
|
||||
InterfaceTool {
|
||||
definition: json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "send_attachment",
|
||||
"description": "Send a file from the local filesystem to the user on Telegram. Images (jpg/png/webp) and videos (mp4/mov/webm) are sent inline by default; any other type is sent as a document. Set as_document=true to force sending as a downloadable file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute or relative path to the file to send."
|
||||
},
|
||||
"caption": {
|
||||
"type": "string",
|
||||
"description": "Optional caption shown below the file."
|
||||
},
|
||||
"as_document": {
|
||||
"type": "boolean",
|
||||
"description": "Force sending as a downloadable file instead of an inline photo/video (default false)."
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
handler: Arc::new(move |args| {
|
||||
let bot = bot.clone();
|
||||
Box::pin(async move {
|
||||
let file_path = args["file_path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("send_attachment: missing `file_path`"))?;
|
||||
let caption = args["caption"].as_str().map(str::to_string);
|
||||
let as_document = args["as_document"].as_bool().unwrap_or(false);
|
||||
|
||||
let path = std::path::Path::new(file_path);
|
||||
if !path.exists() {
|
||||
anyhow::bail!("send_attachment: file not found: {file_path}");
|
||||
}
|
||||
|
||||
// Present images/videos inline by default; everything else (and
|
||||
// anything when as_document=true) as a downloadable document.
|
||||
let ext = path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let kind = if as_document {
|
||||
"document"
|
||||
} else {
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" | "png" | "webp" => "photo",
|
||||
"mp4" | "mov" | "webm" => "video",
|
||||
_ => "document",
|
||||
}
|
||||
};
|
||||
|
||||
let file = InputFile::file(path);
|
||||
let result = match kind {
|
||||
"photo" => {
|
||||
let mut req = bot.send_photo(chat_id, file);
|
||||
if let Some(cap) = caption { req = req.caption(cap); }
|
||||
req.await.map(|_| ())
|
||||
}
|
||||
"video" => {
|
||||
let mut req = bot.send_video(chat_id, file);
|
||||
if let Some(cap) = caption { req = req.caption(cap); }
|
||||
req.await.map(|_| ())
|
||||
}
|
||||
_ => {
|
||||
let mut req = bot.send_document(chat_id, file);
|
||||
if let Some(cap) = caption { req = req.caption(cap); }
|
||||
req.await.map(|_| ())
|
||||
}
|
||||
};
|
||||
|
||||
result.map_err(|e| anyhow::anyhow!("send_attachment: Telegram error: {e}"))?;
|
||||
Ok(format!("File sent ({kind}): {file_path}"))
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── send_voice_message ────────────────────────────────────────────────────────
|
||||
|
||||
fn send_voice_tool(bot: Bot, chat_id: ChatId, synth: Arc<dyn TextToSpeech>) -> InterfaceTool {
|
||||
let instructions_hint = synth
|
||||
.instructions()
|
||||
.map(|i| format!("\n\nVoice instructions: {i}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
InterfaceTool {
|
||||
definition: json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "send_voice_message",
|
||||
"description": format!(
|
||||
"Synthesise text to speech and send it to the user as a Telegram voice message. \
|
||||
Use when audio is a better medium than text — e.g. short answers, \
|
||||
confirmations, or when the user asks you to speak.{instructions_hint}"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "The text to synthesise and send as audio."
|
||||
}
|
||||
},
|
||||
"required": ["text"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
handler: Arc::new(move |args| {
|
||||
let bot = bot.clone();
|
||||
let synth = Arc::clone(&synth);
|
||||
Box::pin(async move {
|
||||
let text = args["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("send_voice_message: missing `text`"))?;
|
||||
|
||||
let audio = synth
|
||||
.synthesize(text, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("send_voice_message: TTS error: {e}"))?;
|
||||
|
||||
// Telegram only renders Ogg/Opus as a playable voice message, so
|
||||
// transcode whatever the synthesiser produced (mp3, wav, raw pcm…).
|
||||
let audio = to_ogg_opus(audio, synth.output_format())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("send_voice_message: audio conversion failed: {e}"))?;
|
||||
|
||||
bot.send_voice(chat_id, InputFile::memory(audio).file_name("voice.ogg"))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("send_voice_message: Telegram error: {e}"))?;
|
||||
|
||||
Ok("Voice message sent.".to_string())
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transcode synthesised audio to Ogg/Opus — the only format Telegram renders as
|
||||
/// a playable voice message — using ffmpeg over stdin/stdout pipes (no temp files).
|
||||
///
|
||||
/// `format` is the synthesiser's [`TextToSpeech::output_format`]. Ogg/Opus input
|
||||
/// is passed through untouched. Raw `pcm` is headerless, so it is described to
|
||||
/// ffmpeg as the 24 kHz / mono / s16le stream OpenAI and Gemini TTS emit; every
|
||||
/// other (self-describing) container is auto-detected by ffmpeg.
|
||||
async fn to_ogg_opus(audio: Vec<u8>, format: &str) -> anyhow::Result<Vec<u8>> {
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// Already a Telegram-native container — nothing to do.
|
||||
if matches!(format, "opus" | "ogg") {
|
||||
return Ok(audio);
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-loglevel", "error"]);
|
||||
if format == "pcm" {
|
||||
cmd.args(["-f", "s16le", "-ar", "24000", "-ac", "1"]);
|
||||
}
|
||||
cmd.args(["-i", "pipe:0", "-c:a", "libopus", "-b:a", "32k", "-f", "ogg", "pipe:1"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| anyhow::anyhow!(
|
||||
"ffmpeg not available (required to convert {format} audio to Telegram Ogg/Opus): {e}"
|
||||
))?;
|
||||
|
||||
// Feed stdin from a separate task so a full stdout pipe can't deadlock the write.
|
||||
let mut stdin = child.stdin.take().expect("stdin piped");
|
||||
let feeder = tokio::spawn(async move {
|
||||
let _ = stdin.write_all(&audio).await;
|
||||
let _ = stdin.shutdown().await;
|
||||
});
|
||||
|
||||
let out = child.wait_with_output().await
|
||||
.map_err(|e| anyhow::anyhow!("ffmpeg execution failed: {e}"))?;
|
||||
let _ = feeder.await;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"ffmpeg ({format} → Ogg/Opus) exited with {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim(),
|
||||
);
|
||||
}
|
||||
Ok(out.stdout)
|
||||
}
|
||||
Reference in New Issue
Block a user