Skip to main content

okbot_core/
prompt.rs

1//! Prompts: a message with buttons, and optionally a form behind a button, that asks a person
2//! for a decision or for input the agent must not see (ADR 0007).
3//!
4//! An MCP server asks the runtime for a prompt; the channel shows it natively (Discord buttons
5//! and modals) and reports the answer with [`InboundSink::answer`](crate::InboundSink::answer).
6//! Answers never become chat messages and never reach the agent.
7//!
8//! In interactive replies (ADR 0015) the agent shows a prompt too: cards, buttons, a menu of
9//! options or a form. Those answers do become the clicker's next message.
10
11use std::collections::BTreeMap;
12
13use crate::id::{ConversationKey, UserId, UserRef};
14use crate::message::SentRef;
15use crate::secret::Secret;
16
17/// What a button looks like.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
19#[serde(rename_all = "lowercase")]
20#[non_exhaustive]
21pub enum ButtonStyle {
22    /// The main action.
23    #[default]
24    Primary,
25    /// A neutral action (cancel).
26    Secondary,
27    /// A safe, positive action.
28    Success,
29    /// A destructive or risky action.
30    Danger,
31}
32
33/// How much text a form field takes.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
35#[serde(rename_all = "lowercase")]
36#[non_exhaustive]
37pub enum FieldStyle {
38    /// One line.
39    #[default]
40    Short,
41    /// Several lines (keys, certificates).
42    Paragraph,
43}
44
45/// One text field of a [`Form`].
46#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
47pub struct FormField {
48    /// Key of the value in the answer.
49    pub id: String,
50    /// Label shown above the field.
51    pub label: String,
52    /// One line or several.
53    #[serde(default)]
54    pub style: FieldStyle,
55    /// The person must fill it in.
56    #[serde(default = "yes")]
57    pub required: bool,
58    /// Hint shown in the empty field.
59    #[serde(default)]
60    pub placeholder: Option<String>,
61    /// Minimum length.
62    #[serde(default)]
63    pub min_len: Option<u16>,
64    /// Maximum length.
65    #[serde(default)]
66    pub max_len: Option<u16>,
67    /// Filled in when the form opens.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub value: Option<String>,
70}
71
72fn yes() -> bool {
73    true
74}
75
76/// A small form (a Discord modal: up to five text fields).
77#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
78pub struct Form {
79    /// Title of the form.
80    pub title: String,
81    /// Fields, in order.
82    pub fields: Vec<FormField>,
83}
84
85/// A button under the prompt message. With a [`Form`], pressing it opens the form and the
86/// answer carries the values.
87#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
88pub struct PromptButton {
89    /// Key of the button in the answer.
90    pub id: String,
91    /// Text on the button.
92    pub label: String,
93    /// Look.
94    #[serde(default)]
95    pub style: ButtonStyle,
96    /// A form to fill in before the answer is sent.
97    #[serde(default)]
98    pub form: Option<Form>,
99    /// A link button: opens this `https` URL and sends no answer (no form).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub url: Option<String>,
102}
103
104/// What a [`Card`] means; sets its colour (or a leading emoji) where the platform has one.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
106#[serde(rename_all = "lowercase")]
107#[non_exhaustive]
108pub enum Tone {
109    /// No particular meaning: the bot's own colour.
110    #[default]
111    Neutral,
112    /// Information.
113    Info,
114    /// Something went well.
115    Success,
116    /// Needs attention.
117    Warning,
118    /// Something failed.
119    Error,
120}
121
122impl Tone {
123    /// An emoji for platforms without coloured cards (empty for [`Tone::Neutral`]).
124    pub fn emoji(self) -> &'static str {
125        match self {
126            Self::Neutral => "",
127            Self::Info => "ℹ️",
128            Self::Success => "✅",
129            Self::Warning => "⚠️",
130            Self::Error => "❌",
131        }
132    }
133
134    /// An RGB colour for platforms with coloured cards.
135    pub fn color(self) -> u32 {
136        match self {
137            Self::Neutral => 0x0058_65F2,
138            Self::Info => 0x003B_82F6,
139            Self::Success => 0x0022_C55E,
140            Self::Warning => 0x00EA_B308,
141            Self::Error => 0x00EF_4444,
142        }
143    }
144}
145
146/// A block of structured content in a prompt: a title and text (ADR 0015).
147#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
148pub struct Card {
149    /// Plain text title.
150    #[serde(default)]
151    pub title: Option<String>,
152    /// Meaning and colour.
153    #[serde(default)]
154    pub tone: Tone,
155    /// Body, already rendered for the channel (like [`Prompt::text`]).
156    pub text: String,
157    /// A chart drawn as a picture, shown with the card (ADR 0016). Only on channels with
158    /// [`UiCaps::images`](crate::UiCaps::images); at most one per prompt.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub image: Option<crate::Image>,
161}
162
163/// One entry of a [`Select`].
164#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
165pub struct SelectOption {
166    /// Key of the option in the answer ([`PromptAnswer::button`]).
167    pub id: String,
168    /// Text shown.
169    pub label: String,
170    /// A second line, where the platform shows one.
171    #[serde(default)]
172    pub description: Option<String>,
173}
174
175/// A menu to pick one option from; the answer carries the option's id as its button.
176#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
177pub struct Select {
178    /// Shown while nothing is picked.
179    pub placeholder: String,
180    /// Two to [`MAX_OPTIONS`] options.
181    pub options: Vec<SelectOption>,
182}
183
184/// A message with buttons, shown in one conversation. Interactive replies (ADR 0015) add cards
185/// and a menu; a prompt with cards may have no text and nothing to answer.
186#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
187pub struct Prompt {
188    /// Unique id, chosen by the runtime.
189    pub id: String,
190    /// The message, already rendered for the channel.
191    pub text: String,
192    /// Up to five buttons.
193    pub buttons: Vec<PromptButton>,
194    /// Who may answer; anyone in the conversation when empty. Others are told it is not theirs.
195    pub allowed: Vec<UserId>,
196    /// Cards shown after the text.
197    #[serde(default)]
198    pub cards: Vec<Card>,
199    /// A menu shown before the buttons.
200    #[serde(default)]
201    pub select: Option<Select>,
202}
203
204/// Limits every channel can honour (Discord's are the tightest).
205pub const MAX_BUTTONS: usize = 5;
206/// Fields per form.
207pub const MAX_FIELDS: usize = 5;
208/// Cards per prompt.
209pub const MAX_CARDS: usize = 5;
210/// Options per menu.
211pub const MAX_OPTIONS: usize = 25;
212/// Characters of all the text of a prompt (Discord counts 4000 over all its text).
213pub const MAX_PROMPT_CHARS: usize = 3800;
214
215impl Prompt {
216    /// Checks the limits and ids; the message says what is wrong.
217    pub fn validate(&self) -> Result<(), String> {
218        if self.buttons.len() > MAX_BUTTONS {
219            return Err(format!("a prompt has at most {MAX_BUTTONS} buttons"));
220        }
221        if self.buttons.is_empty() && self.select.is_none() && self.cards.is_empty() {
222            return Err(format!("a prompt needs 1 to {MAX_BUTTONS} buttons"));
223        }
224        if self.text.trim().is_empty() && self.cards.is_empty() {
225            return Err("a prompt needs text".into());
226        }
227        self.validate_cards()?;
228        let mut seen = std::collections::HashSet::new();
229        self.validate_select(&mut seen)?;
230        for b in &self.buttons {
231            if !id_ok(&b.id, 32) || !seen.insert(b.id.as_str()) {
232                return Err(format!(
233                    "button id {:?}: unique, 1-32 of a-z A-Z 0-9 - _",
234                    b.id
235                ));
236            }
237            if b.label.trim().is_empty() || b.label.chars().count() > 80 {
238                return Err(format!("button {:?}: label of 1-80 characters", b.id));
239            }
240            if let Some(url) = &b.url {
241                if b.form.is_some() {
242                    return Err(format!("button {:?}: a link has no form", b.id));
243                }
244                if !url.starts_with("https://")
245                    || url.len() > 512
246                    || url.contains(char::is_whitespace)
247                {
248                    return Err(format!("button {:?}: links must be https URLs", b.id));
249                }
250            }
251            if let Some(form) = &b.form {
252                if form.fields.is_empty() || form.fields.len() > MAX_FIELDS {
253                    return Err(format!("form of {:?}: 1 to {MAX_FIELDS} fields", b.id));
254                }
255                if form.title.trim().is_empty() || form.title.chars().count() > 45 {
256                    return Err(format!("form of {:?}: title of 1-45 characters", b.id));
257                }
258                let mut fields = std::collections::HashSet::new();
259                for f in &form.fields {
260                    if !id_ok(&f.id, 100) || !fields.insert(f.id.as_str()) {
261                        return Err(format!(
262                            "field id {:?}: unique, 1-100 of a-z A-Z 0-9 - _",
263                            f.id
264                        ));
265                    }
266                    if f.label.trim().is_empty() || f.label.chars().count() > 45 {
267                        return Err(format!("field {:?}: label of 1-45 characters", f.id));
268                    }
269                    let max = f.max_len.map_or(4000, |m| m.min(4000));
270                    if f.value
271                        .as_ref()
272                        .is_some_and(|v| v.chars().count() > usize::from(max))
273                    {
274                        return Err(format!(
275                            "field {:?}: value of at most {max} characters",
276                            f.id
277                        ));
278                    }
279                }
280            }
281        }
282        Ok(())
283    }
284
285    fn validate_cards(&self) -> Result<(), String> {
286        if self.cards.len() > MAX_CARDS {
287            return Err(format!("a prompt has at most {MAX_CARDS} cards"));
288        }
289        let mut chars = self.text.chars().count();
290        for c in &self.cards {
291            let title = c.title.as_deref().unwrap_or_default();
292            if title.chars().count() > 100 {
293                return Err("card title: at most 100 characters".into());
294            }
295            if c.text.trim().is_empty() && title.trim().is_empty() {
296                return Err("a card needs a title or text".into());
297            }
298            // Discord counts bytes per text block.
299            if c.text.len() > 1900 {
300                return Err("card text: at most about 1900 bytes".into());
301            }
302            chars += title.chars().count() + c.text.chars().count();
303        }
304        if chars > MAX_PROMPT_CHARS {
305            return Err(format!(
306                "a prompt has at most {MAX_PROMPT_CHARS} characters of text"
307            ));
308        }
309        Ok(())
310    }
311
312    fn validate_select<'a>(
313        &'a self,
314        seen: &mut std::collections::HashSet<&'a str>,
315    ) -> Result<(), String> {
316        if let Some(select) = &self.select {
317            if select.options.len() < 2 || select.options.len() > MAX_OPTIONS {
318                return Err(format!("a menu needs 2 to {MAX_OPTIONS} options"));
319            }
320            if select.placeholder.chars().count() > 150 {
321                return Err("menu placeholder: at most 150 characters".into());
322            }
323            for o in &select.options {
324                if !id_ok(&o.id, 32) || !seen.insert(o.id.as_str()) {
325                    return Err(format!(
326                        "option id {:?}: unique, 1-32 of a-z A-Z 0-9 - _",
327                        o.id
328                    ));
329                }
330                if o.label.trim().is_empty() || o.label.chars().count() > 100 {
331                    return Err(format!("option {:?}: label of 1-100 characters", o.id));
332                }
333                if o.description
334                    .as_ref()
335                    .is_some_and(|d| d.chars().count() > 100)
336                {
337                    return Err(format!(
338                        "option {:?}: description of at most 100 characters",
339                        o.id
340                    ));
341                }
342            }
343        }
344        Ok(())
345    }
346
347    /// The button `id`.
348    pub fn button(&self, id: &str) -> Option<&PromptButton> {
349        self.buttons.iter().find(|b| b.id == id)
350    }
351
352    /// The menu option `id`.
353    pub fn option(&self, id: &str) -> Option<&SelectOption> {
354        self.select.as_ref()?.options.iter().find(|o| o.id == id)
355    }
356
357    /// Whether `id` names something that answers: a button that is not a link, or an option.
358    pub fn answers_with(&self, id: &str) -> bool {
359        self.button(id).is_some_and(|b| b.url.is_none()) || self.option(id).is_some()
360    }
361
362    /// Whether anything in it can be answered.
363    pub fn is_answerable(&self) -> bool {
364        self.select.is_some() || self.buttons.iter().any(|b| b.url.is_none())
365    }
366
367    /// Whether `user` may answer.
368    pub fn may_answer(&self, user: &UserId) -> bool {
369        self.allowed.is_empty() || self.allowed.contains(user)
370    }
371}
372
373/// A valid id: 1 to `max` of `a-z A-Z 0-9 - _`. Button and option ids end up in a Discord custom
374/// id with the prompt id (100 characters in all); field ids are custom ids of their own, so they
375/// may be as long as Discord allows.
376fn id_ok(s: &str, max: usize) -> bool {
377    !s.is_empty()
378        && s.len() <= max
379        && s.bytes()
380            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
381}
382
383/// Someone pressed a button (and filled in its form).
384#[derive(Debug, Clone)]
385pub struct PromptAnswer {
386    /// [`Prompt::id`].
387    pub prompt: String,
388    /// [`PromptButton::id`].
389    pub button: String,
390    /// Form values by field id; never logged, stored or shown to the agent.
391    pub values: BTreeMap<String, Secret<String>>,
392    /// Who answered (the channel already checked [`Prompt::may_answer`]).
393    pub user: UserRef,
394    /// Where the prompt is.
395    pub conversation: ConversationKey,
396    /// The prompt message, to update with the outcome.
397    pub sent: SentRef,
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn prompt(buttons: Vec<PromptButton>) -> Prompt {
405        Prompt {
406            id: "p1".into(),
407            text: "Deploy?".into(),
408            buttons,
409            ..Prompt::default()
410        }
411    }
412
413    fn button(id: &str, form: Option<Form>) -> PromptButton {
414        PromptButton {
415            id: id.into(),
416            label: "OK".into(),
417            style: ButtonStyle::Primary,
418            form,
419            url: None,
420        }
421    }
422
423    #[test]
424    fn validates_limits_and_ids() {
425        assert!(prompt(vec![button("ok", None)]).validate().is_ok());
426        assert!(prompt(vec![]).validate().is_err());
427        assert!(
428            prompt(vec![button("ok", None), button("ok", None)])
429                .validate()
430                .is_err()
431        );
432        assert!(prompt(vec![button("a b", None)]).validate().is_err());
433        let field = |id: &str| FormField {
434            id: id.into(),
435            label: "Code".into(),
436            style: FieldStyle::Short,
437            required: true,
438            placeholder: None,
439            min_len: None,
440            max_len: None,
441            value: None,
442        };
443        let form = |fields| Form {
444            title: "Confirm".into(),
445            fields,
446        };
447        assert!(
448            prompt(vec![button("ok", Some(form(vec![field("otp")])))])
449                .validate()
450                .is_ok()
451        );
452        assert!(
453            prompt(vec![button("ok", Some(form(vec![])))])
454                .validate()
455                .is_err()
456        );
457        assert!(
458            prompt(vec![button("ok", Some(form(vec![field("a"), field("a")])))])
459                .validate()
460                .is_err()
461        );
462        let long = "LINE_ASSISTANT_CHANNEL_ACCESS_TOKEN";
463        assert!(
464            prompt(vec![button("ok", Some(form(vec![field(long)])))])
465                .validate()
466                .is_ok(),
467            "variable names make natural field ids"
468        );
469        let filled = |value: &str, max_len| FormField {
470            value: Some(value.into()),
471            max_len,
472            ..field("note")
473        };
474        let ok = |f: FormField| prompt(vec![button("ok", Some(form(vec![f])))]).validate();
475        assert!(ok(filled("abc", Some(3))).is_ok());
476        assert!(
477            ok(filled("abcd", Some(3))).is_err(),
478            "longer than the field"
479        );
480        assert!(
481            ok(filled(&"x".repeat(4001), None)).is_err(),
482            "Discord's limit"
483        );
484    }
485
486    #[test]
487    fn cards_menus_and_links() {
488        let mut p = prompt(vec![]);
489        assert!(p.validate().is_err(), "nothing to show but text");
490        p.cards.push(Card {
491            title: Some("Stock".into()),
492            tone: Tone::Info,
493            text: "• **A** · 3".into(),
494            image: None,
495        });
496        assert!(p.validate().is_ok(), "cards alone are enough");
497        assert!(!p.is_answerable());
498        let option = |id: &str| SelectOption {
499            id: id.into(),
500            label: id.into(),
501            description: None,
502        };
503        p.select = Some(Select {
504            placeholder: "Pick".into(),
505            options: vec![option("o1")],
506        });
507        assert!(p.validate().is_err(), "a menu of one");
508        p.select = Some(Select {
509            placeholder: "Pick".into(),
510            options: vec![option("o1"), option("o2")],
511        });
512        assert!(p.validate().is_ok());
513        assert!(p.answers_with("o2") && !p.answers_with("o3"));
514        let mut link = button("docs", None);
515        link.url = Some("http://example.com".into());
516        p.buttons = vec![link.clone()];
517        assert!(p.validate().is_err(), "only https");
518        link.url = Some("https://example.com".into());
519        p.buttons = vec![link];
520        assert!(p.validate().is_ok());
521        assert!(!p.answers_with("docs"), "links send no answer");
522        p.buttons.push(button("o1", None));
523        assert!(
524            p.validate().is_err(),
525            "ids are unique over options and buttons"
526        );
527    }
528
529    #[test]
530    fn who_may_answer() {
531        let mut p = prompt(vec![button("ok", None)]);
532        assert!(p.may_answer(&UserId::new("1")));
533        p.allowed = vec![UserId::new("2")];
534        assert!(!p.may_answer(&UserId::new("1")));
535        assert!(p.may_answer(&UserId::new("2")));
536    }
537}