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::{
14 Channel, IncomingMessage, MediaKind, MediaSource, OutgoingMedia, OutgoingMessage,
15};
16use crate::memory::MemoryBackend;
17
18const 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 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 async fn transcribe_voice(&self, file_id: &str) -> anyhow::Result<String> {
160 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 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 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 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 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 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 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 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 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 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 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 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 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 let text = if let Some(loc) = &msg.location {
453 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 ch.transcribe_voice(&voice.file_id)
465 .await
466 .unwrap_or_default()
467 } else if let Some(audio) = &msg.audio {
468 ch.transcribe_voice(&audio.file_id)
470 .await
471 .unwrap_or_default()
472 } else if let Some(text_content) = &msg.text {
473 text_content.clone()
475 } else {
476 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 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 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}