use std::collections::HashMap;
use serenity::all::{
ActionRowComponent, ButtonKind, ChannelId, CreateActionRow, CreateEmbed, CreateMessage,
EditMessage, GetMessages, Http, MessageId, UserId,
};
use crate::{bot::embed::parse_custom_id, error::Error};
pub const RECENT_MESSAGE_LIMIT: u8 = 100;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Posted {
pub id: MessageId,
pub author: UserId,
pub custom_ids: Vec<String>,
}
pub trait Board {
fn me(&self) -> impl Future<Output = Result<UserId, Error>> + Send;
fn post(
&self,
embed: CreateEmbed,
buttons: CreateActionRow,
) -> impl Future<Output = Result<MessageId, Error>> + Send;
fn edit(
&self,
id: MessageId,
embed: CreateEmbed,
buttons: CreateActionRow,
) -> impl Future<Output = Result<(), Error>> + Send;
fn delete(&self, id: MessageId) -> impl Future<Output = Result<(), Error>> + Send;
fn recent(
&self,
before: Option<MessageId>,
) -> impl Future<Output = Result<Vec<Posted>, Error>> + Send;
}
#[must_use]
pub fn sheep_id_of(message: &Posted) -> Option<u32> {
message
.custom_ids
.iter()
.find_map(|raw| parse_custom_id(raw).map(|(_verb, id)| id))
}
pub async fn rediscover<B: Board>(
board: &B,
me: UserId,
wanted: &[u32],
) -> Result<HashMap<u32, MessageId>, Error> {
let mut found: HashMap<u32, MessageId> = HashMap::new();
let mut before: Option<MessageId> = None;
loop {
let page = board.recent(before).await?;
let full_page = page.len() >= usize::from(RECENT_MESSAGE_LIMIT);
let oldest = page.last().map(|message| message.id);
for message in page {
if message.author != me {
continue;
}
if let Some(sheep) = sheep_id_of(&message) {
found.entry(sheep).or_insert(message.id);
}
}
let all_found = wanted.iter().all(|sheep| found.contains_key(sheep));
let Some(oldest) = oldest else {
return Ok(found);
};
if all_found || !full_page {
return Ok(found);
}
before = Some(oldest);
}
}
pub struct Live {
http: Http,
channel: ChannelId,
}
impl Live {
#[must_use]
pub fn new(token: &str, channel: u64) -> Self {
Self {
http: Http::new(token),
channel: ChannelId::new(channel),
}
}
#[cfg(test)]
#[must_use]
pub fn channel(&self) -> ChannelId {
self.channel
}
}
impl Board for Live {
async fn me(&self) -> Result<UserId, Error> {
Ok(self.http.get_current_user().await?.id)
}
async fn post(&self, embed: CreateEmbed, buttons: CreateActionRow) -> Result<MessageId, Error> {
let message = CreateMessage::new().embed(embed).components(vec![buttons]);
Ok(self.channel.send_message(&self.http, message).await?.id)
}
async fn edit(
&self,
id: MessageId,
embed: CreateEmbed,
buttons: CreateActionRow,
) -> Result<(), Error> {
let message = EditMessage::new().embed(embed).components(vec![buttons]);
self.channel.edit_message(&self.http, id, message).await?;
Ok(())
}
async fn delete(&self, id: MessageId) -> Result<(), Error> {
self.channel.delete_message(&self.http, id).await?;
Ok(())
}
async fn recent(&self, before: Option<MessageId>) -> Result<Vec<Posted>, Error> {
let mut query = GetMessages::new().limit(RECENT_MESSAGE_LIMIT);
if let Some(before) = before {
query = query.before(before);
}
let messages = self.channel.messages(&self.http, query).await?;
Ok(messages
.into_iter()
.map(|message| Posted {
id: message.id,
author: message.author.id,
custom_ids: message
.components
.iter()
.flat_map(|row| &row.components)
.filter_map(|component| match component {
ActionRowComponent::Button(button) => match &button.data {
ButtonKind::NonLink { custom_id, .. } => Some(custom_id.clone()),
ButtonKind::Link { .. } | ButtonKind::Premium { .. } => None,
},
ActionRowComponent::SelectMenu(_)
| ActionRowComponent::InputText(_)
| _ => None,
})
.collect(),
})
.collect())
}
}
#[cfg(test)]
mod tests {
use crate::test_support::CountingChannel;
use super::*;
fn channel_of(count: u32) -> CountingChannel {
let board = CountingChannel::new();
board.preload(
(1..=count)
.rev()
.map(|sheep| message_from(u64::from(sheep), ME, &[format!("restart:{sheep}")]))
.collect(),
);
board
}
fn message_from(id: u64, author: u64, custom_ids: &[impl AsRef<str>]) -> Posted {
Posted {
id: MessageId::new(id),
author: UserId::new(author),
custom_ids: custom_ids
.iter()
.map(|raw| raw.as_ref().to_owned())
.collect(),
}
}
fn message_with_buttons(custom_ids: &[&str]) -> Posted {
message_from(1, ME, custom_ids)
}
const ME: u64 = crate::test_support::BOT_USER;
#[test]
fn a_monitor_message_is_recognised_by_the_buttons_it_carries() {
let message = message_with_buttons(&["restart:7", "stop:7", "delete:7"]);
assert_eq!(sheep_id_of(&message), Some(7));
}
#[test]
fn a_message_this_bot_did_not_write_is_not_adopted() {
assert_eq!(sheep_id_of(&message_with_buttons(&[])), None);
assert_eq!(
sheep_id_of(&message_with_buttons(&["something:else"])),
None
);
}
#[tokio::test]
async fn a_flock_spanning_more_than_one_page_is_adopted_whole() {
let sheep = u32::from(RECENT_MESSAGE_LIMIT) + 50;
let board = channel_of(sheep);
let wanted: Vec<u32> = (1..=sheep).collect();
let found = rediscover(&board, UserId::new(ME), &wanted)
.await
.expect("ok");
assert_eq!(found.len() as u32, sheep, "every sheep is adopted");
assert_eq!(found[&1], MessageId::new(1), "the oldest page included");
assert_eq!(board.pages(), 2, "150 sheep is two pages of 100");
}
#[tokio::test]
async fn paging_stops_once_every_sheep_in_the_flock_is_found() {
let board = channel_of(u32::from(RECENT_MESSAGE_LIMIT) + 50);
let found = rediscover(&board, UserId::new(ME), &[150, 149])
.await
.expect("ok");
assert_eq!(board.pages(), 1, "both sheep are on the newest page");
assert_eq!(
found.len(),
usize::from(RECENT_MESSAGE_LIMIT),
"the page it did read is adopted whole, orphans included, so the first refresh can \
sweep the sheep that are no longer in the flock"
);
}
#[tokio::test]
async fn an_unknown_flock_reads_one_page_rather_than_the_whole_channel() {
let board = channel_of(u32::from(RECENT_MESSAGE_LIMIT) + 50);
let found = rediscover(&board, UserId::new(ME), &[]).await.expect("ok");
assert_eq!(board.pages(), 1);
assert_eq!(found.len(), usize::from(RECENT_MESSAGE_LIMIT));
}
#[test]
fn a_button_from_the_old_name_keyed_scheme_is_not_adopted() {
assert_eq!(sheep_id_of(&message_with_buttons(&["restart:web"])), None);
}
#[tokio::test]
async fn rediscovery_adopts_this_bots_own_messages_and_nobody_elses() {
let board = CountingChannel::new();
board.preload(vec![
message_from(10, ME, &["restart:1"]),
message_from(11, 999, &["restart:2"]),
message_from(12, ME, &["hello"]),
]);
let found = rediscover(&board, UserId::new(ME), &[1]).await.expect("ok");
assert_eq!(
found,
HashMap::from([(1, MessageId::new(10))]),
"only this bot's own monitor messages are adopted"
);
}
#[tokio::test]
async fn a_duplicate_from_an_older_run_keeps_the_newest_message() {
let board = CountingChannel::new();
board.preload(vec![
message_from(20, ME, &["restart:1"]),
message_from(19, ME, &["restart:1"]),
]);
let found = rediscover(&board, UserId::new(ME), &[1]).await.expect("ok");
assert_eq!(found, HashMap::from([(1, MessageId::new(20))]));
}
}