Skip to main content

babyrite/
command.rs

1//! Mention-prefixed lightweight command system.
2//!
3//! Slash commands and text commands both carry more ceremony (registration,
4//! parsing conventions) than this bot needs. Instead, a message that *starts*
5//! with a mention of the bot itself is treated as a command. Requiring the
6//! mention to be the first thing in the message is what keeps an incidental
7//! `@babyrite` buried inside a normal message from being misread as a command.
8
9use crate::config::{BabyriteConfig, LogFormat};
10use serenity::all::{
11    Context, CreateAllowedMentions, CreateMessage, EditMessage, Message, MessageFlags,
12    ShardManager, UserId,
13};
14use serenity::prelude::TypeMapKey;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18/// TypeMap key for the shared shard manager.
19///
20/// Registered alongside [`crate::expand::github::HttpClient`] so the `ping` command
21/// can read the gateway heartbeat latency for the shard handling this event.
22pub struct ShardManagerContainer;
23
24impl TypeMapKey for ShardManagerContainer {
25    type Value = Arc<ShardManager>;
26}
27
28/// A recognized mention-prefixed command.
29#[derive(Debug, PartialEq, Eq)]
30pub enum Command {
31    /// Show the running version, with a link to its GitHub release.
32    Version,
33    /// Show the current gateway and API latency.
34    Ping,
35    /// List the available commands.
36    Help,
37    /// Show the currently loaded configuration.
38    Config,
39    /// Echo a message back as a code block: the message this is a reply to,
40    /// or (if it isn't a reply) the text after the first newline.
41    Debug {
42        /// The text after the first newline, used when this isn't a reply.
43        payload: String,
44    },
45    /// A mention-prefixed message that didn't match any known command word.
46    Unknown(String),
47}
48
49/// Parses a message into a [`Command`] if it starts with a mention of `bot_id`.
50///
51/// Returns `None` when the message doesn't start with the bot's own mention
52/// (leading whitespace aside) — this is the threshold that keeps a mention
53/// appearing mid-message from being treated as a command.
54pub fn parse(content: &str, bot_id: UserId) -> Option<Command> {
55    let rest = strip_bot_mention_prefix(content, bot_id)?.trim_start();
56    if rest.is_empty() {
57        // A bare mention with no command word is treated as a request for help.
58        return Some(Command::Help);
59    }
60
61    let head = rest
62        .split_whitespace()
63        .next()
64        .unwrap_or(rest)
65        .to_ascii_lowercase();
66
67    match head.as_str() {
68        "version" => Some(Command::Version),
69        "ping" => Some(Command::Ping),
70        "help" => Some(Command::Help),
71        "config" => Some(Command::Config),
72        "debug" => {
73            // The payload is everything after the first newline, not just
74            // after the "debug" word, so trailing text on the command line
75            // itself (e.g. a stray space) isn't mistaken for the payload.
76            let payload = rest.split_once('\n').map_or("", |(_, body)| body);
77            Some(Command::Debug {
78                payload: payload.to_string(),
79            })
80        }
81        _ => Some(Command::Unknown(head)),
82    }
83}
84
85/// Strips a leading `<@ID>` or `<@!ID>` mention of `bot_id` from `content`.
86///
87/// Returns `None` if the content (after leading whitespace) doesn't start
88/// with either mention form. This runs on every message the bot sees, so it
89/// parses the mention's ID and compares it numerically instead of formatting
90/// `bot_id` into a `String` to match against — the latter would allocate on
91/// every message regardless of whether it turns out to be a command.
92fn strip_bot_mention_prefix(content: &str, bot_id: UserId) -> Option<&str> {
93    let trimmed = content.trim_start();
94    let rest = trimmed.strip_prefix("<@")?;
95    let rest = rest.strip_prefix('!').unwrap_or(rest);
96    let (id, rest) = rest.split_once('>')?;
97    (id.parse::<u64>().ok()? == bot_id.get()).then_some(rest)
98}
99
100/// Builds the URL for a tagged GitHub release.
101pub fn release_url(repository: &str, version: &str) -> String {
102    format!("{repository}/releases/tag/v{version}")
103}
104
105/// Replaces code fences in `s` so they can't break out of a code block they're embedded in.
106fn sanitize_code_block(s: &str) -> String {
107    s.replace("```", "'''")
108}
109
110/// Executes a parsed command, replying on the channel the request came from.
111#[cfg_attr(coverage_nightly, coverage(off))]
112pub async fn execute(ctx: &Context, request: &Message, command: Command) {
113    // `ping` measures its own reply's round-trip and then edits the latency
114    // in, so it needs the sent `Message` handle rather than a fixed string.
115    let content = match command {
116        Command::Ping => {
117            execute_ping(ctx, request).await;
118            return;
119        }
120        // Needs `request` itself to see whether it's a reply to another
121        // message, which a plain string return from the other `render_*`
122        // functions can't carry.
123        Command::Debug { payload } => render_debug(request, &payload),
124        Command::Version => render_version(),
125        Command::Help => render_help(),
126        Command::Config => render_config(BabyriteConfig::get()),
127        Command::Unknown(word) => render_unknown(&word),
128    };
129
130    send_reply(ctx, request, &content).await;
131}
132
133fn render_version() -> String {
134    let version = env!("CARGO_PKG_VERSION");
135    let url = release_url(env!("CARGO_PKG_REPOSITORY"), version);
136    format!("Running babyrite v{version}\n{url}")
137}
138
139fn render_help() -> String {
140    "Available commands:\n\
141     `version` — Show the running version.\n\
142     `ping` — Show the current latency.\n\
143     `help` — Show this help message.\n\
144     `config` — Show the currently loaded configuration.\n\
145     `debug` — Echo the following lines back as a code block."
146        .to_string()
147}
148
149fn render_config(config: &BabyriteConfig) -> String {
150    let log_format = match config.resolved_log_format() {
151        LogFormat::Compact => "compact",
152        LogFormat::Json => "json",
153    };
154
155    format!(
156        "Current configuration:\n\
157         log.level: `{}`\n\
158         log.format: `{log_format}`\n\
159         features.github_permalink: `{}`\n\
160         features.commands: `{}`\n\
161         github.max_lines: `{}`",
162        config.log.level,
163        config.features.github_permalink,
164        config.features.commands,
165        config.github.max_lines,
166    )
167}
168
169/// Discord rejects message content longer than this many characters.
170const MAX_MESSAGE_CONTENT_CHARS: usize = 2000;
171
172/// Appended to a debug payload that had to be cut to fit `MAX_MESSAGE_CONTENT_CHARS`.
173const TRUNCATION_NOTICE: &str = "\n… (truncated)";
174
175/// Shown when `debug` has nothing to display.
176const DEBUG_MISSING_SOURCE_ERROR: &str = "Error: `debug` needs something to show — reply to the message you want debugged, \
177     or put text on the line(s) after the command.";
178
179/// Renders the `debug` reply body.
180///
181/// When the command message is itself a reply, the referenced message's
182/// content is what the user most likely wants echoed back, so it takes
183/// priority over a typed `payload`. Falls back to `payload`, and finally to
184/// an error if neither has anything to show.
185fn render_debug(request: &Message, payload: &str) -> String {
186    let referenced_content = request
187        .referenced_message
188        .as_deref()
189        .map(|referenced| referenced.content.as_str())
190        .filter(|content| !content.trim().is_empty());
191
192    let Some(source) =
193        referenced_content.or_else(|| Some(payload).filter(|p| !p.trim().is_empty()))
194    else {
195        return DEBUG_MISSING_SOURCE_ERROR.to_string();
196    };
197
198    let sanitized = sanitize_code_block(source);
199    // The "```\n" / "\n```" fence around the payload is 8 characters of fixed overhead.
200    let budget = MAX_MESSAGE_CONTENT_CHARS.saturating_sub(8);
201
202    if sanitized.chars().count() <= budget {
203        return format!("```\n{sanitized}\n```");
204    }
205
206    // `debug`'s entire purpose is pasting arbitrary text (log excerpts, stack
207    // traces), which realistically exceeds Discord's message length limit.
208    // Truncating and saying so beats send_reply's error path silently
209    // dropping the whole reply.
210    let keep = budget.saturating_sub(TRUNCATION_NOTICE.chars().count());
211    let truncated: String = sanitized.chars().take(keep).collect();
212    format!("```\n{truncated}{TRUNCATION_NOTICE}\n```")
213}
214
215fn render_unknown(word: &str) -> String {
216    format!("Unknown command: `{word}`. Try `help`.")
217}
218
219/// Sends `content` as a reply to `request`, suppressing link-preview embeds.
220///
221/// Returns the sent message so callers (namely `ping`) can edit it afterwards.
222/// Returns `None` (after logging) if the send fails.
223#[cfg_attr(coverage_nightly, coverage(off))]
224async fn send_reply(ctx: &Context, request: &Message, content: &str) -> Option<Message> {
225    // `debug` echoes back arbitrary text (typed or quoted from another
226    // message), which can contain `@everyone`/`@here`/role/user mentions.
227    // Discord parses mentions from raw content regardless of code-block
228    // formatting, so every reply explicitly allows none of them.
229    let message = CreateMessage::new()
230        .content(content)
231        .reference_message(request)
232        .flags(MessageFlags::SUPPRESS_EMBEDS)
233        .allowed_mentions(CreateAllowedMentions::new());
234
235    match request.channel_id.send_message(&ctx.http, message).await {
236        Ok(sent) => Some(sent),
237        Err(e) => {
238            tracing::error!(error = ?e, "failed to send command reply");
239            None
240        }
241    }
242}
243
244#[cfg_attr(coverage_nightly, coverage(off))]
245async fn execute_ping(ctx: &Context, request: &Message) {
246    let gateway_latency = current_gateway_latency(ctx).await;
247    let gateway_text = format_latency(gateway_latency);
248
249    let start = Instant::now();
250    let Some(mut sent) = send_reply(ctx, request, &render_pong(&gateway_text, "measuring…")).await
251    else {
252        return;
253    };
254    let api_latency = start.elapsed();
255
256    if let Err(e) = sent
257        .edit(
258            ctx,
259            EditMessage::new().content(render_pong(
260                &gateway_text,
261                &format_latency(Some(api_latency)),
262            )),
263        )
264        .await
265    {
266        tracing::error!(error = ?e, "failed to update ping latency");
267    }
268}
269
270/// Renders the `ping` reply body; used for both the initial send and the
271/// latency edit so the two stay in sync.
272fn render_pong(gateway_text: &str, api_text: &str) -> String {
273    format!("🏓 Pong!\nGateway latency: {gateway_text}\nAPI latency: {api_text}")
274}
275
276fn format_latency(latency: Option<Duration>) -> String {
277    match latency {
278        Some(d) => format!("{}ms", d.as_millis()),
279        // No heartbeat ACK has been received yet (e.g. right after connecting).
280        None => "measuring…".to_string(),
281    }
282}
283
284/// Reads the gateway heartbeat latency for the shard handling this context.
285///
286/// `None` if the shard manager isn't registered in `ctx.data`, or if no
287/// heartbeat acknowledgement has been received yet for this shard.
288#[cfg_attr(coverage_nightly, coverage(off))]
289async fn current_gateway_latency(ctx: &Context) -> Option<Duration> {
290    // Clone the `Arc` and drop the `ctx.data` read guard before awaiting the
291    // shard-runner mutex below, so this never holds one lock while waiting
292    // on another.
293    let manager = {
294        let data = ctx.data.read().await;
295        data.get::<ShardManagerContainer>()?.clone()
296    };
297    let runners = manager.runners.lock().await;
298    runners.get(&ctx.shard_id)?.latency
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn bot_id() -> UserId {
306        UserId::new(123)
307    }
308
309    #[test]
310    fn parses_plain_mention_prefix() {
311        assert_eq!(parse("<@123> ping", bot_id()), Some(Command::Ping));
312    }
313
314    #[test]
315    fn parses_nickname_mention_prefix() {
316        assert_eq!(parse("<@!123> ping", bot_id()), Some(Command::Ping));
317    }
318
319    #[test]
320    fn ignores_mid_message_mention() {
321        // The mention isn't at the start, so this must not be read as a command.
322        assert_eq!(parse("hello <@123> ping", bot_id()), None);
323    }
324
325    #[test]
326    fn ignores_mention_of_a_different_user() {
327        assert_eq!(parse("<@999> ping", bot_id()), None);
328    }
329
330    #[test]
331    fn ignores_malformed_mention_syntax() {
332        assert_eq!(parse("<@abc> ping", bot_id()), None);
333        assert_eq!(parse("<@> ping", bot_id()), None);
334        assert_eq!(parse("<@123 ping", bot_id()), None);
335    }
336
337    #[test]
338    fn bare_mention_shows_help() {
339        assert_eq!(parse("<@123>", bot_id()), Some(Command::Help));
340        assert_eq!(parse("<@123>   ", bot_id()), Some(Command::Help));
341    }
342
343    #[test]
344    fn leading_whitespace_before_mention_is_allowed() {
345        assert_eq!(parse("   <@123> ping", bot_id()), Some(Command::Ping));
346    }
347
348    #[test]
349    fn command_word_is_case_insensitive() {
350        assert_eq!(parse("<@123> PING", bot_id()), Some(Command::Ping));
351    }
352
353    #[test]
354    fn parses_version_help_and_config() {
355        assert_eq!(parse("<@123> version", bot_id()), Some(Command::Version));
356        assert_eq!(parse("<@123> help", bot_id()), Some(Command::Help));
357        assert_eq!(parse("<@123> config", bot_id()), Some(Command::Config));
358    }
359
360    #[test]
361    fn debug_payload_is_the_text_after_the_first_newline() {
362        assert_eq!(
363            parse("<@123> debug\nsome body", bot_id()),
364            Some(Command::Debug {
365                payload: "some body".to_string()
366            })
367        );
368    }
369
370    #[test]
371    fn debug_without_a_newline_has_no_payload() {
372        assert_eq!(
373            parse("<@123> debug", bot_id()),
374            Some(Command::Debug {
375                payload: String::new()
376            })
377        );
378    }
379
380    #[test]
381    fn unknown_command_word_is_preserved() {
382        assert_eq!(
383            parse("<@123> foobar", bot_id()),
384            Some(Command::Unknown("foobar".to_string()))
385        );
386    }
387
388    #[test]
389    fn render_version_puts_the_release_url_on_its_own_line() {
390        let expected = format!(
391            "Running babyrite v{}\n{}/releases/tag/v{}",
392            env!("CARGO_PKG_VERSION"),
393            env!("CARGO_PKG_REPOSITORY"),
394            env!("CARGO_PKG_VERSION")
395        );
396        assert_eq!(render_version(), expected);
397    }
398
399    #[test]
400    fn render_help_lists_commands_without_a_mention_prefix() {
401        let help = render_help();
402        assert!(help.contains("`version`"));
403        assert!(help.contains("`ping`"));
404        assert!(help.contains("`help`"));
405        assert!(help.contains("`config`"));
406        assert!(help.contains("`debug`"));
407        assert!(!help.contains("<@"));
408    }
409
410    #[test]
411    fn render_unknown_hint_has_no_mention_prefix() {
412        let text = render_unknown("foobar");
413        assert!(text.contains("foobar"));
414        assert!(text.contains("`help`"));
415        assert!(!text.contains("<@"));
416    }
417
418    #[test]
419    fn render_config_reports_the_effective_settings() {
420        let config = BabyriteConfig::default();
421        let rendered = render_config(&config);
422
423        assert!(!rendered.contains("```"));
424        assert!(rendered.contains("log.level: `babyrite=info`"));
425        assert!(rendered.contains("log.format: `compact`"));
426        assert!(rendered.contains("features.github_permalink: `true`"));
427        assert!(rendered.contains("features.commands: `true`"));
428        assert!(rendered.contains("github.max_lines: `50`"));
429    }
430
431    fn message_without_reference() -> Message {
432        Message::default()
433    }
434
435    // `Message` is `#[non_exhaustive]`, so it can't be built with struct-literal
436    // syntax here (even with `..Default::default()`) — only field assignment
437    // on an already-constructed instance is allowed outside its crate.
438    fn message_replying_to(content: &str) -> Message {
439        let mut referenced = Message::default();
440        referenced.content = content.to_string();
441
442        let mut request = Message::default();
443        request.referenced_message = Some(Box::new(referenced));
444        request
445    }
446
447    #[test]
448    fn render_debug_errors_when_theres_no_reply_and_no_payload() {
449        let request = message_without_reference();
450        assert!(render_debug(&request, "").starts_with("Error:"));
451        assert!(render_debug(&request, "   ").starts_with("Error:"));
452    }
453
454    #[test]
455    fn render_debug_wraps_payload_in_a_sanitized_code_block() {
456        let request = message_without_reference();
457        assert_eq!(
458            render_debug(&request, "```rust\ncode\n```"),
459            "```\n'''rust\ncode\n'''\n```"
460        );
461    }
462
463    #[test]
464    fn render_debug_truncates_oversized_payloads_to_fit_discords_limit() {
465        let request = message_without_reference();
466        let payload = "a".repeat(3000);
467        let rendered = render_debug(&request, &payload);
468
469        assert!(rendered.chars().count() <= MAX_MESSAGE_CONTENT_CHARS);
470        assert!(rendered.contains(TRUNCATION_NOTICE.trim()));
471    }
472
473    #[test]
474    fn render_debug_shows_the_replied_to_message_over_a_typed_payload() {
475        let request = message_replying_to("target content");
476        assert_eq!(
477            render_debug(&request, "ignored payload"),
478            "```\ntarget content\n```"
479        );
480    }
481
482    #[test]
483    fn render_debug_falls_back_to_payload_when_the_reply_has_no_content() {
484        let request = message_replying_to("");
485        assert_eq!(
486            render_debug(&request, "typed payload"),
487            "```\ntyped payload\n```"
488        );
489    }
490
491    #[test]
492    fn sanitize_code_block_replaces_all_fences() {
493        assert_eq!(
494            sanitize_code_block("```rust\ncode\n```"),
495            "'''rust\ncode\n'''"
496        );
497    }
498
499    #[test]
500    fn release_url_points_to_the_tagged_release() {
501        assert_eq!(
502            release_url("https://github.com/m1sk9/babyrite", "1.2.5"),
503            "https://github.com/m1sk9/babyrite/releases/tag/v1.2.5"
504        );
505    }
506
507    #[test]
508    fn format_latency_reports_measuring_when_unknown() {
509        assert_eq!(format_latency(None), "measuring…");
510        assert_eq!(format_latency(Some(Duration::from_millis(42))), "42ms");
511    }
512}