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, 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>,
) -> 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)),
})
.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>,
}
#[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);
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>) {
let batch = self.build_commands();
match guild.id.set_commands(&ctx.http, batch).await {
Ok(cmds) => {
tracing::debug!(
guild = %guild.id,
count = cmds.len(),
"discord: registered guild slash commands"
);
}
Err(e) => {
tracing::warn!(
guild = %guild.id,
error = %e,
"discord: could not register 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 trigger_id = msg.id.get();
let reply_index = Arc::clone(&self.reply_index);
let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
let http = http.clone();
let reply_index = Arc::clone(&reply_index);
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());
}
}
Ok(())
})
});
let fouko_ctx = Ctx::new_full(
PlatformKind::Discord,
channel_id.to_string(),
msg.author.id.to_string(),
msg.content.clone(),
reply_fn,
Some(is_dm),
None,
)
.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_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 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(to_discord_embed(em));
}
if let Some(kb) = reply.get_keyboard() {
edit = edit.components(build_rows(kb));
}
}
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(to_discord_embed(em));
}
if let Some(kb) = reply.get_keyboard() {
follow = follow.components(build_rows(kb));
}
}
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_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_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)
}
fn build_messages(reply: &Reply) -> Vec<CreateMessage> {
let chunks = split_text(reply.get_text());
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(to_discord_embed(em));
}
if let Some(kb) = reply.get_keyboard() {
msg = msg.components(build_rows(kb));
}
out.push(msg);
return 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(to_discord_embed(em));
}
if let Some(kb) = reply.get_keyboard() {
msg = msg.components(build_rows(kb));
}
}
out.push(msg);
}
out
}
fn build_followups(reply: &Reply) -> Vec<CreateInteractionResponseFollowup> {
let chunks = split_text(reply.get_text());
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(to_discord_embed(em));
}
if let Some(kb) = reply.get_keyboard() {
follow = follow.components(build_rows(kb));
}
out.push(follow);
return 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(to_discord_embed(em));
}
if let Some(kb) = reply.get_keyboard() {
follow = follow.components(build_rows(kb));
}
}
out.push(follow);
}
out
}
fn build_rows(kb: &crate::keyboard::Keyboard) -> Vec<CreateActionRow> {
kb.rows()
.iter()
.map(|row| {
let buttons: Vec<CreateButton> = row
.iter()
.map(|b| 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()),
})
.collect();
CreateActionRow::Buttons(buttons)
})
.collect()
}
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),
}
})
}))
}
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)
)
}