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 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 async fn transcribe_voice(&self, file_id: &str) -> anyhow::Result<String> {
150 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 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 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 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 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 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 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 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 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 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 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 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 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 let text = if let Some(loc) = &msg.location {
444 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 ch.transcribe_voice(&voice.file_id)
456 .await
457 .unwrap_or_default()
458 } else if let Some(audio) = &msg.audio {
459 ch.transcribe_voice(&audio.file_id)
461 .await
462 .unwrap_or_default()
463 } else if let Some(text_content) = &msg.text {
464 text_content.clone()
466 } else {
467 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}