huskies: merge 615_story_extract_timer_service
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
//! Pure parsing logic for timer bot-commands and time display formatting.
|
||||
//!
|
||||
//! No side effects: no filesystem access, no clock reads.
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
use crate::chat::util::strip_bot_mention;
|
||||
|
||||
// ── Command types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A parsed `timer` command.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum TimerCommand {
|
||||
/// `timer <story_id_or_number> <HH:MM>` — schedule a deferred start.
|
||||
Schedule {
|
||||
story_number_or_id: String,
|
||||
hhmm: String,
|
||||
},
|
||||
/// `timer list` — list all pending timers.
|
||||
List,
|
||||
/// `timer cancel <story_id_or_number>` — remove a pending timer.
|
||||
Cancel { story_number_or_id: String },
|
||||
/// Malformed arguments.
|
||||
BadArgs,
|
||||
}
|
||||
|
||||
// ── Command extraction ─────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a `timer` command from a raw message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and matches the `timer` keyword.
|
||||
/// Returns `None` when the message is not a timer command at all.
|
||||
pub fn extract_timer_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<TimerCommand> {
|
||||
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c, a.trim()),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
|
||||
if !cmd.eq_ignore_ascii_case("timer") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// `timer` with no args or `timer list`
|
||||
if args.is_empty() || args.eq_ignore_ascii_case("list") {
|
||||
return Some(TimerCommand::List);
|
||||
}
|
||||
|
||||
let (sub, rest) = match args.split_once(char::is_whitespace) {
|
||||
Some((s, r)) => (s, r.trim()),
|
||||
None => (args, ""),
|
||||
};
|
||||
|
||||
// `timer cancel <id>`
|
||||
if sub.eq_ignore_ascii_case("cancel") {
|
||||
if rest.is_empty() {
|
||||
return Some(TimerCommand::BadArgs);
|
||||
}
|
||||
return Some(TimerCommand::Cancel {
|
||||
story_number_or_id: rest.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// `timer <id> <HH:MM>`
|
||||
if rest.is_empty() {
|
||||
return Some(TimerCommand::BadArgs);
|
||||
}
|
||||
|
||||
Some(TimerCommand::Schedule {
|
||||
story_number_or_id: sub.to_string(),
|
||||
hhmm: rest.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Display helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Format a UTC instant for display in the given timezone (or local time when
|
||||
/// `timezone` is `None`). Returns `(formatted_string, label)` where `label`
|
||||
/// is either the IANA timezone name or `"server local time"`.
|
||||
pub(super) fn format_in_timezone(dt: DateTime<Utc>, timezone: Option<&str>) -> (String, String) {
|
||||
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
|
||||
Some(tz) => {
|
||||
let tz_time = dt.with_timezone(&tz);
|
||||
(tz_time.format("%Y-%m-%d %H:%M").to_string(), tz.to_string())
|
||||
}
|
||||
None => {
|
||||
let local_time = dt.with_timezone(&Local);
|
||||
(
|
||||
local_time.format("%Y-%m-%d %H:%M").to_string(),
|
||||
"server local time".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_timer_command_returns_none() {
|
||||
assert!(extract_timer_command("Timmy help", "Timmy", "@bot:home").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_list_no_args() {
|
||||
assert_eq!(
|
||||
extract_timer_command("Timmy timer", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::List)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_list_explicit() {
|
||||
assert_eq!(
|
||||
extract_timer_command("Timmy timer list", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::List)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_cancel_story_id() {
|
||||
assert_eq!(
|
||||
extract_timer_command("Timmy timer cancel 421_story_foo", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::Cancel {
|
||||
story_number_or_id: "421_story_foo".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_cancel_no_arg_is_bad_args() {
|
||||
assert_eq!(
|
||||
extract_timer_command("Timmy timer cancel", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::BadArgs)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_schedule_with_story_id() {
|
||||
assert_eq!(
|
||||
extract_timer_command("Timmy timer 421_story_foo 14:30", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::Schedule {
|
||||
story_number_or_id: "421_story_foo".to_string(),
|
||||
hhmm: "14:30".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_schedule_with_number() {
|
||||
assert_eq!(
|
||||
extract_timer_command("Timmy timer 421 14:30", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::Schedule {
|
||||
story_number_or_id: "421".to_string(),
|
||||
hhmm: "14:30".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_schedule_missing_time_is_bad_args() {
|
||||
assert_eq!(
|
||||
extract_timer_command("Timmy timer 421_story_foo", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::BadArgs)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user