1use std::collections::HashMap;
6use std::sync::atomic::{AtomicI64, Ordering};
7use std::sync::OnceLock;
8use std::time::Duration;
9
10use tokio::sync::{mpsc, Mutex as AsyncMutex};
11
12use super::super::platform::{
13 Button, CallbackQuery, Capabilities, Inbound, InboundMessage, MessageRef, OutboundMessage,
14 Platform, PlatformError, PlatformResult, ReplyCtx,
15};
16use super::super::render::{chunk_message, MAX_MESSAGE_CHARS};
17
18const DEFAULT_BASE_URL: &str = "https://api.telegram.org";
19const LONG_POLL_TIMEOUT_SECS: u64 = 30;
22const RETRY_BACKOFF: Duration = Duration::from_secs(5);
24const DEFAULT_RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(1);
27
28fn http_client() -> &'static reqwest::Client {
32 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
33 CLIENT.get_or_init(reqwest::Client::new)
34}
35
36#[derive(Debug, serde::Deserialize)]
37struct TelegramResponse<T> {
38 ok: bool,
39 #[serde(default)]
40 result: Option<T>,
41 #[serde(default)]
42 description: Option<String>,
43}
44
45#[derive(Debug, Clone, serde::Deserialize)]
46struct TelegramUpdate {
47 update_id: i64,
48 #[serde(default)]
49 message: Option<TelegramMessage>,
50 #[serde(default)]
51 callback_query: Option<TelegramCallbackQuery>,
52}
53
54#[derive(Debug, Clone, serde::Deserialize)]
60struct TelegramCallbackQuery {
61 id: String,
62 #[serde(default)]
63 from: Option<TelegramUser>,
64 #[serde(default)]
65 message: Option<TelegramMessage>,
66 #[serde(default)]
67 data: Option<String>,
68}
69
70#[derive(Debug, Clone, Default, serde::Deserialize)]
71struct TelegramMessage {
72 #[serde(default)]
73 message_id: i64,
74 #[serde(default)]
76 date: i64,
77 #[serde(default)]
78 chat: TelegramChat,
79 #[serde(default)]
80 from: Option<TelegramUser>,
81 #[serde(default)]
82 text: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, serde::Deserialize)]
86struct TelegramChat {
87 #[serde(default)]
88 id: i64,
89}
90
91#[derive(Debug, Clone, serde::Deserialize)]
92struct TelegramUser {
93 id: i64,
94}
95
96struct RateLimiter {
102 next_allowed: AsyncMutex<HashMap<String, tokio::time::Instant>>,
103 min_interval: Duration,
104}
105
106impl RateLimiter {
107 fn new(min_interval: Duration) -> Self {
108 Self {
109 next_allowed: AsyncMutex::new(HashMap::new()),
110 min_interval,
111 }
112 }
113
114 async fn wait(&self, key: &str) {
115 let now = tokio::time::Instant::now();
116 let scheduled = {
117 let mut guard = self.next_allowed.lock().await;
118 let earliest = guard.get(key).copied().unwrap_or(now);
119 let scheduled = earliest.max(now);
120 guard.insert(key.to_string(), scheduled + self.min_interval);
121 guard.retain(|_, next_allowed| *next_allowed > now);
132 scheduled
133 };
134 if scheduled > now {
135 tokio::time::sleep(scheduled - now).await;
136 }
137 }
138
139 #[cfg(test)]
140 async fn tracked_chat_count(&self) -> usize {
141 self.next_allowed.lock().await.len()
142 }
143}
144
145pub struct TelegramPlatform {
146 token: String,
147 base_url: String,
148 offset: AtomicI64,
149 rate_limiter: RateLimiter,
150}
151
152impl TelegramPlatform {
153 pub fn new(token: String) -> Self {
156 Self::with_options(
157 token,
158 DEFAULT_BASE_URL.to_string(),
159 DEFAULT_RATE_LIMIT_INTERVAL,
160 )
161 }
162
163 pub fn with_options(token: String, base_url: String, rate_limit_interval: Duration) -> Self {
167 Self {
168 token,
169 base_url,
170 offset: AtomicI64::new(0),
171 rate_limiter: RateLimiter::new(rate_limit_interval),
172 }
173 }
174
175 fn api_url(&self, method: &str) -> String {
176 format!(
177 "{}/bot{}/{method}",
178 self.base_url.trim_end_matches('/'),
179 self.token
180 )
181 }
182
183 fn sanitize_error(&self, error: reqwest::Error) -> String {
194 let text = error.without_url().to_string();
195 if self.token.is_empty() {
196 return text;
197 }
198 text.replace(&self.token, "[REDACTED]")
199 }
200
201 async fn get_updates(
202 &self,
203 offset: i64,
204 timeout_secs: u64,
205 ) -> PlatformResult<Vec<TelegramUpdate>> {
206 let allowed_updates =
211 serde_json::to_string(&["message", "callback_query"]).unwrap_or_default();
212 let response = http_client()
213 .get(self.api_url("getUpdates"))
214 .query(&[
215 ("offset", offset.to_string()),
216 ("timeout", timeout_secs.to_string()),
217 ("allowed_updates", allowed_updates),
218 ])
219 .timeout(Duration::from_secs(timeout_secs + 15))
223 .send()
224 .await
225 .map_err(|error| {
226 PlatformError::other(format!(
227 "getUpdates request failed: {}",
228 self.sanitize_error(error)
229 ))
230 })?;
231
232 let parsed: TelegramResponse<Vec<TelegramUpdate>> =
233 response.json().await.map_err(|error| {
234 PlatformError::other(format!(
235 "getUpdates response parse failed: {}",
236 self.sanitize_error(error)
237 ))
238 })?;
239
240 if !parsed.ok {
241 return Err(PlatformError::other(
242 parsed
243 .description
244 .unwrap_or_else(|| "getUpdates returned ok=false".to_string()),
245 ));
246 }
247 Ok(parsed.result.unwrap_or_default())
248 }
249
250 fn to_inbound_message(update: &TelegramUpdate) -> Option<InboundMessage> {
255 let message = update.message.as_ref()?;
256 let text = message.text.clone()?;
257 let from = message.from.as_ref()?;
258 let sent_at = chrono::DateTime::<chrono::Utc>::from_timestamp(message.date, 0)
259 .unwrap_or_else(chrono::Utc::now);
260 Some(InboundMessage {
261 platform: "telegram".to_string(),
262 chat_id: message.chat.id.to_string(),
263 user_id: from.id.to_string(),
264 message_id: update.update_id.to_string(),
265 sent_at,
266 text,
267 reply_ctx: ReplyCtx(serde_json::json!({ "chat_id": message.chat.id })),
268 })
269 }
270
271 fn to_inbound_callback(update: &TelegramUpdate) -> Option<Inbound> {
277 let callback_query = update.callback_query.as_ref()?;
278 let from = callback_query.from.as_ref()?;
279 let message = callback_query.message.as_ref()?;
280 let data = callback_query.data.clone()?;
281 Some(Inbound::Callback(CallbackQuery {
282 platform: "telegram".to_string(),
283 chat_id: message.chat.id.to_string(),
284 user_id: from.id.to_string(),
285 callback_query_id: callback_query.id.clone(),
286 data,
287 reply_ctx: ReplyCtx(serde_json::json!({ "chat_id": message.chat.id })),
288 }))
289 }
290
291 async fn poll_once(&self, timeout_secs: u64) -> PlatformResult<Vec<Inbound>> {
300 let offset = self.offset.load(Ordering::SeqCst);
301 let updates = self.get_updates(offset, timeout_secs).await?;
302
303 let mut events = Vec::with_capacity(updates.len());
304 for update in &updates {
305 self.offset.store(update.update_id + 1, Ordering::SeqCst);
309 if let Some(callback) = Self::to_inbound_callback(update) {
310 events.push(callback);
311 } else if let Some(message) = Self::to_inbound_message(update) {
312 events.push(Inbound::Message(message));
313 }
314 }
315 Ok(events)
316 }
317
318 fn extract_chat_id(ctx: &ReplyCtx) -> PlatformResult<String> {
319 Self::extract_chat_id_value(&ctx.0)
320 }
321
322 fn extract_chat_id_value(value: &serde_json::Value) -> PlatformResult<String> {
323 value
324 .get("chat_id")
325 .and_then(|v| {
326 v.as_i64()
327 .map(|n| n.to_string())
328 .or_else(|| v.as_str().map(|s| s.to_string()))
329 })
330 .ok_or_else(|| PlatformError::other("reply_ctx is missing chat_id"))
331 }
332
333 fn build_reply_markup(buttons: &[Vec<Button>]) -> String {
337 let rows: Vec<Vec<serde_json::Value>> = buttons
338 .iter()
339 .map(|row| {
340 row.iter()
341 .map(|button| {
342 serde_json::json!({
343 "text": button.label,
344 "callback_data": button.callback_data,
345 })
346 })
347 .collect()
348 })
349 .collect();
350 serde_json::json!({ "inline_keyboard": rows }).to_string()
351 }
352}
353
354#[async_trait::async_trait]
355impl Platform for TelegramPlatform {
356 fn name(&self) -> &str {
357 "telegram"
358 }
359
360 fn capabilities(&self) -> Capabilities {
361 Capabilities {
362 buttons: true,
363 edit_message: true,
364 images: false,
365 files: false,
366 }
367 }
368
369 async fn start(&self, inbound: mpsc::Sender<Inbound>) -> PlatformResult<()> {
370 match self.poll_once(0).await {
374 Ok(drained) if !drained.is_empty() => {
375 tracing::info!(
376 "connect: telegram drained {} stale update(s) on start",
377 drained.len()
378 );
379 }
380 Ok(_) => {}
381 Err(error) => {
382 tracing::warn!("connect: telegram drain-on-start failed (continuing): {error}");
383 }
384 }
385
386 loop {
387 match self.poll_once(LONG_POLL_TIMEOUT_SECS).await {
388 Ok(events) => {
389 for event in events {
390 if inbound.send(event).await.is_err() {
391 return Ok(());
393 }
394 }
395 }
396 Err(error) => {
397 tracing::warn!("connect: telegram getUpdates failed, retrying: {error}");
398 tokio::time::sleep(RETRY_BACKOFF).await;
399 }
400 }
401 }
402 }
403
404 async fn reply(&self, ctx: &ReplyCtx, msg: OutboundMessage) -> PlatformResult<MessageRef> {
405 let chat_id = Self::extract_chat_id(ctx)?;
406 let mut last_message_id = None;
407 let reply_markup = msg.buttons.as_deref().map(Self::build_reply_markup);
408 let chunks = chunk_message(&msg.text, MAX_MESSAGE_CHARS);
409 let chunk_count = chunks.len();
410
411 for (index, chunk) in chunks.into_iter().enumerate() {
412 self.rate_limiter.wait(&chat_id).await;
413
414 let mut form: Vec<(&str, String)> = vec![("chat_id", chat_id.clone()), ("text", chunk)];
415 if index + 1 == chunk_count {
418 if let Some(markup) = &reply_markup {
419 form.push(("reply_markup", markup.clone()));
420 }
421 }
422
423 let response = http_client()
424 .post(self.api_url("sendMessage"))
425 .form(&form)
426 .send()
427 .await
428 .map_err(|error| {
429 PlatformError::other(format!(
430 "sendMessage request failed: {}",
431 self.sanitize_error(error)
432 ))
433 })?;
434
435 let parsed: TelegramResponse<TelegramMessage> =
436 response.json().await.map_err(|error| {
437 PlatformError::other(format!(
438 "sendMessage response parse failed: {}",
439 self.sanitize_error(error)
440 ))
441 })?;
442
443 if !parsed.ok {
444 return Err(PlatformError::other(
445 parsed
446 .description
447 .unwrap_or_else(|| "sendMessage returned ok=false".to_string()),
448 ));
449 }
450 last_message_id = parsed.result.map(|m| m.message_id);
451 }
452
453 Ok(MessageRef(serde_json::json!({
454 "chat_id": chat_id,
455 "message_id": last_message_id,
456 })))
457 }
458
459 async fn edit(&self, msg_ref: &MessageRef, new: OutboundMessage) -> PlatformResult<()> {
460 let chat_id = Self::extract_chat_id_value(&msg_ref.0)?;
461 let message_id = msg_ref
462 .0
463 .get("message_id")
464 .and_then(|v| v.as_i64())
465 .ok_or_else(|| PlatformError::other("message_ref is missing message_id"))?;
466
467 self.rate_limiter.wait(&chat_id).await;
468
469 let mut form: Vec<(&str, String)> = vec![
470 ("chat_id", chat_id),
471 ("message_id", message_id.to_string()),
472 ("text", new.text),
473 ];
474 if let Some(buttons) = &new.buttons {
475 form.push(("reply_markup", Self::build_reply_markup(buttons)));
476 }
477
478 let response = http_client()
479 .post(self.api_url("editMessageText"))
480 .form(&form)
481 .send()
482 .await
483 .map_err(|error| {
484 PlatformError::other(format!(
485 "editMessageText request failed: {}",
486 self.sanitize_error(error)
487 ))
488 })?;
489
490 let parsed: TelegramResponse<TelegramMessage> = response.json().await.map_err(|error| {
491 PlatformError::other(format!(
492 "editMessageText response parse failed: {}",
493 self.sanitize_error(error)
494 ))
495 })?;
496
497 if !parsed.ok {
498 return Err(PlatformError::other(parsed.description.unwrap_or_else(
502 || "editMessageText returned ok=false".to_string(),
503 )));
504 }
505 Ok(())
506 }
507
508 async fn answer_callback(
509 &self,
510 callback_query_id: &str,
511 text: Option<&str>,
512 ) -> PlatformResult<()> {
513 let mut form: Vec<(&str, String)> =
518 vec![("callback_query_id", callback_query_id.to_string())];
519 if let Some(text) = text {
520 form.push(("text", text.to_string()));
521 }
522
523 let response = http_client()
524 .post(self.api_url("answerCallbackQuery"))
525 .form(&form)
526 .send()
527 .await
528 .map_err(|error| {
529 PlatformError::other(format!(
530 "answerCallbackQuery request failed: {}",
531 self.sanitize_error(error)
532 ))
533 })?;
534
535 let parsed: TelegramResponse<bool> = response.json().await.map_err(|error| {
536 PlatformError::other(format!(
537 "answerCallbackQuery response parse failed: {}",
538 self.sanitize_error(error)
539 ))
540 })?;
541
542 if !parsed.ok {
543 return Err(PlatformError::other(parsed.description.unwrap_or_else(
544 || "answerCallbackQuery returned ok=false".to_string(),
545 )));
546 }
547 Ok(())
548 }
549
550 async fn stop(&self) -> PlatformResult<()> {
551 Ok(())
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 fn platform_with_stub(base_url: String) -> TelegramPlatform {
560 TelegramPlatform::with_options(
561 "test-token".to_string(),
562 base_url,
563 Duration::from_millis(50),
564 )
565 }
566
567 async fn wait_for_requests(
568 server: &wiremock::MockServer,
569 expected: usize,
570 ) -> Vec<wiremock::Request> {
571 for _ in 0..100 {
572 if let Some(requests) = server.received_requests().await {
573 if requests.len() >= expected {
574 return requests;
575 }
576 }
577 tokio::time::sleep(Duration::from_millis(10)).await;
578 }
579 server.received_requests().await.unwrap_or_default()
580 }
581
582 #[tokio::test]
583 async fn poll_once_advances_offset_past_every_returned_update() {
584 let server = wiremock::MockServer::start().await;
585 wiremock::Mock::given(wiremock::matchers::method("GET"))
586 .and(wiremock::matchers::path("/bottest-token/getUpdates"))
587 .respond_with(
588 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
589 "ok": true,
590 "result": [
591 {
592 "update_id": 100,
593 "message": {
594 "message_id": 1,
595 "date": 1_700_000_000,
596 "chat": { "id": 42 },
597 "from": { "id": 7 },
598 "text": "hello"
599 }
600 },
601 {
602 "update_id": 101
603 }
605 ]
606 })),
607 )
608 .mount(&server)
609 .await;
610
611 let platform = platform_with_stub(server.uri());
612 let events = platform.poll_once(30).await.expect("poll_once succeeds");
613
614 assert_eq!(events.len(), 1);
615 match &events[0] {
616 Inbound::Message(message) => {
617 assert_eq!(message.chat_id, "42");
618 assert_eq!(message.user_id, "7");
619 assert_eq!(message.message_id, "100");
620 assert_eq!(message.text, "hello");
621 }
622 Inbound::Callback(_) => panic!("expected a message event"),
623 }
624 assert_eq!(platform.offset.load(Ordering::SeqCst), 102);
625 }
626
627 #[tokio::test]
628 async fn poll_once_next_call_requests_the_advanced_offset() {
629 let server = wiremock::MockServer::start().await;
630 wiremock::Mock::given(wiremock::matchers::method("GET"))
631 .and(wiremock::matchers::path("/bottest-token/getUpdates"))
632 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
633 "ok": true,
634 "result": [
635 {
636 "update_id": 5,
637 "message": {
638 "message_id": 1, "date": 1, "chat": {"id": 1}, "from": {"id": 1}, "text": "hi"
639 }
640 }
641 ]
642 })))
643 .mount(&server)
644 .await;
645
646 let platform = platform_with_stub(server.uri());
647 platform.poll_once(30).await.unwrap();
648 assert_eq!(platform.offset.load(Ordering::SeqCst), 6);
649
650 let requests = wait_for_requests(&server, 1).await;
651 let query: HashMap<String, String> = requests[0]
652 .url
653 .query_pairs()
654 .map(|(k, v)| (k.to_string(), v.to_string()))
655 .collect();
656 assert_eq!(query.get("offset"), Some(&"0".to_string()));
657
658 let _ = platform.poll_once(30).await;
660 let requests = wait_for_requests(&server, 2).await;
661 let query: HashMap<String, String> = requests[1]
662 .url
663 .query_pairs()
664 .map(|(k, v)| (k.to_string(), v.to_string()))
665 .collect();
666 assert_eq!(query.get("offset"), Some(&"6".to_string()));
667 }
668
669 #[tokio::test]
670 async fn reply_chunks_long_text_into_multiple_send_message_calls() {
671 let server = wiremock::MockServer::start().await;
672 wiremock::Mock::given(wiremock::matchers::method("POST"))
673 .and(wiremock::matchers::path("/bottest-token/sendMessage"))
674 .respond_with(
675 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
676 "ok": true,
677 "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
678 })),
679 )
680 .mount(&server)
681 .await;
682
683 let platform = platform_with_stub(server.uri());
684 let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
685 let long_text = "a".repeat(9000); platform
688 .reply(&ctx, OutboundMessage::text(long_text))
689 .await
690 .expect("reply succeeds");
691
692 let requests = wait_for_requests(&server, 3).await;
693 assert_eq!(requests.len(), 3, "expected exactly 3 sendMessage calls");
694 }
695
696 #[tokio::test]
697 async fn reply_short_text_sends_exactly_one_message() {
698 let server = wiremock::MockServer::start().await;
699 wiremock::Mock::given(wiremock::matchers::method("POST"))
700 .and(wiremock::matchers::path("/bottest-token/sendMessage"))
701 .respond_with(
702 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
703 "ok": true,
704 "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
705 })),
706 )
707 .mount(&server)
708 .await;
709
710 let platform = platform_with_stub(server.uri());
711 let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
712
713 platform
714 .reply(&ctx, OutboundMessage::text("hello"))
715 .await
716 .expect("reply succeeds");
717
718 let requests = wait_for_requests(&server, 1).await;
719 assert_eq!(requests.len(), 1);
720 }
721
722 #[tokio::test]
723 async fn reply_rate_limits_consecutive_sends_to_the_same_chat() {
724 let server = wiremock::MockServer::start().await;
725 wiremock::Mock::given(wiremock::matchers::method("POST"))
726 .and(wiremock::matchers::path("/bottest-token/sendMessage"))
727 .respond_with(
728 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
729 "ok": true,
730 "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
731 })),
732 )
733 .mount(&server)
734 .await;
735
736 let platform = platform_with_stub(server.uri());
738 let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
739
740 let start = tokio::time::Instant::now();
741 platform
742 .reply(&ctx, OutboundMessage::text("first"))
743 .await
744 .unwrap();
745 platform
746 .reply(&ctx, OutboundMessage::text("second"))
747 .await
748 .unwrap();
749 let elapsed = start.elapsed();
750
751 assert!(
752 elapsed >= Duration::from_millis(50),
753 "second send to the same chat must block for the rate-limit interval, elapsed={elapsed:?}"
754 );
755 }
756
757 #[tokio::test]
758 async fn reply_does_not_rate_limit_different_chats() {
759 let server = wiremock::MockServer::start().await;
760 wiremock::Mock::given(wiremock::matchers::method("POST"))
761 .and(wiremock::matchers::path("/bottest-token/sendMessage"))
762 .respond_with(
763 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
764 "ok": true,
765 "result": { "message_id": 1, "date": 1, "chat": { "id": 1 } }
766 })),
767 )
768 .mount(&server)
769 .await;
770
771 let platform = platform_with_stub(server.uri());
772 let ctx_a = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
773 let ctx_b = ReplyCtx(serde_json::json!({ "chat_id": 2 }));
774
775 let start = tokio::time::Instant::now();
776 platform
777 .reply(&ctx_a, OutboundMessage::text("first"))
778 .await
779 .unwrap();
780 platform
781 .reply(&ctx_b, OutboundMessage::text("second"))
782 .await
783 .unwrap();
784 let elapsed = start.elapsed();
785
786 assert!(
787 elapsed < Duration::from_millis(50),
788 "sends to DIFFERENT chats must not share a rate-limit slot, elapsed={elapsed:?}"
789 );
790 }
791
792 #[tokio::test]
797 async fn rate_limiter_sweeps_stale_entries_instead_of_growing_unbounded() {
798 let limiter = RateLimiter::new(Duration::from_millis(20));
799
800 limiter.wait("chat-1").await;
801 assert_eq!(limiter.tracked_chat_count().await, 1);
802
803 tokio::time::sleep(Duration::from_millis(40)).await;
806
807 limiter.wait("chat-2").await;
810 assert_eq!(
811 limiter.tracked_chat_count().await,
812 1,
813 "stale chat-1 entry must have been swept when chat-2 was scheduled"
814 );
815
816 for i in 0..50 {
821 tokio::time::sleep(Duration::from_millis(25)).await;
822 limiter.wait(&format!("burst-chat-{i}")).await;
823 assert!(
824 limiter.tracked_chat_count().await <= 2,
825 "map grew unbounded after {i} one-shot chats"
826 );
827 }
828 }
829
830 #[tokio::test]
831 async fn capabilities_advertise_buttons_and_edit_message() {
832 let platform = platform_with_stub("http://localhost:0".to_string());
833 let caps = platform.capabilities();
834 assert!(caps.buttons);
835 assert!(caps.edit_message);
836 assert!(!caps.images);
837 assert!(!caps.files);
838 }
839
840 #[tokio::test]
841 async fn get_updates_requests_message_and_callback_query_updates() {
842 let server = wiremock::MockServer::start().await;
843 wiremock::Mock::given(wiremock::matchers::method("GET"))
844 .and(wiremock::matchers::path("/bottest-token/getUpdates"))
845 .respond_with(
846 wiremock::ResponseTemplate::new(200)
847 .set_body_json(serde_json::json!({ "ok": true, "result": [] })),
848 )
849 .mount(&server)
850 .await;
851
852 let platform = platform_with_stub(server.uri());
853 platform.poll_once(30).await.unwrap();
854
855 let requests = wait_for_requests(&server, 1).await;
856 let query: HashMap<String, String> = requests[0]
857 .url
858 .query_pairs()
859 .map(|(k, v)| (k.to_string(), v.to_string()))
860 .collect();
861 let allowed: Vec<String> = serde_json::from_str(
862 query
863 .get("allowed_updates")
864 .expect("allowed_updates present"),
865 )
866 .unwrap();
867 assert_eq!(
868 allowed,
869 vec!["message".to_string(), "callback_query".to_string()]
870 );
871 }
872
873 #[tokio::test]
874 async fn poll_once_converts_callback_query_updates_to_inbound_callbacks() {
875 let server = wiremock::MockServer::start().await;
876 wiremock::Mock::given(wiremock::matchers::method("GET"))
877 .and(wiremock::matchers::path("/bottest-token/getUpdates"))
878 .respond_with(
879 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
880 "ok": true,
881 "result": [
882 {
883 "update_id": 200,
884 "callback_query": {
885 "id": "cbq-1",
886 "from": { "id": 7 },
887 "message": {
888 "message_id": 1,
889 "date": 1_700_000_000,
890 "chat": { "id": 42 }
891 },
892 "data": "nonce123:1"
893 }
894 }
895 ]
896 })),
897 )
898 .mount(&server)
899 .await;
900
901 let platform = platform_with_stub(server.uri());
902 let events = platform.poll_once(30).await.expect("poll_once succeeds");
903
904 assert_eq!(events.len(), 1);
905 match &events[0] {
906 Inbound::Callback(callback) => {
907 assert_eq!(callback.platform, "telegram");
908 assert_eq!(callback.chat_id, "42");
909 assert_eq!(callback.user_id, "7");
910 assert_eq!(callback.callback_query_id, "cbq-1");
911 assert_eq!(callback.data, "nonce123:1");
912 }
913 Inbound::Message(_) => panic!("expected a callback event"),
914 }
915 assert_eq!(platform.offset.load(Ordering::SeqCst), 201);
916 }
917
918 #[tokio::test]
919 async fn reply_with_buttons_sends_inline_keyboard_reply_markup() {
920 let server = wiremock::MockServer::start().await;
921 wiremock::Mock::given(wiremock::matchers::method("POST"))
922 .and(wiremock::matchers::path("/bottest-token/sendMessage"))
923 .respond_with(
924 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
925 "ok": true,
926 "result": { "message_id": 9, "date": 1, "chat": { "id": 1 } }
927 })),
928 )
929 .mount(&server)
930 .await;
931
932 let platform = platform_with_stub(server.uri());
933 let ctx = ReplyCtx(serde_json::json!({ "chat_id": 1 }));
934 let outbound = OutboundMessage::text("Approve?").with_buttons(vec![
935 vec![Button::new("Approve", "n1:0")],
936 vec![Button::new("Deny", "n1:1")],
937 ]);
938
939 let msg_ref = platform
940 .reply(&ctx, outbound)
941 .await
942 .expect("reply succeeds");
943 assert_eq!(
944 msg_ref.0.get("message_id").and_then(|v| v.as_i64()),
945 Some(9)
946 );
947
948 let requests = wait_for_requests(&server, 1).await;
949 let body = String::from_utf8(requests[0].body.clone()).unwrap();
950 let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
951 .into_owned()
952 .collect();
953 let markup: serde_json::Value =
954 serde_json::from_str(form.get("reply_markup").expect("reply_markup present")).unwrap();
955 assert_eq!(
956 markup["inline_keyboard"][0][0]["callback_data"],
957 serde_json::json!("n1:0")
958 );
959 assert_eq!(
960 markup["inline_keyboard"][1][0]["text"],
961 serde_json::json!("Deny")
962 );
963 }
964
965 #[tokio::test]
966 async fn edit_sends_edit_message_text_with_the_stored_message_id() {
967 let server = wiremock::MockServer::start().await;
968 wiremock::Mock::given(wiremock::matchers::method("POST"))
969 .and(wiremock::matchers::path("/bottest-token/editMessageText"))
970 .respond_with(
971 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
972 "ok": true,
973 "result": { "message_id": 9, "date": 1, "chat": { "id": 1 } }
974 })),
975 )
976 .mount(&server)
977 .await;
978
979 let platform = platform_with_stub(server.uri());
980 let msg_ref = MessageRef(serde_json::json!({ "chat_id": 1, "message_id": 9 }));
981
982 platform
983 .edit(&msg_ref, OutboundMessage::text("updated"))
984 .await
985 .expect("edit succeeds");
986
987 let requests = wait_for_requests(&server, 1).await;
988 let body = String::from_utf8(requests[0].body.clone()).unwrap();
989 let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
990 .into_owned()
991 .collect();
992 assert_eq!(form.get("message_id").map(String::as_str), Some("9"));
993 assert_eq!(form.get("text").map(String::as_str), Some("updated"));
994 }
995
996 #[tokio::test]
997 async fn edit_returns_an_error_on_a_400_response_instead_of_panicking() {
998 let server = wiremock::MockServer::start().await;
999 wiremock::Mock::given(wiremock::matchers::method("POST"))
1000 .and(wiremock::matchers::path("/bottest-token/editMessageText"))
1001 .respond_with(
1002 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1003 "ok": false,
1004 "description": "Bad Request: message is not modified"
1005 })),
1006 )
1007 .mount(&server)
1008 .await;
1009
1010 let platform = platform_with_stub(server.uri());
1011 let msg_ref = MessageRef(serde_json::json!({ "chat_id": 1, "message_id": 9 }));
1012
1013 let result = platform
1014 .edit(&msg_ref, OutboundMessage::text("same text"))
1015 .await;
1016 assert!(result.is_err());
1020 }
1021
1022 #[tokio::test]
1023 async fn answer_callback_posts_callback_query_id_and_optional_text() {
1024 let server = wiremock::MockServer::start().await;
1025 wiremock::Mock::given(wiremock::matchers::method("POST"))
1026 .and(wiremock::matchers::path(
1027 "/bottest-token/answerCallbackQuery",
1028 ))
1029 .respond_with(
1030 wiremock::ResponseTemplate::new(200)
1031 .set_body_json(serde_json::json!({ "ok": true, "result": true })),
1032 )
1033 .mount(&server)
1034 .await;
1035
1036 let platform = platform_with_stub(server.uri());
1037 platform
1038 .answer_callback("cbq-1", Some("This action has expired."))
1039 .await
1040 .expect("answer_callback succeeds");
1041
1042 let requests = wait_for_requests(&server, 1).await;
1043 let body = String::from_utf8(requests[0].body.clone()).unwrap();
1044 let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
1045 .into_owned()
1046 .collect();
1047 assert_eq!(
1048 form.get("callback_query_id").map(String::as_str),
1049 Some("cbq-1")
1050 );
1051 assert_eq!(
1052 form.get("text").map(String::as_str),
1053 Some("This action has expired.")
1054 );
1055 }
1056
1057 #[tokio::test]
1058 async fn answer_callback_round_trips_without_text() {
1059 let server = wiremock::MockServer::start().await;
1060 wiremock::Mock::given(wiremock::matchers::method("POST"))
1061 .and(wiremock::matchers::path(
1062 "/bottest-token/answerCallbackQuery",
1063 ))
1064 .respond_with(
1065 wiremock::ResponseTemplate::new(200)
1066 .set_body_json(serde_json::json!({ "ok": true, "result": true })),
1067 )
1068 .mount(&server)
1069 .await;
1070
1071 let platform = platform_with_stub(server.uri());
1072 platform
1073 .answer_callback("cbq-2", None)
1074 .await
1075 .expect("answer_callback succeeds");
1076
1077 let requests = wait_for_requests(&server, 1).await;
1078 let body = String::from_utf8(requests[0].body.clone()).unwrap();
1079 let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
1080 .into_owned()
1081 .collect();
1082 assert!(!form.contains_key("text"));
1083 }
1084
1085 #[tokio::test]
1090 async fn transport_errors_never_leak_the_bot_token() {
1091 let token = "123456:SECRET-TOKEN-MUST-NOT-LEAK";
1092 let platform = TelegramPlatform::with_options(
1093 token.to_string(),
1094 "http://127.0.0.1:1".to_string(),
1095 Duration::from_millis(1),
1096 );
1097 let err = platform
1098 .get_updates(0, 0)
1099 .await
1100 .expect_err("port 1 must refuse the connection");
1101 let text = format!("{err}");
1102 assert!(
1103 !text.contains(token) && !text.contains("SECRET-TOKEN"),
1104 "token leaked into error text: {text}"
1105 );
1106 assert!(
1107 text.contains("getUpdates request failed"),
1108 "unexpected error shape: {text}"
1109 );
1110 }
1111}