1use std::collections::BTreeMap;
12
13use crate::id::{ConversationKey, UserId, UserRef};
14use crate::message::SentRef;
15use crate::secret::Secret;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
19#[serde(rename_all = "lowercase")]
20#[non_exhaustive]
21pub enum ButtonStyle {
22 #[default]
24 Primary,
25 Secondary,
27 Success,
29 Danger,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
35#[serde(rename_all = "lowercase")]
36#[non_exhaustive]
37pub enum FieldStyle {
38 #[default]
40 Short,
41 Paragraph,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
47pub struct FormField {
48 pub id: String,
50 pub label: String,
52 #[serde(default)]
54 pub style: FieldStyle,
55 #[serde(default = "yes")]
57 pub required: bool,
58 #[serde(default)]
60 pub placeholder: Option<String>,
61 #[serde(default)]
63 pub min_len: Option<u16>,
64 #[serde(default)]
66 pub max_len: Option<u16>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub value: Option<String>,
70}
71
72fn yes() -> bool {
73 true
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
78pub struct Form {
79 pub title: String,
81 pub fields: Vec<FormField>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
88pub struct PromptButton {
89 pub id: String,
91 pub label: String,
93 #[serde(default)]
95 pub style: ButtonStyle,
96 #[serde(default)]
98 pub form: Option<Form>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub url: Option<String>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
106#[serde(rename_all = "lowercase")]
107#[non_exhaustive]
108pub enum Tone {
109 #[default]
111 Neutral,
112 Info,
114 Success,
116 Warning,
118 Error,
120}
121
122impl Tone {
123 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 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#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
148pub struct Card {
149 #[serde(default)]
151 pub title: Option<String>,
152 #[serde(default)]
154 pub tone: Tone,
155 pub text: String,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub image: Option<crate::Image>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
165pub struct SelectOption {
166 pub id: String,
168 pub label: String,
170 #[serde(default)]
172 pub description: Option<String>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
177pub struct Select {
178 pub placeholder: String,
180 pub options: Vec<SelectOption>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
187pub struct Prompt {
188 pub id: String,
190 pub text: String,
192 pub buttons: Vec<PromptButton>,
194 pub allowed: Vec<UserId>,
196 #[serde(default)]
198 pub cards: Vec<Card>,
199 #[serde(default)]
201 pub select: Option<Select>,
202}
203
204pub const MAX_BUTTONS: usize = 5;
206pub const MAX_FIELDS: usize = 5;
208pub const MAX_CARDS: usize = 5;
210pub const MAX_OPTIONS: usize = 25;
212pub const MAX_PROMPT_CHARS: usize = 3800;
214
215impl Prompt {
216 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 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 pub fn button(&self, id: &str) -> Option<&PromptButton> {
349 self.buttons.iter().find(|b| b.id == id)
350 }
351
352 pub fn option(&self, id: &str) -> Option<&SelectOption> {
354 self.select.as_ref()?.options.iter().find(|o| o.id == id)
355 }
356
357 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 pub fn is_answerable(&self) -> bool {
364 self.select.is_some() || self.buttons.iter().any(|b| b.url.is_none())
365 }
366
367 pub fn may_answer(&self, user: &UserId) -> bool {
369 self.allowed.is_empty() || self.allowed.contains(user)
370 }
371}
372
373fn 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#[derive(Debug, Clone)]
385pub struct PromptAnswer {
386 pub prompt: String,
388 pub button: String,
390 pub values: BTreeMap<String, Secret<String>>,
392 pub user: UserRef,
394 pub conversation: ConversationKey,
396 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}