use core::future::Future;
use std::pin::Pin;
use serenity::all::{
CommandInteraction, ComponentInteraction, Context, CreateCommand,
CreateInteractionResponseFollowup, Permissions,
};
use shep_client::shep_core::{protocol::SelectorSpec, values::UpDuration};
use crate::{
bot::{
command::{Command, State},
commands::bare_subcommand,
embed::parse_custom_id,
monitor::refresh::{self, Refresh},
},
config::MIN_MONITOR_INTERVAL_MS,
error::Error,
limits,
shepherd::Live,
};
pub struct MonitorCommand;
fn interval_for(configured: Option<UpDuration>) -> UpDuration {
configured.unwrap_or(UpDuration::from_millis(MIN_MONITOR_INTERVAL_MS))
}
fn started_reply(interval: &str) -> String {
limits::fit(
&format!(
"The monitor is on, refreshing every {interval}. This is a runtime override and it \
does not survive a restart: set monitor_interval in dogs.toml to have it start on \
its own."
),
limits::MESSAGE_CONTENT_LIMIT,
)
}
fn already_running_message() -> &'static str {
"The monitor is already running."
}
fn stopped_message() -> &'static str {
"The monitor is off. The messages already in the channel stay as they are, and the next \
start picks them up again rather than posting a second set."
}
fn not_running_message() -> &'static str {
"The monitor is not running."
}
fn nothing_to_redraw_message() -> &'static str {
"The monitor is not running, so there is nothing to redraw. Start it with /monitor start."
}
fn redrew_reply(count: usize) -> String {
let sentence = if count == 0 {
"The monitor is up to date. There was nothing in the flock to draw.".to_owned()
} else {
format!("Redrew {count} sheep. The next scheduled refresh is unchanged.")
};
limits::fit(&sentence, limits::MESSAGE_CONTENT_LIMIT)
}
fn no_channel_message() -> &'static str {
"No monitor_channel is set in the [discord] section of dogs.toml, so there is nowhere to \
draw the monitor."
}
impl MonitorCommand {
async fn answer(
&self,
ctx: &Context,
interaction: &CommandInteraction,
content: &str,
) -> Result<(), Error> {
interaction
.create_followup(
ctx,
CreateInteractionResponseFollowup::new()
.content(content)
.ephemeral(true),
)
.await?;
Ok(())
}
fn start(&self, state: &State) -> String {
let Some(board) = state.board.as_ref() else {
return no_channel_message().to_owned();
};
let interval = interval_for(state.config.monitor_interval);
if refresh::start(
&state.monitor,
Refresh::new(board, &state.config, interval, &state.live),
) {
started_reply(&interval.to_string())
} else {
already_running_message().to_owned()
}
}
}
impl MonitorCommand {
async fn update(&self, state: &State) -> Result<String, Error> {
let Some(board) = state.board.as_ref() else {
return Ok(no_channel_message().to_owned());
};
let redrawn = refresh::refresh_now(
&state.monitor,
&**board,
&state.live,
state.config.ignore_dogs,
)
.await?;
Ok(match redrawn {
Some(count) => redrew_reply(count),
None => nothing_to_redraw_message().to_owned(),
})
}
}
impl MonitorCommand {
async fn act_on_button(&self, live: &Live, custom_id: &str) -> Result<Option<String>, Error> {
let Some((verb, id)) = parse_custom_id(custom_id) else {
return Ok(None);
};
let reply = live.act(verb, SelectorSpec::Id(id)).await?;
Ok(Some(limits::fit(&reply, limits::MESSAGE_CONTENT_LIMIT)))
}
}
impl Command for MonitorCommand {
fn data(&self) -> CreateCommand {
CreateCommand::new(self.name())
.description("Turn the live monitor channel on or off.")
.default_member_permissions(Permissions::ADMINISTRATOR)
.add_option(bare_subcommand(
"start",
"Start refreshing one message per sheep until this dog restarts.",
))
.add_option(bare_subcommand(
"update",
"Redraw every sheep now, without waiting for the next refresh.",
))
.add_option(bare_subcommand("stop", "Stop refreshing the monitor."))
}
fn name(&self) -> &'static str {
"monitor"
}
fn handles_button(&self, custom_id: &str) -> bool {
parse_custom_id(custom_id).is_some()
}
fn button<'a>(
&'a self,
ctx: &'a Context,
interaction: &'a ComponentInteraction,
state: &'a State,
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
Box::pin(async move {
let Some(reply) = self
.act_on_button(&state.live, &interaction.data.custom_id)
.await?
else {
return Ok(());
};
interaction
.create_followup(
ctx,
CreateInteractionResponseFollowup::new()
.content(reply)
.ephemeral(true),
)
.await?;
Ok(())
})
}
fn run<'a>(
&'a self,
ctx: &'a Context,
interaction: &'a CommandInteraction,
state: &'a State,
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
Box::pin(async move {
let options = interaction.data.options();
let Some(sub) = options.first() else {
return Ok(());
};
let reply = match sub.name {
"start" => self.start(state),
"update" => self.update(state).await?,
"stop" => {
if refresh::stop(&state.monitor).await {
stopped_message().to_owned()
} else {
not_running_message().to_owned()
}
}
_ => return Ok(()),
};
self.answer(ctx, interaction, &reply).await
})
}
}
#[cfg(test)]
mod tests {
use shep_client::shep_core::protocol::{Request, Response};
use crate::{
bot::embed::custom_id,
shepherd::Verb,
test_support::{assert_no_dashes, assert_no_dashes_deep, sample, test_live},
};
use super::*;
#[test]
fn the_command_is_admin_gated_and_named_for_what_it_registers() {
let json = serde_json::to_value(MonitorCommand.data()).expect("json");
assert_eq!(json["name"], MonitorCommand.name());
assert_eq!(
json["default_member_permissions"],
Permissions::ADMINISTRATOR.bits().to_string()
);
}
#[test]
fn the_command_declares_exactly_start_update_and_stop() {
let json = serde_json::to_value(MonitorCommand.data()).expect("json");
let names: Vec<&str> = json["options"]
.as_array()
.expect("options")
.iter()
.map(|option| option["name"].as_str().expect("name"))
.collect();
assert_eq!(names, vec!["start", "update", "stop"]);
}
#[test]
fn an_unconfigured_interval_falls_back_to_the_floor() {
assert_eq!(
interval_for(None),
UpDuration::from_millis(MIN_MONITOR_INTERVAL_MS)
);
let written = UpDuration::from_millis(MIN_MONITOR_INTERVAL_MS * 4);
assert_eq!(interval_for(Some(written)), written);
}
#[test]
fn the_start_reply_says_the_override_is_not_written_down() {
assert_eq!(
started_reply("1m"),
"The monitor is on, refreshing every 1m. This is a runtime override and it does not \
survive a restart: set monitor_interval in dogs.toml to have it start on its own."
);
}
#[test]
fn an_absurd_interval_cannot_make_a_reply_discord_refuses() {
let reply = started_reply(&"9".repeat(limits::MESSAGE_CONTENT_LIMIT * 2));
assert_eq!(reply.chars().count(), limits::MESSAGE_CONTENT_LIMIT);
assert!(reply.ends_with('\u{2026}'));
}
#[test]
fn a_redraw_reply_names_what_it_drew() {
assert_eq!(
redrew_reply(4),
"Redrew 4 sheep. The next scheduled refresh is unchanged."
);
assert_eq!(
redrew_reply(0),
"The monitor is up to date. There was nothing in the flock to draw."
);
}
#[test]
fn a_refused_redraw_says_how_to_start_the_monitor() {
assert_eq!(
nothing_to_redraw_message(),
"The monitor is not running, so there is nothing to redraw. Start it with /monitor \
start."
);
}
#[test]
fn the_four_fixed_replies_say_exactly_what_they_should() {
assert_eq!(already_running_message(), "The monitor is already running.");
assert_eq!(
stopped_message(),
"The monitor is off. The messages already in the channel stay as they are, and the \
next start picks them up again rather than posting a second set."
);
assert_eq!(not_running_message(), "The monitor is not running.");
assert_eq!(
no_channel_message(),
"No monitor_channel is set in the [discord] section of dogs.toml, so there is \
nowhere to draw the monitor."
);
}
#[tokio::test]
async fn a_button_acts_on_the_sheep_its_id_names() {
let (live, mut fake) = test_live().await;
fake.expect(Request::Restart {
selector: SelectorSpec::Id(7),
})
.answer(Response::Restarted {
accepted: vec![sample("web")],
refused: Vec::new(),
});
let reply = MonitorCommand
.act_on_button(&live, &custom_id(Verb::Restart, 7))
.await
.expect("ok");
assert_eq!(reply.as_deref(), Some("restarted web"));
}
#[tokio::test]
async fn a_refused_verb_reaches_the_operator_as_a_failure() {
let (live, mut fake) = test_live().await;
fake.expect(Request::Stop {
selector: SelectorSpec::Id(7),
})
.answer_wrong_shape("the shepherd refused the stop");
let outcome = MonitorCommand
.act_on_button(&live, &custom_id(Verb::Stop, 7))
.await;
assert!(
matches!(outcome, Err(Error::Unexpected { .. })),
"{outcome:?}"
);
}
#[tokio::test]
async fn an_unreadable_id_is_not_claimed_and_asks_the_shepherd_nothing() {
let (live, _fake) = test_live().await;
assert!(!MonitorCommand.handles_button("restart:web"));
assert_eq!(
MonitorCommand
.act_on_button(&live, "restart:web")
.await
.expect("ok"),
None
);
}
#[test]
fn every_button_the_monitor_draws_is_claimed_by_this_command() {
for verb in [
Verb::Start,
Verb::Stop,
Verb::Restart,
Verb::Delete,
Verb::Flush,
] {
assert!(
MonitorCommand.handles_button(&custom_id(verb, 1)),
"{verb:?}"
);
}
}
#[test]
fn nothing_printed_for_a_person_carries_a_dash() {
assert_no_dashes(&started_reply("1m"));
assert_no_dashes(&redrew_reply(4));
assert_no_dashes(&redrew_reply(0));
assert_no_dashes(nothing_to_redraw_message());
assert_no_dashes(already_running_message());
assert_no_dashes(stopped_message());
assert_no_dashes(not_running_message());
assert_no_dashes(no_channel_message());
assert_no_dashes_deep(&serde_json::to_value(MonitorCommand.data()).expect("json"));
}
}