use crate::{
bot::Router,
ctx::{ChatInfo, ChatInfoFn, Ctx, UrlFn},
error::{Error, Result},
keyboard::{ButtonKind, Embed as FkEmbed, Reply},
platform::PlatformKind,
};
use async_trait::async_trait;
use serenity::{
all::{
ButtonStyle, ChannelId, Command, CommandInteraction, CommandOptionType,
ComponentInteraction, CreateActionRow, CreateAttachment, CreateButton, CreateCommand,
CreateCommandOption, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse,
CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateMessage,
EditInteractionResponse, GatewayIntents, GuildId, Interaction, Message, Ready, User,
UserId,
},
client::{Context as SerenityContext, EventHandler},
http::Http,
model::guild::Guild,
Client,
};
use std::sync::Arc;
pub async fn run(
token: String,
router: Arc<Router>,
commands: Vec<(String, Option<String>, bool)>,
notifier: Option<crate::notifier::Notifier>,
presence: Option<crate::platform::Presence>,
) -> Result<()> {
tracing::info!("starting discord adapter");
let intents = GatewayIntents::GUILDS
| GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT;
let client = Client::builder(&token, intents)
.event_handler(Handler {
router,
commands: Arc::new(commands),
reply_index: Arc::new(std::sync::Mutex::new(ReplyIndex::default())),
self_id: Arc::new(std::sync::atomic::AtomicU64::new(0)),
presence,
})
.await
.map_err(|e| Error::platform("discord", e))?;
if let Some(notifier) = ¬ifier {
let http = client.http.clone();
let send: crate::notifier::SendFn = Arc::new(move |chan_id: String, reply: Reply| {
let http = http.clone();
Box::pin(async move {
let id: u64 = chan_id
.parse()
.map_err(|_| Error::platform("discord", format!("bad channel id {chan_id}")))?;
let channel = ChannelId::new(id);
for out in build_messages(&reply)? {
channel
.send_message(&http, out)
.await
.map_err(|e| Error::platform("discord", e))?;
}
Ok(())
})
});
notifier.register(PlatformKind::Discord, send).await;
let http = client.http.clone();
let dm_cache = Arc::new(std::sync::Mutex::new(DmChannelCache::default()));
let send_dm: crate::notifier::DmSendFn = Arc::new(move |user_id: String, reply: Reply| {
let http = http.clone();
let dm_cache = Arc::clone(&dm_cache);
Box::pin(async move {
let id: u64 = user_id
.parse()
.map_err(|_| Error::platform("discord", format!("bad user id {user_id}")))?;
let cached = dm_cache.lock().ok().and_then(|c| c.get(id));
let channel = match cached {
Some(chan) => ChannelId::new(chan),
None => {
let chan = UserId::new(id)
.create_dm_channel(&http)
.await
.map_err(|e| Error::platform("discord", e))?
.id;
if let Ok(mut cache) = dm_cache.lock() {
cache.record(id, chan.get());
}
chan
}
};
for out in build_messages(&reply)? {
channel
.send_message(&http, out)
.await
.map_err(|e| Error::platform("discord", e))?;
}
Ok(())
})
});
notifier.register_dm(PlatformKind::Discord, send_dm).await;
let http = client.http.clone();
let lookup: crate::notifier::UserLookupFn = Arc::new(move |user_id: String| {
let http = http.clone();
Box::pin(async move {
let id: u64 = user_id
.parse()
.map_err(|_| Error::platform("discord", format!("bad user id {user_id}")))?;
match http.get_user(UserId::new(id)).await {
Ok(user) => Ok(Some(discord_display_name(&user))),
Err(e) if is_not_found(&e) => Ok(None),
Err(e) => Err(Error::platform("discord", e)),
}
})
});
notifier
.register_user_lookup(PlatformKind::Discord, lookup)
.await;
}
let mut client = client;
client
.start()
.await
.map_err(|e| Error::platform("discord", e))?;
Ok(())
}
struct Handler {
router: Arc<Router>,
commands: Arc<Vec<(String, Option<String>, bool)>>,
reply_index: Arc<std::sync::Mutex<ReplyIndex>>,
self_id: Arc<std::sync::atomic::AtomicU64>,
presence: Option<crate::platform::Presence>,
}
#[derive(Default)]
struct ReplyIndex {
map: std::collections::HashMap<u64, Vec<(u64, u64)>>,
order: std::collections::VecDeque<u64>,
}
impl ReplyIndex {
const CAP: usize = 5_000;
fn record(&mut self, trigger: u64, channel: u64, bot_msg: u64) {
let entry = self.map.entry(trigger).or_default();
if entry.is_empty() {
self.order.push_back(trigger);
}
entry.push((channel, bot_msg));
while self.map.len() > Self::CAP {
match self.order.pop_front() {
Some(old) => {
self.map.remove(&old);
}
None => break,
}
}
}
fn take(&mut self, trigger: u64) -> Vec<(u64, u64)> {
self.map.remove(&trigger).unwrap_or_default()
}
}
#[derive(Default)]
struct DmChannelCache {
map: std::collections::HashMap<u64, u64>,
order: std::collections::VecDeque<u64>,
}
impl DmChannelCache {
const CAP: usize = 1_000;
fn get(&self, user: u64) -> Option<u64> {
self.map.get(&user).copied()
}
fn record(&mut self, user: u64, channel: u64) {
if self.map.insert(user, channel).is_none() {
self.order.push_back(user);
}
while self.map.len() > Self::CAP {
match self.order.pop_front() {
Some(old) => {
self.map.remove(&old);
}
None => break,
}
}
}
}
impl Handler {
fn build_commands(&self) -> Vec<CreateCommand> {
let mut batch: Vec<CreateCommand> = Vec::new();
for (name, desc, takes_user) in self.commands.iter() {
let trimmed = name.trim_start_matches('/').to_ascii_lowercase();
if trimmed.is_empty() || !is_valid_slash_name(&trimmed) {
continue;
}
let description = desc
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| trimmed.clone());
let description = truncate_chars(&description, 100);
let mut cmd = CreateCommand::new(trimmed).description(description);
if *takes_user {
cmd = cmd.add_option(
CreateCommandOption::new(CommandOptionType::User, "user", "who to look at")
.required(false),
);
}
cmd = cmd.add_option(
CreateCommandOption::new(
CommandOptionType::String,
"args",
"arguments passed to the command (optional)",
)
.required(false),
);
batch.push(cmd);
}
batch
}
}
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: SerenityContext, ready: Ready) {
tracing::info!(bot = %ready.user.name, "discord adapter ready");
self.self_id
.store(ready.user.id.get(), std::sync::atomic::Ordering::Relaxed);
if let Some(p) = &self.presence {
ctx.set_presence(to_activity(p), to_status(p.status));
tracing::info!("discord: presence published");
}
let batch = self.build_commands();
match Command::set_global_commands(&ctx.http, batch).await {
Ok(cmds) => {
tracing::info!(
count = cmds.len(),
"discord: registered global slash commands"
);
}
Err(e) => {
tracing::warn!(error = %e, "discord: could not register slash commands");
}
}
}
async fn guild_create(&self, ctx: SerenityContext, guild: Guild, _is_new: Option<bool>) {
match guild.id.set_commands(&ctx.http, Vec::new()).await {
Ok(_) => {
tracing::debug!(guild = %guild.id, "discord: cleared per-guild slash commands");
}
Err(e) => {
tracing::debug!(
guild = %guild.id,
error = %e,
"discord: could not clear per-guild slash commands"
);
}
}
}
async fn message(&self, ctx: SerenityContext, msg: Message) {
if msg.author.bot {
return;
}
let router = Arc::clone(&self.router);
let channel_id = msg.channel_id;
let http = ctx.http.clone();
let is_dm = msg.guild_id.is_none();
let image_url = msg
.attachments
.iter()
.find(|a| is_image_attachment(a))
.map(|a| a.url.clone());
let trigger_id = msg.id.get();
let reply_index = Arc::clone(&self.reply_index);
let last_sent: Arc<std::sync::Mutex<Option<serenity::all::MessageId>>> =
Arc::new(std::sync::Mutex::new(None));
let sent_for_reply = Arc::clone(&last_sent);
let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
let http = http.clone();
let reply_index = Arc::clone(&reply_index);
let last_sent = Arc::clone(&sent_for_reply);
Box::pin(async move {
for out in build_messages(&reply)? {
let sent = channel_id
.send_message(&http, out)
.await
.map_err(|e| Error::platform("discord", e))?;
if let Ok(mut index) = reply_index.lock() {
index.record(trigger_id, channel_id.get(), sent.id.get());
}
if let Ok(mut slot) = last_sent.lock() {
*slot = Some(sent.id);
}
}
Ok(())
})
});
let http_for_edit = ctx.http.clone();
let sent_for_edit = Arc::clone(&last_sent);
let reply_fn_index = Arc::clone(&self.reply_index);
let edit_fn: crate::ctx::EditFn = Arc::new(move |reply: Reply| {
let http = http_for_edit.clone();
let last_sent = Arc::clone(&sent_for_edit);
let reply_index = Arc::clone(&reply_fn_index);
Box::pin(async move {
let target = last_sent.lock().ok().and_then(|slot| *slot);
let Some(msg_id) = target else {
for out in build_messages(&reply)? {
let sent = channel_id
.send_message(&http, out)
.await
.map_err(|e| Error::platform("discord", e))?;
if let Ok(mut index) = reply_index.lock() {
index.record(trigger_id, channel_id.get(), sent.id.get());
}
if let Ok(mut slot) = last_sent.lock() {
*slot = Some(sent.id);
}
}
return Ok(());
};
let content = crate::util::truncate_chunk(reply.get_text(), DISCORD_LIMIT);
let mut edit = serenity::all::EditMessage::new().content(content);
let mut embeds = Vec::new();
if let Some(em) = reply.get_embed() {
embeds.push(to_discord_embed(em));
}
edit = edit.embeds(embeds);
let components = match reply.get_keyboard() {
Some(kb) => build_rows(kb),
None => Vec::new(),
};
edit = edit.components(components);
channel_id
.edit_message(&http, msg_id, edit)
.await
.map_err(|e| Error::platform("discord", e))?;
Ok(())
})
});
let fouko_ctx = Ctx::new_with_edit(
PlatformKind::Discord,
channel_id.to_string(),
msg.author.id.to_string(),
msg.content.clone(),
reply_fn,
Some(is_dm),
None,
Some(edit_fn),
)
.with_lookups(
Some(avatar_lookup(msg.author.clone())),
Some(banner_lookup(ctx.http.clone(), msg.author.id)),
chatinfo_lookup(ctx.http.clone(), msg.guild_id, is_dm, channel_id),
)
.with_typing(typing_lookup(ctx.http.clone(), channel_id))
.with_user_avatar(user_avatar_lookup(ctx.http.clone()))
.with_user_name(Some(
msg.author
.global_name
.clone()
.unwrap_or_else(|| msg.author.name.clone()),
))
.with_reply_to_bot(
msg.referenced_message
.as_deref()
.map(|r| {
r.author.id.get() == self.self_id.load(std::sync::atomic::Ordering::Relaxed)
})
.unwrap_or(false),
)
.with_incoming_image(image_url.is_some(), image_url.map(image_lookup))
.with_temp_reply(temp_reply_lookup(ctx.http.clone(), channel_id));
if let Err(e) = router.dispatch(fouko_ctx).await {
tracing::warn!(error = %e, "discord handler error");
}
}
async fn interaction_create(&self, ctx: SerenityContext, interaction: Interaction) {
match interaction {
Interaction::Component(component) => {
handle_component(&ctx, &self.router, component).await;
}
Interaction::Command(command) => {
handle_command(&ctx, &self.router, command).await;
}
_ => {}
}
}
async fn message_delete(
&self,
ctx: SerenityContext,
_channel_id: ChannelId,
deleted: serenity::all::MessageId,
_guild_id: Option<GuildId>,
) {
let replies = match self.reply_index.lock() {
Ok(mut index) => index.take(deleted.get()),
Err(_) => return,
};
for (chan, msg_id) in replies {
let _ = ChannelId::new(chan)
.delete_message(&ctx.http, serenity::all::MessageId::new(msg_id))
.await;
}
}
}
async fn handle_command(ctx: &SerenityContext, router: &Router, command: CommandInteraction) {
let channel_id = command.channel_id;
let user_id = command.user.id.to_string();
let is_dm = command.guild_id.is_none();
let mut text = format!("/{}", command.data.name);
if let Some(opt) = command.data.options.iter().find(|o| o.name == "user") {
if let Some(user_id) = opt.value.as_user_id() {
text.push(' ');
text.push_str(&user_id.to_string());
}
}
if let Some(opt) = command.data.options.iter().find(|o| o.name == "args") {
if let Some(v) = opt.value.as_str() {
text.push(' ');
text.push_str(v);
}
}
let defer = CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new());
let use_interaction = match command.create_response(&ctx.http, defer).await {
Ok(()) => true,
Err(e) => {
tracing::debug!(error = %e, "discord slash-cmd defer failed; falling back to channel sends");
false
}
};
let http = ctx.http.clone();
let cmd_clone = command.clone();
let first_call = Arc::new(std::sync::atomic::AtomicBool::new(true));
let awaiting_reply = first_call.clone();
let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
let http = http.clone();
let command = cmd_clone.clone();
let first = first_call.clone();
Box::pin(async move {
if !use_interaction {
first.store(false, std::sync::atomic::Ordering::SeqCst);
for out in build_messages(&reply)? {
command
.channel_id
.send_message(&http, out)
.await
.map_err(|e| Error::platform("discord", e))?;
}
return Ok(());
}
let is_first = first
.compare_exchange(
true,
false,
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
)
.is_ok();
let chunks = split_text(reply.get_text());
let last = chunks.len().saturating_sub(1);
if is_first {
let attachment = build_attachment(&reply)?;
let mut edit = EditInteractionResponse::new();
if let Some(first_chunk) = chunks.first() {
edit = edit.content(first_chunk.clone());
}
if last == 0 {
if let Some(em) = reply.get_embed() {
edit = edit.embed(embed_for_reply(&reply, em));
}
if let Some(kb) = reply.get_keyboard() {
edit = edit.components(build_rows(kb));
}
if let Some(file) = attachment.clone() {
edit = edit.new_attachment(file);
}
}
command
.edit_response(&http, edit)
.await
.map_err(|e| Error::platform("discord", e))?;
for (i, chunk) in chunks.iter().enumerate().skip(1) {
let mut follow =
CreateInteractionResponseFollowup::new().content(chunk.clone());
if i == last {
if let Some(em) = reply.get_embed() {
follow = follow.add_embed(embed_for_reply(&reply, em));
}
if let Some(kb) = reply.get_keyboard() {
follow = follow.components(build_rows(kb));
}
if let Some(file) = attachment.clone() {
follow = follow.add_file(file);
}
}
command
.create_followup(&http, follow)
.await
.map_err(|e| Error::platform("discord", e))?;
}
} else {
for follow in build_followups(&reply)? {
command
.create_followup(&http, follow)
.await
.map_err(|e| Error::platform("discord", e))?;
}
}
Ok(())
})
});
let fouko_ctx = Ctx::new_full(
PlatformKind::Discord,
channel_id.to_string(),
user_id,
text,
reply_fn,
Some(is_dm),
None,
)
.with_lookups(
Some(avatar_lookup(command.user.clone())),
Some(banner_lookup(ctx.http.clone(), command.user.id)),
chatinfo_lookup(ctx.http.clone(), command.guild_id, is_dm, channel_id),
)
.with_typing(typing_lookup(ctx.http.clone(), channel_id))
.with_temp_reply(temp_reply_lookup(ctx.http.clone(), channel_id))
.with_user_avatar(user_avatar_lookup(ctx.http.clone()))
.with_incoming_image(false, None)
.with_user_name(Some(
command
.user
.global_name
.clone()
.unwrap_or_else(|| command.user.name.clone()),
));
let dispatch_result = router.dispatch(fouko_ctx).await;
if let Err(e) = &dispatch_result {
tracing::warn!(error = %e, "discord slash-cmd handler error");
}
if use_interaction && awaiting_reply.load(std::sync::atomic::Ordering::SeqCst) {
let marker = if dispatch_result.is_ok() {
"\u{2705}"
} else {
"\u{26A0}\u{FE0F}"
};
let edit = EditInteractionResponse::new().content(marker);
if let Err(e) = command.edit_response(&ctx.http, edit).await {
tracing::debug!(error = %e, "discord slash-cmd cleanup edit failed");
}
}
}
async fn handle_component(ctx: &SerenityContext, router: &Router, component: ComponentInteraction) {
let channel_id = component.channel_id;
let http = ctx.http.clone();
let data = component.data.custom_id.clone();
let user_id = component.user.id.to_string();
let is_dm = component.guild_id.is_none();
let defer = CreateInteractionResponse::Acknowledge;
if let Err(e) = component.create_response(&http, defer).await {
tracing::debug!(error = %e, "discord component defer failed");
}
let http_for_reply = http.clone();
let comp_for_reply = component.clone();
let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
let http = http_for_reply.clone();
let comp = comp_for_reply.clone();
Box::pin(async move {
for follow in build_followups(&reply)? {
comp.create_followup(&http, follow)
.await
.map_err(|e| Error::platform("discord", e))?;
}
Ok(())
})
});
let http_for_edit = http.clone();
let comp_for_edit = component.clone();
let edit_fn: crate::ctx::EditFn = Arc::new(move |reply: Reply| {
let http = http_for_edit.clone();
let comp = comp_for_edit.clone();
Box::pin(async move {
let chunks = split_text(reply.get_text());
let mut edit = serenity::all::EditMessage::new();
edit = edit.content(chunks.first().cloned().unwrap_or_default());
let mut embeds = Vec::new();
if let Some(em) = reply.get_embed() {
embeds.push(to_discord_embed(em));
}
edit = edit.embeds(embeds);
let components = match reply.get_keyboard() {
Some(kb) => build_rows(kb),
None => Vec::new(),
};
edit = edit.components(components);
let mut msg = comp.message.clone();
msg.edit(&http, edit)
.await
.map_err(|e| Error::platform("discord", e))?;
for chunk in chunks.iter().skip(1) {
let follow = CreateInteractionResponseFollowup::new().content(chunk.clone());
comp.create_followup(&http, follow)
.await
.map_err(|e| Error::platform("discord", e))?;
}
Ok(())
})
});
let fouko_ctx = Ctx::new_with_edit(
PlatformKind::Discord,
channel_id.to_string(),
user_id,
data.clone(),
reply_fn,
Some(is_dm),
Some(data),
Some(edit_fn),
)
.with_lookups(
Some(avatar_lookup(component.user.clone())),
Some(banner_lookup(ctx.http.clone(), component.user.id)),
chatinfo_lookup(ctx.http.clone(), component.guild_id, is_dm, channel_id),
)
.with_typing(typing_lookup(ctx.http.clone(), channel_id))
.with_user_avatar(user_avatar_lookup(ctx.http.clone()))
.with_user_name(Some(
component
.user
.global_name
.clone()
.unwrap_or_else(|| component.user.name.clone()),
))
.with_incoming_image(false, None)
.with_temp_reply(temp_reply_lookup(ctx.http.clone(), channel_id));
if let Err(e) = router.dispatch(fouko_ctx).await {
tracing::warn!(error = %e, "discord interaction handler error");
}
}
const DISCORD_LIMIT: usize = 2000;
fn split_text(text: &str) -> Vec<String> {
crate::util::split_chunks(text, DISCORD_LIMIT)
}
const DISCORD_MAX_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024;
fn build_attachment(reply: &Reply) -> Result<Option<CreateAttachment>> {
match reply.get_attachment() {
Some((bytes, name, kind)) => {
crate::keyboard::check_attachment_size(bytes, kind)?;
if bytes.len() > DISCORD_MAX_ATTACHMENT_BYTES {
return Err(Error::Other(format!(
"attachment too large for discord: {} bytes (max {DISCORD_MAX_ATTACHMENT_BYTES})",
bytes.len()
)));
}
Ok(Some(CreateAttachment::bytes(bytes.to_vec(), name)))
}
None => Ok(None),
}
}
fn embed_for_reply(reply: &Reply, em: &FkEmbed) -> CreateEmbed {
let converted = to_discord_embed(em);
match reply.get_image_bytes() {
Some((_, name)) => converted.attachment(name.to_owned()),
None => converted,
}
}
fn build_messages(reply: &Reply) -> Result<Vec<CreateMessage>> {
let chunks = split_text(reply.get_text());
let attachment = build_attachment(reply)?;
let mut out = Vec::new();
let last = chunks.len().saturating_sub(1);
if chunks.is_empty() {
let mut msg = CreateMessage::new();
if let Some(em) = reply.get_embed() {
msg = msg.add_embed(embed_for_reply(reply, em));
}
if let Some(kb) = reply.get_keyboard() {
msg = msg.components(build_rows(kb));
}
if let Some(file) = attachment {
msg = msg.add_file(file);
}
out.push(msg);
return Ok(out);
}
for (i, chunk) in chunks.iter().enumerate() {
let mut msg = CreateMessage::new().content(chunk.clone());
if i == last {
if let Some(em) = reply.get_embed() {
msg = msg.add_embed(embed_for_reply(reply, em));
}
if let Some(kb) = reply.get_keyboard() {
msg = msg.components(build_rows(kb));
}
if let Some(file) = attachment.clone() {
msg = msg.add_file(file);
}
}
out.push(msg);
}
Ok(out)
}
fn build_followups(reply: &Reply) -> Result<Vec<CreateInteractionResponseFollowup>> {
let chunks = split_text(reply.get_text());
let attachment = build_attachment(reply)?;
let mut out = Vec::new();
let last = chunks.len().saturating_sub(1);
if chunks.is_empty() {
let mut follow = CreateInteractionResponseFollowup::new();
if let Some(em) = reply.get_embed() {
follow = follow.add_embed(embed_for_reply(reply, em));
}
if let Some(kb) = reply.get_keyboard() {
follow = follow.components(build_rows(kb));
}
if let Some(file) = attachment {
follow = follow.add_file(file);
}
out.push(follow);
return Ok(out);
}
for (i, chunk) in chunks.iter().enumerate() {
let mut follow = CreateInteractionResponseFollowup::new().content(chunk.clone());
if i == last {
if let Some(em) = reply.get_embed() {
follow = follow.add_embed(embed_for_reply(reply, em));
}
if let Some(kb) = reply.get_keyboard() {
follow = follow.components(build_rows(kb));
}
if let Some(file) = attachment.clone() {
follow = follow.add_file(file);
}
}
out.push(follow);
}
Ok(out)
}
fn to_activity(p: &crate::platform::Presence) -> Option<serenity::gateway::ActivityData> {
use crate::platform::PresenceKind;
use serenity::gateway::ActivityData;
let mut activity = match p.kind {
PresenceKind::Playing => ActivityData::playing(&p.name),
PresenceKind::Streaming => {
let url = p.url.as_deref().unwrap_or_default();
match ActivityData::streaming(&p.name, url) {
Ok(a) => a,
Err(e) => {
tracing::warn!(error = %e, url, "discord: bad stream url, presence degrades to playing");
ActivityData::playing(&p.name)
}
}
}
PresenceKind::Listening => ActivityData::listening(&p.name),
PresenceKind::Watching => ActivityData::watching(&p.name),
PresenceKind::Competing => ActivityData::competing(&p.name),
PresenceKind::Custom => ActivityData::custom(&p.name),
};
if p.kind != crate::platform::PresenceKind::Custom {
activity.state = p.state.clone();
}
Some(activity)
}
fn to_status(s: crate::platform::PresenceStatus) -> serenity::model::user::OnlineStatus {
use crate::platform::PresenceStatus;
use serenity::model::user::OnlineStatus;
match s {
PresenceStatus::Online => OnlineStatus::Online,
PresenceStatus::Idle => OnlineStatus::Idle,
PresenceStatus::DoNotDisturb => OnlineStatus::DoNotDisturb,
PresenceStatus::Invisible => OnlineStatus::Invisible,
}
}
fn build_rows(kb: &crate::keyboard::Keyboard) -> Vec<CreateActionRow> {
const MAX_ROWS: usize = 5;
const MAX_PER_ROW: usize = 5;
let make = |b: &crate::keyboard::Button| match &b.kind {
ButtonKind::Callback(id) => CreateButton::new(id.clone())
.label(b.label())
.style(ButtonStyle::Primary),
ButtonKind::Url(u) => CreateButton::new_link(u.clone()).label(b.label()),
ButtonKind::WebApp(u) => CreateButton::new_link(u.clone()).label(b.label()),
};
let fits = kb.rows().len() <= MAX_ROWS && kb.rows().iter().all(|r| r.len() <= MAX_PER_ROW);
if fits {
return kb
.rows()
.iter()
.map(|row| CreateActionRow::Buttons(row.iter().map(make).collect()))
.collect();
}
let total: usize = kb.rows().iter().map(|r| r.len()).sum();
let cap = MAX_ROWS * MAX_PER_ROW;
if total > cap {
tracing::warn!(
total,
cap,
"discord: keyboard overflow, dropping extra buttons"
);
}
let mut rows: Vec<CreateActionRow> = Vec::with_capacity(MAX_ROWS);
let mut current: Vec<CreateButton> = Vec::with_capacity(MAX_PER_ROW);
for b in kb.rows().iter().flatten().take(cap) {
current.push(make(b));
if current.len() == MAX_PER_ROW {
rows.push(CreateActionRow::Buttons(std::mem::take(&mut current)));
}
}
if !current.is_empty() {
rows.push(CreateActionRow::Buttons(current));
}
rows
}
fn is_valid_slash_name(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
&& s.len() <= 32
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
let mut out = String::new();
for (i, ch) in s.chars().enumerate() {
if i >= max_chars {
break;
}
out.push(ch);
}
out
}
fn to_discord_embed(src: &FkEmbed) -> CreateEmbed {
let mut em = CreateEmbed::new();
if let Some(t) = src.get_title() {
em = em.title(t);
}
if let Some(u) = src.get_url() {
em = em.url(u);
}
if let Some(d) = src.get_description() {
em = em.description(d);
}
if let Some(c) = src.get_color() {
em = em.colour(c);
}
for f in src.get_fields() {
em = em.field(f.name(), f.value(), f.is_inline());
}
if let Some(url) = src.get_image() {
em = em.image(url);
}
if let Some(url) = src.get_thumbnail() {
em = em.thumbnail(url);
}
if let Some(foot) = src.get_footer() {
em = em.footer(CreateEmbedFooter::new(foot));
}
em
}
fn avatar_lookup(user: User) -> UrlFn {
Arc::new(move || {
let user = user.clone();
Box::pin(async move { Ok(Some(user.face())) })
})
}
fn banner_lookup(http: Arc<Http>, user_id: UserId) -> UrlFn {
Arc::new(move || {
let http = http.clone();
Box::pin(async move {
match user_id.to_user(&http).await {
Ok(user) => Ok(user.banner_url()),
Err(e) => Err(Error::platform("discord", e)),
}
})
})
}
fn chatinfo_lookup(
http: Arc<Http>,
guild_id: Option<GuildId>,
is_dm: bool,
channel_id: ChannelId,
) -> Option<ChatInfoFn> {
Some(Arc::new(move || {
let http = http.clone();
Box::pin(async move {
match guild_id {
Some(gid) => match gid.to_partial_guild_with_counts(&http).await {
Ok(g) => Ok(ChatInfo {
id: gid.to_string(),
title: Some(g.name.clone()),
member_count: g.approximate_member_count,
icon_url: g.icon_url(),
description: g.description.clone(),
is_private: false,
}),
Err(e) => Err(Error::platform("discord", e)),
},
None => Ok(ChatInfo {
id: channel_id.to_string(),
is_private: is_dm,
..Default::default()
}),
}
})
}))
}
fn typing_lookup(http: Arc<Http>, channel_id: ChannelId) -> Option<crate::ctx::TypingFn> {
Some(Arc::new(move || {
let http = http.clone();
Box::pin(async move {
channel_id
.broadcast_typing(&http)
.await
.map_err(|e| Error::platform("discord", e))?;
Ok(())
})
}))
}
fn temp_reply_lookup(http: Arc<Http>, channel_id: ChannelId) -> Option<crate::ctx::TempReplyFn> {
Some(Arc::new(move |reply: Reply, secs: u64| {
let http = http.clone();
Box::pin(async move {
let mut sent_ids = Vec::new();
for msg in build_messages(&reply)? {
let sent = channel_id
.send_message(&http, msg)
.await
.map_err(|e| Error::platform("discord", e))?;
sent_ids.push(sent.id);
}
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
for id in sent_ids {
let _ = channel_id.delete_message(&http, id).await;
}
});
Ok(())
})
}))
}
fn user_avatar_lookup(http: Arc<Http>) -> Option<crate::ctx::UserUrlFn> {
Some(Arc::new(move |raw_id: String| {
let http = http.clone();
Box::pin(async move {
let Ok(id) = raw_id.parse::<u64>() else {
return Ok(None);
};
match UserId::new(id).to_user(&http).await {
Ok(user) => Ok(Some(user.face())),
Err(_) => Ok(None),
}
})
}))
}
const MAX_INCOMING_IMAGE_BYTES: usize = 10 * 1024 * 1024;
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
fn is_image_attachment(a: &serenity::all::Attachment) -> bool {
if let Some(ct) = &a.content_type {
return ct.starts_with("image/");
}
a.filename
.rsplit('.')
.next()
.map(|ext| IMAGE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
fn image_lookup(url: String) -> crate::ctx::ImageFn {
Arc::new(move || {
let url = url.clone();
Box::pin(async move {
let resp = reqwest::get(&url)
.await
.map_err(|e| Error::platform("discord", format!("image download failed: {e}")))?;
if !resp.status().is_success() {
return Err(Error::platform(
"discord",
format!("image download HTTP {}", resp.status().as_u16()),
));
}
let mut resp = resp;
let mut bytes = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| Error::platform("discord", format!("image download failed: {e}")))?
{
if bytes.len() + chunk.len() > MAX_INCOMING_IMAGE_BYTES {
return Err(Error::platform("discord", "incoming image too large"));
}
bytes.extend_from_slice(&chunk);
}
Ok(Some(bytes))
})
})
}
fn discord_display_name(user: &User) -> String {
let display = user
.global_name
.clone()
.unwrap_or_else(|| user.name.clone());
if display == user.name {
display
} else {
format!("{display} (@{})", user.name)
}
}
fn is_not_found(e: &serenity::Error) -> bool {
matches!(
e,
serenity::Error::Http(http) if http.status_code() == Some(serenity::http::StatusCode::NOT_FOUND)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keyboard::{Button, Keyboard};
#[test]
fn keyboard_within_discord_limits_passes_through() {
let mut kb = Keyboard::new();
for r in 0..5 {
kb = kb.row((0..5).map(|i| Button::callback(format!("b{r}{i}"), format!("c{r}{i}"))));
}
assert_eq!(build_rows(&kb).len(), 5);
}
#[test]
fn oversized_keyboard_is_repacked_and_capped() {
let mut kb = Keyboard::new();
for r in 0..8 {
kb = kb.row((0..2).map(|i| Button::callback(format!("b{r}{i}"), format!("c{r}{i}"))));
}
let rows = build_rows(&kb);
assert!(rows.len() <= 5);
let mut kb = Keyboard::new();
for r in 0..6 {
kb = kb.row((0..5).map(|i| Button::callback(format!("b{r}{i}"), format!("c{r}{i}"))));
}
assert_eq!(build_rows(&kb).len(), 5);
}
}