use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Button {
pub id: String,
pub label: String,
pub emoji: Option<String>,
pub style: ButtonStyle,
pub disabled: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ButtonStyle {
Primary,
Secondary,
Success,
Danger,
}
impl Default for ButtonStyle {
fn default() -> Self {
Self::Primary
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectOption {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ActionRow {
Buttons(Vec<Button>),
SelectMenu {
placeholder: String,
options: Vec<SelectOption>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichItem {
pub title: Option<String>,
pub url: Option<String>,
pub body: String,
pub fields: Vec<(String, String)>,
pub footer: Option<String>,
pub color: Option<u32>,
}
impl RichItem {
pub fn new(body: impl Into<String>) -> Self {
Self {
title: None,
url: None,
body: body.into(),
fields: Vec::new(),
footer: None,
color: None,
}
}
pub fn title(mut self, t: impl Into<String>) -> Self {
self.title = Some(t.into());
self
}
pub fn url(mut self, u: impl Into<String>) -> Self {
self.url = Some(u.into());
self
}
pub fn field(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.fields.push((k.into(), v.into()));
self
}
pub fn footer(mut self, f: impl Into<String>) -> Self {
self.footer = Some(f.into());
self
}
pub fn color(mut self, c: u32) -> Self {
self.color = Some(c);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlatformMessage {
Text(String),
Rich {
header: Option<String>,
items: Vec<RichItem>,
actions: Vec<ActionRow>,
},
File {
filename: String,
data: Vec<u8>,
caption: Option<String>,
},
Ephemeral(Box<PlatformMessage>),
}
impl PlatformMessage {
pub fn text(s: impl Into<String>) -> Self {
Self::Text(s.into())
}
pub fn ephemeral(msg: PlatformMessage) -> Self {
Self::Ephemeral(Box::new(msg))
}
pub fn describe(&self) -> String {
match self {
Self::Text(s) => format!("text: {}", truncate(s, 60)),
Self::Rich { header, items, .. } => format!(
"rich: {} item(s){}",
items.len(),
header
.as_ref()
.map(|h| format!(" header={}", truncate(h, 40)))
.unwrap_or_default()
),
Self::File { filename, .. } => format!("file: {filename}"),
Self::Ephemeral(inner) => format!("ephemeral({})", inner.describe()),
}
}
}
pub fn pagination_row(
session_id: &str,
total: usize,
page_size: usize,
current_page: usize,
) -> ActionRow {
let total_pages = if total == 0 { 1 } else { (total + page_size - 1) / page_size };
let pages = if total_pages == 0 { 1 } else { total_pages };
let s = session_id.to_string();
ActionRow::Buttons(vec![
Button {
id: format!("{s}:first"),
label: String::new(),
emoji: Some("⏮".into()),
style: ButtonStyle::Secondary,
disabled: current_page <= 1,
},
Button {
id: format!("{s}:prev"),
label: String::new(),
emoji: Some("⬅".into()),
style: ButtonStyle::Secondary,
disabled: current_page <= 1,
},
Button {
id: format!("{s}:next"),
label: String::new(),
emoji: Some("➡".into()),
style: ButtonStyle::Secondary,
disabled: current_page >= pages,
},
Button {
id: format!("{s}:last"),
label: String::new(),
emoji: Some("⏭".into()),
style: ButtonStyle::Secondary,
disabled: current_page >= pages,
},
])
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let cut: String = s.chars().take(max).collect();
format!("{cut}…")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pagination_row_disables_at_first() {
let row = pagination_row("sess", 10, 5, 1);
match row {
ActionRow::Buttons(bs) => {
assert_eq!(bs.len(), 4);
assert!(bs[0].disabled && bs[1].disabled);
assert!(!bs[2].disabled && !bs[3].disabled);
assert_eq!(bs[2].id, "sess:next");
}
_ => panic!("expected buttons"),
}
}
#[test]
fn pagination_row_disables_at_last() {
let row = pagination_row("sess", 10, 5, 2);
match row {
ActionRow::Buttons(bs) => {
assert!(bs[2].disabled && bs[3].disabled);
}
_ => panic!("expected buttons"),
}
}
#[test]
fn rich_item_builds() {
let item = RichItem::new("hello")
.title("T")
.url("https://x")
.field("k", "v")
.footer("f")
.color(0x2ecc71);
assert_eq!(item.title.as_deref(), Some("T"));
assert_eq!(item.fields.len(), 1);
assert_eq!(item.footer.as_deref(), Some("f"));
}
#[test]
fn message_describe_truncates() {
let m = PlatformMessage::text("x".repeat(100));
assert!(m.describe().len() < 90);
}
}