use core::fmt;
use serenity::all::{ChannelId, CreateEmbed, CreateMessage, Http};
use crate::stream::{Sink, SinkError, pack::Chunk};
struct Redacted;
impl fmt::Debug for Redacted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<redacted>")
}
}
pub trait Rest {
async fn send_message(
&self,
channel: ChannelId,
message: CreateMessage,
) -> Result<(), serenity::Error>;
}
impl Rest for Http {
async fn send_message(
&self,
channel: ChannelId,
message: CreateMessage,
) -> Result<(), serenity::Error> {
channel.send_message(self, message).await.map(|_message| ())
}
}
pub struct DiscordSink<R> {
rest: R,
}
impl<R> fmt::Debug for DiscordSink<R> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DiscordSink")
.field("rest", &Redacted)
.finish()
}
}
impl<R: Rest> DiscordSink<R> {
fn over(rest: R) -> Self {
Self { rest }
}
}
impl DiscordSink<Http> {
#[must_use]
pub fn new(token: &str) -> Self {
Self::over(Http::new(token))
}
}
impl<R: Rest> Sink for DiscordSink<R> {
async fn send(&self, channel: u64, chunks: Vec<Chunk>) -> Result<(), SinkError> {
if chunks.is_empty() {
return Err(SinkError::BadRequest);
}
let mut message = CreateMessage::new();
for chunk in chunks {
message = message.add_embed(
CreateEmbed::new()
.title(chunk.title)
.description(chunk.description),
);
}
self.rest
.send_message(ChannelId::new(channel), message)
.await
.map_err(|_err| SinkError::BadRequest)
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use serenity::{
all::{ChannelId, CreateMessage},
model::ModelError,
};
use crate::{
limits::{EMBED_DESCRIPTION_LIMIT, EMBED_MAX_COUNT, EMBED_TITLE_LIMIT},
stream::{Sink, SinkError, pack::Chunk},
};
use super::{DiscordSink, Rest};
#[derive(Default)]
struct Recording {
sent: Mutex<Vec<(ChannelId, CreateMessage)>>,
attempts: Mutex<usize>,
refuse: Mutex<Option<serenity::Error>>,
}
impl Recording {
fn new() -> Self {
Self::default()
}
fn refusing(error: serenity::Error) -> Self {
Self {
refuse: Mutex::new(Some(error)),
..Self::default()
}
}
fn sent(&self) -> Vec<(ChannelId, serde_json::Value)> {
self.sent
.lock()
.expect("not poisoned")
.iter()
.map(|(channel, message)| (*channel, serde_json::to_value(message).expect("json")))
.collect()
}
fn attempts(&self) -> usize {
*self.attempts.lock().expect("not poisoned")
}
}
impl Rest for Recording {
async fn send_message(
&self,
channel: ChannelId,
message: CreateMessage,
) -> Result<(), serenity::Error> {
*self.attempts.lock().expect("not poisoned") += 1;
if let Some(error) = self.refuse.lock().expect("not poisoned").take() {
return Err(error);
}
self.sent
.lock()
.expect("not poisoned")
.push((channel, message));
Ok(())
}
}
fn chunk(n: usize) -> Chunk {
Chunk {
title: format!("title-{n}"),
description: format!("description-{n}"),
}
}
fn embeds_of(rest: &Recording) -> Vec<serde_json::Value> {
let sent = rest.sent();
assert_eq!(sent.len(), 1, "one call sends one message");
sent[0].1["embeds"]
.as_array()
.expect("embeds is an array")
.clone()
}
#[tokio::test]
async fn a_batch_at_the_embed_count_cap_is_still_one_message() {
let rest = Recording::new();
let sink = DiscordSink::over(rest);
sink.send(7, (1..=EMBED_MAX_COUNT).map(chunk).collect())
.await
.expect("sent");
assert_eq!(sink.rest.sent().len(), 1, "one message, not one per chunk");
assert_eq!(sink.rest.attempts(), 1);
assert_eq!(embeds_of(&sink.rest).len(), EMBED_MAX_COUNT);
}
#[tokio::test]
async fn each_embed_carries_its_own_chunks_title_and_description() {
let rest = Recording::new();
let sink = DiscordSink::over(rest);
sink.send(7, vec![chunk(1), chunk(2), chunk(3)])
.await
.expect("sent");
let embeds = embeds_of(&sink.rest);
for (at, embed) in embeds.iter().enumerate() {
let n = at + 1;
assert_eq!(embed["title"], format!("title-{n}"));
assert_eq!(embed["description"], format!("description-{n}"));
}
}
#[tokio::test]
async fn a_batch_goes_to_the_channel_the_caller_named() {
let rest = Recording::new();
let sink = DiscordSink::over(rest);
sink.send(11, vec![chunk(1)]).await.expect("sent");
sink.send(22, vec![chunk(2)]).await.expect("sent");
let channels: Vec<ChannelId> = sink
.rest
.sent()
.into_iter()
.map(|(channel, _message)| channel)
.collect();
assert_eq!(channels, vec![ChannelId::new(11), ChannelId::new(22)]);
}
#[tokio::test]
async fn every_refusal_is_reported_as_a_bad_request() {
for error in [
serenity::Error::Other("expected value"),
serenity::Error::Model(ModelError::MessageTooLong(20)),
serenity::Error::Url("not a url".to_owned()),
] {
let sink = DiscordSink::over(Recording::refusing(error));
let outcome = sink.send(7, vec![chunk(1)]).await;
assert_eq!(outcome, Err(SinkError::BadRequest));
assert_eq!(sink.rest.attempts(), 1, "it tried, and was refused");
}
}
#[tokio::test]
async fn an_empty_batch_is_refused_without_a_round_trip() {
let rest = Recording::new();
let sink = DiscordSink::over(rest);
let outcome = sink.send(7, Vec::new()).await;
assert_eq!(outcome, Err(SinkError::BadRequest));
assert_eq!(sink.rest.attempts(), 0, "nothing was sent to be refused");
}
#[tokio::test]
async fn a_batch_reaches_discord_exactly_as_it_was_packed() {
let wide = Chunk {
title: "t".repeat(EMBED_TITLE_LIMIT),
description: "d".repeat(EMBED_DESCRIPTION_LIMIT),
};
let rest = Recording::new();
let sink = DiscordSink::over(rest);
sink.send(7, vec![wide.clone(), wide.clone()])
.await
.expect("sent");
let embeds = embeds_of(&sink.rest);
assert_eq!(embeds.len(), 2, "not re-split, and not dropped");
for embed in &embeds {
assert_eq!(embed["title"], wide.title);
assert_eq!(embed["description"], wide.description);
}
}
#[test]
fn the_token_never_reaches_a_debug_line() {
let sink = DiscordSink::new("MTIzNDU2Nzg5.GaBcDe.ThisIsNotARealToken");
let rendered = format!("{sink:?}");
assert_eq!(rendered, "DiscordSink { rest: <redacted> }");
assert!(!rendered.contains("ThisIsNotARealToken"), "{rendered}");
}
}