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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Tone {
#[default]
Neutral,
Info,
Success,
Warning,
Error,
}
impl Tone {
pub fn emoji(self) -> &'static str {
match self {
Self::Neutral => "",
Self::Info => "ℹ️",
Self::Success => "✅",
Self::Warning => "⚠️",
Self::Error => "❌",
}
}
pub fn color(self) -> u32 {
match self {
Self::Neutral => 0x0058_65F2,
Self::Info => 0x003B_82F6,
Self::Success => 0x0022_C55E,
Self::Warning => 0x00EA_B308,
Self::Error => 0x00EF_4444,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
pub struct Card {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub tone: Tone,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<crate::Image>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct SelectOption {
pub id: String,
pub label: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct Select {
pub placeholder: String,
pub options: Vec<SelectOption>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
pub struct Prompt {
pub id: String,
pub text: String,
pub buttons: Vec<PromptButton>,
pub allowed: Vec<UserId>,
#[serde(default)]
pub cards: Vec<Card>,
#[serde(default)]
pub select: Option<Select>,
}
pub const MAX_BUTTONS: usize = 5;
pub const MAX_FIELDS: usize = 5;
pub const MAX_CARDS: usize = 5;
pub const MAX_OPTIONS: usize = 25;
pub const MAX_PROMPT_CHARS: usize = 3800;
impl Prompt {
pub fn validate(&self) -> Result<(), String> {
if self.buttons.len() > MAX_BUTTONS {
return Err(format!("a prompt has at most {MAX_BUTTONS} buttons"));
}
if self.buttons.is_empty() && self.select.is_none() && self.cards.is_empty() {
return Err(format!("a prompt needs 1 to {MAX_BUTTONS} buttons"));
}
if self.text.trim().is_empty() && self.cards.is_empty() {
return Err("a prompt needs text".into());
}
self.validate_cards()?;
let mut seen = std::collections::HashSet::new();
self.validate_select(&mut seen)?;
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(url) = &b.url {
if b.form.is_some() {
return Err(format!("button {:?}: a link has no form", b.id));
}
if !url.starts_with("https://")
|| url.len() > 512
|| url.contains(char::is_whitespace)
{
return Err(format!("button {:?}: links must be https URLs", 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));
}
let max = f.max_len.map_or(4000, |m| m.min(4000));
if f.value
.as_ref()
.is_some_and(|v| v.chars().count() > usize::from(max))
{
return Err(format!(
"field {:?}: value of at most {max} characters",
f.id
));
}
}
}
}
Ok(())
}
fn validate_cards(&self) -> Result<(), String> {
if self.cards.len() > MAX_CARDS {
return Err(format!("a prompt has at most {MAX_CARDS} cards"));
}
let mut chars = self.text.chars().count();
for c in &self.cards {
let title = c.title.as_deref().unwrap_or_default();
if title.chars().count() > 100 {
return Err("card title: at most 100 characters".into());
}
if c.text.trim().is_empty() && title.trim().is_empty() {
return Err("a card needs a title or text".into());
}
if c.text.len() > 1900 {
return Err("card text: at most about 1900 bytes".into());
}
chars += title.chars().count() + c.text.chars().count();
}
if chars > MAX_PROMPT_CHARS {
return Err(format!(
"a prompt has at most {MAX_PROMPT_CHARS} characters of text"
));
}
Ok(())
}
fn validate_select<'a>(
&'a self,
seen: &mut std::collections::HashSet<&'a str>,
) -> Result<(), String> {
if let Some(select) = &self.select {
if select.options.len() < 2 || select.options.len() > MAX_OPTIONS {
return Err(format!("a menu needs 2 to {MAX_OPTIONS} options"));
}
if select.placeholder.chars().count() > 150 {
return Err("menu placeholder: at most 150 characters".into());
}
for o in &select.options {
if !id_ok(&o.id, 32) || !seen.insert(o.id.as_str()) {
return Err(format!(
"option id {:?}: unique, 1-32 of a-z A-Z 0-9 - _",
o.id
));
}
if o.label.trim().is_empty() || o.label.chars().count() > 100 {
return Err(format!("option {:?}: label of 1-100 characters", o.id));
}
if o.description
.as_ref()
.is_some_and(|d| d.chars().count() > 100)
{
return Err(format!(
"option {:?}: description of at most 100 characters",
o.id
));
}
}
}
Ok(())
}
pub fn button(&self, id: &str) -> Option<&PromptButton> {
self.buttons.iter().find(|b| b.id == id)
}
pub fn option(&self, id: &str) -> Option<&SelectOption> {
self.select.as_ref()?.options.iter().find(|o| o.id == id)
}
pub fn answers_with(&self, id: &str) -> bool {
self.button(id).is_some_and(|b| b.url.is_none()) || self.option(id).is_some()
}
pub fn is_answerable(&self) -> bool {
self.select.is_some() || self.buttons.iter().any(|b| b.url.is_none())
}
pub fn may_answer(&self, user: &UserId) -> bool {
self.allowed.is_empty() || self.allowed.contains(user)
}
}
fn id_ok(s: &str, max: usize) -> bool {
!s.is_empty()
&& s.len() <= max
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
#[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,
..Prompt::default()
}
}
fn button(id: &str, form: Option<Form>) -> PromptButton {
PromptButton {
id: id.into(),
label: "OK".into(),
style: ButtonStyle::Primary,
form,
url: None,
}
}
#[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,
value: 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"
);
let filled = |value: &str, max_len| FormField {
value: Some(value.into()),
max_len,
..field("note")
};
let ok = |f: FormField| prompt(vec![button("ok", Some(form(vec![f])))]).validate();
assert!(ok(filled("abc", Some(3))).is_ok());
assert!(
ok(filled("abcd", Some(3))).is_err(),
"longer than the field"
);
assert!(
ok(filled(&"x".repeat(4001), None)).is_err(),
"Discord's limit"
);
}
#[test]
fn cards_menus_and_links() {
let mut p = prompt(vec![]);
assert!(p.validate().is_err(), "nothing to show but text");
p.cards.push(Card {
title: Some("Stock".into()),
tone: Tone::Info,
text: "• **A** · 3".into(),
image: None,
});
assert!(p.validate().is_ok(), "cards alone are enough");
assert!(!p.is_answerable());
let option = |id: &str| SelectOption {
id: id.into(),
label: id.into(),
description: None,
};
p.select = Some(Select {
placeholder: "Pick".into(),
options: vec![option("o1")],
});
assert!(p.validate().is_err(), "a menu of one");
p.select = Some(Select {
placeholder: "Pick".into(),
options: vec![option("o1"), option("o2")],
});
assert!(p.validate().is_ok());
assert!(p.answers_with("o2") && !p.answers_with("o3"));
let mut link = button("docs", None);
link.url = Some("http://example.com".into());
p.buttons = vec![link.clone()];
assert!(p.validate().is_err(), "only https");
link.url = Some("https://example.com".into());
p.buttons = vec![link];
assert!(p.validate().is_ok());
assert!(!p.answers_with("docs"), "links send no answer");
p.buttons.push(button("o1", None));
assert!(
p.validate().is_err(),
"ids are unique over options and buttons"
);
}
#[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")));
}
}