1use anyhow::{Context, Result, anyhow, bail};
7use std::hash::{Hash, Hasher};
8use std::io::{self, Write as IoWrite};
9use std::path::Path;
10use tracing::{debug, warn};
11
12use super::config::ChannelConfig;
13
14#[derive(Debug, Clone)]
16pub struct InboundMessage {
17 pub from_user_id: i64,
18 pub chat_id: i64,
19 pub text: String,
20}
21
22pub struct TelegramBot {
24 bot_token: String,
25 allowed_user_ids: Vec<i64>,
26 last_update_offset: i64,
27}
28
29impl TelegramBot {
30 pub fn new(bot_token: String, allowed_user_ids: Vec<i64>) -> Self {
32 Self {
33 bot_token,
34 allowed_user_ids,
35 last_update_offset: 0,
36 }
37 }
38
39 pub fn from_config(config: &ChannelConfig) -> Option<Self> {
45 let token = config
46 .bot_token
47 .clone()
48 .or_else(|| std::env::var("BATTY_TELEGRAM_BOT_TOKEN").ok());
49
50 token.map(|t| Self::new(t, config.allowed_user_ids.clone()))
51 }
52
53 pub fn is_authorized(&self, user_id: i64) -> bool {
57 self.allowed_user_ids.contains(&user_id)
58 }
59
60 const MAX_MESSAGE_LEN: usize = 4096;
62
63 pub fn send_message(&self, chat_id: &str, text: &str) -> Result<()> {
68 let chunks = split_message(text, Self::MAX_MESSAGE_LEN);
69 let total_chunks = chunks.len();
70
71 for (index, chunk) in chunks.into_iter().enumerate() {
72 let chunk_id = outbound_chunk_message_id(chat_id, chunk, index, total_chunks);
73 debug!(
74 chat_id,
75 chunk_index = index,
76 total_chunks,
77 chunk_id,
78 "sending telegram message chunk"
79 );
80 self.send_message_chunk(chat_id, chunk)?;
81 }
82 Ok(())
83 }
84
85 fn send_message_chunk(&self, chat_id: &str, text: &str) -> Result<()> {
87 let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
88
89 let body = serde_json::json!({
90 "chat_id": chat_id,
91 "text": text,
92 });
93
94 let resp = ureq::post(&url)
95 .set("Content-Type", "application/json")
96 .send_string(&body.to_string());
97
98 match resp {
99 Ok(r) => {
100 let status = r.status();
101 let body: serde_json::Value = r
102 .into_json()
103 .context("failed to parse sendMessage response body")?;
104 let message_id = parse_send_message_response(&body)?;
105 debug!(status, message_id, "sendMessage accepted by Telegram API");
106 Ok(())
107 }
108 Err(ureq::Error::Status(status, response)) => {
109 let detail = response.into_string().unwrap_or_default();
110 warn!(status, detail = %detail, "sendMessage failed");
111 bail!("Telegram sendMessage failed with status {status}: {detail}");
112 }
113 Err(ureq::Error::Transport(error)) => {
114 warn!(error = %error, "sendMessage transport failed");
115 bail!("Telegram sendMessage transport failed: {error}");
116 }
117 }
118 }
119
120 pub fn poll_updates(&mut self) -> Result<Vec<InboundMessage>> {
127 let url = format!(
128 "https://api.telegram.org/bot{}/getUpdates?offset={}&timeout=0",
129 self.bot_token, self.last_update_offset
130 );
131
132 let resp = ureq::get(&url).call();
133
134 let body: serde_json::Value = match resp {
135 Ok(r) => r.into_json()?,
136 Err(e) => {
137 warn!(error = %e, "getUpdates failed");
138 bail!("Telegram getUpdates failed: {e}");
139 }
140 };
141
142 let (messages, new_offset) =
143 parse_updates_response(&body, &self.allowed_user_ids, self.last_update_offset);
144 self.last_update_offset = new_offset;
145
146 Ok(messages)
147 }
148}
149
150fn parse_send_message_response(json: &serde_json::Value) -> Result<i64> {
151 if json["ok"].as_bool() != Some(true) {
152 return Err(anyhow!("Telegram API returned ok=false"));
153 }
154
155 json.get("result")
156 .and_then(|result| result.get("message_id"))
157 .and_then(|message_id| message_id.as_i64())
158 .ok_or_else(|| anyhow!("Telegram sendMessage response missing result.message_id"))
159}
160
161fn outbound_chunk_message_id(
162 chat_id: &str,
163 chunk: &str,
164 chunk_index: usize,
165 total_chunks: usize,
166) -> u64 {
167 let mut hasher = std::collections::hash_map::DefaultHasher::new();
168 chat_id.hash(&mut hasher);
169 chunk.hash(&mut hasher);
170 chunk_index.hash(&mut hasher);
171 total_chunks.hash(&mut hasher);
172 hasher.finish()
173}
174
175fn parse_updates_response(
179 json: &serde_json::Value,
180 allowed: &[i64],
181 current_offset: i64,
182) -> (Vec<InboundMessage>, i64) {
183 let mut messages = Vec::new();
184 let mut new_offset = current_offset;
185
186 let results = match json.get("result").and_then(|r| r.as_array()) {
187 Some(arr) => arr,
188 None => return (messages, new_offset),
189 };
190
191 for update in results {
192 if let Some(update_id) = update.get("update_id").and_then(|v| v.as_i64()) {
194 if update_id + 1 > new_offset {
195 new_offset = update_id + 1;
196 }
197 }
198
199 let message = match update.get("message") {
200 Some(m) => m,
201 None => continue,
202 };
203
204 let from_id = message
205 .get("from")
206 .and_then(|f| f.get("id"))
207 .and_then(|v| v.as_i64());
208
209 let chat_id = message
210 .get("chat")
211 .and_then(|c| c.get("id"))
212 .and_then(|v| v.as_i64());
213
214 let text = message
215 .get("text")
216 .and_then(|t| t.as_str())
217 .map(|s| s.to_string());
218
219 let (Some(from_id), Some(chat_id), Some(text)) = (from_id, chat_id, text) else {
220 continue;
221 };
222
223 if !allowed.contains(&from_id) {
224 debug!(from_id, "dropping message from unauthorized user");
225 continue;
226 }
227
228 messages.push(InboundMessage {
229 from_user_id: from_id,
230 chat_id,
231 text,
232 });
233 }
234
235 (messages, new_offset)
236}
237
238pub fn setup_telegram(project_root: &Path) -> Result<()> {
241 let config_path = project_root
242 .join(".batty")
243 .join("team_config")
244 .join("team.yaml");
245 if !config_path.exists() {
246 bail!(
247 "no team config found at {}; run `batty init` first",
248 config_path.display()
249 );
250 }
251
252 println!("Telegram Bot Setup");
253 println!("==================\n");
254
255 println!("Step 1: Bot Token");
257 println!(" 1) Open Telegram and chat with @BotFather");
258 println!(" 2) Send /newbot (or /mybots to reuse an existing bot)");
259 println!(" 3) Copy the token (looks like 123456:ABC-DEF...)");
260 println!();
261 let bot_token = prompt_bot_token()?;
262
263 println!("Step 2: Your Telegram User ID");
265 println!(" Option A: DM @userinfobot — it replies with your numeric ID");
266 println!(" Option B: DM @getidsbot");
267 println!(" Option C: Call https://api.telegram.org/bot<TOKEN>/getUpdates");
268 println!(" after sending your bot a message, and read message.from.id");
269 println!();
270 let user_id = prompt_user_id()?;
271
272 println!("Step 3: Test Message");
274 println!(" IMPORTANT: You must DM your bot first (send /start in Telegram)");
275 println!(" before the bot can message you. Telegram blocks bots from initiating");
276 println!(" conversations with users who haven't messaged the bot.");
277 println!();
278 let send_test = prompt_yes_no("Send a test message? [Y/n]: ", true)?;
279 if send_test {
280 let bot = TelegramBot::new(bot_token.clone(), vec![user_id]);
281 let chat_id = user_id.to_string();
282 match bot.send_message(&chat_id, "Batty Telegram bridge configured!") {
283 Ok(()) => println!("Test message sent. Check your Telegram.\n"),
284 Err(e) => {
285 println!("Warning: test message failed: {e}");
286 println!(" Did you DM the bot first? Open your bot in Telegram and send /start,");
287 println!(" then run `batty telegram` again.\n");
288 }
289 }
290 }
291
292 update_team_yaml(&config_path, &bot_token, user_id)?;
294
295 println!("Telegram configured successfully.");
296 println!("Restart the daemon with: batty stop && batty start");
297
298 Ok(())
299}
300
301fn prompt_bot_token() -> Result<String> {
303 loop {
304 let token = prompt("Enter your Telegram bot token (from @BotFather): ")?;
305 if token.is_empty() {
306 println!("Token cannot be empty. Try again.\n");
307 continue;
308 }
309
310 match validate_bot_token(&token) {
311 Ok(username) => {
312 println!("Bot validated: @{username}\n");
313 return Ok(token);
314 }
315 Err(e) => {
316 println!("Validation failed: {e}");
317 let retry = prompt_yes_no("Try again? [Y/n]: ", true)?;
318 if !retry {
319 bail!("setup cancelled");
320 }
321 }
322 }
323 }
324}
325
326fn validate_bot_token(token: &str) -> Result<String> {
329 let url = format!("https://api.telegram.org/bot{token}/getMe");
330 let resp: serde_json::Value = ureq::get(&url).call()?.into_json()?;
331
332 if resp["ok"].as_bool() != Some(true) {
333 bail!("Telegram API returned ok=false");
334 }
335
336 resp["result"]["username"]
337 .as_str()
338 .map(|s| s.to_string())
339 .ok_or_else(|| anyhow::anyhow!("missing username in getMe response"))
340}
341
342fn prompt_user_id() -> Result<i64> {
344 loop {
345 let input = prompt("Enter your Telegram user ID (from @userinfobot): ")?;
346 match input.trim().parse::<i64>() {
347 Ok(id) if id > 0 => return Ok(id),
348 _ => {
349 println!("Invalid user ID — must be a positive number. Try again.\n");
350 }
351 }
352 }
353}
354
355fn prompt(msg: &str) -> Result<String> {
357 print!("{msg}");
358 io::stdout().flush()?;
359 let mut input = String::new();
360 io::stdin().read_line(&mut input)?;
361 Ok(input.trim().to_string())
362}
363
364fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
366 let input = prompt(msg)?;
367 if input.is_empty() {
368 return Ok(default_yes);
369 }
370 Ok(input.starts_with('y') || input.starts_with('Y'))
371}
372
373pub fn parse_get_me_response(json: &serde_json::Value) -> Option<String> {
376 if json["ok"].as_bool() != Some(true) {
377 return None;
378 }
379 json["result"]["username"].as_str().map(|s| s.to_string())
380}
381
382fn update_team_yaml(path: &Path, bot_token: &str, user_id: i64) -> Result<()> {
385 let content = std::fs::read_to_string(path)
386 .with_context(|| format!("failed to read {}", path.display()))?;
387
388 let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
389 .with_context(|| format!("failed to parse {}", path.display()))?;
390
391 let user_id_str = user_id.to_string();
392
393 let roles = doc
395 .get_mut("roles")
396 .and_then(|r| r.as_sequence_mut())
397 .ok_or_else(|| anyhow::anyhow!("no 'roles' sequence in team.yaml"))?;
398
399 let user_role = roles.iter_mut().find(|r| {
400 r.get("role_type")
401 .and_then(|v| v.as_str())
402 .map(|s| s == "user")
403 .unwrap_or(false)
404 });
405
406 if let Some(role) = user_role {
407 role["channel"] = serde_yaml::Value::String("telegram".into());
409
410 if role.get("channel_config").is_none() {
412 if let Some(role_map) = role.as_mapping_mut() {
413 role_map.insert(
414 serde_yaml::Value::String("channel_config".into()),
415 serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
416 );
417 }
418 }
419
420 let cc = &mut role["channel_config"];
421
422 if let Some(mapping) = cc.as_mapping_mut() {
423 mapping.insert(
424 serde_yaml::Value::String("target".into()),
425 serde_yaml::Value::String(user_id_str),
426 );
427 mapping.insert(
428 serde_yaml::Value::String("bot_token".into()),
429 serde_yaml::Value::String(bot_token.into()),
430 );
431 mapping.insert(
432 serde_yaml::Value::String("provider".into()),
433 serde_yaml::Value::String("openclaw".into()),
434 );
435 let ids = vec![serde_yaml::Value::Number(serde_yaml::Number::from(user_id))];
437 mapping.insert(
438 serde_yaml::Value::String("allowed_user_ids".into()),
439 serde_yaml::Value::Sequence(ids),
440 );
441 }
442 } else {
443 let mut new_role = serde_yaml::Mapping::new();
445 new_role.insert("name".into(), "human".into());
446 new_role.insert("role_type".into(), "user".into());
447 new_role.insert("channel".into(), "telegram".into());
448
449 let mut cc = serde_yaml::Mapping::new();
450 cc.insert("target".into(), serde_yaml::Value::String(user_id_str));
451 cc.insert(
452 "bot_token".into(),
453 serde_yaml::Value::String(bot_token.into()),
454 );
455 cc.insert("provider".into(), "openclaw".into());
456 let ids = vec![serde_yaml::Value::Number(serde_yaml::Number::from(user_id))];
457 cc.insert("allowed_user_ids".into(), serde_yaml::Value::Sequence(ids));
458 new_role.insert("channel_config".into(), serde_yaml::Value::Mapping(cc));
459
460 let talks_to = vec!["architect".into()];
461 new_role.insert("talks_to".into(), serde_yaml::Value::Sequence(talks_to));
462
463 roles.push(serde_yaml::Value::Mapping(new_role));
464 }
465
466 let output = serde_yaml::to_string(&doc)?;
467 std::fs::write(path, &output).with_context(|| format!("failed to write {}", path.display()))?;
468
469 Ok(())
470}
471
472fn split_message(text: &str, max_len: usize) -> Vec<&str> {
477 if text.len() <= max_len {
478 return vec![text];
479 }
480
481 let mut chunks = Vec::new();
482 let mut remaining = text;
483
484 while !remaining.is_empty() {
485 if remaining.len() <= max_len {
486 chunks.push(remaining);
487 break;
488 }
489
490 let split_at = remaining[..max_len]
492 .rfind('\n')
493 .map(|pos| pos + 1) .unwrap_or(max_len); chunks.push(&remaining[..split_at]);
497 remaining = &remaining[split_at..];
498 }
499
500 chunks
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn new_constructor_sets_fields() {
509 let bot = TelegramBot::new("token123".into(), vec![111, 222]);
510 assert_eq!(bot.bot_token, "token123");
511 assert_eq!(bot.allowed_user_ids, vec![111, 222]);
512 assert_eq!(bot.last_update_offset, 0);
513 }
514
515 #[test]
516 fn is_authorized_allows_listed_id() {
517 let bot = TelegramBot::new("t".into(), vec![100, 200, 300]);
518 assert!(bot.is_authorized(100));
519 assert!(bot.is_authorized(200));
520 assert!(bot.is_authorized(300));
521 }
522
523 #[test]
524 fn is_authorized_rejects_unlisted_id() {
525 let bot = TelegramBot::new("t".into(), vec![100, 200]);
526 assert!(!bot.is_authorized(999));
527 }
528
529 #[test]
530 fn is_authorized_empty_list_denies_all() {
531 let bot = TelegramBot::new("t".into(), vec![]);
532 assert!(!bot.is_authorized(100));
533 assert!(!bot.is_authorized(0));
534 }
535
536 #[test]
537 fn from_config_returns_none_without_token() {
538 let config = ChannelConfig {
544 target: "12345".into(),
545 provider: "telegram".into(),
546 bot_token: None,
547 allowed_user_ids: vec![],
548 events_channel_id: None,
549 agents_channel_id: None,
550 commands_channel_id: None,
551 board_channel_id: None,
552 };
553
554 if std::env::var("BATTY_TELEGRAM_BOT_TOKEN").is_err() {
556 assert!(TelegramBot::from_config(&config).is_none());
557 }
558 }
559
560 #[test]
561 fn from_config_returns_some_with_config_token() {
562 let config = ChannelConfig {
563 target: "12345".into(),
564 provider: "telegram".into(),
565 bot_token: Some("bot-tok-from-config".into()),
566 allowed_user_ids: vec![42],
567 events_channel_id: None,
568 agents_channel_id: None,
569 commands_channel_id: None,
570 board_channel_id: None,
571 };
572
573 let bot = TelegramBot::from_config(&config).expect("should return Some");
574 assert_eq!(bot.bot_token, "bot-tok-from-config");
575 assert_eq!(bot.allowed_user_ids, vec![42]);
576 }
577
578 #[test]
579 fn from_config_prefers_config_token_over_env() {
580 let config = ChannelConfig {
582 target: "12345".into(),
583 provider: "telegram".into(),
584 bot_token: Some("from-config".into()),
585 allowed_user_ids: vec![],
586 events_channel_id: None,
587 agents_channel_id: None,
588 commands_channel_id: None,
589 board_channel_id: None,
590 };
591
592 let bot = TelegramBot::from_config(&config).unwrap();
593 assert_eq!(bot.bot_token, "from-config");
594 }
595
596 #[test]
597 fn parse_updates_authorized_message() {
598 let json: serde_json::Value = serde_json::json!({
599 "ok": true,
600 "result": [
601 {
602 "update_id": 123456,
603 "message": {
604 "from": {"id": 12345678},
605 "chat": {"id": 12345678},
606 "text": "hello from telegram"
607 }
608 }
609 ]
610 });
611
612 let allowed = vec![12345678_i64];
613 let (msgs, new_offset) = parse_updates_response(&json, &allowed, 0);
614
615 assert_eq!(msgs.len(), 1);
616 assert_eq!(msgs[0].from_user_id, 12345678);
617 assert_eq!(msgs[0].chat_id, 12345678);
618 assert_eq!(msgs[0].text, "hello from telegram");
619 assert_eq!(new_offset, 123457); }
621
622 #[test]
623 fn parse_updates_unauthorized_user_filtered() {
624 let json: serde_json::Value = serde_json::json!({
625 "ok": true,
626 "result": [
627 {
628 "update_id": 100,
629 "message": {
630 "from": {"id": 99999},
631 "chat": {"id": 99999},
632 "text": "sneaky message"
633 }
634 }
635 ]
636 });
637
638 let allowed = vec![12345678_i64];
639 let (msgs, new_offset) = parse_updates_response(&json, &allowed, 0);
640
641 assert!(msgs.is_empty(), "unauthorized message should be filtered");
642 assert_eq!(new_offset, 101); }
644
645 #[test]
646 fn parse_updates_empty_result() {
647 let json: serde_json::Value = serde_json::json!({
648 "ok": true,
649 "result": []
650 });
651
652 let (msgs, new_offset) = parse_updates_response(&json, &[42], 50);
653 assert!(msgs.is_empty());
654 assert_eq!(new_offset, 50); }
656
657 #[test]
658 fn parse_updates_multiple_messages_mixed_auth() {
659 let json: serde_json::Value = serde_json::json!({
660 "ok": true,
661 "result": [
662 {
663 "update_id": 200,
664 "message": {
665 "from": {"id": 111},
666 "chat": {"id": 111},
667 "text": "authorized msg"
668 }
669 },
670 {
671 "update_id": 201,
672 "message": {
673 "from": {"id": 999},
674 "chat": {"id": 999},
675 "text": "unauthorized msg"
676 }
677 },
678 {
679 "update_id": 202,
680 "message": {
681 "from": {"id": 222},
682 "chat": {"id": 222},
683 "text": "also authorized"
684 }
685 }
686 ]
687 });
688
689 let allowed = vec![111_i64, 222];
690 let (msgs, new_offset) = parse_updates_response(&json, &allowed, 0);
691
692 assert_eq!(msgs.len(), 2);
693 assert_eq!(msgs[0].text, "authorized msg");
694 assert_eq!(msgs[1].text, "also authorized");
695 assert_eq!(new_offset, 203);
696 }
697
698 #[test]
699 fn parse_updates_skips_non_message_updates() {
700 let json: serde_json::Value = serde_json::json!({
701 "ok": true,
702 "result": [
703 {
704 "update_id": 300,
705 "edited_message": {
706 "from": {"id": 42},
707 "chat": {"id": 42},
708 "text": "edited"
709 }
710 }
711 ]
712 });
713
714 let (msgs, new_offset) = parse_updates_response(&json, &[42], 0);
715 assert!(msgs.is_empty());
716 assert_eq!(new_offset, 301);
717 }
718
719 #[test]
720 fn parse_updates_skips_message_without_text() {
721 let json: serde_json::Value = serde_json::json!({
722 "ok": true,
723 "result": [
724 {
725 "update_id": 400,
726 "message": {
727 "from": {"id": 42},
728 "chat": {"id": 42}
729 }
730 }
731 ]
732 });
733
734 let (msgs, new_offset) = parse_updates_response(&json, &[42], 0);
735 assert!(msgs.is_empty());
736 assert_eq!(new_offset, 401);
737 }
738
739 #[test]
740 fn parse_get_me_valid_response() {
741 let json: serde_json::Value = serde_json::json!({
742 "ok": true,
743 "result": {
744 "id": 123456789,
745 "is_bot": true,
746 "first_name": "Test Bot",
747 "username": "test_bot"
748 }
749 });
750 assert_eq!(parse_get_me_response(&json), Some("test_bot".to_string()));
751 }
752
753 #[test]
754 fn parse_get_me_invalid_response() {
755 let json: serde_json::Value = serde_json::json!({
756 "ok": false,
757 "description": "Not Found"
758 });
759 assert_eq!(parse_get_me_response(&json), None);
760 }
761
762 #[test]
763 fn parse_send_message_response_requires_api_acceptance() {
764 let json: serde_json::Value = serde_json::json!({
765 "ok": true,
766 "result": {
767 "message_id": 42
768 }
769 });
770
771 assert_eq!(parse_send_message_response(&json).unwrap(), 42);
772 }
773
774 #[test]
775 fn parse_send_message_response_rejects_missing_confirmation() {
776 let json: serde_json::Value = serde_json::json!({
777 "ok": true,
778 "result": {}
779 });
780
781 assert!(
782 parse_send_message_response(&json)
783 .unwrap_err()
784 .to_string()
785 .contains("message_id")
786 );
787 }
788
789 #[test]
790 fn update_team_yaml_updates_existing_user_role() {
791 let tmp = tempfile::tempdir().unwrap();
792 let path = tmp.path().join("team.yaml");
793 std::fs::write(
794 &path,
795 r#"
796name: test-team
797roles:
798 - name: human
799 role_type: user
800 channel: telegram
801 channel_config:
802 target: "placeholder"
803 provider: openclaw
804 talks_to: [architect]
805 - name: architect
806 role_type: architect
807 agent: claude
808"#,
809 )
810 .unwrap();
811
812 update_team_yaml(&path, "123:abc-token", 99887766).unwrap();
813
814 let content = std::fs::read_to_string(&path).unwrap();
815 assert!(content.contains("123:abc-token"));
816 assert!(content.contains("99887766"));
817 }
818
819 #[test]
820 fn update_team_yaml_creates_user_role_if_missing() {
821 let tmp = tempfile::tempdir().unwrap();
822 let path = tmp.path().join("team.yaml");
823 std::fs::write(
824 &path,
825 r#"
826name: test-team
827roles:
828 - name: architect
829 role_type: architect
830 agent: claude
831"#,
832 )
833 .unwrap();
834
835 update_team_yaml(&path, "456:xyz-token", 11223344).unwrap();
836
837 let content = std::fs::read_to_string(&path).unwrap();
838 assert!(content.contains("456:xyz-token"));
839 assert!(content.contains("11223344"));
840 assert!(content.contains("user"));
841 assert!(content.contains("human"));
842 }
843
844 #[test]
845 fn setup_telegram_bails_without_config() {
846 let tmp = tempfile::tempdir().unwrap();
847 let result = setup_telegram(tmp.path());
848 assert!(result.is_err());
849 assert!(result.unwrap_err().to_string().contains("batty init"));
850 }
851
852 #[test]
853 fn split_message_short_text_returns_single_chunk() {
854 let chunks = split_message("hello", 4096);
855 assert_eq!(chunks, vec!["hello"]);
856 }
857
858 #[test]
859 fn split_message_exact_limit_returns_single_chunk() {
860 let text = "a".repeat(4096);
861 let chunks = split_message(&text, 4096);
862 assert_eq!(chunks.len(), 1);
863 assert_eq!(chunks[0].len(), 4096);
864 }
865
866 #[test]
867 fn split_message_splits_on_newline() {
868 let line = "a".repeat(2000);
869 let text = format!("{line}\n{line}\n{line}");
870 let chunks = split_message(&text, 4096);
871 assert_eq!(chunks.len(), 2);
872 assert!(chunks[0].len() <= 4096);
874 assert!(chunks[1].len() <= 4096);
875 let reassembled: String = chunks.iter().copied().collect();
877 assert_eq!(reassembled, text);
878 }
879
880 #[test]
881 fn split_message_hard_splits_long_line() {
882 let text = "a".repeat(5000);
883 let chunks = split_message(&text, 4096);
884 assert_eq!(chunks.len(), 2);
885 assert_eq!(chunks[0].len(), 4096);
886 assert_eq!(chunks[1].len(), 904);
887 }
888
889 #[test]
890 fn split_message_multiple_chunks() {
891 let text = "a".repeat(10000);
892 let chunks = split_message(&text, 4096);
893 assert_eq!(chunks.len(), 3);
894 assert_eq!(chunks[0].len(), 4096);
895 assert_eq!(chunks[1].len(), 4096);
896 assert_eq!(chunks[2].len(), 1808);
897 let reassembled: String = chunks.iter().copied().collect();
898 assert_eq!(reassembled, text);
899 }
900
901 #[test]
902 fn split_message_empty_text() {
903 let chunks = split_message("", 4096);
904 assert_eq!(chunks, vec![""]);
905 }
906
907 #[test]
908 fn outbound_chunk_message_id_changes_per_chunk() {
909 let text = format!("{}\n{}", "a".repeat(4096), "b".repeat(200));
910 let chunks = split_message(&text, 4096);
911 assert_eq!(chunks.len(), 2);
912
913 let first = outbound_chunk_message_id("12345", chunks[0], 0, chunks.len());
914 let second = outbound_chunk_message_id("12345", chunks[1], 1, chunks.len());
915
916 assert_ne!(first, second);
917 }
918}