1use 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#[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
51pub 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 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}