1use crate::{
2 CardBlock, MessageCard, MessageEnvelope, OutKind, OutMessage, ProviderMessageEnvelope,
3 ReplyInput, SendInput, SendMetadata,
4};
5use anyhow::{Result, bail};
6use time::OffsetDateTime;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ValidationIssue {
11 pub field: &'static str,
12 pub message: String,
13}
14
15impl ValidationIssue {
16 pub fn new(field: &'static str, message: impl Into<String>) -> Self {
17 Self {
18 field,
19 message: message.into(),
20 }
21 }
22}
23
24impl std::fmt::Display for ValidationIssue {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 write!(f, "{}: {}", self.field, self.message)
27 }
28}
29
30impl std::error::Error for ValidationIssue {}
31
32pub type ValidationResult<T> = std::result::Result<T, ValidationIssue>;
34
35pub fn validate_send_input(input: &SendInput) -> ValidationResult<()> {
37 if input.to.trim().is_empty() {
38 return Err(ValidationIssue::new("to", "recipient is required"));
39 }
40
41 let has_text = input
42 .text
43 .as_ref()
44 .map(|t| !t.trim().is_empty())
45 .unwrap_or(false);
46 let has_attachments = !input.attachments.is_empty();
47
48 if !has_text && !has_attachments {
49 return Err(ValidationIssue::new(
50 "content",
51 "provide text or at least one attachment",
52 ));
53 }
54
55 if let Some(text) = &input.text
56 && text.trim().is_empty()
57 {
58 return Err(ValidationIssue::new("text", "text cannot be empty"));
59 }
60
61 for (idx, att) in input.attachments.iter().enumerate() {
62 if att.name.trim().is_empty() {
63 return Err(ValidationIssue::new(
64 "attachments",
65 format!("attachment #{idx} name is empty"),
66 ));
67 }
68 if att.content_type.trim().is_empty() {
69 return Err(ValidationIssue::new(
70 "attachments",
71 format!("attachment #{idx} content_type is empty"),
72 ));
73 }
74 if att.data_base64.trim().is_empty() {
75 return Err(ValidationIssue::new(
76 "attachments",
77 format!("attachment #{idx} data_base64 is empty"),
78 ));
79 }
80 }
81
82 if let Some(meta) = &input.metadata {
83 if let Some(thread_id) = &meta.thread_id
84 && thread_id.trim().is_empty()
85 {
86 return Err(ValidationIssue::new(
87 "metadata.thread_id",
88 "thread_id cannot be empty",
89 ));
90 }
91 if let Some(reply_to) = &meta.reply_to
92 && reply_to.trim().is_empty()
93 {
94 return Err(ValidationIssue::new(
95 "metadata.reply_to",
96 "reply_to cannot be empty",
97 ));
98 }
99 for (idx, tag) in meta.tags.iter().enumerate() {
100 if tag.trim().is_empty() {
101 return Err(ValidationIssue::new(
102 "metadata.tags",
103 format!("tag #{idx} is empty"),
104 ));
105 }
106 }
107 }
108
109 Ok(())
110}
111
112pub fn validate_reply_input(input: &ReplyInput) -> ValidationResult<()> {
114 if input.reply_to.trim().is_empty() {
115 return Err(ValidationIssue::new(
116 "reply_to",
117 "reply_to message identifier is required",
118 ));
119 }
120
121 let send_like = SendInput {
122 to: input.to.clone(),
123 text: input.text.clone(),
124 attachments: input.attachments.clone(),
125 metadata: Some(SendMetadata {
126 thread_id: input.metadata.as_ref().and_then(|m| m.thread_id.clone()),
127 reply_to: Some(input.reply_to.clone()),
128 tags: input
129 .metadata
130 .as_ref()
131 .map(|m| m.tags.clone())
132 .unwrap_or_default(),
133 }),
134 };
135
136 validate_send_input(&send_like)
137}
138
139pub fn normalize_envelope(env: &mut ProviderMessageEnvelope) {
141 if let Some(text) = env.text.as_mut() {
142 let trimmed = text.trim();
143 if trimmed.is_empty() {
144 env.text = None;
145 } else {
146 *text = trimmed.to_string();
147 }
148 }
149
150 if let Some(user) = env.user_id.as_mut() {
151 let trimmed = user.trim();
152 if trimmed.is_empty() {
153 env.user_id = None;
154 } else {
155 *user = trimmed.to_string();
156 }
157 }
158
159 let mut normalized = std::collections::BTreeMap::new();
160 for (k, v) in env.metadata.iter() {
161 let key = k.trim();
162 let value = v.trim();
163 if key.is_empty() || value.is_empty() {
164 continue;
165 }
166 normalized.insert(key.to_string(), value.to_string());
167 }
168 env.metadata = normalized;
169}
170
171pub fn validate_envelope(env: &MessageEnvelope) -> Result<()> {
192 if env.tenant.trim().is_empty() {
193 bail!("tenant empty");
194 }
195 if env.chat_id.trim().is_empty() {
196 bail!("chat_id empty");
197 }
198 if env.user_id.trim().is_empty() {
199 bail!("user_id empty");
200 }
201 if env.msg_id.trim().is_empty() {
202 bail!("msg_id empty");
203 }
204 OffsetDateTime::parse(
206 &env.timestamp,
207 &time::format_description::well_known::Rfc3339,
208 )
209 .map_err(|e| anyhow::anyhow!("invalid timestamp: {e}"))?;
210 Ok(())
211}
212
213pub fn validate_out(out: &OutMessage) -> Result<()> {
235 if out.tenant.trim().is_empty() {
236 bail!("tenant empty");
237 }
238 if out.chat_id.trim().is_empty() {
239 bail!("chat_id empty");
240 }
241 match out.kind {
242 OutKind::Text => {
243 if out.text.as_deref().unwrap_or("").trim().is_empty() {
244 bail!("text empty");
245 }
246 }
247 OutKind::Card => {
248 let card = out
249 .message_card
250 .as_ref()
251 .ok_or_else(|| anyhow::anyhow!("card missing"))?;
252 validate_card(card)?;
253 }
254 }
255 Ok(())
256}
257
258pub fn validate_card(card: &MessageCard) -> Result<()> {
283 if card.body.is_empty() && card.title.as_deref().unwrap_or("").is_empty() {
284 bail!("card must have title or body");
285 }
286 for block in &card.body {
287 match block {
288 CardBlock::Text { text, .. } if text.trim().is_empty() => bail!("empty text block"),
289 CardBlock::Fact { label, value }
290 if label.trim().is_empty() || value.trim().is_empty() =>
291 {
292 bail!("empty fact")
293 }
294 CardBlock::Image { url } if url.trim().is_empty() => bail!("empty image url"),
295 _ => {}
296 }
297 }
298 Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::{CardAction, Platform, make_tenant_ctx};
305 use serde_json::json;
306 use std::collections::BTreeMap;
307
308 fn sample_envelope() -> MessageEnvelope {
309 MessageEnvelope {
310 tenant: "acme".into(),
311 platform: Platform::Teams,
312 chat_id: "chat-1".into(),
313 user_id: "user-7".into(),
314 thread_id: None,
315 msg_id: "msg-1".into(),
316 text: Some("Hello".into()),
317 timestamp: "2024-01-01T00:00:00Z".into(),
318 context: BTreeMap::new(),
319 }
320 }
321
322 fn sample_out(kind: OutKind) -> OutMessage {
323 OutMessage {
324 ctx: make_tenant_ctx("acme".into(), None, None),
325 tenant: "acme".into(),
326 platform: Platform::Telegram,
327 chat_id: "chat-1".into(),
328 thread_id: None,
329 kind,
330 text: Some("Hello".into()),
331 message_card: None,
332 #[cfg(feature = "adaptive-cards")]
333 adaptive_card: None,
334 meta: Default::default(),
335 }
336 }
337
338 #[test]
339 fn envelope_rejects_empty_tenant() {
340 let mut env = sample_envelope();
341 env.tenant = " ".into();
342 assert!(validate_envelope(&env).is_err());
343 }
344
345 #[test]
346 fn envelope_rejects_bad_timestamp() {
347 let mut env = sample_envelope();
348 env.timestamp = "not-a-date".into();
349 assert!(validate_envelope(&env).is_err());
350 }
351
352 #[test]
353 fn out_text_requires_content() {
354 let mut out = sample_out(OutKind::Text);
355 out.text = Some(" ".into());
356 assert!(validate_out(&out).is_err());
357 }
358
359 #[test]
360 fn out_card_requires_message_card() {
361 let out = sample_out(OutKind::Card);
362 assert!(validate_out(&out).is_err());
363 }
364
365 #[test]
366 fn card_requires_body_or_title() {
367 let card = MessageCard {
368 title: None,
369 body: vec![],
370 actions: vec![],
371 };
372 assert!(validate_card(&card).is_err());
373 }
374
375 #[test]
376 fn card_rejects_empty_fact_fields() {
377 let card = MessageCard {
378 title: Some("Facts".into()),
379 body: vec![CardBlock::Fact {
380 label: " ".into(),
381 value: "".into(),
382 }],
383 actions: vec![],
384 };
385 assert!(validate_card(&card).is_err());
386 }
387
388 #[test]
389 fn card_accepts_valid_structure() {
390 let card = MessageCard {
391 title: Some("Weather".into()),
392 body: vec![
393 CardBlock::Text {
394 text: "Sunny".into(),
395 markdown: false,
396 },
397 CardBlock::Fact {
398 label: "High".into(),
399 value: "22C".into(),
400 },
401 ],
402 actions: vec![CardAction::Postback {
403 title: "Ack".into(),
404 data: json!({"ok": true}),
405 }],
406 };
407 assert!(validate_card(&card).is_ok());
408 }
409
410 #[test]
411 fn send_validation_requires_text_or_attachment() {
412 let input = SendInput {
413 to: "channel-123".into(),
414 text: None,
415 attachments: vec![],
416 metadata: None,
417 };
418 assert!(validate_send_input(&input).is_err());
419 }
420
421 #[test]
422 fn reply_validation_requires_reply_to() {
423 let input = ReplyInput {
424 to: "channel-1".into(),
425 reply_to: " ".into(),
426 text: Some("hi".into()),
427 attachments: vec![],
428 metadata: None,
429 };
430 assert!(validate_reply_input(&input).is_err());
431 }
432
433 #[test]
434 fn normalize_envelope_trims_and_prunes() {
435 let mut env = ProviderMessageEnvelope {
436 id: "id-1".into(),
437 tenant: make_tenant_ctx("acme".into(), None, None),
438 channel: "channel".into(),
439 session_id: "session".into(),
440 reply_scope: None,
441 user_id: Some(" ".into()),
442 correlation_id: None,
443 text: Some(" hi ".into()),
444 attachments: vec![],
445 metadata: BTreeMap::from_iter([(" key ".into(), " value ".into())]),
446 };
447 normalize_envelope(&mut env);
448 assert_eq!(env.text.as_deref(), Some("hi"));
449 assert!(env.user_id.is_none());
450 assert!(!env.metadata.contains_key(" key "));
451 assert_eq!(env.metadata.get("key"), Some(&"value".to_string()));
452 }
453}