Skip to main content

babyrite/
event.rs

1//! Event handling module for Discord events.
2//!
3//! This module implements the serenity [`EventHandler`] trait to handle
4//! Discord gateway events such as ready and message events.
5
6use crate::cache::invalidate_channel;
7use crate::config::BabyriteConfig;
8use crate::expand::{EXPANDERS, ExpandContext, ExpandedContent};
9use serenity::all::{
10    ActivityData, Context, EventHandler, GuildChannel, Message, PartialGuildChannel, Ready,
11};
12use serenity::futures::future::join_all;
13use tracing::Instrument;
14
15/// Event handler for Babyrite bot.
16pub struct BabyriteEventHandler;
17
18#[serenity::async_trait]
19impl EventHandler for BabyriteEventHandler {
20    async fn ready(&self, ctx: Context, bot: Ready) {
21        let version = format!("v{}", env!("CARGO_PKG_VERSION"));
22        ctx.set_activity(ActivityData::custom(format!("Running {}", version)).into());
23        tracing::info!("Running {}, {} is connected!", version, bot.user.name);
24    }
25
26    // The six handlers below exist only to keep the channel caches from
27    // outliving the permissions they hold. `check_visibility` decides whether a
28    // link may be expanded from the cached permission overwrites, so a cached
29    // channel that Discord has since restricted keeps being treated as visible.
30    // Creations and deletions are included because the guild's channel list is
31    // cached as one value, and a stale list answers for channels that no longer
32    // exist and misses ones that now do.
33
34    async fn channel_create(&self, _ctx: Context, channel: GuildChannel) {
35        invalidate_channel(channel.guild_id, channel.id).await;
36    }
37
38    async fn channel_update(&self, _ctx: Context, _old: Option<GuildChannel>, new: GuildChannel) {
39        invalidate_channel(new.guild_id, new.id).await;
40    }
41
42    async fn channel_delete(
43        &self,
44        _ctx: Context,
45        channel: GuildChannel,
46        _messages: Option<Vec<Message>>,
47    ) {
48        invalidate_channel(channel.guild_id, channel.id).await;
49    }
50
51    async fn thread_create(&self, _ctx: Context, thread: GuildChannel) {
52        invalidate_channel(thread.guild_id, thread.id).await;
53    }
54
55    async fn thread_update(&self, _ctx: Context, _old: Option<GuildChannel>, new: GuildChannel) {
56        invalidate_channel(new.guild_id, new.id).await;
57    }
58
59    async fn thread_delete(
60        &self,
61        _ctx: Context,
62        thread: PartialGuildChannel,
63        _full_thread_data: Option<GuildChannel>,
64    ) {
65        invalidate_channel(thread.guild_id, thread.id).await;
66    }
67
68    async fn message(&self, ctx: Context, request: Message) {
69        if request.author.bot {
70            return;
71        }
72
73        let Some(request_guild_id) = request.guild_id else {
74            return;
75        };
76
77        // Correlation span: every log emitted while handling this message
78        // carries these fields, so a single request can be traced end-to-end
79        // (e.g. via Grafana Loki). `request.id` is the unique Discord message
80        // ID and serves as the correlation key.
81        let span = tracing::info_span!(
82            "message",
83            message_id = %request.id,
84            guild_id = %request_guild_id,
85            channel_id = %request.channel_id,
86            author = %request.author.name,
87        );
88
89        async {
90            let config = BabyriteConfig::get();
91
92            // Expanders are independent of each other, so run them concurrently.
93            // `join_all` preserves registration order in the combined results.
94            let cx = ExpandContext {
95                ctx: &ctx,
96                message: &request,
97                guild_id: request_guild_id,
98            };
99            let results: Vec<ExpandedContent> = join_all(
100                EXPANDERS
101                    .iter()
102                    .filter(|expander| expander.enabled(config))
103                    .map(|expander| expander.expand_all(&cx)),
104            )
105            .await
106            .into_iter()
107            .flatten()
108            .collect();
109
110            if results.is_empty() {
111                tracing::debug!("no expandable content found");
112                return;
113            }
114
115            send_expanded_contents(&ctx, &request, results).await;
116        }
117        .instrument(span)
118        .await;
119    }
120}
121
122/// Sends expanded contents as a reply to the original message.
123///
124/// A failed send is reported and skipped rather than aborting the rest: the
125/// expansions are independent, so one rejected message must not silence the
126/// others.
127async fn send_expanded_contents(ctx: &Context, request: &Message, results: Vec<ExpandedContent>) {
128    let embeds = results
129        .iter()
130        .filter(|result| matches!(result, ExpandedContent::Embed(_)))
131        .count();
132    let code_blocks = results.len() - embeds;
133
134    let messages = crate::reply::build_messages(request, results);
135    let total = messages.len();
136    let mut sent = 0;
137    for message in messages {
138        match request.channel_id.send_message(&ctx.http, message).await {
139            Ok(_) => sent += 1,
140            Err(e) => tracing::error!(error = ?e, "failed to send expanded content"),
141        }
142    }
143
144    // `sent`/`total` count messages, not expansions: every embed shares one.
145    if sent == total {
146        tracing::info!(embeds, code_blocks, "preview sent");
147    } else {
148        tracing::warn!(embeds, code_blocks, sent, total, "preview partially sent");
149    }
150}