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