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::config::BabyriteConfig;
7use crate::expand::{EXPANDERS, ExpandContext, ExpandedContent};
8use serenity::all::{ActivityData, Context, EventHandler, Message, Ready};
9use serenity::futures::future::join_all;
10use serenity_builder::model::message::{SerenityMessage, SerenityMessageMentionType};
11use tracing::Instrument;
12
13/// Event handler for Babyrite bot.
14pub struct BabyriteEventHandler;
15
16#[serenity::async_trait]
17impl EventHandler for BabyriteEventHandler {
18    async fn ready(&self, ctx: Context, bot: Ready) {
19        let version = format!("v{}", env!("CARGO_PKG_VERSION"));
20        ctx.set_activity(ActivityData::custom(format!("Running {}", version)).into());
21        tracing::info!("Running {}, {} is connected!", version, bot.user.name);
22    }
23
24    async fn message(&self, ctx: Context, request: Message) {
25        if request.author.bot {
26            return;
27        }
28
29        let Some(request_guild_id) = request.guild_id else {
30            return;
31        };
32
33        // Correlation span: every log emitted while handling this message
34        // carries these fields, so a single request can be traced end-to-end
35        // (e.g. via Grafana Loki). `request.id` is the unique Discord message
36        // ID and serves as the correlation key.
37        let span = tracing::info_span!(
38            "message",
39            message_id = %request.id,
40            guild_id = %request_guild_id,
41            channel_id = %request.channel_id,
42            author = %request.author.name,
43        );
44
45        async {
46            let text = &request.content;
47            let config = BabyriteConfig::get();
48
49            // Mention-prefixed commands (e.g. `@babyrite ping`) take priority
50            // over link expansion below. A message starting with the bot's
51            // mention followed by an unrecognized word isn't necessarily a
52            // command attempt, though — e.g. "@babyrite check this out:
53            // <link>" — so an unrecognized command only gets its "Unknown
54            // command" hint if the message has no expandable links either;
55            // otherwise the links are expanded as normal.
56            let mut unknown_command = None;
57            if config.features.commands {
58                let bot_id = ctx.cache.current_user().id;
59                match crate::command::parse(text, bot_id) {
60                    Some(crate::command::Command::Unknown(word)) => unknown_command = Some(word),
61                    Some(command) => {
62                        dispatch_command(&ctx, &request, command).await;
63                        return;
64                    }
65                    None => {}
66                }
67            }
68
69            // Expanders are independent of each other, so run them concurrently.
70            // `join_all` preserves registration order in the combined results.
71            let cx = ExpandContext {
72                ctx: &ctx,
73                message: &request,
74                guild_id: request_guild_id,
75            };
76            let results: Vec<ExpandedContent> = join_all(
77                EXPANDERS
78                    .iter()
79                    .filter(|expander| expander.enabled(config))
80                    .map(|expander| expander.expand_all(&cx)),
81            )
82            .await
83            .into_iter()
84            .flatten()
85            .collect();
86
87            if results.is_empty() {
88                if let Some(word) = unknown_command {
89                    dispatch_command(&ctx, &request, crate::command::Command::Unknown(word)).await;
90                } else {
91                    tracing::debug!("no expandable content found");
92                }
93                return;
94            }
95
96            send_expanded_contents(&ctx, &request, results).await;
97        }
98        .instrument(span)
99        .await;
100    }
101}
102
103/// Logs and executes a parsed mention command.
104async fn dispatch_command(ctx: &Context, request: &Message, command: crate::command::Command) {
105    tracing::debug!(?command, "handling mention command");
106    crate::command::execute(ctx, request, command).await;
107}
108
109/// Sends expanded contents as a reply to the original message.
110async fn send_expanded_contents(ctx: &Context, request: &Message, results: Vec<ExpandedContent>) {
111    let mut embeds = Vec::new();
112    let mut code_blocks = Vec::new();
113
114    for result in results {
115        match result {
116            ExpandedContent::Embed(embed) => embeds.push(*embed),
117            ExpandedContent::CodeBlock {
118                language,
119                code,
120                metadata,
121            } => {
122                code_blocks.push(format!("{metadata}\n```{language}\n{code}\n```"));
123            }
124        }
125    }
126
127    let embed_count = embeds.len();
128    let code_block_count = code_blocks.len();
129
130    // Send embeds if any
131    if !embeds.is_empty() {
132        let message_builder = SerenityMessage::builder()
133            .embeds(embeds)
134            .mention_type(SerenityMessageMentionType::Reply(Box::new(request.clone())))
135            .build();
136
137        let converted_message = match message_builder.convert() {
138            Ok(m) => m,
139            Err(e) => {
140                tracing::error!(error = ?e, "failed to convert embed message");
141                return;
142            }
143        };
144
145        if let Err(e) = request
146            .channel_id
147            .send_message(&ctx.http, converted_message)
148            .await
149        {
150            tracing::error!(error = ?e, "failed to send preview");
151            return;
152        }
153    }
154
155    // Send code blocks as plain messages
156    for block in code_blocks {
157        if let Err(e) = request.channel_id.say(&ctx.http, &block).await {
158            tracing::error!(error = ?e, "failed to send code block");
159        }
160    }
161
162    tracing::info!(
163        embeds = embed_count,
164        code_blocks = code_block_count,
165        "preview sent"
166    );
167}