use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use serenity::all::MessageId;
use shep_client::shep_core::protocol::ProcessInfo;
use tokio::sync::Mutex as AsyncMutex;
use crate::{
bot::{channel::Board, embed, monitor::refresh::Running},
error::Error,
shepherd::Live,
};
pub mod refresh;
pub mod watch;
pub struct Monitor {
messages: Mutex<HashMap<u32, Slot>>,
guards: Mutex<HashMap<u32, Arc<AsyncMutex<()>>>>,
task: Mutex<Option<Running>>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Slot {
message: Option<MessageId>,
forgets: u64,
}
impl Monitor {
#[must_use]
pub fn new() -> Self {
Self {
messages: Mutex::new(HashMap::new()),
guards: Mutex::new(HashMap::new()),
task: Mutex::new(None),
}
}
pub fn adopt(&self, found: HashMap<u32, MessageId>) {
let mut messages = self.messages.lock().expect("not poisoned");
for (sheep, message) in found {
messages
.entry(sheep)
.or_default()
.message
.get_or_insert(message);
}
}
fn forget_marks(&self) -> HashMap<u32, u64> {
self.messages
.lock()
.expect("not poisoned")
.iter()
.map(|(sheep, slot)| (*sheep, slot.forgets))
.collect()
}
fn forget_mark(&self, id: u32) -> u64 {
self.messages
.lock()
.expect("not poisoned")
.get(&id)
.map_or(0, |slot| slot.forgets)
}
fn drawn(&self) -> Vec<u32> {
self.messages
.lock()
.expect("not poisoned")
.iter()
.filter(|(_, slot)| slot.message.is_some())
.map(|(sheep, _)| *sheep)
.collect()
}
fn guard_for(&self, id: u32) -> Arc<AsyncMutex<()>> {
Arc::clone(
self.guards
.lock()
.expect("not poisoned")
.entry(id)
.or_default(),
)
}
pub async fn update_one<B: Board>(&self, board: &B, info: &ProcessInfo) -> Result<(), Error> {
self.draw(board, info, self.forget_mark(info.id)).await
}
async fn draw<B: Board>(&self, board: &B, info: &ProcessInfo, mark: u64) -> Result<(), Error> {
let guard = self.guard_for(info.id);
let _in_flight = guard.lock().await;
let slot = self
.messages
.lock()
.expect("not poisoned")
.get(&info.id)
.copied()
.unwrap_or_default();
let embed = embed::process_embed(info);
let buttons = embed::process_buttons(info);
match slot.message {
Some(message) => board.edit(message, embed, buttons).await,
None if slot.forgets != mark => Ok(()),
None => {
let message = board.post(embed, buttons).await?;
self.messages
.lock()
.expect("not poisoned")
.entry(info.id)
.or_default()
.message = Some(message);
Ok(())
}
}
}
pub async fn forget<B: Board>(&self, board: &B, id: u32) {
let guard = self.guard_for(id);
let _in_flight = guard.lock().await;
let existing = {
let mut messages = self.messages.lock().expect("not poisoned");
let slot = messages.entry(id).or_default();
slot.forgets += 1;
slot.message.take()
};
if let Some(message) = existing
&& let Err(err) = board.delete(message).await
{
eprintln!("shep-discord: {err}");
}
}
pub async fn update_all<B: Board>(
&self,
board: &B,
live: &Live,
ignore_dogs: bool,
) -> Result<usize, Error> {
let marks = self.forget_marks();
let already_drawn = self.drawn();
let roll = live.flock().await?;
let drawn: Vec<ProcessInfo> = if ignore_dogs {
roll.into_iter().filter(|info| info.dog.is_none()).collect()
} else {
roll
};
let mut redrawn = 0;
for info in &drawn {
let mark = marks.get(&info.id).copied().unwrap_or_default();
match self.draw(board, info, mark).await {
Ok(()) => redrawn += 1,
Err(err) => eprintln!("shep-discord: {err}"),
}
}
let present: Vec<u32> = drawn.iter().map(|info| info.id).collect();
self.sweep(board, &already_drawn, &present).await;
Ok(redrawn)
}
async fn sweep<B: Board>(&self, board: &B, already_drawn: &[u32], present: &[u32]) {
for id in departed(already_drawn, present) {
self.forget(board, id).await;
}
}
}
impl Default for Monitor {
fn default() -> Self {
Self::new()
}
}
fn departed(cached: &[u32], present: &[u32]) -> Vec<u32> {
let mut gone: Vec<u32> = cached
.iter()
.copied()
.filter(|id| !present.contains(id))
.collect();
gone.sort_unstable();
gone
}
#[cfg(test)]
mod tests {
use shep_client::shep_core::{
protocol::{Request, Response},
status::ProcStatus,
};
use crate::{
limits::{EMBED_TITLE_LIMIT, MESSAGE_CHARACTER_BUDGET},
test_support::{CountingChannel, dog_sample, test_live, worst_case_sample},
};
use super::*;
pub(super) fn info(id: u32, name: &str) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online).build()
}
#[tokio::test]
async fn two_concurrent_updates_for_one_sheep_post_once() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
let web = info(1, "web");
let (first, second) = tokio::join!(
monitor.update_one(&sink, &web),
monitor.update_one(&sink, &web),
);
first.expect("ok");
second.expect("ok");
assert_eq!(
sink.sends(),
1,
"the second call must join the first, not race it"
);
assert_eq!(sink.edits(), 1, "and then edit what the first posted");
}
#[tokio::test]
async fn two_sheep_each_get_their_own_message() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
let (web, api) = (info(1, "web"), info(2, "api"));
let (first, second) = tokio::join!(
monitor.update_one(&sink, &web),
monitor.update_one(&sink, &api),
);
first.expect("ok");
second.expect("ok");
assert_eq!(sink.sends(), 2);
assert_eq!(sink.edits(), 0);
}
#[tokio::test]
async fn a_deleted_sheep_loses_its_monitor_message() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
monitor
.update_one(&sink, &info(1, "web"))
.await
.expect("ok");
monitor.forget(&sink, 1).await;
assert_eq!(sink.deletes(), 1);
}
#[tokio::test]
async fn forgetting_an_undrawn_sheep_deletes_nothing() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
monitor.forget(&sink, 1).await;
assert_eq!(sink.deletes(), 0);
}
#[tokio::test]
async fn a_later_update_edits_rather_than_posting_again() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
monitor
.update_one(&sink, &info(1, "web"))
.await
.expect("ok");
monitor
.update_one(&sink, &info(1, "web"))
.await
.expect("ok");
assert_eq!((sink.sends(), sink.edits()), (1, 1));
}
#[tokio::test]
async fn an_adopted_message_is_edited_rather_than_reposted() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
monitor.adopt(HashMap::from([(1, MessageId::new(42))]));
monitor
.update_one(&sink, &info(1, "web"))
.await
.expect("ok");
assert_eq!((sink.sends(), sink.edits()), (0, 1));
}
#[tokio::test]
async fn a_delete_that_lands_after_the_snapshot_is_not_undone_by_the_refresh() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
let web = info(1, "web");
monitor.update_one(&sink, &web).await.expect("ok");
let marks = monitor.forget_marks();
monitor.forget(&sink, 1).await;
monitor
.draw(&sink, &web, marks.get(&1).copied().unwrap_or_default())
.await
.expect("ok");
assert_eq!(
(sink.sends(), sink.deletes()),
(1, 1),
"the deleted sheep must not be posted a second time"
);
}
#[tokio::test]
async fn a_sheep_drawn_from_a_current_snapshot_is_still_posted() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
let web = info(1, "web");
monitor.update_one(&sink, &web).await.expect("ok");
monitor.forget(&sink, 1).await;
monitor.update_one(&sink, &web).await.expect("ok");
assert_eq!((sink.sends(), sink.deletes()), (2, 1));
}
#[tokio::test]
async fn a_sheep_drawn_during_a_refresh_is_not_swept_away_by_it() {
let (live, mut fake) = test_live().await;
let monitor = Monitor::new();
let sink = CountingChannel::new();
fake.expect(Request::ListFlock)
.answer(Response::Flock(vec![info(1, "web")]));
let api = info(2, "api");
let (refreshed, drawn) = tokio::join!(
monitor.update_all(&sink, &live, false),
monitor.update_one(&sink, &api),
);
refreshed.expect("ok");
drawn.expect("ok");
assert_eq!(
sink.deletes(),
0,
"sheep 2 was drawn after this refresh read which sheep it had messages for, so the \
refresh knows nothing about it and must not sweep it away"
);
assert_eq!(
sink.sends(),
2,
"one message each: the sheep in the roll and the sheep drawn beside it"
);
}
#[test]
fn only_the_cached_sheep_missing_from_the_flock_are_departed() {
assert_eq!(departed(&[1, 2, 3], &[2]), vec![1, 3]);
assert_eq!(departed(&[1], &[1, 2]), Vec::<u32>::new());
assert_eq!(departed(&[], &[1]), Vec::<u32>::new());
}
#[tokio::test]
async fn a_refresh_draws_the_flock_and_deletes_what_left() {
let (live, mut fake) = test_live().await;
let monitor = Monitor::new();
let sink = CountingChannel::new();
monitor.adopt(HashMap::from([(9, MessageId::new(90))]));
fake.expect(Request::ListFlock)
.answer(Response::Flock(vec![info(1, "web")]));
let redrawn = monitor.update_all(&sink, &live, false).await.expect("ok");
assert_eq!(redrawn, 1, "one sheep drawn, and the count says so");
assert_eq!(sink.sends(), 1, "the one live sheep is drawn");
assert_eq!(
sink.deleted(),
vec![MessageId::new(90)],
"the sheep no longer in the flock loses its message"
);
}
#[tokio::test]
async fn a_dog_is_left_out_of_the_monitor_when_ignore_dogs_is_set() {
let (live, mut fake) = test_live().await;
let monitor = Monitor::new();
let sink = CountingChannel::new();
fake.expect(Request::ListFlock).answer(Response::Flock(vec![
info(1, "web"),
dog_sample("dogsbody"),
]));
monitor.update_all(&sink, &live, true).await.expect("ok");
assert_eq!(sink.sends(), 1, "the sheep is drawn and the dog is not");
let posted = sink.posted();
let json = serde_json::to_value(&posted[0]).expect("json");
assert_eq!(
json["title"].as_str().expect("title"),
"web",
"the one message in the channel is the sheep's, not the dog's"
);
}
#[tokio::test]
async fn a_sheep_named_past_the_title_limit_is_still_drawn() {
let monitor = Monitor::new();
let sink = CountingChannel::new();
let sheep = worst_case_sample(1);
assert!(
sheep.name.chars().count() > EMBED_TITLE_LIMIT,
"the input has to exceed the limit for this to prove anything"
);
monitor.update_one(&sink, &sheep).await.expect("ok");
let posted = sink.posted();
let json = serde_json::to_value(&posted[0]).expect("json");
let title = json["title"].as_str().expect("title");
assert_eq!(title.chars().count(), EMBED_TITLE_LIMIT);
assert!(
embed::embed_character_count(&sheep) <= MESSAGE_CHARACTER_BUDGET,
"one sheep's own embed has a whole message's budget to itself here"
);
}
}