Skip to main content

batty_cli/team/
discord.rs

1//! Native Discord Bot API client for batty.
2//!
3//! Uses Discord's HTTP API directly for outbound embeds and command-channel
4//! polling, keeping the implementation aligned with the existing Telegram
5//! bridge's blocking request model.
6
7use std::io::{self, Write as IoWrite};
8use std::path::Path;
9
10use anyhow::{Context, Result, anyhow, bail};
11use tracing::{debug, warn};
12
13use crate::env_file;
14
15use super::config::{ChannelConfig, RoleType, TeamConfig};
16
17const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
18const MAX_EMBED_TITLE_LEN: usize = 256;
19const MAX_EMBED_DESCRIPTION_LEN: usize = 4_000;
20const MAX_CONTENT_LEN: usize = 2_000;
21
22/// An inbound message received from Discord.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct InboundMessage {
25    pub message_id: String,
26    pub channel_id: String,
27    pub from_user_id: i64,
28    pub text: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BotIdentity {
33    pub user_id: String,
34    pub username: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct GuildSummary {
39    pub id: String,
40    pub name: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ChannelSummary {
45    pub id: String,
46    pub name: String,
47    pub kind: u8,
48    pub position: i64,
49}
50
51/// Blocking Discord Bot API client.
52pub struct DiscordBot {
53    bot_token: String,
54    allowed_user_ids: Vec<i64>,
55    commands_channel_id: String,
56    last_message_id: Option<String>,
57}
58
59impl DiscordBot {
60    pub fn new(bot_token: String, allowed_user_ids: Vec<i64>, commands_channel_id: String) -> Self {
61        Self {
62            bot_token,
63            allowed_user_ids,
64            commands_channel_id,
65            last_message_id: None,
66        }
67    }
68
69    /// Build a `DiscordBot` from a `ChannelConfig`.
70    ///
71    /// Returns `None` if either the token or commands channel ID is missing.
72    /// The token can be provided directly or via `BATTY_DISCORD_BOT_TOKEN`.
73    pub fn from_config(config: &ChannelConfig) -> Option<Self> {
74        let token = config
75            .bot_token
76            .clone()
77            .or_else(|| std::env::var("BATTY_DISCORD_BOT_TOKEN").ok())?;
78        let commands_channel_id = config.commands_channel_id.clone()?;
79        Some(Self::new(
80            token,
81            config.allowed_user_ids.clone(),
82            commands_channel_id,
83        ))
84    }
85
86    pub fn commands_channel_id(&self) -> &str {
87        &self.commands_channel_id
88    }
89
90    pub fn send_plain_message(&self, channel_id: &str, text: &str) -> Result<()> {
91        let body = serde_json::json!({
92            "content": truncate_for_discord(text, MAX_CONTENT_LEN),
93            "allowed_mentions": { "parse": [] }
94        });
95        self.post_message(channel_id, &body).map(|_| ())
96    }
97
98    pub fn send_embed(
99        &self,
100        channel_id: &str,
101        title: &str,
102        description: &str,
103        color: u32,
104    ) -> Result<()> {
105        let body = serde_json::json!({
106            "embeds": [{
107                "title": truncate_for_discord(title, MAX_EMBED_TITLE_LEN),
108                "description": truncate_for_discord(description, MAX_EMBED_DESCRIPTION_LEN),
109                "color": color
110            }],
111            "allowed_mentions": { "parse": [] }
112        });
113        self.post_message(channel_id, &body).map(|_| ())
114    }
115
116    pub fn send_command_reply(&self, text: &str) -> Result<()> {
117        self.send_plain_message(&self.commands_channel_id, text)
118    }
119
120    pub fn send_formatted_message(&self, channel_id: &str, message: &str) -> Result<()> {
121        let (title, description, color) = outbound_embed_parts(message);
122        self.send_embed(channel_id, &title, &description, color)
123    }
124
125    pub fn validate_token(&self) -> Result<BotIdentity> {
126        let json = self.get_json(&format!("{DISCORD_API_BASE}/users/@me"))?;
127        parse_bot_identity(&json)
128    }
129
130    pub fn list_guilds(&self) -> Result<Vec<GuildSummary>> {
131        let json = self.get_json(&format!("{DISCORD_API_BASE}/users/@me/guilds"))?;
132        parse_guilds_response(&json)
133    }
134
135    pub fn list_guild_channels(&self, guild_id: &str) -> Result<Vec<ChannelSummary>> {
136        let json = self.get_json(&format!("{DISCORD_API_BASE}/guilds/{guild_id}/channels"))?;
137        parse_channels_response(&json)
138    }
139
140    pub fn get_channel(&self, channel_id: &str) -> Result<ChannelSummary> {
141        let json = self.get_json(&format!("{DISCORD_API_BASE}/channels/{channel_id}"))?;
142        parse_channel_response(&json)
143    }
144
145    pub fn create_message(&self, channel_id: &str, body: &serde_json::Value) -> Result<String> {
146        self.post_message(channel_id, body)
147    }
148
149    pub fn edit_message(
150        &self,
151        channel_id: &str,
152        message_id: &str,
153        body: &serde_json::Value,
154    ) -> Result<()> {
155        let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}");
156        let response = ureq::request("PATCH", &url)
157            .set("Authorization", &format!("Bot {}", self.bot_token))
158            .set("Content-Type", "application/json")
159            .send_string(&body.to_string());
160
161        match response {
162            Ok(resp) => {
163                debug!(
164                    status = resp.status(),
165                    channel_id, message_id, "Discord message edited"
166                );
167                Ok(())
168            }
169            Err(ureq::Error::Status(status, response)) => {
170                let detail = response.into_string().unwrap_or_default();
171                warn!(
172                    status,
173                    detail = %detail,
174                    channel_id,
175                    message_id,
176                    "Discord edit failed"
177                );
178                bail!("Discord edit failed with status {status}: {detail}");
179            }
180            Err(ureq::Error::Transport(error)) => {
181                warn!(
182                    error = %error,
183                    channel_id,
184                    message_id,
185                    "Discord edit transport failed"
186                );
187                bail!("Discord edit transport failed: {error}");
188            }
189        }
190    }
191
192    pub fn pin_message(&self, channel_id: &str, message_id: &str) -> Result<()> {
193        let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/pins/{message_id}");
194        let response = ureq::request("PUT", &url)
195            .set("Authorization", &format!("Bot {}", self.bot_token))
196            .call();
197
198        match response {
199            Ok(resp) => {
200                debug!(
201                    status = resp.status(),
202                    channel_id, message_id, "Discord message pinned"
203                );
204                Ok(())
205            }
206            Err(ureq::Error::Status(status, response)) => {
207                let detail = response.into_string().unwrap_or_default();
208                warn!(
209                    status,
210                    detail = %detail,
211                    channel_id,
212                    message_id,
213                    "Discord pin failed"
214                );
215                bail!("Discord pin failed with status {status}: {detail}");
216            }
217            Err(ureq::Error::Transport(error)) => {
218                warn!(
219                    error = %error,
220                    channel_id,
221                    message_id,
222                    "Discord pin transport failed"
223                );
224                bail!("Discord pin transport failed: {error}");
225            }
226        }
227    }
228
229    pub fn poll_commands(&mut self) -> Result<Vec<InboundMessage>> {
230        let url = match &self.last_message_id {
231            Some(last_id) => format!(
232                "{DISCORD_API_BASE}/channels/{}/messages?limit=100&after={last_id}",
233                self.commands_channel_id
234            ),
235            None => format!(
236                "{DISCORD_API_BASE}/channels/{}/messages?limit=100",
237                self.commands_channel_id
238            ),
239        };
240
241        let response = ureq::get(&url)
242            .set("Authorization", &format!("Bot {}", self.bot_token))
243            .call();
244
245        let json: serde_json::Value = match response {
246            Ok(resp) => resp
247                .into_json()
248                .context("failed to parse Discord messages response")?,
249            Err(ureq::Error::Status(status, response)) => {
250                let detail = response.into_string().unwrap_or_default();
251                warn!(status, detail = %detail, "Discord poll failed");
252                bail!("Discord messages failed with status {status}: {detail}");
253            }
254            Err(ureq::Error::Transport(error)) => {
255                warn!(error = %error, "Discord poll transport failed");
256                bail!("Discord messages transport failed: {error}");
257            }
258        };
259
260        let (messages, latest_message_id) = parse_messages_response(&json, &self.allowed_user_ids)?;
261        if let Some(message_id) = latest_message_id {
262            self.last_message_id = Some(message_id);
263        }
264        Ok(messages)
265    }
266
267    fn get_json(&self, url: &str) -> Result<serde_json::Value> {
268        let response = ureq::get(url)
269            .set("Authorization", &format!("Bot {}", self.bot_token))
270            .call();
271
272        match response {
273            Ok(resp) => resp.into_json().context("failed to parse Discord response"),
274            Err(ureq::Error::Status(status, response)) => {
275                let detail = response.into_string().unwrap_or_default();
276                bail!("Discord request failed with status {status}: {detail}");
277            }
278            Err(ureq::Error::Transport(error)) => {
279                bail!("Discord request transport failed: {error}");
280            }
281        }
282    }
283
284    fn post_message(&self, channel_id: &str, body: &serde_json::Value) -> Result<String> {
285        let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages");
286        let response = ureq::post(&url)
287            .set("Authorization", &format!("Bot {}", self.bot_token))
288            .set("Content-Type", "application/json")
289            .send_string(&body.to_string());
290
291        match response {
292            Ok(resp) => {
293                let json: serde_json::Value = resp
294                    .into_json()
295                    .context("failed to parse Discord post-message response")?;
296                let message_id = json
297                    .get("id")
298                    .and_then(|value| value.as_str())
299                    .ok_or_else(|| anyhow!("Discord post-message response missing id"))?
300                    .to_string();
301                debug!(channel_id, message_id, "Discord message accepted");
302                Ok(message_id)
303            }
304            Err(ureq::Error::Status(status, response)) => {
305                let detail = response.into_string().unwrap_or_default();
306                warn!(status, detail = %detail, channel_id, "Discord send failed");
307                bail!("Discord send failed with status {status}: {detail}");
308            }
309            Err(ureq::Error::Transport(error)) => {
310                warn!(error = %error, channel_id, "Discord send transport failed");
311                bail!("Discord send transport failed: {error}");
312            }
313        }
314    }
315}
316
317pub fn setup_discord(project_root: &Path) -> Result<()> {
318    let config_path = project_root
319        .join(".batty")
320        .join("team_config")
321        .join("team.yaml");
322    if !config_path.exists() {
323        bail!(
324            "no team config found at {}; run `batty init` first",
325            config_path.display()
326        );
327    }
328
329    println!("Discord Bot Setup");
330    println!("=================\n");
331
332    println!("Step 1: Bot Token");
333    println!("  Create a Discord bot in the Developer Portal and copy the bot token.");
334    println!("  You can also export BATTY_DISCORD_BOT_TOKEN before running this wizard.\n");
335    let bot_token = prompt_discord_token()?;
336    let setup_bot = DiscordBot::new(bot_token.clone(), Vec::new(), String::new());
337    let identity = setup_bot.validate_token()?;
338    println!(
339        "Bot validated: {} ({})\n",
340        identity.username, identity.user_id
341    );
342
343    println!("Step 2: Pick A Server");
344    let guilds = setup_bot.list_guilds()?;
345    if guilds.is_empty() {
346        bail!("the bot is not in any Discord servers; invite it first, then retry");
347    }
348    let guild_index = prompt_choice(
349        "Select a server",
350        &guilds.iter().map(|g| g.name.clone()).collect::<Vec<_>>(),
351    )?;
352    let guild = &guilds[guild_index];
353    println!("Selected server: {}\n", guild.name);
354
355    println!("Step 3: Pick Channels");
356    let channels = setup_bot.list_guild_channels(&guild.id)?;
357    if channels.is_empty() {
358        bail!("no text channels found in '{}'", guild.name);
359    }
360    let commands_channel = prompt_channel_choice("commands", &channels, &[])?;
361    let events_channel =
362        prompt_channel_choice("events", &channels, &[commands_channel.id.as_str()])?;
363    let agents_channel = prompt_channel_choice(
364        "agents",
365        &channels,
366        &[commands_channel.id.as_str(), events_channel.id.as_str()],
367    )?;
368    println!();
369
370    println!("Step 4: Allowed User IDs");
371    println!("  Enter one or more Discord user IDs, separated by commas.");
372    let allowed_user_ids = prompt_user_ids()?;
373
374    println!("Step 5: Test Messages");
375    let bot = DiscordBot::new(
376        bot_token.clone(),
377        allowed_user_ids.clone(),
378        commands_channel.id.clone(),
379    );
380    send_setup_test_messages(
381        &bot,
382        commands_channel,
383        events_channel,
384        agents_channel,
385        &guild.name,
386    )?;
387    println!("Test messages sent to all selected channels.\n");
388
389    let env_path = project_root.join(".env");
390    env_file::upsert_env_var(&env_path, "BATTY_DISCORD_BOT_TOKEN", &bot_token)?;
391    update_team_yaml_for_discord(
392        &config_path,
393        &commands_channel.id,
394        &events_channel.id,
395        &agents_channel.id,
396        &allowed_user_ids,
397    )?;
398
399    println!("Discord configured successfully.");
400    println!("Saved BATTY_DISCORD_BOT_TOKEN to {}", env_path.display());
401    println!("Restart the daemon with: batty stop && batty start");
402    Ok(())
403}
404
405pub fn discord_status(project_root: &Path) -> Result<()> {
406    let config_path = project_root
407        .join(".batty")
408        .join("team_config")
409        .join("team.yaml");
410    if !config_path.exists() {
411        bail!(
412            "no team config found at {}; run `batty init` first",
413            config_path.display()
414        );
415    }
416
417    let team_config = TeamConfig::load(&config_path)?;
418    let Some(role) = team_config.roles.iter().find(|role| {
419        role.role_type == RoleType::User && role.channel.as_deref() == Some("discord")
420    }) else {
421        println!("Discord is not configured in team.yaml.");
422        return Ok(());
423    };
424
425    let Some(channel_config) = role.channel_config.as_ref() else {
426        bail!("Discord user role exists but channel_config is missing");
427    };
428    let Some(bot) = DiscordBot::from_config(channel_config) else {
429        bail!("Discord is configured but bot token or commands channel is missing");
430    };
431
432    let identity = bot.validate_token()?;
433    let commands = channel_config
434        .commands_channel_id
435        .as_deref()
436        .map(|id| bot.get_channel(id))
437        .transpose()?;
438    let events = channel_config
439        .events_channel_id
440        .as_deref()
441        .map(|id| bot.get_channel(id))
442        .transpose()?;
443    let agents = channel_config
444        .agents_channel_id
445        .as_deref()
446        .map(|id| bot.get_channel(id))
447        .transpose()?;
448
449    println!("Discord Status");
450    println!("==============");
451    println!("Role: {}", role.name);
452    println!("Bot: {} ({})", identity.username, identity.user_id);
453    println!(
454        "Allowed Users: {}",
455        channel_config
456            .allowed_user_ids
457            .iter()
458            .map(i64::to_string)
459            .collect::<Vec<_>>()
460            .join(", ")
461    );
462    println!(
463        "Commands: {}",
464        commands
465            .as_ref()
466            .map(format_channel_label)
467            .unwrap_or_else(|| "not configured".to_string())
468    );
469    println!(
470        "Events: {}",
471        events
472            .as_ref()
473            .map(format_channel_label)
474            .unwrap_or_else(|| "not configured".to_string())
475    );
476    println!(
477        "Agents: {}",
478        agents
479            .as_ref()
480            .map(format_channel_label)
481            .unwrap_or_else(|| "not configured".to_string())
482    );
483    println!("Health: ok");
484    Ok(())
485}
486
487pub(super) fn outbound_embed_parts(message: &str) -> (String, String, u32) {
488    let trimmed = message.trim();
489    if let Some(rest) = trimmed.strip_prefix("--- Message from ") {
490        if let Some((sender, body)) = rest.split_once("---\n") {
491            let sender = sender.trim();
492            return (
493                format!("Message from {sender}"),
494                body.trim().to_string(),
495                color_for_role(sender),
496            );
497        }
498    }
499
500    (
501        "Batty update".to_string(),
502        trimmed.to_string(),
503        color_for_role("system"),
504    )
505}
506
507pub(super) fn color_for_role(role: &str) -> u32 {
508    let role = role.to_ascii_lowercase();
509    if role.contains("architect") {
510        0x3B82F6
511    } else if role.contains("manager") {
512        0x22C55E
513    } else if role.contains("engineer") || role.starts_with("eng-") {
514        0xF97316
515    } else if role.contains("human") || role.contains("user") {
516        0x8B5CF6
517    } else if role.contains("daemon") || role.contains("system") {
518        0x64748B
519    } else {
520        0x0EA5E9
521    }
522}
523
524fn truncate_for_discord(input: &str, limit: usize) -> String {
525    let mut output = input.chars().take(limit).collect::<String>();
526    if input.chars().count() > limit && limit > 3 {
527        output.truncate(limit.saturating_sub(3));
528        output.push_str("...");
529    }
530    output
531}
532
533fn parse_messages_response(
534    json: &serde_json::Value,
535    allowed_user_ids: &[i64],
536) -> Result<(Vec<InboundMessage>, Option<String>)> {
537    let messages = json
538        .as_array()
539        .ok_or_else(|| anyhow!("Discord messages response was not an array"))?;
540
541    let mut inbound = Vec::new();
542    let mut latest_message_id: Option<(u64, String)> = None;
543
544    for message in messages {
545        let message_id = match message.get("id").and_then(|value| value.as_str()) {
546            Some(id) => id.to_string(),
547            None => continue,
548        };
549        let channel_id = match message.get("channel_id").and_then(|value| value.as_str()) {
550            Some(id) => id.to_string(),
551            None => continue,
552        };
553        let content = match message.get("content").and_then(|value| value.as_str()) {
554            Some(content) if !content.trim().is_empty() => content.trim().to_string(),
555            _ => continue,
556        };
557
558        let author = match message.get("author") {
559            Some(author) => author,
560            None => continue,
561        };
562        if author.get("bot").and_then(|value| value.as_bool()) == Some(true) {
563            continue;
564        }
565
566        let from_user_id = match author.get("id").and_then(|value| value.as_str()) {
567            Some(raw) => raw
568                .parse::<i64>()
569                .with_context(|| format!("invalid Discord user id '{raw}'"))?,
570            None => continue,
571        };
572        if !allowed_user_ids.contains(&from_user_id) {
573            continue;
574        }
575
576        let snowflake = message_id
577            .parse::<u64>()
578            .with_context(|| format!("invalid Discord message id '{message_id}'"))?;
579        match &latest_message_id {
580            Some((latest, _)) if *latest >= snowflake => {}
581            _ => latest_message_id = Some((snowflake, message_id.clone())),
582        }
583
584        inbound.push(InboundMessage {
585            message_id,
586            channel_id,
587            from_user_id,
588            text: content,
589        });
590    }
591
592    inbound.sort_by_key(|message| message.message_id.parse::<u64>().unwrap_or(0));
593    Ok((inbound, latest_message_id.map(|(_, id)| id)))
594}
595
596fn parse_bot_identity(json: &serde_json::Value) -> Result<BotIdentity> {
597    let user_id = json
598        .get("id")
599        .and_then(|value| value.as_str())
600        .ok_or_else(|| anyhow!("Discord identity missing id"))?;
601    let username = json
602        .get("username")
603        .and_then(|value| value.as_str())
604        .ok_or_else(|| anyhow!("Discord identity missing username"))?;
605
606    Ok(BotIdentity {
607        user_id: user_id.to_string(),
608        username: username.to_string(),
609    })
610}
611
612fn parse_guilds_response(json: &serde_json::Value) -> Result<Vec<GuildSummary>> {
613    let guilds = json
614        .as_array()
615        .ok_or_else(|| anyhow!("Discord guilds response was not an array"))?;
616
617    let mut parsed = guilds
618        .iter()
619        .filter_map(|guild| {
620            Some(GuildSummary {
621                id: guild.get("id")?.as_str()?.to_string(),
622                name: guild.get("name")?.as_str()?.to_string(),
623            })
624        })
625        .collect::<Vec<_>>();
626    parsed.sort_by(|left, right| {
627        left.name
628            .cmp(&right.name)
629            .then_with(|| left.id.cmp(&right.id))
630    });
631    Ok(parsed)
632}
633
634fn parse_channels_response(json: &serde_json::Value) -> Result<Vec<ChannelSummary>> {
635    let channels = json
636        .as_array()
637        .ok_or_else(|| anyhow!("Discord channels response was not an array"))?;
638
639    let mut parsed = channels
640        .iter()
641        .filter_map(parse_channel_value)
642        .filter(|channel| matches!(channel.kind, 0 | 5))
643        .collect::<Vec<_>>();
644    parsed.sort_by(|left, right| {
645        left.position
646            .cmp(&right.position)
647            .then_with(|| left.name.cmp(&right.name))
648    });
649    Ok(parsed)
650}
651
652fn parse_channel_response(json: &serde_json::Value) -> Result<ChannelSummary> {
653    parse_channel_value(json).ok_or_else(|| anyhow!("Discord channel response missing fields"))
654}
655
656fn parse_channel_value(json: &serde_json::Value) -> Option<ChannelSummary> {
657    Some(ChannelSummary {
658        id: json.get("id")?.as_str()?.to_string(),
659        name: json.get("name")?.as_str()?.to_string(),
660        kind: json
661            .get("type")?
662            .as_u64()
663            .and_then(|value| u8::try_from(value).ok())?,
664        position: json
665            .get("position")
666            .and_then(|value| value.as_i64())
667            .unwrap_or(0),
668    })
669}
670
671fn prompt_discord_token() -> Result<String> {
672    if let Ok(token) = std::env::var("BATTY_DISCORD_BOT_TOKEN")
673        && !token.trim().is_empty()
674    {
675        println!("Found BATTY_DISCORD_BOT_TOKEN in the environment.");
676        if prompt_yes_no("Use the environment token? [Y/n]: ", true)? {
677            return Ok(token);
678        }
679        println!();
680    }
681
682    loop {
683        let token = prompt("Enter your Discord bot token: ")?;
684        if token.is_empty() {
685            println!("Token cannot be empty. Try again.\n");
686            continue;
687        }
688        return Ok(token);
689    }
690}
691
692fn prompt_user_ids() -> Result<Vec<i64>> {
693    loop {
694        let input = prompt("Enter allowed Discord user IDs (comma-separated): ")?;
695        let ids = input
696            .split(',')
697            .map(str::trim)
698            .filter(|part| !part.is_empty())
699            .map(|part| {
700                part.parse::<i64>()
701                    .with_context(|| format!("invalid Discord user id '{part}'"))
702            })
703            .collect::<Result<Vec<_>>>();
704        match ids {
705            Ok(ids) if !ids.is_empty() => return Ok(ids),
706            Ok(_) => println!("Enter at least one Discord user ID.\n"),
707            Err(error) => println!("{error}\n"),
708        }
709    }
710}
711
712fn prompt_choice(prompt_text: &str, options: &[String]) -> Result<usize> {
713    println!("{prompt_text}:");
714    for (index, option) in options.iter().enumerate() {
715        println!("  {}) {}", index + 1, option);
716    }
717
718    loop {
719        let input = prompt("Enter number: ")?;
720        match input.parse::<usize>() {
721            Ok(choice) if (1..=options.len()).contains(&choice) => return Ok(choice - 1),
722            _ => println!("Invalid selection. Try again.\n"),
723        }
724    }
725}
726
727fn prompt_channel_choice<'a>(
728    label: &str,
729    channels: &'a [ChannelSummary],
730    taken_ids: &[&str],
731) -> Result<&'a ChannelSummary> {
732    println!("Select the #{label} channel:");
733    let options = channels
734        .iter()
735        .map(format_channel_label)
736        .collect::<Vec<_>>();
737
738    loop {
739        let index = prompt_choice("Available channels", &options)?;
740        let channel = &channels[index];
741        if taken_ids.iter().any(|taken| *taken == channel.id) {
742            println!("That channel is already assigned. Pick a different one.\n");
743            continue;
744        }
745        return Ok(channel);
746    }
747}
748
749fn format_channel_label(channel: &ChannelSummary) -> String {
750    format!("#{} ({})", channel.name, channel.id)
751}
752
753fn send_setup_test_messages(
754    bot: &DiscordBot,
755    commands: &ChannelSummary,
756    events: &ChannelSummary,
757    agents: &ChannelSummary,
758    guild_name: &str,
759) -> Result<()> {
760    bot.send_plain_message(
761        &commands.id,
762        &format!("Batty Discord setup complete for {guild_name}. Commands channel verified."),
763    )?;
764    bot.send_plain_message(
765        &events.id,
766        &format!("Batty Discord setup complete for {guild_name}. Events channel verified."),
767    )?;
768    bot.send_plain_message(
769        &agents.id,
770        &format!("Batty Discord setup complete for {guild_name}. Agents channel verified."),
771    )?;
772    Ok(())
773}
774
775fn update_team_yaml_for_discord(
776    path: &Path,
777    commands_channel_id: &str,
778    events_channel_id: &str,
779    agents_channel_id: &str,
780    allowed_user_ids: &[i64],
781) -> Result<()> {
782    let content = std::fs::read_to_string(path)
783        .with_context(|| format!("failed to read {}", path.display()))?;
784    let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
785        .with_context(|| format!("failed to parse {}", path.display()))?;
786
787    let roles = doc
788        .get_mut("roles")
789        .and_then(|value| value.as_sequence_mut())
790        .ok_or_else(|| anyhow!("no 'roles' sequence in team.yaml"))?;
791
792    let user_role = roles.iter_mut().find(|role| {
793        role.get("role_type")
794            .and_then(|value| value.as_str())
795            .map(|role_type| role_type == "user")
796            .unwrap_or(false)
797    });
798
799    if let Some(role) = user_role {
800        role["channel"] = serde_yaml::Value::String("discord".into());
801        if role.get("channel_config").is_none()
802            && let Some(role_map) = role.as_mapping_mut()
803        {
804            role_map.insert(
805                serde_yaml::Value::String("channel_config".into()),
806                serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
807            );
808        }
809
810        let channel_config = &mut role["channel_config"];
811        let mapping = channel_config
812            .as_mapping_mut()
813            .ok_or_else(|| anyhow!("channel_config must be a mapping"))?;
814        mapping.remove(serde_yaml::Value::String("target".into()));
815        mapping.remove(serde_yaml::Value::String("provider".into()));
816        mapping.remove(serde_yaml::Value::String("bot_token".into()));
817        mapping.insert(
818            "commands_channel_id".into(),
819            serde_yaml::Value::String(commands_channel_id.into()),
820        );
821        mapping.insert(
822            "events_channel_id".into(),
823            serde_yaml::Value::String(events_channel_id.into()),
824        );
825        mapping.insert(
826            "agents_channel_id".into(),
827            serde_yaml::Value::String(agents_channel_id.into()),
828        );
829        mapping.insert(
830            "allowed_user_ids".into(),
831            serde_yaml::Value::Sequence(
832                allowed_user_ids
833                    .iter()
834                    .copied()
835                    .map(|id| serde_yaml::Value::Number(serde_yaml::Number::from(id)))
836                    .collect(),
837            ),
838        );
839    } else {
840        let mut new_role = serde_yaml::Mapping::new();
841        new_role.insert("name".into(), "human".into());
842        new_role.insert("role_type".into(), "user".into());
843        new_role.insert("channel".into(), "discord".into());
844
845        let mut channel_config = serde_yaml::Mapping::new();
846        channel_config.insert(
847            "commands_channel_id".into(),
848            serde_yaml::Value::String(commands_channel_id.into()),
849        );
850        channel_config.insert(
851            "events_channel_id".into(),
852            serde_yaml::Value::String(events_channel_id.into()),
853        );
854        channel_config.insert(
855            "agents_channel_id".into(),
856            serde_yaml::Value::String(agents_channel_id.into()),
857        );
858        channel_config.insert(
859            "allowed_user_ids".into(),
860            serde_yaml::Value::Sequence(
861                allowed_user_ids
862                    .iter()
863                    .copied()
864                    .map(|id| serde_yaml::Value::Number(serde_yaml::Number::from(id)))
865                    .collect(),
866            ),
867        );
868        new_role.insert(
869            "channel_config".into(),
870            serde_yaml::Value::Mapping(channel_config),
871        );
872        new_role.insert(
873            "talks_to".into(),
874            serde_yaml::Value::Sequence(vec!["architect".into()]),
875        );
876        roles.push(serde_yaml::Value::Mapping(new_role));
877    }
878
879    let output = serde_yaml::to_string(&doc)?;
880    std::fs::write(path, output).with_context(|| format!("failed to write {}", path.display()))?;
881    Ok(())
882}
883
884fn prompt(message: &str) -> Result<String> {
885    print!("{message}");
886    io::stdout().flush()?;
887    let mut input = String::new();
888    io::stdin().read_line(&mut input)?;
889    Ok(input.trim().to_string())
890}
891
892fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> {
893    let input = prompt(message)?;
894    if input.is_empty() {
895        return Ok(default_yes);
896    }
897    Ok(matches!(input.chars().next(), Some('y' | 'Y')))
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    #[test]
905    fn parse_bot_identity_extracts_username_and_id() {
906        let json = serde_json::json!({
907            "id": "123456789012345678",
908            "username": "batty-bot"
909        });
910
911        let identity = parse_bot_identity(&json).unwrap();
912        assert_eq!(identity.user_id, "123456789012345678");
913        assert_eq!(identity.username, "batty-bot");
914    }
915
916    #[test]
917    fn parse_guilds_response_sorts_by_name() {
918        let json = serde_json::json!([
919            {"id": "2", "name": "Zulu"},
920            {"id": "1", "name": "Alpha"}
921        ]);
922
923        let guilds = parse_guilds_response(&json).unwrap();
924        assert_eq!(
925            guilds,
926            vec![
927                GuildSummary {
928                    id: "1".into(),
929                    name: "Alpha".into(),
930                },
931                GuildSummary {
932                    id: "2".into(),
933                    name: "Zulu".into(),
934                }
935            ]
936        );
937    }
938
939    #[test]
940    fn parse_channels_response_filters_non_text_channels() {
941        let json = serde_json::json!([
942            {"id": "10", "name": "voice", "type": 2, "position": 0},
943            {"id": "11", "name": "commands", "type": 0, "position": 2},
944            {"id": "12", "name": "events", "type": 5, "position": 1}
945        ]);
946
947        let channels = parse_channels_response(&json).unwrap();
948        assert_eq!(
949            channels,
950            vec![
951                ChannelSummary {
952                    id: "12".into(),
953                    name: "events".into(),
954                    kind: 5,
955                    position: 1,
956                },
957                ChannelSummary {
958                    id: "11".into(),
959                    name: "commands".into(),
960                    kind: 0,
961                    position: 2,
962                }
963            ]
964        );
965    }
966
967    #[test]
968    fn outbound_embed_parts_extracts_sender_header() {
969        let (title, description, color) =
970            outbound_embed_parts("--- Message from architect ---\nFocus on tests");
971        assert_eq!(title, "Message from architect");
972        assert_eq!(description, "Focus on tests");
973        assert_eq!(color, color_for_role("architect"));
974    }
975
976    #[test]
977    fn outbound_embed_parts_falls_back_for_plain_text() {
978        let (title, description, color) = outbound_embed_parts("plain message");
979        assert_eq!(title, "Batty update");
980        assert_eq!(description, "plain message");
981        assert_eq!(color, color_for_role("system"));
982    }
983
984    #[test]
985    fn parse_messages_response_filters_unauthorized_and_bot_messages() {
986        let json = serde_json::json!([
987            {
988                "id": "1002",
989                "channel_id": "55",
990                "content": "$status",
991                "author": {"id": "42", "bot": false}
992            },
993            {
994                "id": "1001",
995                "channel_id": "55",
996                "content": "hello",
997                "author": {"id": "999", "bot": false}
998            },
999            {
1000                "id": "1003",
1001                "channel_id": "55",
1002                "content": "ignore me",
1003                "author": {"id": "42", "bot": true}
1004            }
1005        ]);
1006
1007        let (messages, latest_message_id) = parse_messages_response(&json, &[42]).unwrap();
1008        assert_eq!(
1009            messages,
1010            vec![InboundMessage {
1011                message_id: "1002".to_string(),
1012                channel_id: "55".to_string(),
1013                from_user_id: 42,
1014                text: "$status".to_string(),
1015            }]
1016        );
1017        assert_eq!(latest_message_id.as_deref(), Some("1002"));
1018    }
1019
1020    #[test]
1021    fn parse_messages_response_sorts_by_message_id() {
1022        let json = serde_json::json!([
1023            {
1024                "id": "1009",
1025                "channel_id": "55",
1026                "content": "second",
1027                "author": {"id": "42", "bot": false}
1028            },
1029            {
1030                "id": "1008",
1031                "channel_id": "55",
1032                "content": "first",
1033                "author": {"id": "42", "bot": false}
1034            }
1035        ]);
1036
1037        let (messages, latest_message_id) = parse_messages_response(&json, &[42]).unwrap();
1038        assert_eq!(messages[0].text, "first");
1039        assert_eq!(messages[1].text, "second");
1040        assert_eq!(latest_message_id.as_deref(), Some("1009"));
1041    }
1042
1043    #[test]
1044    fn update_team_yaml_for_discord_updates_existing_user_role() {
1045        let tmp = tempfile::tempdir().unwrap();
1046        let path = tmp.path().join("team.yaml");
1047        std::fs::write(
1048            &path,
1049            r#"
1050name: test-team
1051roles:
1052  - name: human
1053    role_type: user
1054    channel: telegram
1055    channel_config:
1056      target: "placeholder"
1057      provider: openclaw
1058    talks_to: [architect]
1059"#,
1060        )
1061        .unwrap();
1062
1063        update_team_yaml_for_discord(&path, "cmd-1", "evt-1", "agt-1", &[111, 222]).unwrap();
1064
1065        let content = std::fs::read_to_string(&path).unwrap();
1066        assert!(content.contains("channel: discord"));
1067        assert!(content.contains("commands_channel_id: cmd-1"));
1068        assert!(content.contains("events_channel_id: evt-1"));
1069        assert!(content.contains("agents_channel_id: agt-1"));
1070        assert!(!content.contains("bot_token"));
1071        assert!(!content.contains("target: placeholder"));
1072        assert!(!content.contains("provider: openclaw"));
1073    }
1074
1075    #[test]
1076    fn update_team_yaml_for_discord_creates_user_role_if_missing() {
1077        let tmp = tempfile::tempdir().unwrap();
1078        let path = tmp.path().join("team.yaml");
1079        std::fs::write(
1080            &path,
1081            r#"
1082name: test-team
1083roles:
1084  - name: architect
1085    role_type: architect
1086    agent: claude
1087"#,
1088        )
1089        .unwrap();
1090
1091        update_team_yaml_for_discord(&path, "cmd-1", "evt-1", "agt-1", &[111]).unwrap();
1092
1093        let content = std::fs::read_to_string(&path).unwrap();
1094        assert!(content.contains("name: human"));
1095        assert!(content.contains("role_type: user"));
1096        assert!(content.contains("channel: discord"));
1097        assert!(content.contains("commands_channel_id: cmd-1"));
1098        assert!(!content.contains("bot_token"));
1099    }
1100
1101    #[test]
1102    fn setup_discord_bails_without_config() {
1103        let tmp = tempfile::tempdir().unwrap();
1104        let result = setup_discord(tmp.path());
1105        assert!(result.is_err());
1106        assert!(result.unwrap_err().to_string().contains("batty init"));
1107    }
1108}