Skip to main content

archivist_core/
core.rs

1//! Platform-neutral message IR (intermediate representation).
2//!
3//! Core commands return a `PlatformMessage` instead of a Discord `CreateReply`.
4//! Each platform adapter (Discord today; Telegram/Matrix/Slack/IRC/Mastodon/Web/
5//! CLI tomorrow) renders the IR natively. This is the seam that makes the bot
6//! core protocol-agnostic: `api.rs`, `cache.rs`, `store.rs`, `dispatch.rs` never
7//! import serenity/poise.
8
9use serde::{Deserialize, Serialize};
10
11/// A button/action in an action row. `id` is the opaque callback id the
12/// platform will echo back (Discord custom_id, Telegram callback_data, ...).
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Button {
15    /// Opaque callback id the platform echoes back (Discord `custom_id`,
16    /// Telegram `callback_data`, ...).
17    pub id: String,
18    /// Button label (may be empty when only an emoji is shown).
19    pub label: String,
20    /// Optional emoji rendered alongside the label.
21    pub emoji: Option<String>,
22    /// Visual style hint (rendered as colors/skins where supported).
23    pub style: ButtonStyle,
24    /// Whether the button is rendered disabled.
25    pub disabled: bool,
26}
27
28/// Visual style hint for a [`Button`].
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
30pub enum ButtonStyle {
31    /// Accent/primary style.
32    Primary,
33    /// Muted/secondary style.
34    Secondary,
35    /// Success/green style.
36    Success,
37    /// Danger/red style.
38    Danger,
39}
40
41impl Default for ButtonStyle {
42    fn default() -> Self {
43        Self::Primary
44    }
45}
46
47/// A select-menu option (future filter chips; unused on text-only platforms).
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SelectOption {
50    /// Label shown to the user.
51    pub label: String,
52    /// Opaque value sent back when selected.
53    pub value: String,
54}
55
56/// A row of interactive actions (buttons or a select menu).
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub enum ActionRow {
59    /// A row of buttons.
60    Buttons(Vec<Button>),
61    /// A select menu (future filter chips; unused on text-only platforms).
62    SelectMenu {
63        /// Placeholder text when nothing is selected.
64        placeholder: String,
65        /// Selectable options.
66        options: Vec<SelectOption>,
67    },
68}
69
70/// One rich item (renders as a Discord embed / Telegram HTML block / Matrix
71/// formatted message / Slack section).
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RichItem {
74    /// Optional title line (renders as embed title / bold header).
75    pub title: Option<String>,
76    /// Optional link target for the title.
77    pub url: Option<String>,
78    /// Body text (may contain Markdown; adapters sanitize as needed).
79    pub body: String,
80    /// Key/value field rows (renders as embed fields / definition list).
81    pub fields: Vec<(String, String)>,
82    /// Optional footer line.
83    pub footer: Option<String>,
84    /// Optional accent color as RGB (Discord embed color, etc.).
85    pub color: Option<u32>,
86}
87
88impl RichItem {
89    /// Create an item from a body string (all optional parts empty).
90    pub fn new(body: impl Into<String>) -> Self {
91        Self {
92            title: None,
93            url: None,
94            body: body.into(),
95            fields: Vec::new(),
96            footer: None,
97            color: None,
98        }
99    }
100
101    /// Set the title (builder).
102    pub fn title(mut self, t: impl Into<String>) -> Self {
103        self.title = Some(t.into());
104        self
105    }
106
107    /// Set the title link target (builder).
108    pub fn url(mut self, u: impl Into<String>) -> Self {
109        self.url = Some(u.into());
110        self
111    }
112
113    /// Append a key/value field row (builder).
114    pub fn field(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
115        self.fields.push((k.into(), v.into()));
116        self
117    }
118
119    /// Set the footer line (builder).
120    pub fn footer(mut self, f: impl Into<String>) -> Self {
121        self.footer = Some(f.into());
122        self
123    }
124
125    /// Set the accent color as RGB (builder).
126    pub fn color(mut self, c: u32) -> Self {
127        self.color = Some(c);
128        self
129    }
130}
131
132/// Platform-neutral reply produced by every core command.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub enum PlatformMessage {
135    /// Plain text (no formatting; safe everywhere).
136    Text(String),
137    /// Rich formatted message: optional header line + one or more items +
138    /// optional action rows (buttons / select menus).
139    Rich {
140        /// Optional header line shown above the items.
141        header: Option<String>,
142        /// One or more rich items.
143        items: Vec<RichItem>,
144        /// Optional action rows (buttons / select menus).
145        actions: Vec<ActionRow>,
146    },
147    /// A file upload (EPUB etc.). Data owned here; the adapter handles limits.
148    File {
149        /// Upload filename (e.g. `title.epub`).
150        filename: String,
151        /// File bytes.
152        data: Vec<u8>,
153        /// Optional caption shown with the upload.
154        caption: Option<String>,
155    },
156    /// Private to the invoking user (Discord ephemeral, Telegram reply-only,
157    /// Matrix private note, ...).
158    Ephemeral(Box<PlatformMessage>),
159}
160
161impl PlatformMessage {
162    /// Build a plain-text message.
163    pub fn text(s: impl Into<String>) -> Self {
164        Self::Text(s.into())
165    }
166
167    /// Wrap a message so the adapter delivers it privately to the invoking user.
168    pub fn ephemeral(msg: PlatformMessage) -> Self {
169        Self::Ephemeral(Box::new(msg))
170    }
171
172    /// Render a terse one-line description (for logs / "interpreted as" header).
173    pub fn describe(&self) -> String {
174        match self {
175            Self::Text(s) => format!("text: {}", truncate(s, 60)),
176            Self::Rich { header, items, .. } => format!(
177                "rich: {} item(s){}",
178                items.len(),
179                header
180                    .as_ref()
181                    .map(|h| format!(" header={}", truncate(h, 40)))
182                    .unwrap_or_default()
183            ),
184            Self::File { filename, .. } => format!("file: {filename}"),
185            Self::Ephemeral(inner) => format!("ephemeral({})", inner.describe()),
186        }
187    }
188}
189
190/// Build the standard `⏮ ⬅ ➡ ⏭` pagination action row (platform-neutral).
191pub fn pagination_row(
192    session_id: &str,
193    total: usize,
194    page_size: usize,
195    current_page: usize,
196) -> ActionRow {
197    let total_pages = if total == 0 { 1 } else { (total + page_size - 1) / page_size };
198    let pages = if total_pages == 0 { 1 } else { total_pages };
199    let s = session_id.to_string();
200    ActionRow::Buttons(vec![
201        Button {
202            id: format!("{s}:first"),
203            label: String::new(),
204            emoji: Some("⏮".into()),
205            style: ButtonStyle::Secondary,
206            disabled: current_page <= 1,
207        },
208        Button {
209            id: format!("{s}:prev"),
210            label: String::new(),
211            emoji: Some("⬅".into()),
212            style: ButtonStyle::Secondary,
213            disabled: current_page <= 1,
214        },
215        Button {
216            id: format!("{s}:next"),
217            label: String::new(),
218            emoji: Some("➡".into()),
219            style: ButtonStyle::Secondary,
220            disabled: current_page >= pages,
221        },
222        Button {
223            id: format!("{s}:last"),
224            label: String::new(),
225            emoji: Some("⏭".into()),
226            style: ButtonStyle::Secondary,
227            disabled: current_page >= pages,
228        },
229    ])
230}
231
232fn truncate(s: &str, max: usize) -> String {
233    if s.chars().count() <= max {
234        s.to_string()
235    } else {
236        let cut: String = s.chars().take(max).collect();
237        format!("{cut}…")
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn pagination_row_disables_at_first() {
247        let row = pagination_row("sess", 10, 5, 1);
248        match row {
249            ActionRow::Buttons(bs) => {
250                assert_eq!(bs.len(), 4);
251                assert!(bs[0].disabled && bs[1].disabled);
252                assert!(!bs[2].disabled && !bs[3].disabled);
253                assert_eq!(bs[2].id, "sess:next");
254            }
255            _ => panic!("expected buttons"),
256        }
257    }
258
259    #[test]
260    fn pagination_row_disables_at_last() {
261        let row = pagination_row("sess", 10, 5, 2);
262        match row {
263            ActionRow::Buttons(bs) => {
264                assert!(bs[2].disabled && bs[3].disabled);
265            }
266            _ => panic!("expected buttons"),
267        }
268    }
269
270    #[test]
271    fn rich_item_builds() {
272        let item = RichItem::new("hello")
273            .title("T")
274            .url("https://x")
275            .field("k", "v")
276            .footer("f")
277            .color(0x2ecc71);
278        assert_eq!(item.title.as_deref(), Some("T"));
279        assert_eq!(item.fields.len(), 1);
280        assert_eq!(item.footer.as_deref(), Some("f"));
281    }
282
283    #[test]
284    fn message_describe_truncates() {
285        let m = PlatformMessage::text("x".repeat(100));
286        assert!(m.describe().len() < 90);
287    }
288}