1use 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
16const 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 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 async fn transcribe_voice(&self, file_id: &str) -> anyhow::Result<String> {
158 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 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 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 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 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 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 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 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 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 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 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 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 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 let text = if let Some(loc) = &msg.location {
451 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 ch.transcribe_voice(&voice.file_id)
463 .await
464 .unwrap_or_default()
465 } else if let Some(audio) = &msg.audio {
466 ch.transcribe_voice(&audio.file_id)
468 .await
469 .unwrap_or_default()
470 } else if let Some(text_content) = &msg.text {
471 text_content.clone()
473 } else {
474 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}