use std::collections::BTreeMap;
use crate::id::{ConversationKey, UserId, UserRef};
use crate::message::SentRef;
use crate::secret::Secret;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ButtonStyle {
#[default]
Primary,
Secondary,
Success,
Danger,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum FieldStyle {
#[default]
Short,
Paragraph,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct FormField {
pub id: String,
pub label: String,
#[serde(default)]
pub style: FieldStyle,
#[serde(default = "yes")]
pub required: bool,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub min_len: Option<u16>,
#[serde(default)]
pub max_len: Option<u16>,
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct Form {
pub title: String,
pub fields: Vec<FormField>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct PromptButton {
pub id: String,
pub label: String,
#[serde(default)]
pub style: ButtonStyle,
#[serde(default)]
pub form: Option<Form>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prompt {
pub id: String,
pub text: String,
pub buttons: Vec<PromptButton>,
pub allowed: Vec<UserId>,
}
pub const MAX_BUTTONS: usize = 5;
pub const MAX_FIELDS: usize = 5;
impl Prompt {
pub fn validate(&self) -> Result<(), String> {
let id_ok = |s: &str, max: usize| {
!s.is_empty()
&& s.len() <= max
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
};
if self.buttons.is_empty() || self.buttons.len() > MAX_BUTTONS {
return Err(format!("a prompt needs 1 to {MAX_BUTTONS} buttons"));
}
if self.text.trim().is_empty() {
return Err("a prompt needs text".into());
}
let mut seen = std::collections::HashSet::new();
for b in &self.buttons {
if !id_ok(&b.id, 32) || !seen.insert(b.id.as_str()) {
return Err(format!(
"button id {:?}: unique, 1-32 of a-z A-Z 0-9 - _",
b.id
));
}
if b.label.trim().is_empty() || b.label.chars().count() > 80 {
return Err(format!("button {:?}: label of 1-80 characters", b.id));
}
if let Some(form) = &b.form {
if form.fields.is_empty() || form.fields.len() > MAX_FIELDS {
return Err(format!("form of {:?}: 1 to {MAX_FIELDS} fields", b.id));
}
if form.title.trim().is_empty() || form.title.chars().count() > 45 {
return Err(format!("form of {:?}: title of 1-45 characters", b.id));
}
let mut fields = std::collections::HashSet::new();
for f in &form.fields {
if !id_ok(&f.id, 100) || !fields.insert(f.id.as_str()) {
return Err(format!(
"field id {:?}: unique, 1-100 of a-z A-Z 0-9 - _",
f.id
));
}
if f.label.trim().is_empty() || f.label.chars().count() > 45 {
return Err(format!("field {:?}: label of 1-45 characters", f.id));
}
}
}
}
Ok(())
}
pub fn button(&self, id: &str) -> Option<&PromptButton> {
self.buttons.iter().find(|b| b.id == id)
}
pub fn may_answer(&self, user: &UserId) -> bool {
self.allowed.is_empty() || self.allowed.contains(user)
}
}
#[derive(Debug, Clone)]
pub struct PromptAnswer {
pub prompt: String,
pub button: String,
pub values: BTreeMap<String, Secret<String>>,
pub user: UserRef,
pub conversation: ConversationKey,
pub sent: SentRef,
}
#[cfg(test)]
mod tests {
use super::*;
fn prompt(buttons: Vec<PromptButton>) -> Prompt {
Prompt {
id: "p1".into(),
text: "Deploy?".into(),
buttons,
allowed: vec![],
}
}
fn button(id: &str, form: Option<Form>) -> PromptButton {
PromptButton {
id: id.into(),
label: "OK".into(),
style: ButtonStyle::Primary,
form,
}
}
#[test]
fn validates_limits_and_ids() {
assert!(prompt(vec![button("ok", None)]).validate().is_ok());
assert!(prompt(vec![]).validate().is_err());
assert!(
prompt(vec![button("ok", None), button("ok", None)])
.validate()
.is_err()
);
assert!(prompt(vec![button("a b", None)]).validate().is_err());
let field = |id: &str| FormField {
id: id.into(),
label: "Code".into(),
style: FieldStyle::Short,
required: true,
placeholder: None,
min_len: None,
max_len: None,
};
let form = |fields| Form {
title: "Confirm".into(),
fields,
};
assert!(
prompt(vec![button("ok", Some(form(vec![field("otp")])))])
.validate()
.is_ok()
);
assert!(
prompt(vec![button("ok", Some(form(vec![])))])
.validate()
.is_err()
);
assert!(
prompt(vec![button("ok", Some(form(vec![field("a"), field("a")])))])
.validate()
.is_err()
);
let long = "LINE_ASSISTANT_CHANNEL_ACCESS_TOKEN";
assert!(
prompt(vec![button("ok", Some(form(vec![field(long)])))])
.validate()
.is_ok(),
"variable names make natural field ids"
);
}
#[test]
fn who_may_answer() {
let mut p = prompt(vec![button("ok", None)]);
assert!(p.may_answer(&UserId::new("1")));
p.allowed = vec![UserId::new("2")];
assert!(!p.may_answer(&UserId::new("1")));
assert!(p.may_answer(&UserId::new("2")));
}
}