Skip to main content

apollo/channels/
telegram.rs

1//! Telegram channel — polling mode with progress feedback via message editing.
2//! - Sends "thinking..." message immediately
3//! - Edits it with tool call progress
4//! - Deletes progress and sends final clean message
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11
12use super::formatting::{chunk_outgoing_text, format_outgoing_text, FormatTarget};
13use super::traits::{Channel, IncomingMessage, OutgoingMessage};
14use crate::memory::MemoryBackend;
15
16/// Telegram message length limit
17const TELEGRAM_MAX_LEN: usize = 4096;
18
19#[derive(Clone, Default)]
20pub struct TelegramIngressFilter {
21    pub allowed_chat_ids: Vec<String>,
22    pub allowed_sender_ids: Vec<String>,
23}
24
25impl TelegramIngressFilter {
26    pub fn allows(&self, chat_id: &str, sender_id: &str) -> bool {
27        if !self.allowed_chat_ids.is_empty() && !self.allowed_chat_ids.iter().any(|c| c == chat_id)
28        {
29            return false;
30        }
31        if !self.allowed_sender_ids.is_empty()
32            && !sender_id.is_empty()
33            && !self.allowed_sender_ids.iter().any(|s| s == sender_id)
34        {
35            return false;
36        }
37        true
38    }
39}
40
41#[derive(Clone)]
42pub struct TelegramChannel {
43    bot_token: String,
44    api_base: String,
45    chat_id: i64,
46    client: reqwest::Client,
47    memory: Option<Arc<dyn MemoryBackend>>,
48    ingress: TelegramIngressFilter,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52struct TelegramResponse {
53    ok: bool,
54    result: Option<Vec<Update>>,
55}
56
57#[derive(Debug, Serialize, Deserialize, Clone)]
58struct Update {
59    update_id: i64,
60    message: Option<Message>,
61}
62
63#[derive(Debug, Serialize, Deserialize, Clone)]
64struct Voice {
65    file_id: String,
66    #[serde(default)]
67    file_unique_id: String,
68}
69
70#[derive(Debug, Serialize, Deserialize, Clone)]
71struct Audio {
72    file_id: String,
73    #[serde(default)]
74    file_unique_id: String,
75}
76
77#[derive(Debug, Serialize, Deserialize, Clone)]
78struct Location {
79    latitude: f64,
80    longitude: f64,
81}
82
83#[derive(Debug, Serialize, Deserialize, Clone)]
84struct Sticker {
85    file_id: String,
86    #[serde(default)]
87    file_unique_id: String,
88    #[serde(default)]
89    emoji: Option<String>,
90    #[serde(default)]
91    set_name: Option<String>,
92    #[serde(default)]
93    is_animated: bool,
94    #[serde(default)]
95    is_video: bool,
96}
97
98#[derive(Debug, Serialize, Deserialize, Clone)]
99struct Message {
100    message_id: i64,
101    chat: Chat,
102    text: Option<String>,
103    from: Option<User>,
104    voice: Option<Voice>,
105    audio: Option<Audio>,
106    location: Option<Location>,
107    sticker: Option<Sticker>,
108}
109
110#[derive(Debug, Serialize, Deserialize, Clone)]
111struct Chat {
112    id: i64,
113    #[serde(rename = "type", default)]
114    chat_type: Option<String>,
115}
116
117#[derive(Debug, Serialize, Deserialize, Clone)]
118struct User {
119    id: i64,
120    first_name: Option<String>,
121    username: Option<String>,
122}
123
124impl TelegramChannel {
125    pub fn new(bot_token: String, chat_id: i64) -> Self {
126        Self {
127            bot_token,
128            api_base: "https://api.telegram.org".to_string(),
129            chat_id,
130            client: crate::http::shared(),
131            memory: None,
132            ingress: TelegramIngressFilter::default(),
133        }
134    }
135
136    /// Point the Telegram API at another origin (conformance tests).
137    pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
138        self.api_base = base.into().trim_end_matches('/').to_string();
139        self
140    }
141
142    pub fn with_memory(mut self, memory: Arc<dyn MemoryBackend>) -> Self {
143        self.memory = Some(memory);
144        self
145    }
146
147    pub fn with_ingress_filter(mut self, ingress: TelegramIngressFilter) -> Self {
148        self.ingress = ingress;
149        self
150    }
151
152    fn api_url(&self, method: &str) -> String {
153        format!("{}/bot{}/{}", self.api_base, self.bot_token, method)
154    }
155
156    /// Transcribe voice/audio file using faster-whisper
157    async fn transcribe_voice(&self, file_id: &str) -> anyhow::Result<String> {
158        // 1. Get file info from Telegram API
159        let file_info_url = format!(
160            "{}/bot{}/getFile?file_id={}",
161            self.api_base, self.bot_token, file_id
162        );
163
164        let resp = self.client.get(&file_info_url).send().await?;
165        if !resp.status().is_success() {
166            tracing::error!("Telegram getFile failed: HTTP {}", resp.status());
167            return Ok(String::new());
168        }
169
170        let body: Value = resp.json().await?;
171        if body["ok"].as_bool() != Some(true) {
172            tracing::error!("Telegram getFile error: {:?}", body["error_description"]);
173            return Ok(String::new());
174        }
175
176        let file_path = body["result"]["file_path"]
177            .as_str()
178            .ok_or_else(|| anyhow::anyhow!("No file_path in response"))?;
179
180        // 2. Download the file
181        let download_url = format!("{}/file/bot{}/{}", self.api_base, self.bot_token, file_path);
182
183        let file_resp = self.client.get(&download_url).send().await?;
184        if !file_resp.status().is_success() {
185            tracing::error!("Telegram download failed: HTTP {}", file_resp.status());
186            return Ok(String::new());
187        }
188        let file_bytes = file_resp.bytes().await?;
189
190        // 3. Create temp file
191        let temp_dir = std::env::temp_dir();
192        let temp_path = temp_dir.join(format!("apollo_voice_{}.ogg", uuid::Uuid::new_v4()));
193        tokio::fs::write(&temp_path, file_bytes).await?;
194
195        // 4. Call faster-whisper via Python (async)
196        // Check for python3 first
197        let py_check = tokio::process::Command::new("python3")
198            .arg("--version")
199            .output()
200            .await;
201
202        if py_check.is_err() {
203            tracing::warn!("python3 not found, skipping transcription");
204            let _ = tokio::fs::remove_file(&temp_path).await;
205            return Ok(
206                "[Voice message received but python3 is missing for transcription]".to_string(),
207            );
208        }
209
210        let output = tokio::process::Command::new("python3")
211            .arg("-c")
212            .arg(format!(
213                r#"
214import sys
215try:
216    from faster_whisper import WhisperModel
217    model = WhisperModel("tiny", device="cpu", compute_type="int8")
218    segments, _ = model.transcribe(r"{}", language="en")
219    text = " ".join([segment.text for segment in segments])
220    print(text.strip())
221except ImportError:
222    print("ERROR: faster-whisper not installed")
223    sys.exit(1)
224except Exception as e:
225    print(f"ERROR: {{e}}")
226    sys.exit(1)
227"#,
228                temp_path.display()
229            ))
230            .output()
231            .await?;
232
233        // Clean up temp file
234        let _ = tokio::fs::remove_file(&temp_path).await;
235
236        if output.status.success() {
237            let transcription = String::from_utf8_lossy(&output.stdout).trim().to_string();
238            if transcription.is_empty() {
239                Ok("[Voice message: no speech detected]".to_string())
240            } else {
241                Ok(transcription)
242            }
243        } else {
244            let err_msg = String::from_utf8_lossy(&output.stdout);
245            if err_msg.contains("faster-whisper not installed") {
246                Ok("[Voice message: faster-whisper not installed]".to_string())
247            } else {
248                tracing::error!("Transcription failed: {}", err_msg);
249                Ok("[Voice message: transcription failed]".to_string())
250            }
251        }
252    }
253
254    /// Send a message and return its message_id (of the last chunk if split)
255    pub async fn send_message(&self, text: &str) -> anyhow::Result<i64> {
256        self.send_message_to(self.chat_id, text).await
257    }
258
259    async fn send_message_to(&self, chat_id: i64, text: &str) -> anyhow::Result<i64> {
260        let formatted = format_outgoing_text(FormatTarget::Telegram, text);
261        let chunks = chunk_outgoing_text(FormatTarget::Telegram, &formatted, TELEGRAM_MAX_LEN);
262
263        let mut last_msg_id = 0;
264
265        for (i, chunk) in chunks.iter().enumerate() {
266            // Try with Markdown first
267            let resp = self
268                .client
269                .post(self.api_url("sendMessage"))
270                .json(&serde_json::json!({
271                    "chat_id": chat_id,
272                    "text": chunk,
273                    "parse_mode": "Markdown",
274                }))
275                .send()
276                .await?;
277
278            let body: Value = resp.json().await?;
279
280            if body["ok"].as_bool() == Some(true) {
281                last_msg_id = body["result"]["message_id"].as_i64().unwrap_or(0);
282            } else {
283                // Markdown failed, retry without parse_mode
284                let resp = self
285                    .client
286                    .post(self.api_url("sendMessage"))
287                    .json(&serde_json::json!({
288                        "chat_id": chat_id,
289                        "text": chunk,
290                    }))
291                    .send()
292                    .await?;
293                let body: Value = resp.json().await?;
294                last_msg_id = body["result"]["message_id"].as_i64().unwrap_or(0);
295            }
296
297            // Add delay between chunks to avoid rate limiting
298            if i < chunks.len() - 1 {
299                tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
300            }
301        }
302
303        Ok(last_msg_id)
304    }
305
306    /// Edit an existing message
307    pub async fn edit_message(&self, message_id: i64, text: &str) -> anyhow::Result<()> {
308        let formatted = format_outgoing_text(FormatTarget::Telegram, text);
309        let edit_text = formatted.chars().take(TELEGRAM_MAX_LEN).collect::<String>();
310        let _ = self
311            .client
312            .post(self.api_url("editMessageText"))
313            .json(&serde_json::json!({
314                "chat_id": self.chat_id,
315                "message_id": message_id,
316                "text": edit_text,
317            }))
318            .send()
319            .await?;
320        Ok(())
321    }
322
323    async fn sticker_text(&self, sticker: &Sticker) -> anyhow::Result<String> {
324        if let Some(memory) = &self.memory {
325            if !sticker.file_unique_id.is_empty() {
326                if let Some(cached) = memory.get_sticker_cache(&sticker.file_unique_id).await? {
327                    return Ok(cached);
328                }
329            }
330        }
331
332        let mut parts = vec!["Sticker received".to_string()];
333        if let Some(emoji) = &sticker.emoji {
334            if !emoji.is_empty() {
335                parts.push(format!("emoji {}", emoji));
336            }
337        }
338        if let Some(set_name) = &sticker.set_name {
339            if !set_name.is_empty() {
340                parts.push(format!("set {}", set_name));
341            }
342        }
343        if sticker.is_animated {
344            parts.push("animated".to_string());
345        }
346        if sticker.is_video {
347            parts.push("video".to_string());
348        }
349
350        let description = format!("🎨 {}", parts.join(" • "));
351
352        if let Some(memory) = &self.memory {
353            if !sticker.file_unique_id.is_empty() {
354                let _ = memory
355                    .store_sticker_cache(&sticker.file_unique_id, &sticker.file_id, &description)
356                    .await;
357            }
358        }
359
360        Ok(description)
361    }
362
363    /// Delete a message
364    pub async fn delete_message(&self, message_id: i64) -> anyhow::Result<()> {
365        let _ = self
366            .client
367            .post(self.api_url("deleteMessage"))
368            .json(&serde_json::json!({
369                "chat_id": self.chat_id,
370                "message_id": message_id,
371            }))
372            .send()
373            .await?;
374        Ok(())
375    }
376
377    /// Send typing indicator
378    pub async fn send_typing(&self, _chat_id: &str) -> anyhow::Result<()> {
379        let _ = self
380            .client
381            .post(self.api_url("sendChatAction"))
382            .json(&serde_json::json!({
383                "chat_id": self.chat_id,
384                "action": "typing",
385            }))
386            .send()
387            .await?;
388        Ok(())
389    }
390
391    /// Get updates (long polling)
392    async fn get_updates(&self, offset: i64) -> anyhow::Result<Vec<Update>> {
393        let url = format!(
394            "{}?offset={}&limit=100&timeout=30",
395            self.api_url("getUpdates"),
396            offset
397        );
398
399        match self.client.get(&url).send().await {
400            Ok(resp) => {
401                if let Ok(data) = resp.json::<TelegramResponse>().await {
402                    Ok(data.result.unwrap_or_default())
403                } else {
404                    Ok(Vec::new())
405                }
406            }
407            Err(_) => Ok(Vec::new()),
408        }
409    }
410}
411
412#[async_trait]
413impl Channel for TelegramChannel {
414    fn name(&self) -> &str {
415        "telegram"
416    }
417
418    async fn start(&mut self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>> {
419        let (tx, rx) = mpsc::channel(100);
420        let bot_token = self.bot_token.clone();
421        let api_base = self.api_base.clone();
422        let chat_id = self.chat_id;
423        let client = self.client.clone();
424        let memory = self.memory.clone();
425        let ingress = self.ingress.clone();
426
427        tokio::spawn(async move {
428            let ch = TelegramChannel {
429                bot_token,
430                api_base,
431                chat_id,
432                client,
433                memory,
434                ingress,
435            };
436            let mut offset = 0;
437            loop {
438                if let Ok(updates) = ch.get_updates(offset).await {
439                    for update in updates {
440                        if let Some(msg) = &update.message {
441                            let from = msg.from.as_ref();
442                            let is_group = msg
443                                .chat
444                                .chat_type
445                                .as_deref()
446                                .map(|t| t == "group" || t == "supergroup")
447                                .unwrap_or(false);
448
449                            // Determine message content based on message type
450                            let text = if let Some(loc) = &msg.location {
451                                // Location: format as coordinates with Google Maps link
452                                format!(
453                                    "📍 Location: {}, {} (https://maps.google.com/?q={},{})",
454                                    loc.latitude, loc.longitude, loc.latitude, loc.longitude
455                                )
456                            } else if let Some(sticker) = &msg.sticker {
457                                ch.sticker_text(sticker)
458                                    .await
459                                    .unwrap_or_else(|_| "🎨 Sticker received".to_string())
460                            } else if let Some(voice) = &msg.voice {
461                                // Voice: transcribe with faster-whisper
462                                ch.transcribe_voice(&voice.file_id)
463                                    .await
464                                    .unwrap_or_default()
465                            } else if let Some(audio) = &msg.audio {
466                                // Audio: transcribe with faster-whisper
467                                ch.transcribe_voice(&audio.file_id)
468                                    .await
469                                    .unwrap_or_default()
470                            } else if let Some(text_content) = &msg.text {
471                                // Regular text
472                                text_content.clone()
473                            } else {
474                                // Unknown message type, skip
475                                continue;
476                            };
477
478                            if text.is_empty() {
479                                continue;
480                            }
481
482                            let chat_id_str = msg.chat.id.to_string();
483                            let sender_id_str = from.map(|u| u.id.to_string()).unwrap_or_default();
484                            if !ch.ingress.allows(&chat_id_str, &sender_id_str) {
485                                tracing::warn!(
486                                    "Telegram ingress denied chat={} sender={}",
487                                    chat_id_str,
488                                    sender_id_str
489                                );
490                                continue;
491                            }
492
493                            let incoming = IncomingMessage {
494                                id: msg.message_id.to_string(),
495                                sender_id: from.map(|u| u.id.to_string()).unwrap_or_default(),
496                                sender_name: from.and_then(|u| {
497                                    u.username.clone().or_else(|| u.first_name.clone())
498                                }),
499                                chat_id: msg.chat.id.to_string(),
500                                text,
501                                is_group,
502                                reply_to: None,
503                                timestamp: chrono::Utc::now(),
504                            };
505                            let _ = tx.send(incoming).await;
506                        }
507                        offset = update.update_id + 1;
508                    }
509                }
510                tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
511            }
512        });
513
514        Ok(rx)
515    }
516
517    async fn send(&self, message: OutgoingMessage) -> anyhow::Result<Option<String>> {
518        let chat_id = message.chat_id.parse::<i64>()?;
519        let msg_id = self.send_message_to(chat_id, &message.text).await?;
520        if msg_id > 0 {
521            Ok(Some(msg_id.to_string()))
522        } else {
523            Ok(None)
524        }
525    }
526
527    async fn send_typing(&self, chat_id: &str) -> anyhow::Result<()> {
528        let chat_id = chat_id.parse::<i64>()?;
529        let _ = self
530            .client
531            .post(self.api_url("sendChatAction"))
532            .json(&serde_json::json!({
533                "chat_id": chat_id,
534                "action": "typing",
535            }))
536            .send()
537            .await?;
538        Ok(())
539    }
540
541    async fn edit(&self, _chat_id: &str, message_id: &str, new_text: &str) -> anyhow::Result<()> {
542        let message_id = message_id.parse::<i64>().unwrap_or(0);
543        if message_id > 0 {
544            self.edit_message(message_id, new_text).await?;
545        }
546        Ok(())
547    }
548
549    async fn stop(&mut self) -> anyhow::Result<()> {
550        Ok(())
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::memory::surreal::SurrealMemory;
558
559    #[tokio::test]
560    async fn sticker_cache_is_used_when_available() {
561        let dir = tempfile::tempdir().unwrap();
562        let memory: Arc<dyn MemoryBackend> =
563            Arc::new(SurrealMemory::new(dir.path()).await.unwrap());
564        memory
565            .store_sticker_cache("uniq-1", "file-1", "🎨 cached sticker")
566            .await
567            .unwrap();
568
569        let channel = TelegramChannel::new("token".to_string(), 1).with_memory(memory);
570        let sticker = Sticker {
571            file_id: "file-1".to_string(),
572            file_unique_id: "uniq-1".to_string(),
573            emoji: Some("🙂".to_string()),
574            set_name: Some("test_set".to_string()),
575            is_animated: false,
576            is_video: false,
577        };
578
579        let text = channel.sticker_text(&sticker).await.unwrap();
580        assert_eq!(text, "🎨 cached sticker");
581    }
582}