use std::io::{self, Write as IoWrite};
use std::path::Path;
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use tracing::{debug, warn};
use crate::env_file;
use super::config::{ChannelConfig, RoleType, TeamConfig};
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
const MAX_EMBED_TITLE_LEN: usize = 256;
const MAX_EMBED_DESCRIPTION_LEN: usize = 4_000;
const MAX_EMBED_FIELD_NAME_LEN: usize = 256;
const MAX_EMBED_FIELD_VALUE_LEN: usize = 1_024;
const MAX_EMBED_FOOTER_LEN: usize = 2_048;
const MAX_EMBED_AUTHOR_NAME_LEN: usize = 256;
const MAX_EMBED_FIELDS: usize = 25;
const MAX_CONTENT_LEN: usize = 2_000;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EmbedField {
pub name: String,
pub value: String,
pub inline: bool,
}
impl EmbedField {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
inline: false,
}
}
pub fn inline(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
inline: true,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RichEmbed {
pub title: String,
pub description: Option<String>,
pub color: u32,
pub url: Option<String>,
pub author_name: Option<String>,
pub author_icon_url: Option<String>,
pub author_url: Option<String>,
pub footer: Option<String>,
pub footer_icon_url: Option<String>,
pub timestamp: Option<String>,
pub thumbnail_url: Option<String>,
pub fields: Vec<EmbedField>,
}
impl RichEmbed {
pub fn new(title: impl Into<String>, color: u32) -> Self {
Self {
title: title.into(),
color,
..Self::default()
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_author(mut self, name: impl Into<String>) -> Self {
self.author_name = Some(name.into());
self
}
pub fn with_footer(mut self, footer: impl Into<String>) -> Self {
self.footer = Some(footer.into());
self
}
pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.timestamp = Some(timestamp.into());
self
}
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
pub fn push_field(mut self, field: EmbedField) -> Self {
if self.fields.len() < MAX_EMBED_FIELDS {
self.fields.push(field);
}
self
}
pub fn to_json(&self) -> serde_json::Value {
let mut embed = serde_json::json!({
"title": truncate_for_discord(&self.title, MAX_EMBED_TITLE_LEN),
"color": self.color,
});
if let Some(description) = self.description.as_deref() {
embed["description"] = serde_json::Value::String(truncate_for_discord(
description,
MAX_EMBED_DESCRIPTION_LEN,
));
}
if let Some(url) = self.url.as_deref() {
embed["url"] = serde_json::Value::String(url.to_string());
}
if let Some(author_name) = self.author_name.as_deref() {
let mut author = serde_json::json!({
"name": truncate_for_discord(author_name, MAX_EMBED_AUTHOR_NAME_LEN),
});
if let Some(icon_url) = self.author_icon_url.as_deref() {
author["icon_url"] = serde_json::Value::String(icon_url.to_string());
}
if let Some(author_url) = self.author_url.as_deref() {
author["url"] = serde_json::Value::String(author_url.to_string());
}
embed["author"] = author;
}
if let Some(footer) = self.footer.as_deref() {
let mut footer_obj = serde_json::json!({
"text": truncate_for_discord(footer, MAX_EMBED_FOOTER_LEN),
});
if let Some(icon_url) = self.footer_icon_url.as_deref() {
footer_obj["icon_url"] = serde_json::Value::String(icon_url.to_string());
}
embed["footer"] = footer_obj;
}
if let Some(timestamp) = self.timestamp.as_deref() {
embed["timestamp"] = serde_json::Value::String(timestamp.to_string());
}
if let Some(thumbnail) = self.thumbnail_url.as_deref() {
embed["thumbnail"] = serde_json::json!({ "url": thumbnail });
}
if !self.fields.is_empty() {
let fields: Vec<serde_json::Value> = self
.fields
.iter()
.take(MAX_EMBED_FIELDS)
.map(|field| {
serde_json::json!({
"name": truncate_for_discord(&field.name, MAX_EMBED_FIELD_NAME_LEN),
"value": truncate_for_discord(&field.value, MAX_EMBED_FIELD_VALUE_LEN),
"inline": field.inline,
})
})
.collect();
embed["fields"] = serde_json::Value::Array(fields);
}
embed
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboundMessage {
pub message_id: String,
pub channel_id: String,
pub from_user_id: i64,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BotIdentity {
pub user_id: String,
pub username: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuildSummary {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelSummary {
pub id: String,
pub name: String,
pub kind: u8,
pub position: i64,
}
pub struct DiscordBot {
bot_token: String,
allowed_user_ids: Vec<i64>,
commands_channel_id: String,
last_message_id: Option<String>,
last_message_content_fault: Option<DiscordMessageContentFault>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscordMessageContentFault {
pub total_received: usize,
pub dropped_empty_content: usize,
pub dropped_bot_author: usize,
pub dropped_unauthorized_user: usize,
}
impl DiscordMessageContentFault {
pub fn status_message(&self) -> String {
format!(
"Discord MESSAGE_CONTENT intent fault: received {} messages and dropped {} empty-content messages. Enable MESSAGE CONTENT INTENT for the bot in Discord Developer Portal -> Bot -> Privileged Gateway Intents, then restart Batty.",
self.total_received, self.dropped_empty_content
)
}
}
impl DiscordBot {
pub fn new(bot_token: String, allowed_user_ids: Vec<i64>, commands_channel_id: String) -> Self {
Self {
bot_token,
allowed_user_ids,
commands_channel_id,
last_message_id: None,
last_message_content_fault: None,
}
}
pub fn from_config(config: &ChannelConfig) -> Option<Self> {
let token = config
.bot_token
.clone()
.or_else(|| std::env::var("BATTY_DISCORD_BOT_TOKEN").ok())?;
let commands_channel_id = config.commands_channel_id.clone()?;
Some(Self::new(
token,
config.allowed_user_ids.clone(),
commands_channel_id,
))
}
pub fn commands_channel_id(&self) -> &str {
&self.commands_channel_id
}
pub fn send_plain_message(&self, channel_id: &str, text: &str) -> Result<()> {
let body = serde_json::json!({
"content": truncate_for_discord(text, MAX_CONTENT_LEN),
"allowed_mentions": { "parse": [] }
});
self.post_message(channel_id, &body).map(|_| ())
}
pub fn send_embed(
&self,
channel_id: &str,
title: &str,
description: &str,
color: u32,
) -> Result<()> {
let body = serde_json::json!({
"embeds": [{
"title": truncate_for_discord(title, MAX_EMBED_TITLE_LEN),
"description": truncate_for_discord(description, MAX_EMBED_DESCRIPTION_LEN),
"color": color
}],
"allowed_mentions": { "parse": [] }
});
self.post_message(channel_id, &body).map(|_| ())
}
pub fn send_rich_embed(&self, channel_id: &str, embed: &RichEmbed) -> Result<()> {
let body = serde_json::json!({
"embeds": [embed.to_json()],
"allowed_mentions": { "parse": [] }
});
self.post_message(channel_id, &body).map(|_| ())
}
pub fn send_command_reply(&self, text: &str) -> Result<()> {
self.send_plain_message(&self.commands_channel_id, text)
}
pub fn send_formatted_message(&self, channel_id: &str, message: &str) -> Result<()> {
let embed = outbound_embed(message);
self.send_rich_embed(channel_id, &embed)
}
pub fn validate_token(&self) -> Result<BotIdentity> {
let json = self.get_json(&format!("{DISCORD_API_BASE}/users/@me"))?;
parse_bot_identity(&json)
}
pub fn list_guilds(&self) -> Result<Vec<GuildSummary>> {
let json = self.get_json(&format!("{DISCORD_API_BASE}/users/@me/guilds"))?;
parse_guilds_response(&json)
}
pub fn list_guild_channels(&self, guild_id: &str) -> Result<Vec<ChannelSummary>> {
let json = self.get_json(&format!("{DISCORD_API_BASE}/guilds/{guild_id}/channels"))?;
parse_channels_response(&json)
}
pub fn get_channel(&self, channel_id: &str) -> Result<ChannelSummary> {
let json = self.get_json(&format!("{DISCORD_API_BASE}/channels/{channel_id}"))?;
parse_channel_response(&json)
}
pub fn create_message(&self, channel_id: &str, body: &serde_json::Value) -> Result<String> {
self.post_message(channel_id, body)
}
pub fn edit_message(
&self,
channel_id: &str,
message_id: &str,
body: &serde_json::Value,
) -> Result<()> {
let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}");
let response = ureq::request("PATCH", &url)
.set("Authorization", &format!("Bot {}", self.bot_token))
.set("Content-Type", "application/json")
.send_string(&body.to_string());
match response {
Ok(resp) => {
debug!(
status = resp.status(),
channel_id, message_id, "Discord message edited"
);
Ok(())
}
Err(ureq::Error::Status(status, response)) => {
let detail = response.into_string().unwrap_or_default();
warn!(
status,
detail = %detail,
channel_id,
message_id,
"Discord edit failed"
);
bail!("Discord edit failed with status {status}: {detail}");
}
Err(ureq::Error::Transport(error)) => {
warn!(
error = %error,
channel_id,
message_id,
"Discord edit transport failed"
);
bail!("Discord edit transport failed: {error}");
}
}
}
pub fn pin_message(&self, channel_id: &str, message_id: &str) -> Result<()> {
let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/pins/{message_id}");
let response = ureq::request("PUT", &url)
.set("Authorization", &format!("Bot {}", self.bot_token))
.call();
match response {
Ok(resp) => {
debug!(
status = resp.status(),
channel_id, message_id, "Discord message pinned"
);
Ok(())
}
Err(ureq::Error::Status(status, response)) => {
let detail = response.into_string().unwrap_or_default();
warn!(
status,
detail = %detail,
channel_id,
message_id,
"Discord pin failed"
);
bail!("Discord pin failed with status {status}: {detail}");
}
Err(ureq::Error::Transport(error)) => {
warn!(
error = %error,
channel_id,
message_id,
"Discord pin transport failed"
);
bail!("Discord pin transport failed: {error}");
}
}
}
pub fn poll_commands(&mut self) -> Result<Vec<InboundMessage>> {
let url = match &self.last_message_id {
Some(last_id) => format!(
"{DISCORD_API_BASE}/channels/{}/messages?limit=100&after={last_id}",
self.commands_channel_id
),
None => format!(
"{DISCORD_API_BASE}/channels/{}/messages?limit=100",
self.commands_channel_id
),
};
let response = ureq::get(&url)
.set("Authorization", &format!("Bot {}", self.bot_token))
.call();
let json: serde_json::Value = match response {
Ok(resp) => resp
.into_json()
.context("failed to parse Discord messages response")?,
Err(ureq::Error::Status(status, response)) => {
let detail = response.into_string().unwrap_or_default();
warn!(status, detail = %detail, "Discord poll failed");
bail!("Discord messages failed with status {status}: {detail}");
}
Err(ureq::Error::Transport(error)) => {
warn!(error = %error, "Discord poll transport failed");
bail!("Discord messages transport failed: {error}");
}
};
let (messages, latest_message_id, message_content_fault) =
parse_messages_response(&json, &self.allowed_user_ids)?;
if let Some(message_id) = latest_message_id {
self.last_message_id = Some(message_id);
}
self.last_message_content_fault = message_content_fault;
Ok(messages)
}
pub fn take_message_content_fault(&mut self) -> Option<DiscordMessageContentFault> {
self.last_message_content_fault.take()
}
fn get_json(&self, url: &str) -> Result<serde_json::Value> {
let response = ureq::get(url)
.set("Authorization", &format!("Bot {}", self.bot_token))
.call();
match response {
Ok(resp) => resp.into_json().context("failed to parse Discord response"),
Err(ureq::Error::Status(status, response)) => {
let detail = response.into_string().unwrap_or_default();
bail!("Discord request failed with status {status}: {detail}");
}
Err(ureq::Error::Transport(error)) => {
bail!("Discord request transport failed: {error}");
}
}
}
fn post_message(&self, channel_id: &str, body: &serde_json::Value) -> Result<String> {
let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages");
let response = ureq::post(&url)
.set("Authorization", &format!("Bot {}", self.bot_token))
.set("Content-Type", "application/json")
.send_string(&body.to_string());
match response {
Ok(resp) => {
let json: serde_json::Value = resp
.into_json()
.context("failed to parse Discord post-message response")?;
let message_id = json
.get("id")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow!("Discord post-message response missing id"))?
.to_string();
debug!(channel_id, message_id, "Discord message accepted");
Ok(message_id)
}
Err(ureq::Error::Status(status, response)) => {
let detail = response.into_string().unwrap_or_default();
warn!(status, detail = %detail, channel_id, "Discord send failed");
bail!("Discord send failed with status {status}: {detail}");
}
Err(ureq::Error::Transport(error)) => {
warn!(error = %error, channel_id, "Discord send transport failed");
bail!("Discord send transport failed: {error}");
}
}
}
}
pub fn setup_discord(project_root: &Path) -> Result<()> {
let config_path = project_root
.join(".batty")
.join("team_config")
.join("team.yaml");
if !config_path.exists() {
bail!(
"no team config found at {}; run `batty init` first",
config_path.display()
);
}
println!("Discord Bot Setup");
println!("=================\n");
println!("Step 1: Bot Token");
println!(" Create a Discord bot in the Developer Portal and copy the bot token.");
println!(" You can also export BATTY_DISCORD_BOT_TOKEN before running this wizard.\n");
let bot_token = prompt_discord_token()?;
let setup_bot = DiscordBot::new(bot_token.clone(), Vec::new(), String::new());
let identity = setup_bot.validate_token()?;
println!(
"Bot validated: {} ({})\n",
identity.username, identity.user_id
);
println!("Step 2: Pick A Server");
let guilds = setup_bot.list_guilds()?;
if guilds.is_empty() {
bail!("the bot is not in any Discord servers; invite it first, then retry");
}
let guild_index = prompt_choice(
"Select a server",
&guilds.iter().map(|g| g.name.clone()).collect::<Vec<_>>(),
)?;
let guild = &guilds[guild_index];
println!("Selected server: {}\n", guild.name);
println!("Step 3: Pick Channels");
let channels = setup_bot.list_guild_channels(&guild.id)?;
if channels.is_empty() {
bail!("no text channels found in '{}'", guild.name);
}
let commands_channel = prompt_channel_choice("commands", &channels, &[])?;
let events_channel =
prompt_channel_choice("events", &channels, &[commands_channel.id.as_str()])?;
let agents_channel = prompt_channel_choice(
"agents",
&channels,
&[commands_channel.id.as_str(), events_channel.id.as_str()],
)?;
println!();
println!("Step 4: Allowed User IDs");
println!(" Enter one or more Discord user IDs, separated by commas.");
let allowed_user_ids = prompt_user_ids()?;
println!("Step 5: Test Messages");
let bot = DiscordBot::new(
bot_token.clone(),
allowed_user_ids.clone(),
commands_channel.id.clone(),
);
send_setup_test_messages(
&bot,
commands_channel,
events_channel,
agents_channel,
&guild.name,
)?;
println!("Test messages sent to all selected channels.\n");
let env_path = project_root.join(".env");
env_file::upsert_env_var(&env_path, "BATTY_DISCORD_BOT_TOKEN", &bot_token)?;
update_team_yaml_for_discord(
&config_path,
&commands_channel.id,
&events_channel.id,
&agents_channel.id,
&allowed_user_ids,
)?;
println!("Discord configured successfully.");
println!("Saved BATTY_DISCORD_BOT_TOKEN to {}", env_path.display());
println!("Restart the daemon with: batty stop && batty start");
Ok(())
}
pub fn discord_status(project_root: &Path) -> Result<()> {
let config_path = project_root
.join(".batty")
.join("team_config")
.join("team.yaml");
if !config_path.exists() {
bail!(
"no team config found at {}; run `batty init` first",
config_path.display()
);
}
let team_config = TeamConfig::load(&config_path)?;
let Some(role) = team_config.roles.iter().find(|role| {
role.role_type == RoleType::User && role.channel.as_deref() == Some("discord")
}) else {
println!("Discord is not configured in team.yaml.");
return Ok(());
};
let Some(channel_config) = role.channel_config.as_ref() else {
bail!("Discord user role exists but channel_config is missing");
};
let Some(bot) = DiscordBot::from_config(channel_config) else {
bail!("Discord is configured but bot token or commands channel is missing");
};
let identity = bot.validate_token()?;
let commands = channel_config
.commands_channel_id
.as_deref()
.map(|id| bot.get_channel(id))
.transpose()?;
let events = channel_config
.events_channel_id
.as_deref()
.map(|id| bot.get_channel(id))
.transpose()?;
let agents = channel_config
.agents_channel_id
.as_deref()
.map(|id| bot.get_channel(id))
.transpose()?;
println!("Discord Status");
println!("==============");
println!("Role: {}", role.name);
println!("Bot: {} ({})", identity.username, identity.user_id);
println!(
"Allowed Users: {}",
channel_config
.allowed_user_ids
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(", ")
);
println!(
"Commands: {}",
commands
.as_ref()
.map(format_channel_label)
.unwrap_or_else(|| "not configured".to_string())
);
println!(
"Events: {}",
events
.as_ref()
.map(format_channel_label)
.unwrap_or_else(|| "not configured".to_string())
);
println!(
"Agents: {}",
agents
.as_ref()
.map(format_channel_label)
.unwrap_or_else(|| "not configured".to_string())
);
println!("Health: ok");
Ok(())
}
pub(super) fn outbound_embed(message: &str) -> RichEmbed {
let trimmed = message.trim();
if let Some(rest) = trimmed.strip_prefix("--- Message from ") {
if let Some((sender, body)) = rest.split_once("---\n") {
let sender = sender.trim();
return RichEmbed::new("💬 Command Update", color_for_role(sender))
.with_author(role_author_label(sender))
.with_description(body.trim())
.with_footer("batty · command surface")
.with_timestamp(Utc::now().to_rfc3339());
}
}
RichEmbed::new("💬 Batty Update", color_for_role("system"))
.with_description(trimmed)
.with_footer("batty · command surface")
.with_timestamp(Utc::now().to_rfc3339())
}
pub(super) fn color_for_role(role: &str) -> u32 {
let role = role.to_ascii_lowercase();
if role.contains("architect") {
0x3B82F6
} else if role.contains("manager") {
0x22C55E
} else if role.contains("engineer") || role.starts_with("eng-") {
0xF97316
} else if role.contains("human") || role.contains("user") {
0x8B5CF6
} else if role.contains("daemon") || role.contains("system") {
0x64748B
} else {
0x0EA5E9
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Success,
Info,
Warn,
Error,
Critical,
Neutral,
}
impl Severity {
pub fn color(self) -> u32 {
match self {
Severity::Success => 0x57F287, Severity::Info => 0x5865F2, Severity::Warn => 0xFEE75C, Severity::Error => 0xED4245, Severity::Critical => 0x992D22, Severity::Neutral => 0x99AAB5, }
}
}
pub fn severity_for_event(event: &str) -> Severity {
use Severity::*;
match event {
"merge_success"
| "task_auto_merged"
| "task_manual_merged"
| "verification_evidence_collected"
| "daemon_started"
| "agent_spawned"
| "auto_doctor_action" => Success,
"task_assigned"
| "task_claim_created"
| "verification_phase_changed"
| "standup_posted"
| "merge_confidence_scored" => Info,
"task_stale"
| "dispatch_overlap_skipped"
| "pattern_detected"
| "narration_rejection"
| "review_aging" => Warn,
"task_escalated"
| "stall_detected"
| "context_exhausted"
| "verification_failed"
| "merge_conflict"
| "merge_failed"
| "pane_death"
| "scope_fence_violation" => Error,
"backend_quota_exhausted" | "daemon_stopped" | "loop_step_error" | "shim_crash" => Critical,
_ => Neutral,
}
}
pub(super) fn role_author_label(role: &str) -> String {
let role_lc = role.to_ascii_lowercase();
if role_lc.contains("architect") {
format!("🏗️ {role}")
} else if role_lc.contains("manager") {
format!("📋 {role}")
} else if role_lc.contains("design") {
format!("🎨 {role}")
} else if role_lc.starts_with("eng") || role_lc.contains("engineer") {
format!("🔧 {role}")
} else if role_lc.contains("human") || role_lc.contains("user") {
format!("👤 {role}")
} else if role_lc.contains("daemon") || role_lc.contains("system") || role_lc == "batty" {
format!("⚙️ {role}")
} else {
role.to_string()
}
}
fn truncate_for_discord(input: &str, limit: usize) -> String {
let mut output = input.chars().take(limit).collect::<String>();
if input.chars().count() > limit && limit > 3 {
output.truncate(limit.saturating_sub(3));
output.push_str("...");
}
output
}
fn parse_messages_response(
json: &serde_json::Value,
allowed_user_ids: &[i64],
) -> Result<(
Vec<InboundMessage>,
Option<String>,
Option<DiscordMessageContentFault>,
)> {
let messages = json
.as_array()
.ok_or_else(|| anyhow!("Discord messages response was not an array"))?;
let mut inbound = Vec::new();
let mut latest_message_id: Option<(u64, String)> = None;
let mut dropped_empty_content = 0usize;
let mut dropped_bot_author = 0usize;
let mut dropped_unauthorized_user = 0usize;
let mut latest_any_snowflake: Option<(u64, String)> = None;
for message in messages {
let message_id = match message.get("id").and_then(|value| value.as_str()) {
Some(id) => id.to_string(),
None => continue,
};
if let Ok(any_snowflake) = message_id.parse::<u64>() {
match &latest_any_snowflake {
Some((latest, _)) if *latest >= any_snowflake => {}
_ => latest_any_snowflake = Some((any_snowflake, message_id.clone())),
}
}
let channel_id = match message.get("channel_id").and_then(|value| value.as_str()) {
Some(id) => id.to_string(),
None => continue,
};
let content = match message.get("content").and_then(|value| value.as_str()) {
Some(content) if !content.trim().is_empty() => content.trim().to_string(),
_ => {
dropped_empty_content += 1;
continue;
}
};
let author = match message.get("author") {
Some(author) => author,
None => continue,
};
if author.get("bot").and_then(|value| value.as_bool()) == Some(true) {
dropped_bot_author += 1;
continue;
}
let from_user_id = match author.get("id").and_then(|value| value.as_str()) {
Some(raw) => raw
.parse::<i64>()
.with_context(|| format!("invalid Discord user id '{raw}'"))?,
None => continue,
};
if !allowed_user_ids.contains(&from_user_id) {
dropped_unauthorized_user += 1;
continue;
}
let snowflake = message_id
.parse::<u64>()
.with_context(|| format!("invalid Discord message id '{message_id}'"))?;
match &latest_message_id {
Some((latest, _)) if *latest >= snowflake => {}
_ => latest_message_id = Some((snowflake, message_id.clone())),
}
inbound.push(InboundMessage {
message_id,
channel_id,
from_user_id,
text: content,
});
}
let total = messages.len();
let message_content_fault = (total > 0 && inbound.is_empty() && dropped_empty_content == total)
.then_some(DiscordMessageContentFault {
total_received: total,
dropped_empty_content,
dropped_bot_author,
dropped_unauthorized_user,
});
if total > 0 && inbound.is_empty() {
warn!(
total_received = total,
dropped_empty_content,
dropped_bot_author,
dropped_unauthorized_user,
"Discord poll returned messages but none were delivered; \
if dropped_empty_content == total_received, the bot likely \
lacks the MESSAGE_CONTENT privileged intent"
);
} else if total > 0 {
debug!(
total_received = total,
delivered = inbound.len(),
dropped_empty_content,
dropped_bot_author,
dropped_unauthorized_user,
"Discord poll parsed"
);
}
inbound.sort_by_key(|message| message.message_id.parse::<u64>().unwrap_or(0));
let cursor = match (latest_message_id, latest_any_snowflake) {
(Some((delivered, delivered_id)), Some((any, any_id))) => {
if any > delivered {
Some(any_id)
} else {
Some(delivered_id)
}
}
(Some((_, id)), None) | (None, Some((_, id))) => Some(id),
(None, None) => None,
};
Ok((inbound, cursor, message_content_fault))
}
fn parse_bot_identity(json: &serde_json::Value) -> Result<BotIdentity> {
let user_id = json
.get("id")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow!("Discord identity missing id"))?;
let username = json
.get("username")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow!("Discord identity missing username"))?;
Ok(BotIdentity {
user_id: user_id.to_string(),
username: username.to_string(),
})
}
fn parse_guilds_response(json: &serde_json::Value) -> Result<Vec<GuildSummary>> {
let guilds = json
.as_array()
.ok_or_else(|| anyhow!("Discord guilds response was not an array"))?;
let mut parsed = guilds
.iter()
.filter_map(|guild| {
Some(GuildSummary {
id: guild.get("id")?.as_str()?.to_string(),
name: guild.get("name")?.as_str()?.to_string(),
})
})
.collect::<Vec<_>>();
parsed.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(parsed)
}
fn parse_channels_response(json: &serde_json::Value) -> Result<Vec<ChannelSummary>> {
let channels = json
.as_array()
.ok_or_else(|| anyhow!("Discord channels response was not an array"))?;
let mut parsed = channels
.iter()
.filter_map(parse_channel_value)
.filter(|channel| matches!(channel.kind, 0 | 5))
.collect::<Vec<_>>();
parsed.sort_by(|left, right| {
left.position
.cmp(&right.position)
.then_with(|| left.name.cmp(&right.name))
});
Ok(parsed)
}
fn parse_channel_response(json: &serde_json::Value) -> Result<ChannelSummary> {
parse_channel_value(json).ok_or_else(|| anyhow!("Discord channel response missing fields"))
}
fn parse_channel_value(json: &serde_json::Value) -> Option<ChannelSummary> {
Some(ChannelSummary {
id: json.get("id")?.as_str()?.to_string(),
name: json.get("name")?.as_str()?.to_string(),
kind: json
.get("type")?
.as_u64()
.and_then(|value| u8::try_from(value).ok())?,
position: json
.get("position")
.and_then(|value| value.as_i64())
.unwrap_or(0),
})
}
fn prompt_discord_token() -> Result<String> {
if let Ok(token) = std::env::var("BATTY_DISCORD_BOT_TOKEN")
&& !token.trim().is_empty()
{
println!("Found BATTY_DISCORD_BOT_TOKEN in the environment.");
if prompt_yes_no("Use the environment token? [Y/n]: ", true)? {
return Ok(token);
}
println!();
}
loop {
let token = prompt("Enter your Discord bot token: ")?;
if token.is_empty() {
println!("Token cannot be empty. Try again.\n");
continue;
}
return Ok(token);
}
}
fn prompt_user_ids() -> Result<Vec<i64>> {
loop {
let input = prompt("Enter allowed Discord user IDs (comma-separated): ")?;
let ids = input
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| {
part.parse::<i64>()
.with_context(|| format!("invalid Discord user id '{part}'"))
})
.collect::<Result<Vec<_>>>();
match ids {
Ok(ids) if !ids.is_empty() => return Ok(ids),
Ok(_) => println!("Enter at least one Discord user ID.\n"),
Err(error) => println!("{error}\n"),
}
}
}
fn prompt_choice(prompt_text: &str, options: &[String]) -> Result<usize> {
println!("{prompt_text}:");
for (index, option) in options.iter().enumerate() {
println!(" {}) {}", index + 1, option);
}
loop {
let input = prompt("Enter number: ")?;
match input.parse::<usize>() {
Ok(choice) if (1..=options.len()).contains(&choice) => return Ok(choice - 1),
_ => println!("Invalid selection. Try again.\n"),
}
}
}
fn prompt_channel_choice<'a>(
label: &str,
channels: &'a [ChannelSummary],
taken_ids: &[&str],
) -> Result<&'a ChannelSummary> {
println!("Select the #{label} channel:");
let options = channels
.iter()
.map(format_channel_label)
.collect::<Vec<_>>();
loop {
let index = prompt_choice("Available channels", &options)?;
let channel = &channels[index];
if taken_ids.iter().any(|taken| *taken == channel.id) {
println!("That channel is already assigned. Pick a different one.\n");
continue;
}
return Ok(channel);
}
}
fn format_channel_label(channel: &ChannelSummary) -> String {
format!("#{} ({})", channel.name, channel.id)
}
fn send_setup_test_messages(
bot: &DiscordBot,
commands: &ChannelSummary,
events: &ChannelSummary,
agents: &ChannelSummary,
guild_name: &str,
) -> Result<()> {
bot.send_plain_message(
&commands.id,
&format!("Batty Discord setup complete for {guild_name}. Commands channel verified."),
)?;
bot.send_plain_message(
&events.id,
&format!("Batty Discord setup complete for {guild_name}. Events channel verified."),
)?;
bot.send_plain_message(
&agents.id,
&format!("Batty Discord setup complete for {guild_name}. Agents channel verified."),
)?;
Ok(())
}
fn update_team_yaml_for_discord(
path: &Path,
commands_channel_id: &str,
events_channel_id: &str,
agents_channel_id: &str,
allowed_user_ids: &[i64],
) -> Result<()> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
let roles = doc
.get_mut("roles")
.and_then(|value| value.as_sequence_mut())
.ok_or_else(|| anyhow!("no 'roles' sequence in team.yaml"))?;
let user_role = roles.iter_mut().find(|role| {
role.get("role_type")
.and_then(|value| value.as_str())
.map(|role_type| role_type == "user")
.unwrap_or(false)
});
if let Some(role) = user_role {
role["channel"] = serde_yaml::Value::String("discord".into());
if role.get("channel_config").is_none()
&& let Some(role_map) = role.as_mapping_mut()
{
role_map.insert(
serde_yaml::Value::String("channel_config".into()),
serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
);
}
let channel_config = &mut role["channel_config"];
let mapping = channel_config
.as_mapping_mut()
.ok_or_else(|| anyhow!("channel_config must be a mapping"))?;
mapping.remove(serde_yaml::Value::String("target".into()));
mapping.remove(serde_yaml::Value::String("provider".into()));
mapping.remove(serde_yaml::Value::String("bot_token".into()));
mapping.insert(
"commands_channel_id".into(),
serde_yaml::Value::String(commands_channel_id.into()),
);
mapping.insert(
"events_channel_id".into(),
serde_yaml::Value::String(events_channel_id.into()),
);
mapping.insert(
"agents_channel_id".into(),
serde_yaml::Value::String(agents_channel_id.into()),
);
mapping.insert(
"allowed_user_ids".into(),
serde_yaml::Value::Sequence(
allowed_user_ids
.iter()
.copied()
.map(|id| serde_yaml::Value::Number(serde_yaml::Number::from(id)))
.collect(),
),
);
} else {
let mut new_role = serde_yaml::Mapping::new();
new_role.insert("name".into(), "human".into());
new_role.insert("role_type".into(), "user".into());
new_role.insert("channel".into(), "discord".into());
let mut channel_config = serde_yaml::Mapping::new();
channel_config.insert(
"commands_channel_id".into(),
serde_yaml::Value::String(commands_channel_id.into()),
);
channel_config.insert(
"events_channel_id".into(),
serde_yaml::Value::String(events_channel_id.into()),
);
channel_config.insert(
"agents_channel_id".into(),
serde_yaml::Value::String(agents_channel_id.into()),
);
channel_config.insert(
"allowed_user_ids".into(),
serde_yaml::Value::Sequence(
allowed_user_ids
.iter()
.copied()
.map(|id| serde_yaml::Value::Number(serde_yaml::Number::from(id)))
.collect(),
),
);
new_role.insert(
"channel_config".into(),
serde_yaml::Value::Mapping(channel_config),
);
new_role.insert(
"talks_to".into(),
serde_yaml::Value::Sequence(vec!["architect".into()]),
);
roles.push(serde_yaml::Value::Mapping(new_role));
}
let output = serde_yaml::to_string(&doc)?;
std::fs::write(path, output).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn prompt(message: &str) -> Result<String> {
print!("{message}");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> {
let input = prompt(message)?;
if input.is_empty() {
return Ok(default_yes);
}
Ok(matches!(input.chars().next(), Some('y' | 'Y')))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_bot_identity_extracts_username_and_id() {
let json = serde_json::json!({
"id": "123456789012345678",
"username": "batty-bot"
});
let identity = parse_bot_identity(&json).unwrap();
assert_eq!(identity.user_id, "123456789012345678");
assert_eq!(identity.username, "batty-bot");
}
#[test]
fn parse_guilds_response_sorts_by_name() {
let json = serde_json::json!([
{"id": "2", "name": "Zulu"},
{"id": "1", "name": "Alpha"}
]);
let guilds = parse_guilds_response(&json).unwrap();
assert_eq!(
guilds,
vec![
GuildSummary {
id: "1".into(),
name: "Alpha".into(),
},
GuildSummary {
id: "2".into(),
name: "Zulu".into(),
}
]
);
}
#[test]
fn parse_channels_response_filters_non_text_channels() {
let json = serde_json::json!([
{"id": "10", "name": "voice", "type": 2, "position": 0},
{"id": "11", "name": "commands", "type": 0, "position": 2},
{"id": "12", "name": "events", "type": 5, "position": 1}
]);
let channels = parse_channels_response(&json).unwrap();
assert_eq!(
channels,
vec![
ChannelSummary {
id: "12".into(),
name: "events".into(),
kind: 5,
position: 1,
},
ChannelSummary {
id: "11".into(),
name: "commands".into(),
kind: 0,
position: 2,
}
]
);
}
#[test]
fn outbound_embed_extracts_sender_header() {
let embed = outbound_embed("--- Message from architect ---\nFocus on tests");
assert_eq!(embed.title, "💬 Command Update");
assert_eq!(embed.description.as_deref(), Some("Focus on tests"));
assert_eq!(embed.color, color_for_role("architect"));
assert_eq!(embed.author_name.as_deref(), Some("🏗️ architect"));
assert_eq!(embed.footer.as_deref(), Some("batty · command surface"));
assert!(
embed
.timestamp
.as_deref()
.is_some_and(|ts| ts.contains('T'))
);
}
#[test]
fn outbound_embed_falls_back_for_plain_text() {
let embed = outbound_embed("plain message");
assert_eq!(embed.title, "💬 Batty Update");
assert_eq!(embed.description.as_deref(), Some("plain message"));
assert_eq!(embed.color, color_for_role("system"));
assert_eq!(embed.author_name, None);
assert_eq!(embed.footer.as_deref(), Some("batty · command surface"));
}
#[test]
fn parse_messages_response_filters_unauthorized_and_bot_messages() {
let json = serde_json::json!([
{
"id": "1002",
"channel_id": "55",
"content": "$status",
"author": {"id": "42", "bot": false}
},
{
"id": "1001",
"channel_id": "55",
"content": "hello",
"author": {"id": "999", "bot": false}
},
{
"id": "1003",
"channel_id": "55",
"content": "ignore me",
"author": {"id": "42", "bot": true}
}
]);
let (messages, latest_message_id, fault) = parse_messages_response(&json, &[42]).unwrap();
assert_eq!(
messages,
vec![InboundMessage {
message_id: "1002".to_string(),
channel_id: "55".to_string(),
from_user_id: 42,
text: "$status".to_string(),
}]
);
assert_eq!(latest_message_id.as_deref(), Some("1003"));
assert_eq!(fault, None);
}
#[test]
fn parse_messages_response_sorts_by_message_id() {
let json = serde_json::json!([
{
"id": "1009",
"channel_id": "55",
"content": "second",
"author": {"id": "42", "bot": false}
},
{
"id": "1008",
"channel_id": "55",
"content": "first",
"author": {"id": "42", "bot": false}
}
]);
let (messages, latest_message_id, fault) = parse_messages_response(&json, &[42]).unwrap();
assert_eq!(messages[0].text, "first");
assert_eq!(messages[1].text, "second");
assert_eq!(latest_message_id.as_deref(), Some("1009"));
assert_eq!(fault, None);
}
#[test]
fn parse_messages_response_reports_message_content_intent_fault() {
let json = serde_json::json!([
{
"id": "1010",
"channel_id": "55",
"content": "",
"author": {"id": "42", "bot": false}
},
{
"id": "1011",
"channel_id": "55",
"content": "",
"author": {"id": "42", "bot": false}
}
]);
let (messages, latest_message_id, fault) = parse_messages_response(&json, &[42]).unwrap();
assert!(messages.is_empty());
assert_eq!(latest_message_id.as_deref(), Some("1011"));
assert_eq!(
fault,
Some(DiscordMessageContentFault {
total_received: 2,
dropped_empty_content: 2,
dropped_bot_author: 0,
dropped_unauthorized_user: 0,
})
);
assert!(
fault
.unwrap()
.status_message()
.contains("MESSAGE CONTENT INTENT")
);
}
#[test]
fn parse_messages_response_recovers_after_content_resumes() {
let json = serde_json::json!([
{
"id": "1012",
"channel_id": "55",
"content": "",
"author": {"id": "42", "bot": false}
},
{
"id": "1013",
"channel_id": "55",
"content": "$status",
"author": {"id": "42", "bot": false}
}
]);
let (messages, latest_message_id, fault) = parse_messages_response(&json, &[42]).unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].text, "$status");
assert_eq!(latest_message_id.as_deref(), Some("1013"));
assert_eq!(fault, None);
}
#[test]
fn update_team_yaml_for_discord_updates_existing_user_role() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("team.yaml");
std::fs::write(
&path,
r#"
name: test-team
roles:
- name: human
role_type: user
channel: telegram
channel_config:
target: "placeholder"
provider: openclaw
talks_to: [architect]
"#,
)
.unwrap();
update_team_yaml_for_discord(&path, "cmd-1", "evt-1", "agt-1", &[111, 222]).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("channel: discord"));
assert!(content.contains("commands_channel_id: cmd-1"));
assert!(content.contains("events_channel_id: evt-1"));
assert!(content.contains("agents_channel_id: agt-1"));
assert!(!content.contains("bot_token"));
assert!(!content.contains("target: placeholder"));
assert!(!content.contains("provider: openclaw"));
}
#[test]
fn update_team_yaml_for_discord_creates_user_role_if_missing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("team.yaml");
std::fs::write(
&path,
r#"
name: test-team
roles:
- name: architect
role_type: architect
agent: claude
"#,
)
.unwrap();
update_team_yaml_for_discord(&path, "cmd-1", "evt-1", "agt-1", &[111]).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("name: human"));
assert!(content.contains("role_type: user"));
assert!(content.contains("channel: discord"));
assert!(content.contains("commands_channel_id: cmd-1"));
assert!(!content.contains("bot_token"));
}
#[test]
fn setup_discord_bails_without_config() {
let tmp = tempfile::tempdir().unwrap();
let result = setup_discord(tmp.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("batty init"));
}
#[test]
fn rich_embed_to_json_serializes_optional_sections_and_limits_fields() {
let mut embed = RichEmbed::new("Title", Severity::Info.color())
.with_description("Description")
.with_author("Author")
.with_footer("Footer")
.with_timestamp("2026-04-11T20:00:00Z")
.with_url("https://example.com");
embed.author_icon_url = Some("https://example.com/author.png".into());
embed.author_url = Some("https://example.com/author".into());
embed.footer_icon_url = Some("https://example.com/footer.png".into());
embed.thumbnail_url = Some("https://example.com/thumb.png".into());
for i in 0..30 {
embed = embed.push_field(EmbedField::inline(
format!("Field {i}"),
format!("Value {i}"),
));
}
let json = embed.to_json();
assert_eq!(json["title"].as_str(), Some("Title"));
assert_eq!(json["description"].as_str(), Some("Description"));
assert_eq!(json["author"]["name"].as_str(), Some("Author"));
assert_eq!(
json["author"]["icon_url"].as_str(),
Some("https://example.com/author.png")
);
assert_eq!(
json["author"]["url"].as_str(),
Some("https://example.com/author")
);
assert_eq!(json["footer"]["text"].as_str(), Some("Footer"));
assert_eq!(
json["footer"]["icon_url"].as_str(),
Some("https://example.com/footer.png")
);
assert_eq!(json["timestamp"].as_str(), Some("2026-04-11T20:00:00Z"));
assert_eq!(json["url"].as_str(), Some("https://example.com"));
assert_eq!(
json["thumbnail"]["url"].as_str(),
Some("https://example.com/thumb.png")
);
assert_eq!(json["fields"].as_array().map(Vec::len), Some(25));
assert_eq!(json["fields"][0]["inline"].as_bool(), Some(true));
}
#[test]
fn rich_embed_to_json_truncates_long_sections() {
let long = "x".repeat(5000);
let field_name = "n".repeat(400);
let field_value = "v".repeat(2000);
let embed = RichEmbed::new("T".repeat(400), Severity::Warn.color())
.with_description(long.clone())
.with_author("A".repeat(400))
.with_footer("F".repeat(3000))
.push_field(EmbedField::new(field_name, field_value));
let json = embed.to_json();
assert_eq!(json["title"].as_str().unwrap().chars().count(), 256);
assert_eq!(json["description"].as_str().unwrap().chars().count(), 4000);
assert_eq!(
json["author"]["name"].as_str().unwrap().chars().count(),
256
);
assert_eq!(
json["footer"]["text"].as_str().unwrap().chars().count(),
2048
);
assert_eq!(
json["fields"][0]["name"].as_str().unwrap().chars().count(),
256
);
assert_eq!(
json["fields"][0]["value"].as_str().unwrap().chars().count(),
1024
);
}
#[test]
fn severity_and_role_author_label_cover_key_variants() {
assert_eq!(severity_for_event("task_auto_merged"), Severity::Success);
assert_eq!(severity_for_event("task_assigned"), Severity::Info);
assert_eq!(severity_for_event("pattern_detected"), Severity::Warn);
assert_eq!(severity_for_event("task_escalated"), Severity::Error);
assert_eq!(severity_for_event("daemon_stopped"), Severity::Critical);
assert_eq!(severity_for_event("totally_new_event"), Severity::Neutral);
assert!(role_author_label("architect").contains("🏗️"));
assert!(role_author_label("manager").contains("📋"));
assert!(role_author_label("eng-1-1").contains("🔧"));
assert!(role_author_label("sam-designer-1").contains("🎨"));
assert_eq!(role_author_label("unknown-role"), "unknown-role");
}
}