use serde::Serialize;
use crate::WebhookError;
pub fn json_code_block(value: &impl Serialize) -> Result<String, WebhookError> {
let pretty = serde_json::to_string_pretty(value)?;
Ok(format!("```json\n{pretty}\n```"))
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct AllowedMentions {
pub parse: Vec<AllowedMentionType>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub users: Vec<String>,
}
impl AllowedMentions {
pub fn none() -> Self {
Self {
parse: vec![],
roles: vec![],
users: vec![],
}
}
pub fn users(ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
parse: vec![],
roles: vec![],
users: ids.into_iter().map(Into::into).collect(),
}
}
pub fn all() -> Self {
Self {
parse: vec![
AllowedMentionType::Everyone,
AllowedMentionType::Roles,
AllowedMentionType::Users,
],
roles: vec![],
users: vec![],
}
}
}
#[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "lowercase")]
pub enum AllowedMentionType {
Roles,
Users,
Everyone,
}
pub mod flags {
pub const SUPPRESS_EMBEDS: u64 = 1 << 2; pub const SUPPRESS_NOTIFICATIONS: u64 = 1 << 12; pub const IS_COMPONENTS_V2: u64 = 1 << 15; }
#[derive(Serialize, Debug, Clone)]
pub struct EmbedField {
pub name: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub inline: Option<bool>,
}
#[derive(Serialize, Debug, Clone)]
pub struct EmbedFooter {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
#[derive(Serialize, Debug, Clone)]
pub struct EmbedAuthor {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
#[derive(Serialize, Debug, Clone)]
pub struct EmbedImage {
pub url: String,
}
#[derive(Serialize, Debug, Clone)]
pub struct EmbedThumbnail {
pub url: String,
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct Embed {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub footer: Option<EmbedFooter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<EmbedThumbnail>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<EmbedImage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<EmbedAuthor>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<EmbedField>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
impl Embed {
pub fn builder() -> EmbedBuilder {
EmbedBuilder::default()
}
}
#[derive(Default)]
pub struct EmbedBuilder {
inner: Embed,
}
impl EmbedBuilder {
pub fn title(mut self, title: impl Into<String>) -> Self {
self.inner.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.inner.description = Some(description.into());
self
}
pub fn url(mut self, url: impl Into<String>) -> Self {
self.inner.url = Some(url.into());
self
}
pub fn color(mut self, color: u32) -> Self {
self.inner.color = Some(color);
self
}
pub fn footer(mut self, text: impl Into<String>) -> Self {
self.inner.footer = Some(EmbedFooter {
text: text.into(),
icon_url: None,
});
self
}
pub fn footer_with_icon(
mut self,
text: impl Into<String>,
icon_url: impl Into<String>,
) -> Self {
self.inner.footer = Some(EmbedFooter {
text: text.into(),
icon_url: Some(icon_url.into()),
});
self
}
pub fn thumbnail(mut self, url: impl Into<String>) -> Self {
self.inner.thumbnail = Some(EmbedThumbnail { url: url.into() });
self
}
pub fn image(mut self, url: impl Into<String>) -> Self {
self.inner.image = Some(EmbedImage { url: url.into() });
self
}
pub fn author(mut self, name: impl Into<String>) -> Self {
self.inner.author = Some(EmbedAuthor {
name: name.into(),
url: None,
icon_url: None,
});
self
}
pub fn author_full(
mut self,
name: impl Into<String>,
url: Option<impl Into<String>>,
icon_url: Option<impl Into<String>>,
) -> Self {
self.inner.author = Some(EmbedAuthor {
name: name.into(),
url: url.map(Into::into),
icon_url: icon_url.map(Into::into),
});
self
}
pub fn field(
mut self,
name: impl Into<String>,
value: impl Into<String>,
inline: bool,
) -> Self {
self.inner.fields.push(EmbedField {
name: name.into(),
value: value.into(),
inline: Some(inline),
});
self
}
pub fn timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.inner.timestamp = Some(timestamp.into());
self
}
pub fn json_description(mut self, value: &impl Serialize) -> Result<Self, WebhookError> {
self.inner.description = Some(json_code_block(value)?);
Ok(self)
}
pub fn json_field(
mut self,
name: impl Into<String>,
value: &impl Serialize,
inline: bool,
) -> Result<Self, WebhookError> {
self.inner.fields.push(EmbedField {
name: name.into(),
value: json_code_block(value)?,
inline: Some(inline),
});
Ok(self)
}
pub fn build(self) -> Embed {
self.inner
}
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct WebhookMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tts: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub embeds: Vec<Embed>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_mentions: Option<AllowedMentions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_name: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub applied_tags: Vec<String>,
}
impl WebhookMessage {
pub fn builder() -> WebhookMessageBuilder {
WebhookMessageBuilder::default()
}
}
#[derive(Default)]
pub struct WebhookMessageBuilder {
inner: WebhookMessage,
}
impl WebhookMessageBuilder {
pub fn content(mut self, content: impl Into<String>) -> Self {
self.inner.content = Some(content.into());
self
}
pub fn username(mut self, username: impl Into<String>) -> Self {
self.inner.username = Some(username.into());
self
}
pub fn avatar_url(mut self, url: impl Into<String>) -> Self {
self.inner.avatar_url = Some(url.into());
self
}
pub fn tts(mut self, tts: bool) -> Self {
self.inner.tts = Some(tts);
self
}
pub fn allowed_mentions(mut self, allowed_mentions: AllowedMentions) -> Self {
self.inner.allowed_mentions = Some(allowed_mentions);
self
}
pub fn flag(mut self, flag: u64) -> Self {
self.inner.flags = Some(self.inner.flags.unwrap_or(0) | flag);
self
}
pub fn thread_name(mut self, name: impl Into<String>) -> Self {
self.inner.thread_name = Some(name.into());
self
}
pub fn applied_tag(mut self, tag_id: impl Into<String>) -> Self {
self.inner.applied_tags.push(tag_id.into());
self
}
pub fn json_content(mut self, value: &impl Serialize) -> Result<Self, WebhookError> {
let block = json_code_block(value)?;
self.inner.content = Some(match self.inner.content.take() {
Some(existing) => format!("{existing}\n{block}"),
None => block,
});
Ok(self)
}
pub fn embed(mut self, embed: Embed) -> Self {
self.inner.embeds.push(embed);
self
}
pub fn build(self) -> Result<WebhookMessage, WebhookError> {
if self.inner.content.is_none() && self.inner.embeds.is_empty() {
return Err(WebhookError::EmptyMessage);
}
Ok(self.inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_builder_is_rejected() {
let err = WebhookMessage::builder().build().unwrap_err();
assert!(matches!(err, WebhookError::EmptyMessage));
}
#[test]
fn content_only_message_builds() {
let msg = WebhookMessage::builder().content("Hello!").build().unwrap();
assert_eq!(msg.content.as_deref(), Some("Hello!"));
assert!(msg.embeds.is_empty());
}
#[test]
fn embed_only_message_builds() {
let embed = Embed::builder().title("Title").build();
let msg = WebhookMessage::builder().embed(embed).build().unwrap();
assert_eq!(msg.embeds.len(), 1);
assert!(msg.content.is_none());
}
#[test]
fn flags_are_ored_together() {
let msg = WebhookMessage::builder()
.content("test")
.flag(flags::SUPPRESS_EMBEDS)
.flag(flags::SUPPRESS_NOTIFICATIONS)
.build()
.unwrap();
assert_eq!(
msg.flags,
Some(flags::SUPPRESS_EMBEDS | flags::SUPPRESS_NOTIFICATIONS)
);
}
#[test]
fn json_content_appends_to_existing_content() {
#[derive(serde::Serialize)]
struct Val {
x: u32,
}
let msg = WebhookMessage::builder()
.content("Summary:")
.json_content(&Val { x: 42 })
.unwrap()
.build()
.unwrap();
let content = msg.content.unwrap();
assert!(
content.starts_with("Summary:\n"),
"should be separated by newline"
);
assert!(content.contains("\"x\""));
}
#[test]
fn json_content_works_without_prior_content() {
#[derive(serde::Serialize)]
struct Val {
ok: bool,
}
let msg = WebhookMessage::builder()
.json_content(&Val { ok: true })
.unwrap()
.build()
.unwrap();
let content = msg.content.unwrap();
assert!(content.starts_with("```json\n"));
}
#[test]
fn embed_builder_stores_fields() {
let embed = Embed::builder()
.title("Alert")
.description("Something happened")
.color(0xFF0000)
.field("Key", "Value", true)
.build();
assert_eq!(embed.title.as_deref(), Some("Alert"));
assert_eq!(embed.description.as_deref(), Some("Something happened"));
assert_eq!(embed.color, Some(0xFF0000));
assert_eq!(embed.fields.len(), 1);
assert_eq!(embed.fields[0].inline, Some(true));
}
#[test]
fn json_code_block_format() {
#[derive(serde::Serialize)]
struct Payload {
ok: bool,
}
let block = json_code_block(&Payload { ok: true }).unwrap();
assert!(block.starts_with("```json\n"), "must open with json fence");
assert!(block.ends_with("\n```"), "must close with fence");
assert!(block.contains("\"ok\": true"));
}
#[test]
fn allowed_mentions_none_serializes_empty_parse() {
let json = serde_json::to_string(&AllowedMentions::none()).unwrap();
assert!(json.contains("\"parse\":[]"));
}
#[test]
fn allowed_mentions_users_contains_ids() {
let am = AllowedMentions::users(["123", "456"]);
let json = serde_json::to_string(&am).unwrap();
assert!(json.contains("\"123\""));
assert!(json.contains("\"456\""));
}
#[test]
fn discord_message_macro_content_only() {
let msg = crate::discord_message!(content = "Hi").unwrap();
assert_eq!(msg.content.as_deref(), Some("Hi"));
}
#[test]
fn discord_message_macro_multiple_fields() {
let msg = crate::discord_message!(content = "Hello", username = "Bot",).unwrap();
assert_eq!(msg.content.as_deref(), Some("Hello"));
assert_eq!(msg.username.as_deref(), Some("Bot"));
}
#[test]
fn embed_macro_basic() {
let embed = crate::embed!(title = "Test", color = 0xFF0000u32,);
assert_eq!(embed.title.as_deref(), Some("Test"));
assert_eq!(embed.color, Some(0xFF0000));
}
}