Skip to main content

batty_cli/team/
telegram.rs

1//! Native Telegram Bot API client for batty.
2//!
3//! Provides a blocking HTTP client that sends messages and polls for updates
4//! via the Telegram Bot API. Access control is enforced by numeric user IDs.
5
6use 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/// An inbound message received from a Telegram user.
15#[derive(Debug, Clone)]
16pub struct InboundMessage {
17    pub from_user_id: i64,
18    pub chat_id: i64,
19    pub text: String,
20}
21
22/// Blocking Telegram Bot API client.
23pub struct TelegramBot {
24    bot_token: String,
25    allowed_user_ids: Vec<i64>,
26    last_update_offset: i64,
27}
28
29impl TelegramBot {
30    /// Create a new `TelegramBot` with the given token and allowed user IDs.
31    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    /// Build a `TelegramBot` from a `ChannelConfig`.
40    ///
41    /// Returns `None` if no bot token is available — checks the config's
42    /// `bot_token` field first, then falls back to the `BATTY_TELEGRAM_BOT_TOKEN`
43    /// environment variable.
44    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    /// Check whether a Telegram user ID is in the allowed list.
54    ///
55    /// An empty `allowed_user_ids` list denies everyone.
56    pub fn is_authorized(&self, user_id: i64) -> bool {
57        self.allowed_user_ids.contains(&user_id)
58    }
59
60    /// Maximum message length allowed by the Telegram Bot API.
61    const MAX_MESSAGE_LEN: usize = 4096;
62
63    /// Send a text message to a Telegram chat.
64    ///
65    /// Messages longer than 4096 characters are split into multiple messages.
66    /// POST `https://api.telegram.org/bot{token}/sendMessage`
67    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    /// Send a single message chunk (must be <= 4096 chars).
86    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    /// Poll the Telegram Bot API for new updates and return authorized inbound
121    /// messages.
122    ///
123    /// GET `https://api.telegram.org/bot{token}/getUpdates?offset={}&timeout=0`
124    ///
125    /// Unauthorized messages are silently dropped (logged at debug level).
126    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
175/// Parse a Telegram `getUpdates` JSON response into authorized inbound messages.
176///
177/// Returns the list of messages and the updated offset (max `update_id` + 1).
178fn 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        // Track offset regardless of message content
193        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
238/// Interactive Telegram bot setup wizard.
239/// Called by `batty telegram` CLI command.
240pub 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    // Step 1: Get and validate bot token
256    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    // Step 2: Get and validate user ID
264    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    // Step 3: Optional test message
273    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    // Step 4: Update team.yaml
293    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
301/// Prompt for bot token and validate via getMe API.
302fn 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
326/// Validate a bot token by calling the getMe API.
327/// Returns the bot username on success.
328fn 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
342/// Prompt for numeric Telegram user ID.
343fn 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
355/// Read a line from stdin with a prompt.
356fn 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
364/// Prompt for yes/no with a default.
365fn 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
373/// Parse a getMe API response and extract the bot username.
374/// Exposed for testing.
375pub 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
382/// Update team.yaml with Telegram credentials.
383/// Uses serde_yaml round-trip (simple, loses comments but team templates have few).
384fn 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    // Find or create the user role
394    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        // Update existing user role
408        role["channel"] = serde_yaml::Value::String("telegram".into());
409
410        // Ensure channel_config exists as a mapping
411        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            // allowed_user_ids as a sequence of integers
436            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        // Append a new user role
444        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
472/// Split a message into chunks of at most `max_len` characters.
473///
474/// Tries to split on newline boundaries first, falling back to hard splits
475/// when a single line exceeds `max_len`.
476fn 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        // Try to find a newline to split on within the limit
491        let split_at = remaining[..max_len]
492            .rfind('\n')
493            .map(|pos| pos + 1) // include the newline in the current chunk
494            .unwrap_or(max_len); // hard split if no newline found
495
496        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        // Neither config nor env var has a token.
539        // We cannot safely unset an env var in edition 2024, so we just ensure
540        // the config field is None. If the env var happens to be set externally,
541        // from_config would return Some — that's correct behaviour. We only
542        // assert None when we're confident the env var is absent.
543        let config = ChannelConfig {
544            target: "12345".into(),
545            provider: "telegram".into(),
546            bot_token: None,
547            allowed_user_ids: vec![],
548        };
549
550        // If the env var is not set, from_config must return None.
551        if std::env::var("BATTY_TELEGRAM_BOT_TOKEN").is_err() {
552            assert!(TelegramBot::from_config(&config).is_none());
553        }
554    }
555
556    #[test]
557    fn from_config_returns_some_with_config_token() {
558        let config = ChannelConfig {
559            target: "12345".into(),
560            provider: "telegram".into(),
561            bot_token: Some("bot-tok-from-config".into()),
562            allowed_user_ids: vec![42],
563        };
564
565        let bot = TelegramBot::from_config(&config).expect("should return Some");
566        assert_eq!(bot.bot_token, "bot-tok-from-config");
567        assert_eq!(bot.allowed_user_ids, vec![42]);
568    }
569
570    #[test]
571    fn from_config_prefers_config_token_over_env() {
572        // Even if the env var is set, the config token takes precedence.
573        let config = ChannelConfig {
574            target: "12345".into(),
575            provider: "telegram".into(),
576            bot_token: Some("from-config".into()),
577            allowed_user_ids: vec![],
578        };
579
580        let bot = TelegramBot::from_config(&config).unwrap();
581        assert_eq!(bot.bot_token, "from-config");
582    }
583
584    #[test]
585    fn parse_updates_authorized_message() {
586        let json: serde_json::Value = serde_json::json!({
587            "ok": true,
588            "result": [
589                {
590                    "update_id": 123456,
591                    "message": {
592                        "from": {"id": 12345678},
593                        "chat": {"id": 12345678},
594                        "text": "hello from telegram"
595                    }
596                }
597            ]
598        });
599
600        let allowed = vec![12345678_i64];
601        let (msgs, new_offset) = parse_updates_response(&json, &allowed, 0);
602
603        assert_eq!(msgs.len(), 1);
604        assert_eq!(msgs[0].from_user_id, 12345678);
605        assert_eq!(msgs[0].chat_id, 12345678);
606        assert_eq!(msgs[0].text, "hello from telegram");
607        assert_eq!(new_offset, 123457); // update_id + 1
608    }
609
610    #[test]
611    fn parse_updates_unauthorized_user_filtered() {
612        let json: serde_json::Value = serde_json::json!({
613            "ok": true,
614            "result": [
615                {
616                    "update_id": 100,
617                    "message": {
618                        "from": {"id": 99999},
619                        "chat": {"id": 99999},
620                        "text": "sneaky message"
621                    }
622                }
623            ]
624        });
625
626        let allowed = vec![12345678_i64];
627        let (msgs, new_offset) = parse_updates_response(&json, &allowed, 0);
628
629        assert!(msgs.is_empty(), "unauthorized message should be filtered");
630        assert_eq!(new_offset, 101); // offset still advances
631    }
632
633    #[test]
634    fn parse_updates_empty_result() {
635        let json: serde_json::Value = serde_json::json!({
636            "ok": true,
637            "result": []
638        });
639
640        let (msgs, new_offset) = parse_updates_response(&json, &[42], 50);
641        assert!(msgs.is_empty());
642        assert_eq!(new_offset, 50); // unchanged
643    }
644
645    #[test]
646    fn parse_updates_multiple_messages_mixed_auth() {
647        let json: serde_json::Value = serde_json::json!({
648            "ok": true,
649            "result": [
650                {
651                    "update_id": 200,
652                    "message": {
653                        "from": {"id": 111},
654                        "chat": {"id": 111},
655                        "text": "authorized msg"
656                    }
657                },
658                {
659                    "update_id": 201,
660                    "message": {
661                        "from": {"id": 999},
662                        "chat": {"id": 999},
663                        "text": "unauthorized msg"
664                    }
665                },
666                {
667                    "update_id": 202,
668                    "message": {
669                        "from": {"id": 222},
670                        "chat": {"id": 222},
671                        "text": "also authorized"
672                    }
673                }
674            ]
675        });
676
677        let allowed = vec![111_i64, 222];
678        let (msgs, new_offset) = parse_updates_response(&json, &allowed, 0);
679
680        assert_eq!(msgs.len(), 2);
681        assert_eq!(msgs[0].text, "authorized msg");
682        assert_eq!(msgs[1].text, "also authorized");
683        assert_eq!(new_offset, 203);
684    }
685
686    #[test]
687    fn parse_updates_skips_non_message_updates() {
688        let json: serde_json::Value = serde_json::json!({
689            "ok": true,
690            "result": [
691                {
692                    "update_id": 300,
693                    "edited_message": {
694                        "from": {"id": 42},
695                        "chat": {"id": 42},
696                        "text": "edited"
697                    }
698                }
699            ]
700        });
701
702        let (msgs, new_offset) = parse_updates_response(&json, &[42], 0);
703        assert!(msgs.is_empty());
704        assert_eq!(new_offset, 301);
705    }
706
707    #[test]
708    fn parse_updates_skips_message_without_text() {
709        let json: serde_json::Value = serde_json::json!({
710            "ok": true,
711            "result": [
712                {
713                    "update_id": 400,
714                    "message": {
715                        "from": {"id": 42},
716                        "chat": {"id": 42}
717                    }
718                }
719            ]
720        });
721
722        let (msgs, new_offset) = parse_updates_response(&json, &[42], 0);
723        assert!(msgs.is_empty());
724        assert_eq!(new_offset, 401);
725    }
726
727    #[test]
728    fn parse_get_me_valid_response() {
729        let json: serde_json::Value = serde_json::json!({
730            "ok": true,
731            "result": {
732                "id": 123456789,
733                "is_bot": true,
734                "first_name": "Test Bot",
735                "username": "test_bot"
736            }
737        });
738        assert_eq!(parse_get_me_response(&json), Some("test_bot".to_string()));
739    }
740
741    #[test]
742    fn parse_get_me_invalid_response() {
743        let json: serde_json::Value = serde_json::json!({
744            "ok": false,
745            "description": "Not Found"
746        });
747        assert_eq!(parse_get_me_response(&json), None);
748    }
749
750    #[test]
751    fn parse_send_message_response_requires_api_acceptance() {
752        let json: serde_json::Value = serde_json::json!({
753            "ok": true,
754            "result": {
755                "message_id": 42
756            }
757        });
758
759        assert_eq!(parse_send_message_response(&json).unwrap(), 42);
760    }
761
762    #[test]
763    fn parse_send_message_response_rejects_missing_confirmation() {
764        let json: serde_json::Value = serde_json::json!({
765            "ok": true,
766            "result": {}
767        });
768
769        assert!(
770            parse_send_message_response(&json)
771                .unwrap_err()
772                .to_string()
773                .contains("message_id")
774        );
775    }
776
777    #[test]
778    fn update_team_yaml_updates_existing_user_role() {
779        let tmp = tempfile::tempdir().unwrap();
780        let path = tmp.path().join("team.yaml");
781        std::fs::write(
782            &path,
783            r#"
784name: test-team
785roles:
786  - name: human
787    role_type: user
788    channel: telegram
789    channel_config:
790      target: "placeholder"
791      provider: openclaw
792    talks_to: [architect]
793  - name: architect
794    role_type: architect
795    agent: claude
796"#,
797        )
798        .unwrap();
799
800        update_team_yaml(&path, "123:abc-token", 99887766).unwrap();
801
802        let content = std::fs::read_to_string(&path).unwrap();
803        assert!(content.contains("123:abc-token"));
804        assert!(content.contains("99887766"));
805    }
806
807    #[test]
808    fn update_team_yaml_creates_user_role_if_missing() {
809        let tmp = tempfile::tempdir().unwrap();
810        let path = tmp.path().join("team.yaml");
811        std::fs::write(
812            &path,
813            r#"
814name: test-team
815roles:
816  - name: architect
817    role_type: architect
818    agent: claude
819"#,
820        )
821        .unwrap();
822
823        update_team_yaml(&path, "456:xyz-token", 11223344).unwrap();
824
825        let content = std::fs::read_to_string(&path).unwrap();
826        assert!(content.contains("456:xyz-token"));
827        assert!(content.contains("11223344"));
828        assert!(content.contains("user"));
829        assert!(content.contains("human"));
830    }
831
832    #[test]
833    fn setup_telegram_bails_without_config() {
834        let tmp = tempfile::tempdir().unwrap();
835        let result = setup_telegram(tmp.path());
836        assert!(result.is_err());
837        assert!(result.unwrap_err().to_string().contains("batty init"));
838    }
839
840    #[test]
841    fn split_message_short_text_returns_single_chunk() {
842        let chunks = split_message("hello", 4096);
843        assert_eq!(chunks, vec!["hello"]);
844    }
845
846    #[test]
847    fn split_message_exact_limit_returns_single_chunk() {
848        let text = "a".repeat(4096);
849        let chunks = split_message(&text, 4096);
850        assert_eq!(chunks.len(), 1);
851        assert_eq!(chunks[0].len(), 4096);
852    }
853
854    #[test]
855    fn split_message_splits_on_newline() {
856        let line = "a".repeat(2000);
857        let text = format!("{line}\n{line}\n{line}");
858        let chunks = split_message(&text, 4096);
859        assert_eq!(chunks.len(), 2);
860        // First chunk: two lines with newlines = 2001 + 2001 = 4002
861        assert!(chunks[0].len() <= 4096);
862        assert!(chunks[1].len() <= 4096);
863        // Reassembled text matches original
864        let reassembled: String = chunks.iter().copied().collect();
865        assert_eq!(reassembled, text);
866    }
867
868    #[test]
869    fn split_message_hard_splits_long_line() {
870        let text = "a".repeat(5000);
871        let chunks = split_message(&text, 4096);
872        assert_eq!(chunks.len(), 2);
873        assert_eq!(chunks[0].len(), 4096);
874        assert_eq!(chunks[1].len(), 904);
875    }
876
877    #[test]
878    fn split_message_multiple_chunks() {
879        let text = "a".repeat(10000);
880        let chunks = split_message(&text, 4096);
881        assert_eq!(chunks.len(), 3);
882        assert_eq!(chunks[0].len(), 4096);
883        assert_eq!(chunks[1].len(), 4096);
884        assert_eq!(chunks[2].len(), 1808);
885        let reassembled: String = chunks.iter().copied().collect();
886        assert_eq!(reassembled, text);
887    }
888
889    #[test]
890    fn split_message_empty_text() {
891        let chunks = split_message("", 4096);
892        assert_eq!(chunks, vec![""]);
893    }
894
895    #[test]
896    fn outbound_chunk_message_id_changes_per_chunk() {
897        let text = format!("{}\n{}", "a".repeat(4096), "b".repeat(200));
898        let chunks = split_message(&text, 4096);
899        assert_eq!(chunks.len(), 2);
900
901        let first = outbound_chunk_message_id("12345", chunks[0], 0, chunks.len());
902        let second = outbound_chunk_message_id("12345", chunks[1], 1, chunks.len());
903
904        assert_ne!(first, second);
905    }
906}