use std::time::Duration;
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::case::CaseRef;
use crate::command::{CommandOrigin, ResolutionChannel, RiskClass};
use crate::error::{InteractionError, InteractionSpecError};
use crate::hash::{Digest, HashError, canonical_digest};
use crate::ids::{
AccountId, CaseRevision, ConversationId, InteractionId, OperationKey, OptionId, TurnId,
};
use crate::locale::LocalizedText;
use crate::reduce::CommandRef;
use crate::turn::{ActorContext, InteractionResponse};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum InteractionKind {
Boolean,
SingleSelect,
MultiSelect,
Freeform,
ReviewChanges,
ConfirmCommand,
SelectTarget,
ResolveValidationError,
Reauthenticate,
ExternalSignature,
}
impl InteractionKind {
pub const ALL: [Self; 10] = [
Self::Boolean,
Self::SingleSelect,
Self::MultiSelect,
Self::Freeform,
Self::ReviewChanges,
Self::ConfirmCommand,
Self::SelectTarget,
Self::ResolveValidationError,
Self::Reauthenticate,
Self::ExternalSignature,
];
#[must_use]
pub fn authorizes_commands(self) -> bool {
matches!(
self,
Self::ConfirmCommand
| Self::ReviewChanges
| Self::Reauthenticate
| Self::ExternalSignature
)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum InteractionStatus {
Active,
Resolving,
Resolved,
Declined,
Dismissed,
Invalidated,
Expired,
Failed,
}
impl InteractionStatus {
pub const ALL: [Self; 8] = [
Self::Active,
Self::Resolving,
Self::Resolved,
Self::Declined,
Self::Dismissed,
Self::Invalidated,
Self::Expired,
Self::Failed,
];
#[must_use]
pub fn is_terminal(self) -> bool {
!matches!(self, Self::Active | Self::Resolving)
}
#[must_use]
pub fn is_open(self) -> bool {
matches!(self, Self::Active | Self::Resolving)
}
#[must_use]
pub fn can_transition(from: Self, to: Self) -> bool {
match from {
Self::Active => to != Self::Active,
Self::Resolving => matches!(to, Self::Resolved | Self::Failed | Self::Active),
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FreeformPolicy {
#[default]
Forbidden,
Optional {
max_len: usize,
},
Required {
max_len: usize,
},
}
impl FreeformPolicy {
#[must_use]
pub fn allows(self) -> bool {
!matches!(self, Self::Forbidden)
}
#[must_use]
pub fn requires(self) -> bool {
matches!(self, Self::Required { .. })
}
#[must_use]
pub fn max_len(self) -> Option<usize> {
match self {
Self::Forbidden => None,
Self::Optional { max_len } | Self::Required { max_len } => Some(max_len),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TextResolutionPolicy {
#[default]
Never,
ModelInterpretedLowRisk,
}
impl TextResolutionPolicy {
#[must_use]
pub fn allows_model_interpretation(&self) -> bool {
matches!(self, Self::ModelInterpretedLowRisk)
}
#[must_use]
pub fn admits(&self, channel: ResolutionChannel) -> bool {
match channel {
ResolutionChannel::Click => true,
ResolutionChannel::ModelInterpreted => self.allows_model_interpretation(),
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ActionClass {
ConfirmsCommands,
AppliesOperation,
NoCommands,
}
impl ActionClass {
#[must_use]
pub fn authorizes_commands(self) -> bool {
matches!(self, Self::ConfirmsCommands | Self::AppliesOperation)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum StoredInteractionAction {
ConfirmCommands {
command_refs: Vec<CommandRef>,
},
DeclineCommands,
SelectTarget {
case_ref: CaseRef,
},
ResolveClarification {
answer_key: String,
},
ApplyOperation {
operation: OperationKey,
arguments: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
freeform_argument: Option<String>,
},
DeclineAndRecord {
operation: OperationKey,
},
Dismiss,
Custom {
key: String,
payload: serde_json::Value,
},
}
impl StoredInteractionAction {
#[must_use]
pub fn action_class(&self) -> ActionClass {
match self {
Self::ConfirmCommands { .. } => ActionClass::ConfirmsCommands,
Self::ApplyOperation { .. } => ActionClass::AppliesOperation,
Self::DeclineCommands
| Self::DeclineAndRecord { .. }
| Self::SelectTarget { .. }
| Self::ResolveClarification { .. }
| Self::Dismiss
| Self::Custom { .. } => ActionClass::NoCommands,
}
}
#[must_use]
pub fn declines(&self) -> bool {
matches!(
self,
Self::DeclineCommands | Self::DeclineAndRecord { .. } | Self::Dismiss
)
}
#[must_use]
pub fn records(&self) -> Option<&OperationKey> {
match self {
Self::DeclineAndRecord { operation } => Some(operation),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OptionStyle {
Primary,
#[default]
Secondary,
Danger,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionOption {
pub id: OptionId,
pub label: LocalizedText,
pub action: StoredInteractionAction,
#[serde(default)]
pub freeform_policy: FreeformPolicy,
#[serde(default)]
pub style: OptionStyle,
}
impl InteractionOption {
#[must_use]
pub fn new(
id: impl Into<OptionId>,
label: impl Into<LocalizedText>,
action: StoredInteractionAction,
) -> Self {
Self {
id: id.into(),
label: label.into(),
action,
freeform_policy: FreeformPolicy::Forbidden,
style: OptionStyle::Secondary,
}
}
#[must_use]
pub fn with_freeform(mut self, policy: FreeformPolicy) -> Self {
self.freeform_policy = policy;
self
}
#[must_use]
pub fn with_style(mut self, style: OptionStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn view(&self) -> InteractionOptionView {
InteractionOptionView {
id: self.id.clone(),
label: self.label.clone(),
freeform_policy: self.freeform_policy,
style: self.style,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum FieldValue {
#[default]
Absent,
Present(serde_json::Value),
}
impl FieldValue {
#[must_use]
pub fn present(value: impl Into<serde_json::Value>) -> Self {
Self::Present(value.into())
}
#[must_use]
pub fn cleared() -> Self {
Self::Present(serde_json::Value::Null)
}
#[must_use]
pub fn is_absent(&self) -> bool {
matches!(self, Self::Absent)
}
#[must_use]
pub fn value(&self) -> Option<&serde_json::Value> {
match self {
Self::Absent => None,
Self::Present(value) => Some(value),
}
}
}
impl From<serde_json::Value> for FieldValue {
fn from(value: serde_json::Value) -> Self {
Self::Present(value)
}
}
impl Serialize for FieldValue {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Absent => serializer.serialize_unit(),
Self::Present(value) => value.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for FieldValue {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
serde_json::Value::deserialize(deserializer).map(Self::Present)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewDiffEntry {
pub field: String,
pub label: LocalizedText,
#[serde(default, skip_serializing_if = "FieldValue::is_absent")]
pub before: FieldValue,
#[serde(default, skip_serializing_if = "FieldValue::is_absent")]
pub after: FieldValue,
}
impl ReviewDiffEntry {
#[must_use]
pub fn new(field: impl Into<String>, label: impl Into<LocalizedText>) -> Self {
Self {
field: field.into(),
label: label.into(),
before: FieldValue::Absent,
after: FieldValue::Absent,
}
}
#[must_use]
pub fn with_before(mut self, before: FieldValue) -> Self {
self.before = before;
self
}
#[must_use]
pub fn with_after(mut self, after: FieldValue) -> Self {
self.after = after;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionPayload {
pub title: LocalizedText,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<LocalizedText>,
#[serde(default)]
pub options: Vec<InteractionOption>,
#[serde(default)]
pub review_entries: Vec<ReviewDiffEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freeform_prompt: Option<LocalizedText>,
#[serde(default)]
pub metadata: serde_json::Value,
}
impl InteractionPayload {
#[must_use]
pub fn new(title: impl Into<LocalizedText>) -> Self {
Self {
title: title.into(),
body: None,
options: Vec::new(),
review_entries: Vec::new(),
freeform_prompt: None,
metadata: serde_json::Value::Null,
}
}
#[must_use]
pub fn with_body(mut self, body: impl Into<LocalizedText>) -> Self {
self.body = Some(body.into());
self
}
#[must_use]
pub fn with_option(mut self, option: InteractionOption) -> Self {
self.options.push(option);
self
}
#[must_use]
pub fn with_review_entry(mut self, entry: ReviewDiffEntry) -> Self {
self.review_entries.push(entry);
self
}
#[must_use]
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = metadata;
self
}
pub fn hash(&self) -> Result<Digest, HashError> {
canonical_digest(self)
}
#[must_use]
pub fn option(&self, id: &OptionId) -> Option<&InteractionOption> {
self.options.iter().find(|o| &o.id == id)
}
#[must_use]
pub fn option_ids(&self) -> Vec<OptionId> {
self.options.iter().map(|o| o.id.clone()).collect()
}
#[must_use]
pub fn with_freeform_prompt(mut self, prompt: impl Into<LocalizedText>) -> Self {
self.freeform_prompt = Some(prompt.into());
self
}
pub fn validate_for(&self, kind: InteractionKind) -> Result<(), InteractionSpecError> {
let mut seen = std::collections::BTreeSet::new();
for option in &self.options {
if !seen.insert(&option.id) {
return Err(InteractionSpecError::DuplicateOptionId {
option_id: option.id.clone(),
});
}
}
let authorizing = self
.options
.iter()
.filter(|o| o.action.action_class().authorizes_commands())
.count();
let declining = self.options.iter().filter(|o| o.action.declines()).count();
let required_freeform = self
.options
.iter()
.filter(|o| o.freeform_policy.requires())
.count();
let require_options = |expected: usize| {
if self.options.len() < expected {
Err(InteractionSpecError::NotEnoughOptions {
interaction_kind: kind,
required: expected,
found: self.options.len(),
})
} else {
Ok(())
}
};
match kind {
InteractionKind::MultiSelect => {
return Err(InteractionSpecError::UnsupportedKind {
interaction_kind: kind,
});
}
InteractionKind::Boolean => {
if self.options.len() != 2 {
return Err(InteractionSpecError::NotEnoughOptions {
interaction_kind: kind,
required: 2,
found: self.options.len(),
});
}
}
InteractionKind::SingleSelect
| InteractionKind::SelectTarget
| InteractionKind::ResolveValidationError => require_options(1)?,
InteractionKind::ConfirmCommand | InteractionKind::ReviewChanges => {
require_options(2)?;
if authorizing == 0 {
return Err(InteractionSpecError::MissingAuthorizingOption {
interaction_kind: kind,
});
}
if declining == 0 {
return Err(InteractionSpecError::MissingDeclineOption {
interaction_kind: kind,
});
}
if kind == InteractionKind::ReviewChanges && self.review_entries.is_empty() {
return Err(InteractionSpecError::MissingReviewEntries);
}
}
InteractionKind::Reauthenticate | InteractionKind::ExternalSignature => {
require_options(1)?;
if authorizing == 0 {
return Err(InteractionSpecError::MissingAuthorizingOption {
interaction_kind: kind,
});
}
}
InteractionKind::Freeform => {
require_options(1)?;
if self.freeform_prompt.is_none() {
return Err(InteractionSpecError::MissingFreeformPrompt);
}
if required_freeform == 0 {
return Err(InteractionSpecError::MissingFreeformOption);
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionSpec {
pub key: String,
pub case_ref: CaseRef,
pub kind: InteractionKind,
pub blocking: bool,
pub payload: InteractionPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_in: Option<Duration>,
#[serde(default)]
pub text_resolution: TextResolutionPolicy,
#[serde(default = "RiskClass::conservative")]
pub confirms_risk: RiskClass,
pub binds_to_revision: bool,
}
impl InteractionSpec {
#[must_use]
pub fn new(
key: impl Into<String>,
case_ref: CaseRef,
kind: InteractionKind,
payload: InteractionPayload,
) -> Self {
Self {
key: key.into(),
case_ref,
kind,
blocking: true,
payload,
expires_in: None,
text_resolution: TextResolutionPolicy::Never,
confirms_risk: RiskClass::conservative(),
binds_to_revision: true,
}
}
#[must_use]
pub fn with_confirms_risk(mut self, risk: RiskClass) -> Self {
self.confirms_risk = risk;
self
}
pub fn validate(&self) -> Result<(), InteractionSpecError> {
self.payload.validate_for(self.kind)?;
if self.text_resolution != TextResolutionPolicy::Never
&& (self.confirms_risk > RiskClass::ReversibleLowRisk
|| self.kind.authorizes_commands())
{
return Err(InteractionSpecError::TextResolutionNotAllowed {
interaction_kind: self.kind,
confirms_risk: self.confirms_risk,
});
}
Ok(())
}
#[must_use]
pub fn non_blocking(mut self) -> Self {
self.blocking = false;
self
}
#[must_use]
pub fn with_text_resolution(mut self, policy: TextResolutionPolicy) -> Self {
self.text_resolution = policy;
self
}
#[must_use]
pub fn expires_in(mut self, ttl: Duration) -> Self {
self.expires_in = Some(ttl);
self
}
#[must_use]
pub fn revision_independent(mut self) -> Self {
self.binds_to_revision = false;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Interaction {
pub id: InteractionId,
pub account_id: AccountId,
pub conversation_id: ConversationId,
pub case_ref: CaseRef,
pub created_by_turn: TurnId,
pub kind: InteractionKind,
pub blocking: bool,
pub payload: InteractionPayload,
pub payload_hash: Digest,
pub status: InteractionStatus,
pub revision_independent: bool,
pub text_resolution: TextResolutionPolicy,
#[serde(default = "RiskClass::conservative")]
pub confirms_risk: RiskClass,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_option_id: Option<OptionId>,
}
impl Interaction {
pub fn from_spec(
spec: InteractionSpec,
id: InteractionId,
account_id: AccountId,
conversation_id: ConversationId,
created_by_turn: TurnId,
now: DateTime<Utc>,
) -> Result<Self, InteractionError> {
spec.validate()?;
let payload_hash = spec.payload.hash().map_err(|_| InteractionError::Hash)?;
let expires_at = spec
.expires_in
.map(|ttl| {
chrono::Duration::from_std(ttl)
.ok()
.and_then(|ttl| now.checked_add_signed(ttl))
.ok_or(InteractionError::InvalidTtl)
})
.transpose()?;
Ok(Self {
id,
account_id,
conversation_id,
case_ref: spec.case_ref,
created_by_turn,
kind: spec.kind,
blocking: spec.blocking,
payload: spec.payload,
payload_hash,
status: InteractionStatus::Active,
revision_independent: !spec.binds_to_revision,
text_resolution: spec.text_resolution,
confirms_risk: spec.confirms_risk,
created_at: now,
expires_at,
resolved_at: None,
resolved_option_id: None,
})
}
#[must_use]
pub fn bound_revision(&self) -> Option<CaseRevision> {
(!self.revision_independent).then_some(self.case_ref.expected_revision)
}
#[must_use]
pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
self.expires_at.is_some_and(|at| at <= now)
}
pub fn verify_payload_hash(&self) -> Result<bool, HashError> {
Ok(self.payload.hash()? == self.payload_hash)
}
pub fn transition(&mut self, to: InteractionStatus) -> Result<(), InteractionError> {
if !InteractionStatus::can_transition(self.status, to) {
return Err(InteractionError::InvalidTransition {
interaction_id: self.id,
from: self.status,
to,
});
}
self.status = to;
Ok(())
}
pub fn begin_resolution(
&mut self,
option_id: OptionId,
now: DateTime<Utc>,
) -> Result<(), InteractionError> {
self.transition(InteractionStatus::Resolving)?;
self.resolved_option_id = Some(option_id);
self.resolved_at = Some(now);
Ok(())
}
#[must_use]
pub fn view(&self) -> InteractionView {
InteractionView {
id: self.id,
kind: self.kind,
status: self.status,
blocking: self.blocking,
case_ref: self.case_ref.clone(),
title: self.payload.title.clone(),
body: self.payload.body.clone(),
options: self
.payload
.options
.iter()
.map(InteractionOption::view)
.collect(),
review_entries: self.payload.review_entries.clone(),
freeform_prompt: self.payload.freeform_prompt.clone(),
metadata: self.payload.metadata.clone(),
expires_at: self.expires_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionOptionView {
pub id: OptionId,
pub label: LocalizedText,
pub freeform_policy: FreeformPolicy,
pub style: OptionStyle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionView {
pub id: InteractionId,
pub kind: InteractionKind,
pub status: InteractionStatus,
pub blocking: bool,
pub case_ref: CaseRef,
pub title: LocalizedText,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<LocalizedText>,
pub options: Vec<InteractionOptionView>,
#[serde(default)]
pub review_entries: Vec<ReviewDiffEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freeform_prompt: Option<LocalizedText>,
#[serde(default)]
pub metadata: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum InteractionRejection {
#[error("interaction not found")]
NotFound,
#[error("interaction is not active ({status:?})")]
NotActive {
status: InteractionStatus,
},
#[error("interaction already resolved")]
AlreadyResolved {
option_id: Option<OptionId>,
},
#[error("interaction expired")]
Expired,
#[error("interaction stale: bound {bound_revision}, current {current_revision}")]
Stale {
bound_revision: CaseRevision,
current_revision: CaseRevision,
},
#[error("unknown option")]
UnknownOption,
#[error("freeform input not allowed")]
FreeformNotAllowed,
#[error("freeform input required")]
FreeformRequired,
#[error("freeform input exceeds {max_len} characters")]
FreeformTooLong {
max_len: usize,
},
#[error("interaction payload does not match its stored hash")]
PayloadCorrupt,
#[error("interaction cannot be resolved through the {channel:?} channel")]
ChannelNotAllowed {
channel: ResolutionChannel,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcceptedResponse {
pub interaction_id: InteractionId,
pub case_ref: CaseRef,
pub kind: InteractionKind,
pub option_id: OptionId,
pub action: StoredInteractionAction,
pub channel: ResolutionChannel,
pub freeform_input: Option<String>,
pub payload_hash: Digest,
}
impl AcceptedResponse {
#[must_use]
pub fn origin(&self) -> Option<CommandOrigin> {
let action_class = self.action.action_class();
action_class
.authorizes_commands()
.then(|| CommandOrigin::ConfirmedInteraction {
interaction_id: self.interaction_id,
payload_hash: self.payload_hash.clone(),
interaction_kind: self.kind,
action_class,
channel: self.channel,
})
}
}
pub fn validate_response(
interaction: &Interaction,
response: &InteractionResponse,
channel: ResolutionChannel,
actor: &ActorContext,
conversation_id: &ConversationId,
current_revision: CaseRevision,
now: DateTime<Utc>,
) -> Result<AcceptedResponse, InteractionRejection> {
if response.interaction_id != interaction.id
|| actor.account_id != interaction.account_id
|| *conversation_id != interaction.conversation_id
{
return Err(InteractionRejection::NotFound);
}
match interaction.status {
InteractionStatus::Active => {}
InteractionStatus::Resolved => {
return Err(InteractionRejection::AlreadyResolved {
option_id: interaction.resolved_option_id.clone(),
});
}
status => return Err(InteractionRejection::NotActive { status }),
}
if interaction.is_expired(now) {
return Err(InteractionRejection::Expired);
}
if let Some(bound) = interaction.bound_revision()
&& (response.expected_case_revision != bound || current_revision != bound)
{
return Err(InteractionRejection::Stale {
bound_revision: bound,
current_revision,
});
}
if !matches!(interaction.verify_payload_hash(), Ok(true)) {
return Err(InteractionRejection::PayloadCorrupt);
}
if !interaction.text_resolution.admits(channel) {
return Err(InteractionRejection::ChannelNotAllowed { channel });
}
let option = interaction
.payload
.option(&response.option_id)
.ok_or(InteractionRejection::UnknownOption)?;
let freeform_input = match (&response.freeform_input, option.freeform_policy) {
(Some(_), FreeformPolicy::Forbidden) => {
return Err(InteractionRejection::FreeformNotAllowed);
}
(None, FreeformPolicy::Required { .. }) => {
return Err(InteractionRejection::FreeformRequired);
}
(
Some(text),
FreeformPolicy::Optional { max_len } | FreeformPolicy::Required { max_len },
) => {
if text.chars().count() > max_len {
return Err(InteractionRejection::FreeformTooLong { max_len });
}
if text.trim().is_empty() {
if option.freeform_policy.requires() {
return Err(InteractionRejection::FreeformRequired);
}
None
} else {
Some(text.clone())
}
}
(None, _) => None,
};
Ok(AcceptedResponse {
interaction_id: interaction.id,
case_ref: interaction.case_ref.clone(),
kind: interaction.kind,
option_id: option.id.clone(),
action: option.action.clone(),
channel,
freeform_input,
payload_hash: interaction.payload_hash.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::ConfirmationPolicy;
fn now() -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000, 0).unwrap()
}
fn confirm_option() -> InteractionOption {
InteractionOption::new(
"confirm",
"Confirm",
StoredInteractionAction::ApplyOperation {
operation: OperationKey::from("trip.rebook"),
arguments: serde_json::Value::Null,
freeform_argument: None,
},
)
}
fn decline_option() -> InteractionOption {
InteractionOption::new("no", "Cancel", StoredInteractionAction::DeclineCommands)
}
fn payload() -> InteractionPayload {
InteractionPayload::new("Rebook this flight?")
.with_option(confirm_option())
.with_option(decline_option())
.with_option(
InteractionOption::new("note", "Add a note", StoredInteractionAction::Dismiss)
.with_freeform(FreeformPolicy::Optional { max_len: 5 }),
)
.with_option(
InteractionOption::new("why", "Explain", StoredInteractionAction::Dismiss)
.with_freeform(FreeformPolicy::Required { max_len: 100 }),
)
}
fn spec() -> InteractionSpec {
InteractionSpec::new(
"send",
CaseRef::new("trip", "i1", CaseRevision(12)),
InteractionKind::ConfirmCommand,
payload(),
)
}
fn interaction() -> Interaction {
Interaction::from_spec(
spec(),
InteractionId::nil(),
AccountId::from("acct"),
ConversationId::nil(),
TurnId::nil(),
now(),
)
.unwrap()
}
fn response(option: &str) -> InteractionResponse {
InteractionResponse {
interaction_id: InteractionId::nil(),
option_id: OptionId::from(option),
expected_case_revision: CaseRevision(12),
freeform_input: None,
}
}
fn actor() -> ActorContext {
ActorContext::new("acct", "u1")
}
fn validate(
i: &Interaction,
r: &InteractionResponse,
) -> Result<AcceptedResponse, InteractionRejection> {
validate_response(
i,
r,
ResolutionChannel::Click,
&actor(),
&ConversationId::nil(),
CaseRevision(12),
now(),
)
}
#[test]
fn happy_path_yields_confirmed_origin() {
let i = interaction();
let accepted = validate(&i, &response("confirm")).unwrap();
assert_eq!(accepted.option_id, OptionId::from("confirm"));
assert_eq!(
accepted.origin(),
Some(CommandOrigin::ConfirmedInteraction {
interaction_id: i.id,
payload_hash: i.payload_hash.clone(),
interaction_kind: InteractionKind::ConfirmCommand,
action_class: ActionClass::AppliesOperation,
channel: ResolutionChannel::Click,
})
);
assert!(crate::command::origin_satisfies(
&accepted.origin().unwrap(),
&crate::command::CommandPolicy::conservative()
));
}
#[test]
fn an_option_that_authorizes_nothing_mints_no_origin() {
let i = interaction();
for option in ["no", "note"] {
let accepted = validate(&i, &response(option)).unwrap();
assert_eq!(accepted.origin(), None, "{option}");
}
let selection = Interaction::from_spec(
InteractionSpec::new(
"which",
CaseRef::new("trip", "i1", CaseRevision(12)),
InteractionKind::SelectTarget,
InteractionPayload::new("Which trip?").with_option(InteractionOption::new(
"a",
"Trip A",
StoredInteractionAction::SelectTarget {
case_ref: CaseRef::new("trip", "a", CaseRevision(1)),
},
)),
),
InteractionId::nil(),
AccountId::from("acct"),
ConversationId::nil(),
TurnId::nil(),
now(),
)
.unwrap();
let accepted = validate(&selection, &response("a")).unwrap();
assert_eq!(accepted.origin(), None);
}
#[test]
fn wrong_account_and_conversation_are_indistinguishable() {
let i = interaction();
let other_actor = ActorContext::new("other", "u1");
let err_account = validate_response(
&i,
&response("confirm"),
ResolutionChannel::Click,
&other_actor,
&ConversationId::nil(),
CaseRevision(12),
now(),
)
.unwrap_err();
let err_conv = validate_response(
&i,
&response("confirm"),
ResolutionChannel::Click,
&actor(),
&ConversationId::new(),
CaseRevision(12),
now(),
)
.unwrap_err();
let mut r = response("confirm");
r.interaction_id = InteractionId::new();
let err_id = validate(&i, &r).unwrap_err();
assert_eq!(err_account, InteractionRejection::NotFound);
assert_eq!(err_conv, InteractionRejection::NotFound);
assert_eq!(err_id, InteractionRejection::NotFound);
}
#[test]
fn identity_is_checked_before_status_and_expiry() {
let mut resolved = interaction();
resolved
.begin_resolution(OptionId::from("confirm"), now())
.unwrap();
resolved.transition(InteractionStatus::Resolved).unwrap();
let mut expired = interaction();
expired.expires_at = Some(now() - chrono::Duration::seconds(1));
for card in [&resolved, &expired] {
let err = validate_response(
card,
&response("confirm"),
ResolutionChannel::Click,
&ActorContext::new("other", "u1"),
&ConversationId::nil(),
CaseRevision(12),
now(),
)
.unwrap_err();
assert_eq!(err, InteractionRejection::NotFound);
}
}
#[test]
fn unknown_option_rejected() {
assert_eq!(
validate(&interaction(), &response("nope")).unwrap_err(),
InteractionRejection::UnknownOption
);
}
#[test]
fn freeform_rules() {
let i = interaction();
let mut r = response("confirm");
r.freeform_input = Some("x".into());
assert_eq!(
validate(&i, &r).unwrap_err(),
InteractionRejection::FreeformNotAllowed
);
let mut r = response("note");
r.freeform_input = Some("toolong".into());
assert_eq!(
validate(&i, &r).unwrap_err(),
InteractionRejection::FreeformTooLong { max_len: 5 }
);
r.freeform_input = Some("exact".into());
assert_eq!(
validate(&i, &r).unwrap().freeform_input.as_deref(),
Some("exact"),
"a string of exactly max_len characters is accepted"
);
r.freeform_input = Some("ok".into());
assert_eq!(
validate(&i, &r).unwrap().freeform_input.as_deref(),
Some("ok")
);
assert!(validate(&i, &response("note")).is_ok());
assert_eq!(
validate(&i, &response("why")).unwrap_err(),
InteractionRejection::FreeformRequired
);
}
#[test]
fn blank_text_does_not_satisfy_a_required_freeform() {
let i = interaction();
for blank in ["", " ", "\n\t "] {
let mut r = response("why");
r.freeform_input = Some(blank.into());
assert_eq!(
validate(&i, &r).unwrap_err(),
InteractionRejection::FreeformRequired,
"{blank:?}"
);
}
let mut r = response("note");
r.freeform_input = Some(" ".into());
assert_eq!(validate(&i, &r).unwrap().freeform_input, None);
}
#[test]
fn a_tampered_payload_is_refused() {
let mut i = interaction();
i.payload.options[0].action = StoredInteractionAction::ApplyOperation {
operation: OperationKey::from("trip.withdraw"),
arguments: serde_json::Value::Null,
freeform_argument: None,
};
assert!(!i.verify_payload_hash().unwrap());
assert_eq!(
validate(&i, &response("confirm")).unwrap_err(),
InteractionRejection::PayloadCorrupt
);
}
#[test]
fn a_card_only_answers_on_the_channels_it_admits() {
let i = interaction();
let channel = ResolutionChannel::ModelInterpreted;
let err = validate_response(
&i,
&response("confirm"),
channel,
&actor(),
&ConversationId::nil(),
CaseRevision(12),
now(),
)
.unwrap_err();
assert_eq!(err, InteractionRejection::ChannelNotAllowed { channel });
let mut aliased = Interaction::from_spec(
InteractionSpec::new(
"pick",
CaseRef::new("trip", "i1", CaseRevision(12)),
InteractionKind::SingleSelect,
InteractionPayload::new("Which?").with_option(InteractionOption::new(
"a",
"A",
StoredInteractionAction::ResolveClarification {
answer_key: "a".into(),
},
)),
)
.with_confirms_risk(RiskClass::ReversibleLowRisk)
.with_text_resolution(TextResolutionPolicy::Never),
InteractionId::nil(),
AccountId::from("acct"),
ConversationId::nil(),
TurnId::nil(),
now(),
)
.unwrap();
aliased.text_resolution = TextResolutionPolicy::ModelInterpretedLowRisk;
let accepted = validate_response(
&aliased,
&response("a"),
ResolutionChannel::ModelInterpreted,
&actor(),
&ConversationId::nil(),
CaseRevision(12),
now(),
)
.unwrap();
assert_eq!(accepted.channel, ResolutionChannel::ModelInterpreted);
assert_eq!(
accepted.origin(),
None,
"a clarification authorizes nothing"
);
}
#[test]
fn stale_revision_rejected_both_ways() {
let i = interaction();
let mut r = response("confirm");
r.expected_case_revision = CaseRevision(11);
assert_eq!(
validate(&i, &r).unwrap_err(),
InteractionRejection::Stale {
bound_revision: CaseRevision(12),
current_revision: CaseRevision(12)
}
);
let err = validate_response(
&i,
&response("confirm"),
ResolutionChannel::Click,
&actor(),
&ConversationId::nil(),
CaseRevision(13),
now(),
)
.unwrap_err();
assert_eq!(
err,
InteractionRejection::Stale {
bound_revision: CaseRevision(12),
current_revision: CaseRevision(13)
}
);
let mut independent = interaction();
independent.revision_independent = true;
assert!(
validate_response(
&independent,
&response("confirm"),
ResolutionChannel::Click,
&actor(),
&ConversationId::nil(),
CaseRevision(99),
now(),
)
.is_ok()
);
}
#[test]
fn already_resolved_returns_original_option() {
let mut i = interaction();
i.begin_resolution(OptionId::from("confirm"), now())
.unwrap();
i.transition(InteractionStatus::Resolved).unwrap();
assert_eq!(
validate(&i, &response("confirm")).unwrap_err(),
InteractionRejection::AlreadyResolved {
option_id: Some(OptionId::from("confirm"))
}
);
}
#[test]
fn expired_and_not_active() {
let mut i = interaction();
i.expires_at = Some(now() - chrono::Duration::seconds(1));
assert_eq!(
validate(&i, &response("confirm")).unwrap_err(),
InteractionRejection::Expired
);
let mut i = interaction();
i.status = InteractionStatus::Invalidated;
assert_eq!(
validate(&i, &response("confirm")).unwrap_err(),
InteractionRejection::NotActive {
status: InteractionStatus::Invalidated
}
);
let mut i = interaction();
i.status = InteractionStatus::Resolving;
assert!(matches!(
validate(&i, &response("confirm")).unwrap_err(),
InteractionRejection::NotActive { .. }
));
}
#[test]
fn state_machine_table() {
use InteractionStatus as S;
let allowed: &[(S, S)] = &[
(S::Active, S::Resolving),
(S::Active, S::Resolved),
(S::Active, S::Declined),
(S::Active, S::Dismissed),
(S::Active, S::Invalidated),
(S::Active, S::Expired),
(S::Active, S::Failed),
(S::Resolving, S::Resolved),
(S::Resolving, S::Failed),
(S::Resolving, S::Active),
];
for from in S::ALL {
for to in S::ALL {
let expected = allowed.contains(&(from, to));
assert_eq!(S::can_transition(from, to), expected, "{from:?} -> {to:?}");
}
}
for terminal in [
S::Resolved,
S::Declined,
S::Dismissed,
S::Invalidated,
S::Expired,
S::Failed,
] {
assert!(terminal.is_terminal());
assert!(!terminal.is_open());
}
}
#[test]
fn transition_refuses_what_the_table_forbids() {
let mut i = interaction();
i.transition(InteractionStatus::Resolving).unwrap();
i.transition(InteractionStatus::Resolved).unwrap();
let err = i.transition(InteractionStatus::Active).unwrap_err();
assert!(matches!(
err,
InteractionError::InvalidTransition {
from: InteractionStatus::Resolved,
to: InteractionStatus::Active,
..
}
));
let mut done = interaction();
done.status = InteractionStatus::Expired;
assert!(
done.begin_resolution(OptionId::from("confirm"), now())
.is_err()
);
assert_eq!(done.resolved_option_id, None);
}
#[test]
fn view_hides_actions() {
let json = serde_json::to_value(interaction().view()).unwrap();
assert!(json["options"][0].get("action").is_none());
assert_eq!(json["options"][0]["id"], "confirm");
}
#[test]
fn a_card_must_be_answerable_for_its_kind() {
let title_only = InteractionPayload::new("Anything?");
for kind in InteractionKind::ALL {
assert!(
title_only.validate_for(kind).is_err(),
"{kind:?} accepted a card with no options"
);
}
let boolean = InteractionPayload::new("Ready?")
.with_option(InteractionOption::new(
"yes",
"Yes",
StoredInteractionAction::ConfirmCommands {
command_refs: vec![],
},
))
.with_option(decline_option());
assert_eq!(boolean.validate_for(InteractionKind::Boolean), Ok(()));
let three = boolean.clone().with_option(InteractionOption::new(
"maybe",
"Maybe",
StoredInteractionAction::Dismiss,
));
assert!(matches!(
three.validate_for(InteractionKind::Boolean),
Err(InteractionSpecError::NotEnoughOptions { .. })
));
let no_decline = InteractionPayload::new("Send?")
.with_option(confirm_option())
.with_option(InteractionOption::new(
"later",
"Later",
StoredInteractionAction::ResolveClarification {
answer_key: "later".into(),
},
));
assert!(matches!(
no_decline.validate_for(InteractionKind::ConfirmCommand),
Err(InteractionSpecError::MissingDeclineOption { .. })
));
let no_authority = InteractionPayload::new("Send?")
.with_option(decline_option())
.with_option(InteractionOption::new(
"dismiss",
"Close",
StoredInteractionAction::Dismiss,
));
assert!(matches!(
no_authority.validate_for(InteractionKind::ConfirmCommand),
Err(InteractionSpecError::MissingAuthorizingOption { .. })
));
assert!(matches!(
boolean.validate_for(InteractionKind::ReviewChanges),
Err(InteractionSpecError::MissingReviewEntries)
));
let review = boolean.clone().with_review_entry(
ReviewDiffEntry::new("total", "Total")
.with_after(FieldValue::present(serde_json::Value::from(10))),
);
assert_eq!(review.validate_for(InteractionKind::ReviewChanges), Ok(()));
let freeform_option =
InteractionOption::new("text", "Send", StoredInteractionAction::Dismiss)
.with_freeform(FreeformPolicy::Required { max_len: 200 });
let no_prompt = InteractionPayload::new("Note").with_option(freeform_option.clone());
assert!(matches!(
no_prompt.validate_for(InteractionKind::Freeform),
Err(InteractionSpecError::MissingFreeformPrompt)
));
let no_field = InteractionPayload::new("Note")
.with_freeform_prompt("Write the note")
.with_option(decline_option());
assert!(matches!(
no_field.validate_for(InteractionKind::Freeform),
Err(InteractionSpecError::MissingFreeformOption)
));
let good = InteractionPayload::new("Note")
.with_freeform_prompt("Write the note")
.with_option(freeform_option);
assert_eq!(good.validate_for(InteractionKind::Freeform), Ok(()));
let duplicated = InteractionPayload::new("Pick")
.with_option(InteractionOption::new(
"a",
"A",
StoredInteractionAction::Dismiss,
))
.with_option(InteractionOption::new(
"a",
"A again",
StoredInteractionAction::Dismiss,
));
assert!(matches!(
duplicated.validate_for(InteractionKind::SingleSelect),
Err(InteractionSpecError::DuplicateOptionId { .. })
));
}
#[test]
fn multi_select_cannot_be_persisted() {
let payload = InteractionPayload::new("Pick some")
.with_option(InteractionOption::new(
"a",
"A",
StoredInteractionAction::Dismiss,
))
.with_option(InteractionOption::new(
"b",
"B",
StoredInteractionAction::Dismiss,
));
assert!(matches!(
payload.validate_for(InteractionKind::MultiSelect),
Err(InteractionSpecError::UnsupportedKind {
interaction_kind: InteractionKind::MultiSelect
})
));
let spec = InteractionSpec::new(
"pick",
CaseRef::new("trip", "i1", CaseRevision(1)),
InteractionKind::MultiSelect,
payload,
);
assert!(matches!(
Interaction::from_spec(
spec,
InteractionId::nil(),
AccountId::from("acct"),
ConversationId::nil(),
TurnId::nil(),
now()
),
Err(InteractionError::InvalidSpec(_))
));
}
#[test]
fn a_confirming_card_may_never_be_resolved_from_text() {
let base = spec();
let policy = TextResolutionPolicy::ModelInterpretedLowRisk;
let risky = base.clone().with_text_resolution(policy.clone());
assert!(
matches!(
risky.validate(),
Err(InteractionSpecError::TextResolutionNotAllowed { .. })
),
"a ConfirmCommand card is authorizing whatever its risk"
);
let mut low_kind = base.clone().with_text_resolution(policy);
low_kind.kind = InteractionKind::SingleSelect;
low_kind.payload = InteractionPayload::new("Which?").with_option(InteractionOption::new(
"a",
"A",
StoredInteractionAction::Dismiss,
));
assert!(matches!(
low_kind.validate(),
Err(InteractionSpecError::TextResolutionNotAllowed { .. })
));
assert_eq!(
low_kind
.with_confirms_risk(RiskClass::ReversibleLowRisk)
.validate(),
Ok(())
);
assert_eq!(base.validate(), Ok(()));
assert_eq!(
spec().confirms_risk,
RiskClass::Irreversible,
"a card that declares nothing is treated as consequential"
);
}
#[test]
fn an_unusable_time_to_live_is_an_error_not_an_immortal_card() {
let ttl = spec().expires_in(Duration::from_secs(60));
let card = Interaction::from_spec(
ttl,
InteractionId::nil(),
AccountId::from("acct"),
ConversationId::nil(),
TurnId::nil(),
now(),
)
.unwrap();
assert_eq!(card.expires_at, Some(now() + chrono::Duration::seconds(60)));
assert!(!card.is_expired(now()));
assert!(card.is_expired(now() + chrono::Duration::seconds(61)));
let absurd = spec().expires_in(Duration::from_secs(u64::MAX));
assert!(matches!(
Interaction::from_spec(
absurd,
InteractionId::nil(),
AccountId::from("acct"),
ConversationId::nil(),
TurnId::nil(),
now()
),
Err(InteractionError::InvalidTtl)
));
}
#[test]
fn an_explicit_null_survives_the_round_trip() {
let entry = ReviewDiffEntry::new("traveler.email", "Email")
.with_before(FieldValue::present(serde_json::Value::from("a@b.it")))
.with_after(FieldValue::cleared());
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["after"], serde_json::Value::Null);
let back: ReviewDiffEntry = serde_json::from_value(json).unwrap();
assert_eq!(back, entry);
assert_eq!(back.after, FieldValue::Present(serde_json::Value::Null));
let absent = ReviewDiffEntry::new("traveler.email", "Email");
let json = serde_json::to_value(&absent).unwrap();
assert!(json.get("after").is_none(), "an absent side omits the key");
assert_eq!(
serde_json::from_value::<ReviewDiffEntry>(json).unwrap(),
absent
);
let payload = InteractionPayload::new("Clear the email?")
.with_option(confirm_option())
.with_option(decline_option())
.with_review_entry(entry);
let card = Interaction::from_spec(
InteractionSpec::new(
"clear",
CaseRef::new("trip", "i1", CaseRevision(1)),
InteractionKind::ReviewChanges,
payload,
),
InteractionId::nil(),
AccountId::from("acct"),
ConversationId::nil(),
TurnId::nil(),
now(),
)
.unwrap();
assert!(card.verify_payload_hash().unwrap());
let reloaded: Interaction =
serde_json::from_str(&serde_json::to_string(&card).unwrap()).unwrap();
assert_eq!(reloaded, card);
assert!(reloaded.verify_payload_hash().unwrap());
}
#[test]
fn action_classes_follow_the_stored_action() {
assert_eq!(
StoredInteractionAction::ConfirmCommands {
command_refs: vec![]
}
.action_class(),
ActionClass::ConfirmsCommands
);
assert_eq!(
StoredInteractionAction::ApplyOperation {
operation: OperationKey::from("x"),
arguments: serde_json::Value::Null,
freeform_argument: None,
}
.action_class(),
ActionClass::AppliesOperation
);
for action in [
StoredInteractionAction::DeclineCommands,
StoredInteractionAction::Dismiss,
StoredInteractionAction::SelectTarget {
case_ref: CaseRef::new("w", "c", CaseRevision(1)),
},
StoredInteractionAction::ResolveClarification {
answer_key: "k".into(),
},
StoredInteractionAction::Custom {
key: "k".into(),
payload: serde_json::Value::Null,
},
] {
assert_eq!(action.action_class(), ActionClass::NoCommands);
assert!(!action.action_class().authorizes_commands());
}
assert!(InteractionKind::ConfirmCommand.authorizes_commands());
assert!(InteractionKind::ExternalSignature.authorizes_commands());
assert!(!InteractionKind::SelectTarget.authorizes_commands());
assert_eq!(
ConfirmationPolicy::ExplicitClick.interaction_kind(),
Some(InteractionKind::ConfirmCommand)
);
}
}