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
8use std::collections::BTreeMap;
9
10use crate::id::{ConversationKey, UserId, UserRef};
11use crate::message::SentRef;
12use crate::secret::Secret;
13
14/// What a button looks like.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
16#[serde(rename_all = "lowercase")]
17#[non_exhaustive]
18pub enum ButtonStyle {
19    /// The main action.
20    #[default]
21    Primary,
22    /// A neutral action (cancel).
23    Secondary,
24    /// A safe, positive action.
25    Success,
26    /// A destructive or risky action.
27    Danger,
28}
29
30/// How much text a form field takes.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
32#[serde(rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum FieldStyle {
35    /// One line.
36    #[default]
37    Short,
38    /// Several lines (keys, certificates).
39    Paragraph,
40}
41
42/// One text field of a [`Form`].
43#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
44pub struct FormField {
45    /// Key of the value in the answer.
46    pub id: String,
47    /// Label shown above the field.
48    pub label: String,
49    /// One line or several.
50    #[serde(default)]
51    pub style: FieldStyle,
52    /// The person must fill it in.
53    #[serde(default = "yes")]
54    pub required: bool,
55    /// Hint shown in the empty field.
56    #[serde(default)]
57    pub placeholder: Option<String>,
58    /// Minimum length.
59    #[serde(default)]
60    pub min_len: Option<u16>,
61    /// Maximum length.
62    #[serde(default)]
63    pub max_len: Option<u16>,
64}
65
66fn yes() -> bool {
67    true
68}
69
70/// A small form (a Discord modal: up to five text fields).
71#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
72pub struct Form {
73    /// Title of the form.
74    pub title: String,
75    /// Fields, in order.
76    pub fields: Vec<FormField>,
77}
78
79/// A button under the prompt message. With a [`Form`], pressing it opens the form and the
80/// answer carries the values.
81#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
82pub struct PromptButton {
83    /// Key of the button in the answer.
84    pub id: String,
85    /// Text on the button.
86    pub label: String,
87    /// Look.
88    #[serde(default)]
89    pub style: ButtonStyle,
90    /// A form to fill in before the answer is sent.
91    #[serde(default)]
92    pub form: Option<Form>,
93}
94
95/// A message with buttons, shown in one conversation.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Prompt {
98    /// Unique id, chosen by the runtime.
99    pub id: String,
100    /// The message, already rendered for the channel.
101    pub text: String,
102    /// One to five buttons.
103    pub buttons: Vec<PromptButton>,
104    /// Who may answer; anyone in the conversation when empty. Others are told it is not theirs.
105    pub allowed: Vec<UserId>,
106}
107
108/// Limits every channel can honour (Discord's are the tightest).
109pub const MAX_BUTTONS: usize = 5;
110/// Fields per form.
111pub const MAX_FIELDS: usize = 5;
112
113impl Prompt {
114    /// Checks the limits and ids; the message says what is wrong.
115    pub fn validate(&self) -> Result<(), String> {
116        // Button ids end up in a Discord custom id with the prompt id (100 characters in all);
117        // field ids are custom ids of their own, so they may be as long as Discord allows.
118        let id_ok = |s: &str, max: usize| {
119            !s.is_empty()
120                && s.len() <= max
121                && s.bytes()
122                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
123        };
124        if self.buttons.is_empty() || self.buttons.len() > MAX_BUTTONS {
125            return Err(format!("a prompt needs 1 to {MAX_BUTTONS} buttons"));
126        }
127        if self.text.trim().is_empty() {
128            return Err("a prompt needs text".into());
129        }
130        let mut seen = std::collections::HashSet::new();
131        for b in &self.buttons {
132            if !id_ok(&b.id, 32) || !seen.insert(b.id.as_str()) {
133                return Err(format!(
134                    "button id {:?}: unique, 1-32 of a-z A-Z 0-9 - _",
135                    b.id
136                ));
137            }
138            if b.label.trim().is_empty() || b.label.chars().count() > 80 {
139                return Err(format!("button {:?}: label of 1-80 characters", b.id));
140            }
141            if let Some(form) = &b.form {
142                if form.fields.is_empty() || form.fields.len() > MAX_FIELDS {
143                    return Err(format!("form of {:?}: 1 to {MAX_FIELDS} fields", b.id));
144                }
145                if form.title.trim().is_empty() || form.title.chars().count() > 45 {
146                    return Err(format!("form of {:?}: title of 1-45 characters", b.id));
147                }
148                let mut fields = std::collections::HashSet::new();
149                for f in &form.fields {
150                    if !id_ok(&f.id, 100) || !fields.insert(f.id.as_str()) {
151                        return Err(format!(
152                            "field id {:?}: unique, 1-100 of a-z A-Z 0-9 - _",
153                            f.id
154                        ));
155                    }
156                    if f.label.trim().is_empty() || f.label.chars().count() > 45 {
157                        return Err(format!("field {:?}: label of 1-45 characters", f.id));
158                    }
159                }
160            }
161        }
162        Ok(())
163    }
164
165    /// The button `id`.
166    pub fn button(&self, id: &str) -> Option<&PromptButton> {
167        self.buttons.iter().find(|b| b.id == id)
168    }
169
170    /// Whether `user` may answer.
171    pub fn may_answer(&self, user: &UserId) -> bool {
172        self.allowed.is_empty() || self.allowed.contains(user)
173    }
174}
175
176/// Someone pressed a button (and filled in its form).
177#[derive(Debug, Clone)]
178pub struct PromptAnswer {
179    /// [`Prompt::id`].
180    pub prompt: String,
181    /// [`PromptButton::id`].
182    pub button: String,
183    /// Form values by field id; never logged, stored or shown to the agent.
184    pub values: BTreeMap<String, Secret<String>>,
185    /// Who answered (the channel already checked [`Prompt::may_answer`]).
186    pub user: UserRef,
187    /// Where the prompt is.
188    pub conversation: ConversationKey,
189    /// The prompt message, to update with the outcome.
190    pub sent: SentRef,
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn prompt(buttons: Vec<PromptButton>) -> Prompt {
198        Prompt {
199            id: "p1".into(),
200            text: "Deploy?".into(),
201            buttons,
202            allowed: vec![],
203        }
204    }
205
206    fn button(id: &str, form: Option<Form>) -> PromptButton {
207        PromptButton {
208            id: id.into(),
209            label: "OK".into(),
210            style: ButtonStyle::Primary,
211            form,
212        }
213    }
214
215    #[test]
216    fn validates_limits_and_ids() {
217        assert!(prompt(vec![button("ok", None)]).validate().is_ok());
218        assert!(prompt(vec![]).validate().is_err());
219        assert!(
220            prompt(vec![button("ok", None), button("ok", None)])
221                .validate()
222                .is_err()
223        );
224        assert!(prompt(vec![button("a b", None)]).validate().is_err());
225        let field = |id: &str| FormField {
226            id: id.into(),
227            label: "Code".into(),
228            style: FieldStyle::Short,
229            required: true,
230            placeholder: None,
231            min_len: None,
232            max_len: None,
233        };
234        let form = |fields| Form {
235            title: "Confirm".into(),
236            fields,
237        };
238        assert!(
239            prompt(vec![button("ok", Some(form(vec![field("otp")])))])
240                .validate()
241                .is_ok()
242        );
243        assert!(
244            prompt(vec![button("ok", Some(form(vec![])))])
245                .validate()
246                .is_err()
247        );
248        assert!(
249            prompt(vec![button("ok", Some(form(vec![field("a"), field("a")])))])
250                .validate()
251                .is_err()
252        );
253        let long = "LINE_ASSISTANT_CHANNEL_ACCESS_TOKEN";
254        assert!(
255            prompt(vec![button("ok", Some(form(vec![field(long)])))])
256                .validate()
257                .is_ok(),
258            "variable names make natural field ids"
259        );
260    }
261
262    #[test]
263    fn who_may_answer() {
264        let mut p = prompt(vec![button("ok", None)]);
265        assert!(p.may_answer(&UserId::new("1")));
266        p.allowed = vec![UserId::new("2")];
267        assert!(!p.may_answer(&UserId::new("1")));
268        assert!(p.may_answer(&UserId::new("2")));
269    }
270}