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