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