use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::InvalidInputError;
use crate::hash::Digest;
use crate::ids::{
AccountId, AttachmentId, CaseRevision, ConversationId, InteractionId, OptionId, OriginToken,
TurnId, UserId,
};
use crate::locale::Locale;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActorContext {
pub account_id: AccountId,
pub user_id: UserId,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub attributes: IndexMap<String, serde_json::Value>,
}
impl ActorContext {
#[must_use]
pub fn new(account_id: impl Into<AccountId>, user_id: impl Into<UserId>) -> Self {
Self {
account_id: account_id.into(),
user_id: user_id.into(),
roles: Vec::new(),
attributes: IndexMap::new(),
}
}
#[must_use]
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.roles.push(role.into());
self
}
#[must_use]
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttachmentRef {
pub attachment_id: AttachmentId,
pub media_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<Digest>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachmentContent {
pub media_type: String,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AttachmentError {
#[error("attachment {attachment_id} is no longer held")]
Gone {
attachment_id: AttachmentId,
},
#[error("attachment {attachment_id} is not readable by this actor")]
Unauthorized {
attachment_id: AttachmentId,
},
#[error("fetching attachment {attachment_id} timed out")]
Timeout {
attachment_id: AttachmentId,
},
#[error("attachment {attachment_id} failed: {code}")]
Other {
attachment_id: AttachmentId,
code: String,
},
}
#[async_trait::async_trait]
pub trait AttachmentSource: Send + Sync {
async fn fetch(
&self,
turn_id: &TurnId,
attachment: &AttachmentRef,
) -> Result<AttachmentContent, AttachmentError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OriginRef {
pub origin_token: OriginToken,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct InteractionResponse {
pub interaction_id: InteractionId,
pub option_id: OptionId,
pub expected_case_revision: CaseRevision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freeform_input: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TurnInput {
pub turn_id: TurnId,
pub conversation_id: ConversationId,
pub actor: ActorContext,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interaction_response: Option<InteractionResponse>,
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<OriginRef>,
pub locale: Locale,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<crate::effort::Effort>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TurnLimits {
pub max_text_bytes: Option<usize>,
pub max_attachments: Option<usize>,
}
impl TurnLimits {
#[must_use]
pub const fn conservative() -> Self {
Self {
max_text_bytes: None,
max_attachments: None,
}
}
#[must_use]
pub const fn with_max_text_bytes(mut self, max_text_bytes: Option<usize>) -> Self {
self.max_text_bytes = max_text_bytes;
self
}
#[must_use]
pub const fn with_max_attachments(mut self, max_attachments: Option<usize>) -> Self {
self.max_attachments = max_attachments;
self
}
}
impl Default for TurnLimits {
fn default() -> Self {
Self::conservative()
}
}
impl TurnInput {
pub fn validate_shape(&self) -> Result<(), InvalidInputError> {
self.validate_shape_within(&TurnLimits::conservative())
}
pub fn validate_shape_within(&self, limits: &TurnLimits) -> Result<(), InvalidInputError> {
if !self.has_text() && self.interaction_response.is_none() && self.attachments.is_empty() {
return Err(InvalidInputError::EmptyTurn);
}
if self.locale.as_str().trim().is_empty() {
return Err(InvalidInputError::EmptyLocale);
}
if self.actor.account_id.is_empty() {
return Err(InvalidInputError::EmptyAccount);
}
if let Some(max_bytes) = limits.max_text_bytes
&& self.text.as_ref().is_some_and(|t| t.len() > max_bytes)
{
return Err(InvalidInputError::TextTooLong { max_bytes });
}
if let Some(max) = limits.max_attachments
&& self.attachments.len() > max
{
return Err(InvalidInputError::TooManyAttachments { max });
}
Ok(())
}
#[must_use]
pub fn has_text(&self) -> bool {
self.text.as_deref().is_some_and(|t| !t.trim().is_empty())
}
#[must_use]
pub fn is_button_only(&self) -> bool {
self.interaction_response.is_some() && !self.has_text() && self.attachments.is_empty()
}
#[must_use]
pub fn account_id(&self) -> &AccountId {
&self.actor.account_id
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> TurnInput {
TurnInput {
turn_id: TurnId::nil(),
conversation_id: ConversationId::nil(),
actor: ActorContext::new("acct", "user"),
text: None,
interaction_response: None,
attachments: Vec::new(),
origin: None,
locale: Locale::from("it-IT"),
effort: None,
}
}
#[test]
fn empty_turn_is_rejected() {
assert_eq!(base().validate_shape(), Err(InvalidInputError::EmptyTurn));
let mut blank = base();
blank.text = Some(" ".into());
assert_eq!(blank.validate_shape(), Err(InvalidInputError::EmptyTurn));
}
#[test]
fn text_and_interaction_response_coexist() {
let mut turn = base();
turn.text = Some("confirm and tell me why".into());
turn.interaction_response = Some(InteractionResponse {
interaction_id: InteractionId::nil(),
option_id: OptionId::from("confirm"),
expected_case_revision: CaseRevision(12),
freeform_input: None,
});
assert_eq!(turn.validate_shape(), Ok(()));
assert!(!turn.is_button_only());
turn.text = None;
assert!(turn.is_button_only());
}
#[test]
fn limits_are_enforced_where_they_are_declared() {
let mut long = base();
long.text = Some("x".repeat(64));
assert_eq!(
long.validate_shape_within(&TurnLimits::conservative().with_max_text_bytes(Some(32))),
Err(InvalidInputError::TextTooLong { max_bytes: 32 })
);
assert_eq!(long.validate_shape(), Ok(()));
let mut many = base();
many.text = Some("hi".into());
many.attachments = (0..3)
.map(|n| AttachmentRef {
attachment_id: AttachmentId::from(format!("a{n}")),
media_type: "application/pdf".into(),
filename: None,
size_bytes: None,
digest: None,
})
.collect();
assert_eq!(
many.validate_shape_within(&TurnLimits::conservative().with_max_attachments(Some(2))),
Err(InvalidInputError::TooManyAttachments { max: 2 })
);
assert_eq!(many.validate_shape(), Ok(()));
}
#[test]
fn client_payload_rejects_unknown_fields() {
let json = r#"{"interaction_id":"00000000-0000-0000-0000-000000000000","option_id":"a","expected_case_revision":1,"value":"evil"}"#;
assert!(serde_json::from_str::<InteractionResponse>(json).is_err());
}
}