use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use serenity::builder::{CreateCommand, CreateCommandOption};
use serenity::http::Http;
use serenity::model::application::{CommandOptionType, CommandType};
use serenity::model::id::{ChannelId, GuildId};
use crate::brain::{BrainLoader, CommandLoader, UserCommand};
pub(crate) const NAME_MAX: usize = 32;
pub(crate) const DESCRIPTION_MAX: usize = 100;
pub(crate) const GUILD_COMMAND_CAP: usize = 100;
pub(crate) const TREE_CHAR_CAP: usize = 8000;
pub(crate) const ARGS_OPTION: &str = "args";
pub(crate) const ARGS_OPTION_DESCRIPTION: &str = "Text to pass to the command";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DropReason {
InvalidName,
Collision {
kept: String,
},
OverCap,
TreeTooLarge,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Dropped {
pub(crate) source: String,
pub(crate) reason: DropReason,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct CommandPlan {
pub(crate) commands: Vec<CreateCommand>,
pub(crate) entries: Vec<(String, String, String)>,
pub(crate) dropped: Vec<Dropped>,
}
impl CommandPlan {
pub(crate) fn signature(&self) -> u64 {
let mut items: Vec<(String, String)> = self
.entries
.iter()
.map(|(n, _, d)| (n.clone(), d.clone()))
.collect();
items.sort();
let mut hasher = DefaultHasher::new();
for (name, description) in &items {
name.hash(&mut hasher);
description.hash(&mut hasher);
}
hasher.finish()
}
}
pub(crate) fn sanitize_name(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_start_matches('/').to_lowercase();
let mut out = String::new();
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
} else {
out.push('-');
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() {
return None;
}
Some(out.chars().take(NAME_MAX).collect())
}
pub(crate) fn description_for(description: &str, name: &str) -> String {
let trimmed = description.trim();
let chosen = if trimmed.is_empty() { name } else { trimmed };
chosen.chars().take(DESCRIPTION_MAX).collect()
}
pub(crate) fn plan_commands(catalog: &[UserCommand]) -> CommandPlan {
let mut plan = CommandPlan::default();
let mut tree_chars = 0usize;
for entry in catalog {
let Some(name) = sanitize_name(&entry.name) else {
tracing::warn!(
"discord: dropping command {:?}: no legal Discord name after sanitizing",
entry.name
);
plan.dropped.push(Dropped {
source: entry.name.clone(),
reason: DropReason::InvalidName,
});
continue;
};
if let Some((_, winner, _)) = plan
.entries
.iter()
.find(|(registered, _, _)| registered == &name)
{
tracing::warn!(
"discord: dropping command {:?}: it maps to {:?}, already used by {:?}",
entry.name,
name,
winner
);
plan.dropped.push(Dropped {
source: entry.name.clone(),
reason: DropReason::Collision {
kept: winner.clone(),
},
});
continue;
}
if plan.commands.len() >= GUILD_COMMAND_CAP {
plan.dropped.push(Dropped {
source: entry.name.clone(),
reason: DropReason::OverCap,
});
continue;
}
let description = description_for(&entry.description, &name);
let cost = name.chars().count()
+ description.chars().count()
+ ARGS_OPTION.len()
+ ARGS_OPTION_DESCRIPTION.len();
if tree_chars + cost > TREE_CHAR_CAP {
plan.dropped.push(Dropped {
source: entry.name.clone(),
reason: DropReason::TreeTooLarge,
});
continue;
}
tree_chars += cost;
let builder = CreateCommand::new(name.clone())
.description(description.clone())
.kind(CommandType::ChatInput)
.add_option(
CreateCommandOption::new(
CommandOptionType::String,
ARGS_OPTION,
ARGS_OPTION_DESCRIPTION,
)
.required(false),
);
plan.entries.push((name, entry.name.clone(), description));
plan.commands.push(builder);
}
plan
}
pub(crate) fn sync_key(plan_sig: u64, guilds: &[GuildId]) -> u64 {
let mut ids: Vec<u64> = guilds.iter().map(|guild| guild.get()).collect();
ids.sort_unstable();
ids.dedup();
let mut hasher = DefaultHasher::new();
plan_sig.hash(&mut hasher);
ids.hash(&mut hasher);
hasher.finish()
}
pub(crate) fn with_menu_builtins(mut catalog: Vec<UserCommand>) -> Vec<UserCommand> {
const MENU_BUILTINS: &[(&str, &str)] = &[
("/respond_to", "Show this bot's respond mode (owner only)"),
("/cowork", "Open this channel to its members (owner only)"),
];
for (name, description) in MENU_BUILTINS {
if !catalog.iter().any(|c| c.name == *name) {
catalog.push(UserCommand {
name: (*name).to_string(),
description: (*description).to_string(),
action: "system".to_string(),
prompt: String::new(),
});
}
}
catalog
}
pub(crate) async fn sync_commands(
http: &Arc<Http>,
guilds: &[GuildId],
last_key: Option<u64>,
) -> Option<u64> {
let catalog =
with_menu_builtins(CommandLoader::from_brain_path(&BrainLoader::resolve_path()).load());
if catalog.is_empty() {
tracing::info!(
"discord: command catalog is empty, syncing an empty command list to {} guild(s)",
guilds.len()
);
}
let plan = plan_commands(&catalog);
let key = sync_key(plan.signature(), guilds);
if Some(key) == last_key {
tracing::debug!(
"discord: application commands unchanged ({} registered), skipping sync",
plan.commands.len()
);
return Some(key);
}
if guilds.is_empty() {
tracing::warn!(
"discord: no guild to register {} application command(s) in",
plan.commands.len()
);
return Some(key);
}
let mut any_succeeded = false;
for guild in guilds {
match guild.set_commands(http, plan.commands.clone()).await {
Ok(registered) => {
any_succeeded = true;
tracing::info!(
"discord: synced {} application command(s) to guild {guild}",
registered.len()
);
}
Err(why) => {
tracing::error!(
"discord: application command sync failed for guild {guild}: {why}. If \
this is a permissions error, the bot was invited without the \
`applications.commands` OAuth2 scope: re-invite it with \
`bot applications.commands`."
);
}
}
}
if !any_succeeded {
tracing::warn!(
"discord: application command sync failed for all {} guild(s); leaving the \
comparison key unset so the next reconnect retries",
guilds.len()
);
return None;
}
Some(key)
}
pub(crate) fn owner_scope_text(text: &str, is_owner: bool) -> bool {
is_owner
&& (crate::channels::respond_to_scope::is_respond_to_command(text)
|| super::cowork::is_cowork_command(text))
}
pub(crate) fn refusal_reason(
admitted: bool,
channel_ok: bool,
policy_ok: bool,
channel_id: &str,
) -> String {
if !admitted {
"You are not allowed to use this bot here. Your user id is not in \
channels.discord.allowed_users or allowed_roles, and this channel is not \
open. Ask the bot owner to add you, or to run /cowork in this channel."
.to_string()
} else if !channel_ok {
format!(
"This channel is not in channels.discord.allowed_channels. The bot owner \
can run /cowork here to enable it, or add a \
[channels.discord.channels.{channel_id}] block to config.toml."
)
} else if !policy_ok {
format!(
"This channel is set to dm_only, so the bot does not answer commands here. \
The bot owner can run /respond_to all here, or set \
channels.discord.channels.{channel_id}.respond_to to \"all\"."
)
} else {
"This bot is not enabled for you or this channel.".to_string()
}
}
pub(crate) fn identity_admitted(
unconfigured: bool,
is_owner: bool,
in_allowlist: bool,
role_granted: bool,
open_here: bool,
) -> bool {
!unconfigured && (is_owner || in_allowlist || role_granted || open_here)
}
pub(crate) fn write_channel_respond_to(channel_id: &str, mode: &str) -> Result<(), String> {
crate::config::Config::write_key(
&format!("channels.discord.channels.{channel_id}"),
"respond_to",
mode,
)
.map(|_| ())
.map_err(|e| e.to_string())
}
pub(crate) async fn parent_channel_id(http: &Http, channel: ChannelId) -> Option<String> {
match channel.to_channel(http).await {
Ok(serenity::model::channel::Channel::Guild(gc)) => {
gc.parent_id.map(|p| p.get().to_string())
}
Ok(_) => None,
Err(e) => {
tracing::warn!("Discord: could not resolve parent of channel {channel}: {e}");
None
}
}
}
pub(crate) fn holds_allowed_role(allowed_roles: &[String], role_ids: &[u64]) -> bool {
role_ids
.iter()
.any(|r| allowed_roles.iter().any(|ar| ar == &r.to_string()))
}