1use 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
18pub struct ShardManagerContainer;
23
24impl TypeMapKey for ShardManagerContainer {
25 type Value = Arc<ShardManager>;
26}
27
28#[derive(Debug, PartialEq, Eq)]
30pub enum Command {
31 Version,
33 Ping,
35 Help,
37 Config,
39 Debug {
42 payload: String,
44 },
45 Unknown(String),
47}
48
49pub 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 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 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
85fn 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
100pub fn release_url(repository: &str, version: &str) -> String {
102 format!("{repository}/releases/tag/v{version}")
103}
104
105fn sanitize_code_block(s: &str) -> String {
107 s.replace("```", "'''")
108}
109
110#[cfg_attr(coverage_nightly, coverage(off))]
112pub async fn execute(ctx: &Context, request: &Message, command: Command) {
113 let content = match command {
116 Command::Ping => {
117 execute_ping(ctx, request).await;
118 return;
119 }
120 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
169const MAX_MESSAGE_CONTENT_CHARS: usize = 2000;
171
172const TRUNCATION_NOTICE: &str = "\n… (truncated)";
174
175const 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
179fn 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 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 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#[cfg_attr(coverage_nightly, coverage(off))]
224async fn send_reply(ctx: &Context, request: &Message, content: &str) -> Option<Message> {
225 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
270fn 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 None => "measuring…".to_string(),
281 }
282}
283
284#[cfg_attr(coverage_nightly, coverage(off))]
289async fn current_gateway_latency(ctx: &Context) -> Option<Duration> {
290 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 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 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}