use std::sync::Arc;
use serenity::client::{Context, EventHandler};
use serenity::model::application::Interaction;
use serenity::model::channel::{Attachment, Channel, Message};
use serenity::model::gateway::Ready;
use serenity::model::id::ChannelId;
use crate::chat::commands::{TranslatedCommand, acknowledge, translate};
use crate::chat::inbound::DeletionDecision;
use crate::chat::inbound::{
InboundDecision, RawAttachment, RawDeletion, RawMessage, classify, classify_deletion,
is_permitted, without_bot_mention,
};
use crate::config::schema::ChatConfig;
use crate::log::{LogValue, Logger, fields};
pub const MAX_ATTEMPTS: u32 = 10;
#[derive(Debug)]
pub struct GiveUp {
attempts: u32,
}
impl GiveUp {
pub fn new() -> Self {
Self { attempts: 0 }
}
pub fn connected(&mut self) {
self.attempts = 0;
}
pub fn disconnected(&mut self) -> bool {
self.attempts += 1;
self.attempts > MAX_ATTEMPTS
}
pub fn attempts(&self) -> u32 {
self.attempts
}
}
impl Default for GiveUp {
fn default() -> Self {
Self::new()
}
}
pub fn is_permanent(error: &serenity::Error) -> bool {
matches!(
error,
serenity::Error::Gateway(
serenity::gateway::GatewayError::InvalidAuthentication
| serenity::gateway::GatewayError::InvalidGatewayIntents
| serenity::gateway::GatewayError::DisallowedGatewayIntents,
)
)
}
pub fn to_raw(message: &Message, parent_channel_id: Option<ChannelId>) -> RawMessage {
RawMessage {
id: message.id.get().to_string(),
author_id: message.author.id.get().to_string(),
author_name: message
.author
.global_name
.clone()
.or_else(|| Some(message.author.name.clone())),
author_is_bot: message.author.bot,
channel_id: message.channel_id.get().to_string(),
parent_channel_id: parent_channel_id.map(|channel| channel.get().to_string()),
content: message.content.clone(),
attachments: message.attachments.iter().map(attachment_of).collect(),
}
}
fn attachment_of(file: &Attachment) -> RawAttachment {
RawAttachment {
id: file.id.get().to_string(),
name: file.filename.clone(),
url: file.url.clone(),
size: u64::from(file.size),
content_type: file.content_type.clone(),
}
}
#[cfg(test)]
mod tests;
pub type OnMessage = Arc<dyn Fn(RawMessage, InboundDecision) + Send + Sync>;
pub type OnCommand = Arc<dyn Fn(TranslatedCommand, Arc<dyn Fn(&str) + Send + Sync>) + Send + Sync>;
#[expect(clippy::struct_field_names)]
pub struct GatewayHandlers {
pub on_message: OnMessage,
pub on_command: OnCommand,
pub on_thread_closed: Arc<dyn Fn(String) + Send + Sync>,
pub on_withdrawn: Arc<dyn Fn(String, Option<String>) + Send + Sync>,
pub on_connected: Arc<dyn Fn() + Send + Sync>,
pub on_disconnected: Arc<dyn Fn() + Send + Sync>,
pub on_gave_up: Arc<dyn Fn(u32) + Send + Sync>,
}
pub struct Gateway {
config: ChatConfig,
handlers: Arc<GatewayHandlers>,
log: Logger,
give_up: std::sync::Mutex<GiveUp>,
bot_id: std::sync::Mutex<Option<String>>,
shard: std::sync::Mutex<Option<serenity::gateway::ShardMessenger>>,
ready: tokio::sync::watch::Receiver<bool>,
ready_sender: tokio::sync::watch::Sender<bool>,
}
impl Gateway {
pub fn new(config: ChatConfig, handlers: GatewayHandlers, log: Logger) -> Arc<Self> {
let (ready_sender, ready) = tokio::sync::watch::channel(false);
Arc::new(Self {
config,
handlers: Arc::new(handlers),
log,
give_up: std::sync::Mutex::new(GiveUp::new()),
bot_id: std::sync::Mutex::new(None),
shard: std::sync::Mutex::new(None),
ready,
ready_sender,
})
}
pub async fn wait_ready(&self, timeout_ms: u64) -> Result<(), String> {
let mut ready = self.ready.clone();
let wait = async {
loop {
if *ready.borrow() {
return Ok(());
}
if ready.changed().await.is_err() {
return Err("the connection was closed before it was ready".to_owned());
}
}
};
tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), wait)
.await
.map_err(|_| format!("the connection was not ready in {timeout_ms}ms"))?
}
pub fn intents() -> serenity::model::gateway::GatewayIntents {
serenity::model::gateway::GatewayIntents::GUILDS
| serenity::model::gateway::GatewayIntents::GUILD_MESSAGES
| serenity::model::gateway::GatewayIntents::MESSAGE_CONTENT
}
pub fn login_is_permanent(error: &serenity::Error) -> bool {
is_permanent(error)
}
}
#[serenity::async_trait]
impl EventHandler for Gateway {
async fn message(&self, ctx: Context, message: Message) {
let parent = parent_of(&ctx, message.channel_id).await;
let raw = to_raw(&message, parent);
let own = self.bot_id.lock().expect("the bot id lock").clone();
let decision = classify(&raw, &self.config, own.as_deref());
let InboundDecision::Ignore { reason } = &decision else {
let asked = if decision == InboundDecision::Start
&& self.config.start_on_mention
&& let Some(own) = &own
{
let mut without = raw.clone();
without.content = without_bot_mention(&raw.content, own);
without
} else {
raw
};
(self.handlers.on_message)(asked, decision);
return;
};
self.log.info(
"ignored a message",
&fields([("reason", LogValue::from(*reason))]),
);
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
let Some(command) = interaction.as_command().cloned() else {
return;
};
if !is_permitted(&self.config, command.user.id.get().to_string().as_str()) {
acknowledge(&ctx, &command, "you are not permitted to use this bot").await;
return;
}
let is_thread = command.channel.as_ref().is_some_and(|channel| {
matches!(
channel.kind,
serenity::model::channel::ChannelType::PublicThread
| serenity::model::channel::ChannelType::PrivateThread
| serenity::model::channel::ChannelType::NewsThread
)
});
let user_name = command
.user
.global_name
.clone()
.or_else(|| Some(command.user.name.clone()))
.unwrap_or_default();
let translated = translate(
&command.data.name,
&command.data.options,
command.channel_id.get().to_string().as_str(),
is_thread,
command.user.id.get().to_string().as_str(),
user_name.as_str(),
);
let ack: Arc<dyn Fn(&str) + Send + Sync> = {
let ctx = ctx.clone();
Arc::new(move |text: &str| {
let ctx = ctx.clone();
let command = command.clone();
let text = text.to_owned();
tokio::spawn(async move {
acknowledge(&ctx, &command, &text).await;
});
})
};
(self.handlers.on_command)(translated, ack);
}
async fn message_delete(
&self,
ctx: Context,
channel_id: ChannelId,
deleted_message_id: serenity::model::id::MessageId,
_guild_id: Option<serenity::model::id::GuildId>,
) {
let parent = parent_of(&ctx, channel_id).await;
let decision = classify_deletion(
&RawDeletion {
id: deleted_message_id.get().to_string(),
channel_id: channel_id.get().to_string(),
parent_channel_id: parent.map(|channel| channel.get().to_string()),
},
&self.config,
);
match decision {
DeletionDecision::Ignore { .. } => {}
DeletionDecision::Withdraw {
message_id,
thread_id,
} => {
self.log.info(
"a message was withdrawn",
&fields([
("messageId", LogValue::from(message_id.as_str())),
(
"threadId",
LogValue::from(thread_id.clone().unwrap_or_default()),
),
]),
);
(self.handlers.on_withdrawn)(message_id, thread_id);
}
}
}
async fn thread_update(
&self,
_ctx: Context,
_old: Option<serenity::model::channel::GuildChannel>,
new: serenity::model::channel::GuildChannel,
) {
if new.parent_id != Some(ChannelId::new(self.config.channel_id.parse().unwrap_or(0))) {
return;
}
let archived = new
.thread_metadata
.as_ref()
.is_some_and(|metadata| metadata.archived);
if !archived {
return;
}
(self.handlers.on_thread_closed)(new.id.get().to_string());
}
async fn thread_delete(
&self,
_ctx: Context,
thread: serenity::model::channel::PartialGuildChannel,
_full: Option<serenity::model::channel::GuildChannel>,
) {
if thread.parent_id.get().to_string() != self.config.channel_id {
return;
}
(self.handlers.on_thread_closed)(thread.id.get().to_string());
}
async fn shard_stage_update(
&self,
_ctx: Context,
event: serenity::gateway::ShardStageUpdateEvent,
) {
use serenity::gateway::ConnectionStage;
if event.new == ConnectionStage::Connected {
self.give_up.lock().expect("the give-up lock").connected();
(self.handlers.on_connected)();
return;
}
if event.new == ConnectionStage::Disconnected {
if let Some(old) = Some(event.old)
&& old != ConnectionStage::Connected
{
return;
}
(self.handlers.on_disconnected)();
if self
.give_up
.lock()
.expect("the give-up lock")
.disconnected()
{
self.log.error(
"giving up on reconnecting",
&fields([(
"attempts",
LogValue::from(i64::from(
self.give_up.lock().expect("the give-up lock").attempts(),
)),
)]),
);
(self.handlers.on_gave_up)(
self.give_up.lock().expect("the give-up lock").attempts(),
);
}
}
}
async fn ready(&self, ctx: Context, ready: Ready) {
*self.bot_id.lock().expect("the bot id lock") = Some(ready.user.id.get().to_string());
*self.shard.lock().expect("the shard lock") = Some(ctx.shard);
self.ready_sender.send_replace(true);
}
}
async fn parent_of(ctx: &Context, channel_id: ChannelId) -> Option<ChannelId> {
match channel_id.to_channel(ctx).await {
Ok(Channel::Guild(channel)) => channel.parent_id,
_ => None,
}
}
impl Gateway {
pub fn set_status(&self, text: Option<&str>) {
let Some(shard) = self.shard.lock().expect("the shard lock").clone() else {
return;
};
let activity = text.map(serenity::gateway::ActivityData::custom);
shard.set_presence(activity, serenity::model::user::OnlineStatus::Online);
}
}