1use std::collections::BTreeMap;
9
10use crate::id::{ConversationKey, UserId, UserRef};
11use crate::message::SentRef;
12use crate::secret::Secret;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
16#[serde(rename_all = "lowercase")]
17#[non_exhaustive]
18pub enum ButtonStyle {
19 #[default]
21 Primary,
22 Secondary,
24 Success,
26 Danger,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
32#[serde(rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum FieldStyle {
35 #[default]
37 Short,
38 Paragraph,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
44pub struct FormField {
45 pub id: String,
47 pub label: String,
49 #[serde(default)]
51 pub style: FieldStyle,
52 #[serde(default = "yes")]
54 pub required: bool,
55 #[serde(default)]
57 pub placeholder: Option<String>,
58 #[serde(default)]
60 pub min_len: Option<u16>,
61 #[serde(default)]
63 pub max_len: Option<u16>,
64}
65
66fn yes() -> bool {
67 true
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
72pub struct Form {
73 pub title: String,
75 pub fields: Vec<FormField>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
82pub struct PromptButton {
83 pub id: String,
85 pub label: String,
87 #[serde(default)]
89 pub style: ButtonStyle,
90 #[serde(default)]
92 pub form: Option<Form>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Prompt {
98 pub id: String,
100 pub text: String,
102 pub buttons: Vec<PromptButton>,
104 pub allowed: Vec<UserId>,
106}
107
108pub const MAX_BUTTONS: usize = 5;
110pub const MAX_FIELDS: usize = 5;
112
113impl Prompt {
114 pub fn validate(&self) -> Result<(), String> {
116 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 pub fn button(&self, id: &str) -> Option<&PromptButton> {
167 self.buttons.iter().find(|b| b.id == id)
168 }
169
170 pub fn may_answer(&self, user: &UserId) -> bool {
172 self.allowed.is_empty() || self.allowed.contains(user)
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct PromptAnswer {
179 pub prompt: String,
181 pub button: String,
183 pub values: BTreeMap<String, Secret<String>>,
185 pub user: UserRef,
187 pub conversation: ConversationKey,
189 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}