1mod business_events;
7mod customer_hub;
8mod event_outbox;
9mod marketing;
10mod organization;
11mod person;
12mod storage;
13
14pub use business_events::{
15 is_business_event_type, normalize_business_event, normalize_emitted_event, stable_event_id,
16 BusinessEventDefinition, BusinessEventError, BusinessEventField, EmittedBusinessEvent,
17};
18pub use customer_hub::{
19 env_flag_true, hub_call, hub_customer_from_person, is_customer_hub_enabled,
20 is_customer_hub_optional, pick_identity, read_identity_phone, seed_from_person,
21 ConsentContext, CustomerState, MembershipContext, PlatformCapabilities,
22 PlatformCustomerBinding, PlatformCustomerContext, PlatformStorageBinding,
23 PlatformStorageContext, TimelineContext,
24};
25pub use event_outbox::{default_outbox_dir, EventOutbox};
26pub use marketing::{
27 build_marketing_context, is_marketing_enabled, to_marketing_capability,
28 validate_marketing_definition, MarketingAction, MarketingAudience, MarketingAudienceAppQuery,
29 MarketingAudienceCustomerHub, MarketingCapability, MarketingChannel, MarketingContext,
30 MarketingDefinition, MarketingError, MarketingLandingPage, MarketingMetadata,
31 MarketingRegistration, MarketingVariable,
32};
33pub use organization::{
34 build_organization_context, is_organization_enabled, to_organization_capability,
35 validate_organization_definition, OrganizationAction, OrganizationCapabilities,
36 OrganizationCapability, OrganizationContext, OrganizationDefinition, OrganizationError,
37 OrganizationEvent, OrganizationMetadata, OrganizationTaskType,
38};
39pub use person::{
40 on_person_created, on_person_merged, on_person_status_changed, on_person_updated,
41 person_event_trigger, PersonContext, PersonMutation, PERSON_ACTIVITY_CREATED, PERSON_ASSIGNED,
42 PERSON_CREATED, PERSON_MERGED, PERSON_STATUS_CHANGED, PERSON_TAG_CREATED, PERSON_UPDATED,
43};
44pub use storage::{build_storage_context, StorageContext};
45
46use std::collections::HashMap;
47use std::future::Future;
48use std::pin::Pin;
49use std::sync::{Arc, Mutex as StdMutex, RwLock};
50use std::time::{SystemTime, UNIX_EPOCH};
51
52use anyhow::{anyhow, Result};
53use async_trait::async_trait;
54use axum::body::Bytes;
55use axum::extract::State;
56use axum::http::{HeaderMap, StatusCode};
57use axum::response::IntoResponse;
58use axum::routing::post;
59use axum::{Json, Router};
60use chrono::Utc;
61use hmac::{Hmac, Mac};
62use serde::{Deserialize, Serialize};
63use serde_json::{json, Value};
64use sha2::Sha256;
65use subtle::ConstantTimeEq;
66use tokio::sync::Mutex;
67use uuid::Uuid;
68
69type HmacSha256 = Hmac<Sha256>;
70
71pub const SDK_NAME: &str = "qefro-backend-sdk";
73pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
75
76type ToolHandler = Arc<dyn Fn(ToolContext) -> ToolFuture + Send + Sync>;
77type ToolFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send>>;
78type BeforeHook = Arc<dyn Fn(ToolContext) -> HookFuture + Send + Sync>;
79type AfterHook = Arc<dyn Fn(ToolContext, Value) -> AfterFuture + Send + Sync>;
80type HookFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
81type AfterFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send>>;
82type MiddlewareFn = Arc<
83 dyn Fn(ToolContext, NextFn) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
84 + Send
85 + Sync,
86>;
87type NextFn = Box<dyn FnOnce(ToolContext) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>> + Send>;
88
89#[derive(Debug, Clone)]
90pub struct QefroConfig {
91 pub signing_secret: String,
92 pub protocol_version: String,
93 pub max_timestamp_skew_secs: i64,
94 pub endpoint_path: String,
95 pub event_outbox_dir: Option<String>,
96 pub event_ingest_url: Option<String>,
97}
98
99impl QefroConfig {
100 pub fn new(signing_secret: impl Into<String>) -> Self {
101 Self {
102 signing_secret: signing_secret.into(),
103 protocol_version: "1".into(),
104 max_timestamp_skew_secs: 300,
105 endpoint_path: "/qefro".into(),
106 event_outbox_dir: None,
107 event_ingest_url: None,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum ToolAuthMode {
115 None,
116 #[default]
117 Optional,
118 Required,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
123pub struct ToolLookup {
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub by: Option<String>,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub required: Vec<String>,
130}
131
132pub fn normalize_lookup(lookup: Option<&ToolLookup>) -> Vec<String> {
134 let Some(lookup) = lookup else {
135 return Vec::new();
136 };
137 let mut seen = std::collections::HashSet::new();
138 let mut out = Vec::new();
139 for item in lookup
140 .required
141 .iter()
142 .cloned()
143 .chain(lookup.by.iter().cloned())
144 {
145 let key = item.trim().to_ascii_lowercase();
146 if key.is_empty() || !seen.insert(key.clone()) {
147 continue;
148 }
149 out.push(key);
150 }
151 out
152}
153
154fn normalized_lookup_field(lookup: Option<&ToolLookup>) -> Option<ToolLookup> {
155 let attrs = normalize_lookup(lookup);
156 if attrs.is_empty() {
157 None
158 } else {
159 Some(ToolLookup {
160 by: None,
161 required: attrs,
162 })
163 }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, Default)]
167pub struct ToolMetadata {
168 pub name: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub description: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub input_schema: Option<Value>,
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub authentication_methods: Vec<String>,
175 #[serde(default)]
176 pub auth: ToolAuthMode,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub permissions: Vec<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub timeout: Option<u64>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub default_auth_method: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub lookup: Option<ToolLookup>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub chat: Option<bool>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct RegisteredTool {
193 pub name: String,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub description: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub input_schema: Option<Value>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub authentication_methods: Vec<String>,
200 #[serde(default)]
201 pub auth: ToolAuthMode,
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub permissions: Vec<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub timeout: Option<u64>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub lookup: Option<ToolLookup>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub chat: Option<bool>,
210}
211
212fn default_flow_version() -> u32 {
217 1
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
224pub struct BusinessFlowMetadata {
225 pub id: String,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub name: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub description: Option<String>,
230 #[serde(default = "default_flow_version")]
232 pub version: u32,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub category: Option<String>,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub tags: Vec<String>,
237 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub intent: Vec<String>,
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub inputs: Vec<String>,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub outputs: Vec<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub trigger: Option<FlowTrigger>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(tag = "type", rename_all = "snake_case")]
255pub enum FlowTrigger {
256 Conversation,
257 Event {
258 event: String,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 when: Option<String>,
261 },
262 Schedule { cron: String },
263 Webhook {
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 name: Option<String>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 when: Option<String>,
268 },
269}
270
271impl FlowTrigger {
272 pub fn normalize(self) -> Result<Self, FlowError> {
273 match self {
274 FlowTrigger::Conversation => Ok(FlowTrigger::Conversation),
275 FlowTrigger::Event { event, when } => {
276 let event = event.trim().to_string();
277 if event.is_empty() {
278 return Err(FlowError::InvalidTrigger(
279 "trigger.type=event requires a non-empty event name".into(),
280 ));
281 }
282 if !event.contains('.') {
283 return Err(FlowError::InvalidTrigger(
284 "trigger.event must be namespaced (e.g. shopify.order.created)".into(),
285 ));
286 }
287 let when = when
288 .map(|w| w.trim().to_string())
289 .filter(|w| !w.is_empty());
290 Ok(FlowTrigger::Event { event, when })
291 }
292 FlowTrigger::Schedule { cron } => {
293 let cron = cron.trim().to_string();
294 if cron.is_empty() {
295 return Err(FlowError::InvalidTrigger(
296 "trigger.type=schedule requires a non-empty cron expression".into(),
297 ));
298 }
299 Ok(FlowTrigger::Schedule { cron })
300 }
301 FlowTrigger::Webhook { name, when } => {
302 let name = name
303 .map(|n| n.trim().to_string())
304 .filter(|n| !n.is_empty());
305 let when = when
306 .map(|w| w.trim().to_string())
307 .filter(|w| !w.is_empty());
308 Ok(FlowTrigger::Webhook { name, when })
309 }
310 }
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
316pub struct EventHandlerDefinition {
317 pub name: String,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub description: Option<String>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub cron: Option<String>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
328#[serde(tag = "type", content = "config", rename_all = "snake_case")]
329pub enum FlowStepKind {
330 Ask {
331 field: String,
332 prompt: String,
333 },
334 Tool {
335 tool_ref: String,
337 },
338 Challenge {
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 message: Option<String>,
341 },
342 Upload {
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 field: Option<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 prompt: Option<String>,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 accept: Vec<String>,
349 },
350 Condition {
351 when: String,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 then: Option<String>,
354 #[serde(rename = "else", default, skip_serializing_if = "Option::is_none")]
355 else_step: Option<String>,
356 },
357 Delay {
358 duration_seconds: u64,
359 },
360 Approval {
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 prompt: Option<String>,
363 },
364 Complete {
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 message: Option<String>,
367 },
368 Message {
369 message: String,
370 },
371 Tag {
372 name: String,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 color: Option<String>,
375 },
376 Activity {
377 activity_type: String,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 source: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 payload: Option<Value>,
382 },
383 Assign {
384 to: String,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 handoff: Option<bool>,
387 },
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
392pub struct FlowStep {
393 pub id: String,
394 #[serde(flatten)]
395 pub kind: FlowStepKind,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
400pub struct BusinessFlow {
401 pub metadata: BusinessFlowMetadata,
402 pub steps: Vec<FlowStep>,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq)]
408pub enum FlowError {
409 EmptyFlowId,
410 DuplicateFlowId(String),
411 EmptyStepId { flow: String },
412 DuplicateStepId { flow: String, step: String },
413 InvalidTrigger(String),
414 EmptyHandlerName(&'static str),
415 DuplicateHandler { kind: &'static str, name: String },
416}
417
418impl std::fmt::Display for FlowError {
419 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420 match self {
421 FlowError::EmptyFlowId => write!(f, "flow() requires a non-empty metadata.id"),
422 FlowError::DuplicateFlowId(id) => write!(f, "flow \"{id}\" is already registered"),
423 FlowError::EmptyStepId { flow } => {
424 write!(f, "flow \"{flow}\": every step requires a non-empty id")
425 }
426 FlowError::DuplicateStepId { flow, step } => {
427 write!(f, "flow \"{flow}\": duplicate step id \"{step}\"")
428 }
429 FlowError::InvalidTrigger(msg) => write!(f, "{msg}"),
430 FlowError::EmptyHandlerName(kind) => {
431 write!(f, "{kind}() requires a non-empty name")
432 }
433 FlowError::DuplicateHandler { kind, name } => {
434 write!(f, "{kind} \"{name}\" is already registered")
435 }
436 }
437 }
438}
439
440impl std::error::Error for FlowError {}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct AuthenticationContextPayload {
444 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
445 pub credential_type: Option<String>,
446 #[serde(skip_serializing_if = "Option::is_none")]
447 pub access_token: Option<String>,
448 #[serde(skip_serializing_if = "Option::is_none")]
449 pub credential: Option<String>,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub refresh_token: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub expires_in: Option<i64>,
454 #[serde(skip_serializing_if = "Option::is_none")]
455 pub customer_id: Option<String>,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct ChallengePayload {
460 #[serde(rename = "type")]
461 pub challenge_type: String,
462 pub message: String,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub destination_hint: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
466 pub login_url: Option<String>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct QefroRequest {
471 pub protocol_version: String,
472 #[serde(default)]
473 pub request_id: String,
474 #[serde(rename = "type")]
475 pub request_type: String,
476 pub organization_id: Option<String>,
477 pub conversation_id: Option<String>,
478 pub channel: Option<String>,
479 pub identity: Option<Value>,
480 pub tool: Option<String>,
481 pub parameters: Option<Value>,
482 pub authentication: Option<Value>,
483 pub resume_token: Option<String>,
484 pub challenge_response: Option<String>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub person: Option<Value>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub platform: Option<PlatformCapabilities>,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub settings: Option<Value>,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
497#[serde(tag = "type", rename_all = "snake_case")]
498pub enum QefroResponse {
499 Pong {
500 protocol_version: String,
501 sdk_version: String,
502 },
503 #[serde(rename = "tools.list")]
504 ToolsList {
505 tools: Vec<RegisteredTool>,
506 protocol_version: String,
507 sdk_version: String,
508 },
509 #[serde(rename = "capabilities.list")]
510 CapabilitiesList {
511 tools: Vec<RegisteredTool>,
512 flows: Vec<BusinessFlow>,
513 #[serde(default, skip_serializing_if = "Vec::is_empty")]
514 events: Vec<EventHandlerDefinition>,
515 #[serde(default, skip_serializing_if = "Vec::is_empty")]
516 webhooks: Vec<EventHandlerDefinition>,
517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
518 schedules: Vec<EventHandlerDefinition>,
519 #[serde(default, skip_serializing_if = "Vec::is_empty")]
521 business_events: Vec<BusinessEventDefinition>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 marketing: Option<MarketingCapability>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 organization: Option<OrganizationCapability>,
528 protocol_version: String,
529 sdk_version: String,
530 sdk_name: String,
531 },
532 Result {
533 output: Value,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 authentication_context: Option<AuthenticationContextPayload>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
537 person_mutations: Option<Vec<PersonMutation>>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 events: Option<Vec<EmittedBusinessEvent>>,
540 },
541 Challenge {
542 resume_token: String,
543 challenge: ChallengePayload,
544 },
545 Error {
546 code: String,
547 message: String,
548 },
549}
550
551#[derive(Debug, Clone)]
552struct PendingInvocation {
553 tool: String,
554 conversation_id: String,
555 parameters: Value,
556 identity: Option<Value>,
557 channel: Option<String>,
558 platform: Option<PlatformCapabilities>,
559 person: Option<Value>,
560 settings: Option<Value>,
561 created_at_ms: i64,
562}
563
564#[derive(Debug, Clone)]
565struct StoredAuth {
566 customer: Value,
567 auth: AuthenticationContextPayload,
568 expires_at_epoch_ms: i64,
569}
570
571#[derive(Debug, Clone)]
572pub struct Conversation {
573 pub id: String,
574}
575
576#[derive(Clone)]
577pub struct ToolContext {
578 pub identity: Value,
579 pub parameters: Value,
580 pub conversation: Conversation,
581 pub channel: Option<String>,
582 pub authentication: Option<Value>,
583 pub auth_response: Option<String>,
584 pub customer: Option<Value>,
586 customer_api: Option<CustomerApi>,
587 pub timeline: TimelineContext,
589 pub membership: MembershipContext,
591 pub consent: ConsentContext,
593 pub platform: Option<PlatformCapabilities>,
595 pub settings: Option<Value>,
596 pub install_settings: Option<Value>,
597 pub trace_id: Option<String>,
598 pub person: PersonContext,
599 pub storage: StorageContext,
600 emit: Arc<dyn Fn(EmittedBusinessEvent) -> Result<()> + Send + Sync>,
601}
602
603impl ToolContext {
604 pub fn customer_api(&self) -> Option<&CustomerApi> {
606 self.customer_api.as_ref()
607 }
608
609 pub fn emit(&self, event: EmittedBusinessEvent) -> Result<()> {
611 (self.emit)(event)
612 }
613
614 pub fn raise_challenge(challenge: ChallengePayload) -> Result<Value> {
616 Err(ChallengeSignal { challenge }.into())
617 }
618}
619
620#[derive(Debug, Clone)]
622pub struct ChallengeSignal {
623 pub challenge: ChallengePayload,
624}
625
626impl std::fmt::Display for ChallengeSignal {
627 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
628 write!(f, "{}", self.challenge.message)
629 }
630}
631
632impl std::error::Error for ChallengeSignal {}
633
634#[derive(Debug, Clone)]
635pub enum AuthOutcome {
636 Success {
637 customer: Value,
638 auth: AuthenticationContextPayload,
639 },
640 Challenge(ChallengePayload),
641 Denied,
642 NotFound,
643}
644
645#[derive(Debug, Clone)]
647pub struct AuthBuilder {
648 pub response: Option<String>,
649}
650
651impl AuthBuilder {
652 pub fn new(response: Option<String>) -> Self {
653 Self { response }
654 }
655
656 pub fn success(
657 &self,
658 customer: Value,
659 mut auth: AuthenticationContextPayload,
660 ) -> AuthOutcome {
661 if auth.customer_id.is_none() {
662 auth.customer_id = customer
663 .get("id")
664 .and_then(|v| v.as_str())
665 .map(str::to_string);
666 }
667 AuthOutcome::Success { customer, auth }
668 }
669
670 pub fn denied(&self) -> AuthOutcome {
671 AuthOutcome::Denied
672 }
673
674 pub fn not_found(&self) -> AuthOutcome {
675 AuthOutcome::NotFound
676 }
677
678 pub fn email_otp(&self, email: &str, message: Option<&str>) -> AuthOutcome {
679 AuthOutcome::Challenge(ChallengePayload {
680 challenge_type: "email_otp".into(),
681 message: message
682 .unwrap_or("Enter the OTP sent to your email.")
683 .into(),
684 destination_hint: Some(mask(email)),
685 login_url: None,
686 })
687 }
688
689 pub fn sms_otp(&self, phone: &str, message: Option<&str>) -> AuthOutcome {
690 AuthOutcome::Challenge(ChallengePayload {
691 challenge_type: "sms_otp".into(),
692 message: message
693 .unwrap_or("Enter the OTP sent to your phone.")
694 .into(),
695 destination_hint: Some(mask(phone)),
696 login_url: None,
697 })
698 }
699
700 pub fn login(&self, url: &str, message: Option<&str>) -> AuthOutcome {
701 AuthOutcome::Challenge(ChallengePayload {
702 challenge_type: "login".into(),
703 message: message
704 .unwrap_or("Please continue in your login page.")
705 .into(),
706 destination_hint: None,
707 login_url: Some(url.into()),
708 })
709 }
710
711 pub fn custom(&self, challenge: ChallengePayload) -> AuthOutcome {
712 AuthOutcome::Challenge(challenge)
713 }
714}
715
716fn mask(value: &str) -> String {
717 if value.len() <= 4 {
718 return value.to_string();
719 }
720 format!("{}***{}", &value[..2], &value[value.len() - 2..])
721}
722
723#[derive(Debug, Clone)]
724pub struct CustomerLookupContext {
725 pub identity: Value,
726 pub parameters: Value,
727 pub conversation: Conversation,
728 pub channel: Option<String>,
729}
730
731#[derive(Debug, Clone)]
732pub struct CustomerAuthorizeContext {
733 pub customer: Value,
734 pub method: Option<String>,
735 pub response: Option<String>,
736 pub identity: Value,
737 pub parameters: Value,
738 pub conversation: Conversation,
739 pub channel: Option<String>,
740}
741
742#[async_trait]
743pub trait CustomerProvider: Send + Sync {
744 async fn lookup(&self, ctx: &CustomerLookupContext) -> Result<Option<Value>>;
745 async fn authorize(&self, ctx: &CustomerAuthorizeContext) -> Result<AuthOutcome>;
746}
747
748#[derive(Clone)]
749struct ToolRegistration {
750 metadata: ToolMetadata,
751 handler: ToolHandler,
752}
753
754#[derive(Clone)]
755struct FlowRegistration {
756 metadata: BusinessFlowMetadata,
757 steps: Vec<FlowStep>,
758 error: Option<FlowError>,
761}
762
763pub struct FlowBuilder {
767 inner: Arc<Inner>,
768 flow_id: String,
769}
770
771impl FlowBuilder {
772 pub fn ask(self, id: impl Into<String>, field: impl Into<String>, prompt: impl Into<String>) -> Self {
773 self.push(
774 id.into(),
775 FlowStepKind::Ask {
776 field: field.into(),
777 prompt: prompt.into(),
778 },
779 )
780 }
781
782 pub fn tool(self, id: impl Into<String>, tool_ref: impl Into<String>) -> Self {
783 self.push(
784 id.into(),
785 FlowStepKind::Tool {
786 tool_ref: tool_ref.into(),
787 },
788 )
789 }
790
791 pub fn challenge(self, id: impl Into<String>, message: Option<String>) -> Self {
792 self.push(id.into(), FlowStepKind::Challenge { message })
793 }
794
795 pub fn upload(
796 self,
797 id: impl Into<String>,
798 field: Option<String>,
799 prompt: Option<String>,
800 accept: Vec<String>,
801 ) -> Self {
802 self.push(
803 id.into(),
804 FlowStepKind::Upload {
805 field,
806 prompt,
807 accept,
808 },
809 )
810 }
811
812 pub fn condition(
813 self,
814 id: impl Into<String>,
815 when: impl Into<String>,
816 then: Option<String>,
817 else_step: Option<String>,
818 ) -> Self {
819 self.push(
820 id.into(),
821 FlowStepKind::Condition {
822 when: when.into(),
823 then,
824 else_step,
825 },
826 )
827 }
828
829 pub fn delay(self, id: impl Into<String>, duration_seconds: u64) -> Self {
830 self.push(id.into(), FlowStepKind::Delay { duration_seconds })
831 }
832
833 pub fn approval(self, id: impl Into<String>, prompt: Option<String>) -> Self {
834 self.push(id.into(), FlowStepKind::Approval { prompt })
835 }
836
837 pub fn complete_step(self, id: impl Into<String>, message: Option<String>) -> Self {
841 self.push(id.into(), FlowStepKind::Complete { message })
842 }
843
844 pub fn message(self, id: impl Into<String>, message: impl Into<String>) -> Self {
845 self.push(
846 id.into(),
847 FlowStepKind::Message {
848 message: message.into(),
849 },
850 )
851 }
852
853 pub fn tag(self, id: impl Into<String>, name: impl Into<String>, color: Option<String>) -> Self {
854 self.push(
855 id.into(),
856 FlowStepKind::Tag {
857 name: name.into(),
858 color,
859 },
860 )
861 }
862
863 pub fn activity(
864 self,
865 id: impl Into<String>,
866 activity_type: impl Into<String>,
867 source: Option<String>,
868 payload: Option<Value>,
869 ) -> Self {
870 self.push(
871 id.into(),
872 FlowStepKind::Activity {
873 activity_type: activity_type.into(),
874 source,
875 payload,
876 },
877 )
878 }
879
880 pub fn assign(self, id: impl Into<String>, to: impl Into<String>, handoff: Option<bool>) -> Self {
881 self.push(
882 id.into(),
883 FlowStepKind::Assign {
884 to: to.into(),
885 handoff,
886 },
887 )
888 }
889
890 #[must_use = "handle the FlowError so malformed flows fail fast at startup"]
892 pub fn complete(self, id: impl Into<String>, message: Option<String>) -> Result<(), FlowError> {
893 let flow_id = self.flow_id.clone();
894 let inner = self.inner.clone();
895 let _ = self.push(id.into(), FlowStepKind::Complete { message });
896 let flows = inner.flows.read().expect("flows");
897 match flows.get(&flow_id).and_then(|r| r.error.clone()) {
898 Some(err) => Err(err),
899 None => Ok(()),
900 }
901 }
902
903 fn push(self, id: String, kind: FlowStepKind) -> Self {
904 let step_id = id.trim().to_string();
905 let mut flows = self.inner.flows.write().expect("flows");
906 if let Some(reg) = flows.get_mut(&self.flow_id) {
907 if reg.error.is_none() {
908 if step_id.is_empty() {
909 reg.error = Some(FlowError::EmptyStepId {
910 flow: self.flow_id.clone(),
911 });
912 } else if reg.steps.iter().any(|s| s.id == step_id) {
913 reg.error = Some(FlowError::DuplicateStepId {
914 flow: self.flow_id.clone(),
915 step: step_id.clone(),
916 });
917 } else {
918 reg.steps.push(FlowStep { id: step_id, kind });
919 }
920 }
921 }
922 drop(flows);
923 self
924 }
925}
926
927#[derive(Debug, Clone)]
928pub struct ListenOptions {
929 pub port: u16,
930 pub host: Option<String>,
931 pub path: Option<String>,
932}
933
934pub struct ListenHandle {
935 pub url: String,
936 shutdown: Option<tokio::sync::oneshot::Sender<()>>,
937 join: Option<tokio::task::JoinHandle<()>>,
938}
939
940impl ListenHandle {
941 pub async fn close(mut self) {
942 if let Some(tx) = self.shutdown.take() {
943 let _ = tx.send(());
944 }
945 if let Some(join) = self.join.take() {
946 let _ = join.await;
947 }
948 }
949}
950
951struct Inner {
952 config: QefroConfig,
953 tools: RwLock<HashMap<String, ToolRegistration>>,
954 flows: RwLock<HashMap<String, FlowRegistration>>,
955 events: RwLock<HashMap<String, EventHandlerDefinition>>,
956 webhooks: RwLock<HashMap<String, EventHandlerDefinition>>,
957 schedules: RwLock<HashMap<String, EventHandlerDefinition>>,
958 marketing: RwLock<Option<MarketingRegistration>>,
959 organization: RwLock<Option<OrganizationCapabilities>>,
960 business_events: RwLock<HashMap<String, BusinessEventDefinition>>,
961 outbox: EventOutbox,
962 event_ingest_url: Option<String>,
963 flush_in_flight: tokio::sync::Mutex<bool>,
964 pending: Mutex<HashMap<String, PendingInvocation>>,
965 auth_by_conversation: Mutex<HashMap<String, StoredAuth>>,
966 customer_provider: RwLock<Option<Arc<dyn CustomerProvider>>>,
967 middlewares: RwLock<Vec<MiddlewareFn>>,
968 before_hooks: RwLock<Vec<BeforeHook>>,
969 after_hooks: RwLock<Vec<AfterHook>>,
970}
971
972#[derive(Clone)]
978pub struct CustomerApi {
979 app: Qefro,
980 identity: Value,
981 parameters: Value,
982 conversation_id: String,
983 channel: Option<String>,
984 auth_response: Option<String>,
985 state: Arc<Mutex<CustomerState>>,
986 platform: Option<PlatformCapabilities>,
987}
988
989impl CustomerApi {
990 async fn set_current(&self, customer: Option<Value>) -> Option<Value> {
991 let mut state = self.state.lock().await;
992 state.current = customer.clone();
993 state.lookup_completed = true;
994 customer
995 }
996
997 async fn require_current_id(&self) -> Result<String> {
998 let state = self.state.lock().await;
999 if let Some(id) = state
1000 .current
1001 .as_ref()
1002 .and_then(|v| v.get("id"))
1003 .and_then(|v| v.as_str())
1004 .filter(|s| !s.is_empty())
1005 {
1006 return Ok(id.to_string());
1007 }
1008 Err(anyhow!("customer_not_found"))
1009 }
1010
1011 pub async fn resolve(&self, input: Option<Value>) -> Result<Option<Value>> {
1013 let identity = pick_identity(input.as_ref(), &self.identity);
1014 let mut body = Value::Object(identity);
1015 if let Some(obj) = body.as_object_mut() {
1016 if let Some(ch) = self.channel.as_ref() {
1017 obj.insert("channel".into(), json!(ch));
1018 }
1019 obj.insert("conversation_id".into(), json!(self.conversation_id.to_string()));
1020 }
1021 let out = hub_call(self.platform.as_ref(), "resolve", body).await?;
1022 let hub = hub_customer_from_person(out.as_ref());
1023 if hub.is_some() {
1024 self.set_current(hub.clone()).await;
1025 }
1026 Ok(hub)
1027 }
1028
1029 pub async fn lookup(&self, input: Option<Value>) -> Result<Option<Value>> {
1031 let provider = self
1032 .app
1033 .inner
1034 .customer_provider
1035 .read()
1036 .expect("customer_provider")
1037 .clone();
1038
1039 if input.is_some() || provider.is_none() {
1040 {
1041 let state = self.state.lock().await;
1042 if state.lookup_completed && input.is_none() && state.current.is_some() {
1043 return Ok(state.current.clone());
1044 }
1045 }
1046 let identity = pick_identity(input.as_ref(), &self.identity);
1047 let mut body = Value::Object(identity);
1048 if let Some(obj) = body.as_object_mut() {
1049 if let Some(ch) = self.channel.as_ref() {
1050 obj.insert("channel".into(), json!(ch));
1051 }
1052 obj.insert(
1053 "conversation_id".into(),
1054 json!(self.conversation_id.to_string()),
1055 );
1056 }
1057 let out = hub_call(self.platform.as_ref(), "lookup", body).await?;
1058 let hub = hub_customer_from_person(out.as_ref());
1059 self.set_current(hub.clone()).await;
1060 return Ok(hub);
1061 }
1062
1063 {
1064 let state = self.state.lock().await;
1065 if state.lookup_completed {
1066 return Ok(state.current.clone());
1067 }
1068 }
1069
1070 let customer = provider
1071 .unwrap()
1072 .lookup(&CustomerLookupContext {
1073 identity: self.identity.clone(),
1074 parameters: self.parameters.clone(),
1075 conversation: Conversation {
1076 id: self.conversation_id.clone(),
1077 },
1078 channel: self.channel.clone(),
1079 })
1080 .await?;
1081
1082 Ok(self.set_current(customer).await)
1083 }
1084
1085 pub async fn lookup_by_phone(&self, phone: Option<&str>) -> Result<Option<Value>> {
1086 let source = phone
1087 .map(str::to_string)
1088 .or_else(|| read_identity_phone(&self.identity));
1089
1090 let Some(source) = source else {
1091 let mut state = self.state.lock().await;
1092 state.lookup_completed = true;
1093 state.current = None;
1094 return Ok(None);
1095 };
1096
1097 let provider = self
1098 .app
1099 .inner
1100 .customer_provider
1101 .read()
1102 .expect("customer_provider")
1103 .clone();
1104
1105 if provider.is_none() || is_customer_hub_enabled() {
1106 return self
1107 .lookup(Some(json!({
1108 "phone_number": source,
1109 "whatsapp_number": source,
1110 })))
1111 .await;
1112 }
1113
1114 let mut identity = self.identity.clone();
1115 if let Some(obj) = identity.as_object_mut() {
1116 obj.insert("phone".into(), json!(source));
1117 }
1118
1119 let customer = provider
1120 .unwrap()
1121 .lookup(&CustomerLookupContext {
1122 identity,
1123 parameters: self.parameters.clone(),
1124 conversation: Conversation {
1125 id: self.conversation_id.clone(),
1126 },
1127 channel: self.channel.clone(),
1128 })
1129 .await?;
1130
1131 Ok(self.set_current(customer).await)
1132 }
1133
1134 pub async fn create(&self, input: Value) -> Result<Option<Value>> {
1135 let identity = pick_identity(Some(&input), &self.identity);
1136 let mut body = Value::Object(identity);
1137 if let Some(obj) = body.as_object_mut() {
1138 if let Some(ch) = self.channel.as_ref() {
1139 obj.insert("channel".into(), json!(ch));
1140 }
1141 obj.insert(
1142 "conversation_id".into(),
1143 json!(self.conversation_id.to_string()),
1144 );
1145 }
1146 let out = hub_call(self.platform.as_ref(), "create", body).await?;
1147 let hub = hub_customer_from_person(out.as_ref());
1148 if hub.is_some() {
1149 self.set_current(hub.clone()).await;
1150 }
1151 Ok(hub)
1152 }
1153
1154 pub async fn update(&self, input: Value) -> Result<Option<Value>> {
1155 let id = input
1156 .get("id")
1157 .and_then(|v| v.as_str())
1158 .map(str::to_string)
1159 .or_else(|| {
1160 None
1162 });
1163 let id = match id {
1164 Some(id) => id,
1165 None => {
1166 let state = self.state.lock().await;
1167 match state
1168 .current
1169 .as_ref()
1170 .and_then(|v| v.get("id"))
1171 .and_then(|v| v.as_str())
1172 .map(str::to_string)
1173 {
1174 Some(id) => id,
1175 None if is_customer_hub_optional() => return Ok(None),
1176 None => return Err(anyhow!("customer_not_found")),
1177 }
1178 }
1179 };
1180 let identity = pick_identity(Some(&input), &self.identity);
1181 let mut body = Value::Object(identity);
1182 if let Some(obj) = body.as_object_mut() {
1183 obj.insert("id".into(), json!(id));
1184 }
1185 let out = hub_call(self.platform.as_ref(), "update", body).await?;
1186 let hub = hub_customer_from_person(out.as_ref());
1187 if hub.is_some() {
1188 self.set_current(hub.clone()).await;
1189 }
1190 Ok(hub)
1191 }
1192
1193 pub async fn note(&self, content: &str, options: Option<Value>) -> Result<()> {
1194 let trimmed = content.trim();
1195 if trimmed.is_empty() {
1196 return Err(anyhow!("customer_note_empty"));
1197 }
1198 let customer_id = match self.require_current_id().await {
1199 Ok(id) => id,
1200 Err(_err) if is_customer_hub_optional() => return Ok(()),
1201 Err(err) => return Err(err),
1202 };
1203 let author_id = options
1204 .as_ref()
1205 .and_then(|v| v.get("author_id"))
1206 .cloned()
1207 .unwrap_or(Value::Null);
1208 hub_call(
1209 self.platform.as_ref(),
1210 "note",
1211 json!({
1212 "customer_id": customer_id,
1213 "content": trimmed,
1214 "author_id": author_id,
1215 }),
1216 )
1217 .await?;
1218 Ok(())
1219 }
1220
1221 pub async fn tag(&self, name: &str, options: Option<Value>) -> Result<()> {
1222 let trimmed = name.trim();
1223 if trimmed.is_empty() {
1224 return Err(anyhow!("customer_tag_empty"));
1225 }
1226 let customer_id = match self.require_current_id().await {
1227 Ok(id) => id,
1228 Err(_err) if is_customer_hub_optional() => return Ok(()),
1229 Err(err) => return Err(err),
1230 };
1231 let color = options
1232 .as_ref()
1233 .and_then(|v| v.get("color"))
1234 .cloned()
1235 .unwrap_or(Value::Null);
1236 hub_call(
1237 self.platform.as_ref(),
1238 "tag",
1239 json!({
1240 "customer_id": customer_id,
1241 "name": trimmed,
1242 "color": color,
1243 }),
1244 )
1245 .await?;
1246 Ok(())
1247 }
1248
1249 pub async fn authorize(&self, method: Option<String>) -> Result<Value> {
1250 let provider = self
1251 .app
1252 .inner
1253 .customer_provider
1254 .read()
1255 .expect("customer_provider")
1256 .clone()
1257 .ok_or_else(|| anyhow!("customer_provider_missing"))?;
1258
1259 {
1260 let auth = self.app.inner.auth_by_conversation.lock().await;
1261 if let Some(existing) = auth.get(&self.conversation_id) {
1262 if existing.expires_at_epoch_ms > Utc::now().timestamp_millis() {
1263 let mut state = self.state.lock().await;
1264 state.current = Some(existing.customer.clone());
1265 state.lookup_completed = true;
1266 return Ok(existing.customer.clone());
1267 }
1268 }
1269 }
1270
1271 let customer = self
1272 .lookup(None)
1273 .await?
1274 .ok_or_else(|| anyhow!("customer_not_found"))?;
1275
1276 let outcome = provider
1277 .authorize(&CustomerAuthorizeContext {
1278 customer: customer.clone(),
1279 method,
1280 response: self.auth_response.clone(),
1281 identity: self.identity.clone(),
1282 parameters: self.parameters.clone(),
1283 conversation: Conversation {
1284 id: self.conversation_id.clone(),
1285 },
1286 channel: self.channel.clone(),
1287 })
1288 .await?;
1289
1290 self.app
1291 .consume_auth_outcome(
1292 outcome,
1293 self.conversation_id.clone(),
1294 Some(self.state.clone()),
1295 None,
1296 None,
1297 None,
1298 None,
1299 )
1300 .await
1301 }
1302
1303 pub async fn get(&self) -> Option<Value> {
1304 self.state.lock().await.current.clone()
1305 }
1306
1307 pub async fn require(&self) -> Result<Value> {
1308 self.get()
1309 .await
1310 .ok_or_else(|| anyhow!("customer_not_found"))
1311 }
1312
1313 pub async fn id(&self) -> Option<String> {
1315 self.get()
1316 .await
1317 .and_then(|v| v.get("id")?.as_str().map(str::to_string))
1318 }
1319
1320 pub async fn phone_number(&self) -> Option<String> {
1321 self.get().await.and_then(|v| {
1322 v.get("phone_number")?
1323 .as_str()
1324 .map(str::to_string)
1325 .or_else(|| v.get("phone")?.as_str().map(str::to_string))
1326 })
1327 }
1328
1329 pub async fn whatsapp_number(&self) -> Option<String> {
1330 self.get()
1331 .await
1332 .and_then(|v| v.get("whatsapp_number")?.as_str().map(str::to_string))
1333 }
1334
1335 pub async fn display_name(&self) -> Option<String> {
1336 self.get().await.and_then(|v| {
1337 v.get("display_name")?
1338 .as_str()
1339 .map(str::to_string)
1340 .or_else(|| v.get("name")?.as_str().map(str::to_string))
1341 })
1342 }
1343}
1344
1345#[derive(Clone)]
1346pub struct Qefro {
1347 inner: Arc<Inner>,
1348}
1349
1350impl Qefro {
1351 pub fn new(config: QefroConfig) -> Self {
1352 let outbox_dir = config
1353 .event_outbox_dir
1354 .clone()
1355 .map(std::path::PathBuf::from)
1356 .unwrap_or_else(|| default_outbox_dir(&config.signing_secret));
1357 let event_ingest_url = config
1358 .event_ingest_url
1359 .as_deref()
1360 .map(str::trim)
1361 .filter(|s| !s.is_empty())
1362 .map(str::to_string);
1363 Self {
1364 inner: Arc::new(Inner {
1365 outbox: EventOutbox::new(outbox_dir),
1366 event_ingest_url,
1367 flush_in_flight: tokio::sync::Mutex::new(false),
1368 business_events: RwLock::new(HashMap::new()),
1369 config,
1370 tools: RwLock::new(HashMap::new()),
1371 flows: RwLock::new(HashMap::new()),
1372 events: RwLock::new(HashMap::new()),
1373 webhooks: RwLock::new(HashMap::new()),
1374 schedules: RwLock::new(HashMap::new()),
1375 marketing: RwLock::new(None),
1376 organization: RwLock::new(None),
1377 pending: Mutex::new(HashMap::new()),
1378 auth_by_conversation: Mutex::new(HashMap::new()),
1379 customer_provider: RwLock::new(None),
1380 middlewares: RwLock::new(Vec::new()),
1381 before_hooks: RwLock::new(Vec::new()),
1382 after_hooks: RwLock::new(Vec::new()),
1383 }),
1384 }
1385 }
1386
1387 pub fn customer<P>(&self, provider: P) -> &Self
1388 where
1389 P: CustomerProvider + 'static,
1390 {
1391 *self.inner.customer_provider.write().expect("customer_provider") =
1392 Some(Arc::new(provider));
1393 self
1394 }
1395
1396 pub fn marketing(
1399 &self,
1400 def: MarketingDefinition,
1401 ) -> std::result::Result<&Self, MarketingError> {
1402 let mut slot = self.inner.marketing.write().expect("marketing");
1403 if slot.is_some() {
1404 return Err(MarketingError::Message(
1405 "marketing() may only be called once".into(),
1406 ));
1407 }
1408 *slot = Some(validate_marketing_definition(def)?);
1409 Ok(self)
1410 }
1411
1412 pub fn organization(
1415 &self,
1416 def: OrganizationDefinition,
1417 ) -> std::result::Result<&Self, OrganizationError> {
1418 let mut slot = self.inner.organization.write().expect("organization");
1419 if slot.is_some() {
1420 return Err(OrganizationError::Message(
1421 "organization() may only be called once".into(),
1422 ));
1423 }
1424 *slot = Some(validate_organization_definition(def)?);
1425 Ok(self)
1426 }
1427
1428 pub fn business_event(
1429 &self,
1430 def: BusinessEventDefinition,
1431 ) -> std::result::Result<&Self, BusinessEventError> {
1432 let normalized = normalize_business_event(def)?;
1433 let mut map = self.inner.business_events.write().expect("business_events");
1434 if map.contains_key(&normalized.event_type) {
1435 return Err(BusinessEventError::Message(format!(
1436 "businessEvent \"{}\" is already registered",
1437 normalized.event_type
1438 )));
1439 }
1440 map.insert(normalized.event_type.clone(), normalized);
1441 Ok(self)
1442 }
1443
1444 pub fn tool<F, Fut>(&self, metadata: ToolMetadata, handler: F) -> &Self
1445 where
1446 F: Fn(ToolContext) -> Fut + Send + Sync + 'static,
1447 Fut: Future<Output = Result<Value>> + Send + 'static,
1448 {
1449 let name = metadata.name.clone();
1450 let lookup = normalized_lookup_field(metadata.lookup.as_ref());
1451 let metadata = ToolMetadata {
1452 lookup,
1453 ..metadata
1454 };
1455 let registration = ToolRegistration {
1456 metadata,
1457 handler: Arc::new(move |ctx| Box::pin(handler(ctx))),
1458 };
1459 self.inner
1460 .tools
1461 .write()
1462 .expect("tools")
1463 .insert(name, registration);
1464 self
1465 }
1466
1467 pub fn flow(&self, metadata: BusinessFlowMetadata) -> std::result::Result<FlowBuilder, FlowError> {
1472 let id = metadata.id.trim().to_string();
1473 if id.is_empty() {
1474 return Err(FlowError::EmptyFlowId);
1475 }
1476 {
1477 let flows = self.inner.flows.read().expect("flows");
1478 if flows.contains_key(&id) {
1479 return Err(FlowError::DuplicateFlowId(id));
1480 }
1481 }
1482 let version = if metadata.version == 0 { 1 } else { metadata.version };
1483 let trigger = match metadata.trigger.clone() {
1484 Some(t) => Some(t.normalize()?),
1485 None => None,
1486 };
1487 let metadata = BusinessFlowMetadata {
1488 id: id.clone(),
1489 version,
1490 trigger,
1491 ..metadata
1492 };
1493 self.inner.flows.write().expect("flows").insert(
1494 id.clone(),
1495 FlowRegistration {
1496 metadata,
1497 steps: Vec::new(),
1498 error: None,
1499 },
1500 );
1501 Ok(FlowBuilder {
1502 inner: self.inner.clone(),
1503 flow_id: id,
1504 })
1505 }
1506
1507 pub fn event(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1510 self.register_named_handler("event", &self.inner.events, def)
1511 }
1512
1513 pub fn webhook(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1515 self.register_named_handler("webhook", &self.inner.webhooks, def)
1516 }
1517
1518 pub fn schedule(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1520 let cron = def.cron.as_deref().unwrap_or("").trim();
1521 if cron.is_empty() {
1522 return Err(FlowError::InvalidTrigger(
1523 "schedule() requires a non-empty cron expression".into(),
1524 ));
1525 }
1526 self.register_named_handler("schedule", &self.inner.schedules, def)
1527 }
1528
1529 fn register_named_handler(
1530 &self,
1531 kind: &'static str,
1532 lock: &RwLock<HashMap<String, EventHandlerDefinition>>,
1533 def: EventHandlerDefinition,
1534 ) -> std::result::Result<&Self, FlowError> {
1535 let name = def.name.trim().to_string();
1536 if name.is_empty() {
1537 return Err(FlowError::EmptyHandlerName(kind));
1538 }
1539 let mut map = lock.write().expect(kind);
1540 if map.contains_key(&name) {
1541 return Err(FlowError::DuplicateHandler { kind, name });
1542 }
1543 map.insert(
1544 name.clone(),
1545 EventHandlerDefinition {
1546 name,
1547 description: def.description,
1548 cron: def.cron,
1549 },
1550 );
1551 Ok(self)
1552 }
1553
1554 fn list_named_handlers(
1555 lock: &RwLock<HashMap<String, EventHandlerDefinition>>,
1556 ) -> Vec<EventHandlerDefinition> {
1557 lock.read()
1558 .expect("handlers")
1559 .values()
1560 .cloned()
1561 .collect()
1562 }
1563
1564 pub(crate) fn list_registered_flows(&self) -> Vec<BusinessFlow> {
1567 let flows = self.inner.flows.read().expect("flows");
1568 flows
1569 .values()
1570 .filter_map(|reg| {
1571 if let Some(err) = ®.error {
1572 eprintln!("[qefro] skipping invalid flow \"{}\": {err}", reg.metadata.id);
1573 None
1574 } else {
1575 Some(BusinessFlow {
1576 metadata: reg.metadata.clone(),
1577 steps: reg.steps.clone(),
1578 })
1579 }
1580 })
1581 .collect()
1582 }
1583
1584 pub fn before<F, Fut>(&self, hook: F) -> &Self
1585 where
1586 F: Fn(ToolContext) -> Fut + Send + Sync + 'static,
1587 Fut: Future<Output = Result<()>> + Send + 'static,
1588 {
1589 let hook: BeforeHook = Arc::new(move |ctx| Box::pin(hook(ctx)));
1590 self.inner
1591 .before_hooks
1592 .write()
1593 .expect("before_hooks")
1594 .push(hook);
1595 self
1596 }
1597
1598 pub fn after<F, Fut>(&self, hook: F) -> &Self
1599 where
1600 F: Fn(ToolContext, Value) -> Fut + Send + Sync + 'static,
1601 Fut: Future<Output = Result<Value>> + Send + 'static,
1602 {
1603 let hook: AfterHook = Arc::new(move |ctx, out| Box::pin(hook(ctx, out)));
1604 self.inner
1605 .after_hooks
1606 .write()
1607 .expect("after_hooks")
1608 .push(hook);
1609 self
1610 }
1611
1612 pub fn use_middleware<F>(&self, middleware: F) -> &Self
1614 where
1615 F: Fn(ToolContext, NextFn) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
1616 + Send
1617 + Sync
1618 + 'static,
1619 {
1620 let mw: MiddlewareFn = Arc::new(middleware);
1621 self.inner.middlewares.write().expect("middlewares").push(mw);
1622 self
1623 }
1624
1625 pub fn verify_signature(&self, signature: &str, timestamp: i64, body: &str) -> bool {
1626 let now = Utc::now().timestamp();
1627 if (now - timestamp).abs() > self.inner.config.max_timestamp_skew_secs {
1628 return false;
1629 }
1630 let payload = format!("v1:{timestamp}:{body}");
1631 let mut mac = HmacSha256::new_from_slice(self.inner.config.signing_secret.as_bytes())
1632 .expect("HMAC accepts any key length");
1633 mac.update(payload.as_bytes());
1634 let expected = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
1635 let a = expected.as_bytes();
1636 let b = signature.as_bytes();
1637 a.len() == b.len() && bool::from(a.ct_eq(b))
1638 }
1639
1640 fn list_registered_tools(&self) -> Vec<RegisteredTool> {
1641 let tools = self.inner.tools.read().expect("tools");
1642 tools
1643 .values()
1644 .map(|r| RegisteredTool {
1645 name: r.metadata.name.clone(),
1646 description: r.metadata.description.clone(),
1647 input_schema: r.metadata.input_schema.clone(),
1648 authentication_methods: r.metadata.authentication_methods.clone(),
1649 auth: r.metadata.auth,
1650 permissions: r.metadata.permissions.clone(),
1651 timeout: r.metadata.timeout,
1652 lookup: r.metadata.lookup.clone(),
1653 chat: r.metadata.chat,
1654 })
1655 .collect()
1656 }
1657
1658 pub async fn handle(&self, request: QefroRequest) -> QefroResponse {
1660 self.handle_with_trace(request, None).await
1661 }
1662
1663 pub(crate) async fn handle_with_trace(&self, request: QefroRequest, trace_id: Option<String>) -> QefroResponse {
1664 if request.protocol_version != self.inner.config.protocol_version {
1665 return QefroResponse::Error {
1666 code: "protocol_mismatch".into(),
1667 message: "Unsupported protocol version".into(),
1668 };
1669 }
1670
1671 match request.request_type.as_str() {
1672 "ping" => QefroResponse::Pong {
1673 protocol_version: self.inner.config.protocol_version.clone(),
1674 sdk_version: SDK_VERSION.into(),
1675 },
1676 "tools.list" => QefroResponse::ToolsList {
1677 tools: self.list_registered_tools(),
1678 protocol_version: self.inner.config.protocol_version.clone(),
1679 sdk_version: SDK_VERSION.into(),
1680 },
1681 "capabilities.list" => {
1682 let business_events = self
1683 .inner
1684 .business_events
1685 .read()
1686 .expect("business_events")
1687 .values()
1688 .cloned()
1689 .collect();
1690 QefroResponse::CapabilitiesList {
1691 tools: self.list_registered_tools(),
1692 flows: self.list_registered_flows(),
1693 events: Self::list_named_handlers(&self.inner.events),
1694 webhooks: Self::list_named_handlers(&self.inner.webhooks),
1695 schedules: Self::list_named_handlers(&self.inner.schedules),
1696 business_events,
1697 marketing: self
1698 .inner
1699 .marketing
1700 .read()
1701 .expect("marketing")
1702 .as_ref()
1703 .map(to_marketing_capability),
1704 organization: self
1705 .inner
1706 .organization
1707 .read()
1708 .expect("organization")
1709 .as_ref()
1710 .map(to_organization_capability),
1711 protocol_version: self.inner.config.protocol_version.clone(),
1712 sdk_version: SDK_VERSION.into(),
1713 sdk_name: SDK_NAME.into(),
1714 }
1715 }
1716 "tool.invoke" => {
1717 self.invoke(
1718 request.tool,
1719 request.parameters.unwrap_or_else(|| json!({})),
1720 request
1721 .conversation_id
1722 .unwrap_or_else(|| Uuid::new_v4().to_string()),
1723 request.identity,
1724 request.channel,
1725 request.authentication,
1726 None,
1727 request.platform,
1728 request.person,
1729 request.settings,
1730 trace_id,
1731 )
1732 .await
1733 }
1734 "tool.resume" => {
1735 let Some(resume_token) = request.resume_token else {
1736 return QefroResponse::Error {
1737 code: "invalid_request".into(),
1738 message: "resume_token is required".into(),
1739 };
1740 };
1741 let Some(challenge_response) = request.challenge_response else {
1742 return QefroResponse::Error {
1743 code: "invalid_request".into(),
1744 message: "challenge_response is required".into(),
1745 };
1746 };
1747 let pending = self.take_pending(&resume_token).await;
1748 let Some(pending) = pending else {
1749 return QefroResponse::Error {
1750 code: "not_found".into(),
1751 message: "resume token not found or expired".into(),
1752 };
1753 };
1754 self.invoke(
1755 Some(pending.tool),
1756 pending.parameters,
1757 pending.conversation_id,
1758 pending.identity,
1759 pending.channel,
1760 request.authentication,
1761 Some(challenge_response),
1762 request.platform.or(pending.platform),
1763 request.person.or(pending.person),
1764 request.settings.or(pending.settings),
1765 trace_id,
1766 )
1767 .await
1768 }
1769 _ => QefroResponse::Error {
1770 code: "invalid_request".into(),
1771 message: "Unsupported request type".into(),
1772 },
1773 }
1774 }
1775
1776 pub async fn handle_raw(
1778 &self,
1779 body: &str,
1780 headers: &HeaderMap,
1781 ) -> (StatusCode, QefroResponse) {
1782 let signature = header_str(headers, "x-qefro-signature");
1783 let timestamp = header_str(headers, "x-qefro-timestamp")
1784 .and_then(|t| t.parse::<i64>().ok());
1785
1786 let protocol_header = header_str(headers, "x-qefro-protocol")
1787 .or_else(|| header_str(headers, "x-qefro-protocol-version"));
1788 if let Some(proto) = protocol_header {
1789 if proto != self.inner.config.protocol_version {
1790 return (
1791 StatusCode::BAD_REQUEST,
1792 QefroResponse::Error {
1793 code: "protocol_mismatch".into(),
1794 message: format!("Unsupported protocol version {proto}"),
1795 },
1796 );
1797 }
1798 }
1799
1800 match (signature, timestamp) {
1801 (Some(sig), Some(ts)) if self.verify_signature(sig, ts, body) => {}
1802 _ => {
1803 return (
1804 StatusCode::UNAUTHORIZED,
1805 QefroResponse::Error {
1806 code: "invalid_signature".into(),
1807 message: "Invalid Qefro signature".into(),
1808 },
1809 );
1810 }
1811 }
1812
1813 let request: QefroRequest = match serde_json::from_str(body) {
1814 Ok(r) => r,
1815 Err(e) => {
1816 return (
1817 StatusCode::BAD_REQUEST,
1818 QefroResponse::Error {
1819 code: "invalid_request".into(),
1820 message: e.to_string(),
1821 },
1822 );
1823 }
1824 };
1825
1826 let trace_id = header_str(headers, "x-qefro-trace-id")
1827 .map(str::trim)
1828 .filter(|s| !s.is_empty())
1829 .map(str::to_string);
1830 let resp = self.handle_with_trace(request, trace_id).await;
1831 self.schedule_flush();
1832 (StatusCode::OK, resp)
1833 }
1834
1835 pub async fn listen(&self, options: ListenOptions) -> Result<ListenHandle> {
1837 let host = options
1838 .host
1839 .unwrap_or_else(|| "0.0.0.0".to_string());
1840 let path = options
1841 .path
1842 .unwrap_or_else(|| self.inner.config.endpoint_path.clone());
1843 let path = if path.starts_with('/') {
1844 path
1845 } else {
1846 format!("/{path}")
1847 };
1848
1849 let app_state = self.clone();
1850 let router = Router::new()
1851 .route(&path, post(http_handler))
1852 .with_state(app_state);
1853
1854 let addr = format!("{host}:{}", options.port);
1855 let listener = tokio::net::TcpListener::bind(&addr).await?;
1856 let bound_port = listener.local_addr()?.port();
1857 let url = format!("http://{host}:{bound_port}{path}");
1858
1859 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1860 let join = tokio::spawn(async move {
1861 let _ = axum::serve(listener, router)
1862 .with_graceful_shutdown(async {
1863 let _ = rx.await;
1864 })
1865 .await;
1866 });
1867
1868 Ok(ListenHandle {
1869 url,
1870 shutdown: Some(tx),
1871 join: Some(join),
1872 })
1873 }
1874
1875 async fn invoke(
1876 &self,
1877 tool: Option<String>,
1878 parameters: Value,
1879 conversation_id: String,
1880 identity: Option<Value>,
1881 channel: Option<String>,
1882 authentication: Option<Value>,
1883 auth_response: Option<String>,
1884 platform: Option<PlatformCapabilities>,
1885 person: Option<Value>,
1886 settings: Option<Value>,
1887 trace_id: Option<String>,
1888 ) -> QefroResponse {
1889 let Some(tool_name) = tool else {
1890 return QefroResponse::Error {
1891 code: "invalid_request".into(),
1892 message: "tool is required".into(),
1893 };
1894 };
1895
1896 let registration = {
1897 let tools = self.inner.tools.read().expect("tools");
1898 tools.get(&tool_name).cloned()
1899 };
1900 let Some(registration) = registration else {
1901 return QefroResponse::Error {
1902 code: "not_found".into(),
1903 message: format!("Unknown tool: {tool_name}"),
1904 };
1905 };
1906
1907 let identity_value = identity.clone().unwrap_or_else(|| json!({}));
1908 let customer_state = Arc::new(Mutex::new(CustomerState::default()));
1909
1910 {
1911 let auth = self.inner.auth_by_conversation.lock().await;
1912 if let Some(stored) = auth.get(&conversation_id) {
1913 if stored.expires_at_epoch_ms > Utc::now().timestamp_millis() {
1914 let mut state = customer_state.lock().await;
1915 state.current = Some(stored.customer.clone());
1916 state.lookup_completed = true;
1917 }
1918 }
1919 }
1920
1921 if let Some(ref person_val) = person {
1922 if let Some(seeded) = seed_from_person(person_val) {
1923 let mut state = customer_state.lock().await;
1924 state.current = Some(seeded);
1925 state.lookup_completed = true;
1926 }
1927 }
1928
1929 let customer_api = CustomerApi {
1930 app: self.clone(),
1931 identity: identity_value.clone(),
1932 parameters: parameters.clone(),
1933 conversation_id: conversation_id.clone(),
1934 channel: channel.clone(),
1935 auth_response: auth_response.clone(),
1936 state: customer_state.clone(),
1937 platform: platform.clone(),
1938 };
1939
1940 let mut current_customer = customer_state.lock().await.current.clone();
1941
1942 if registration.metadata.auth == ToolAuthMode::Required {
1943 match customer_api
1944 .authorize(registration.metadata.default_auth_method.clone())
1945 .await
1946 {
1947 Ok(customer) => current_customer = Some(customer),
1948 Err(e) => {
1949 return map_invoke_error(
1950 e,
1951 self,
1952 &tool_name,
1953 ¶meters,
1954 conversation_id,
1955 identity.clone(),
1956 channel.clone(),
1957 platform.clone(),
1958 person.clone(),
1959 settings.clone(),
1960 )
1961 .await
1962 }
1963 }
1964 }
1965
1966 let solution_id = platform
1967 .as_ref()
1968 .and_then(|p| p.customer.as_ref())
1969 .and_then(|c| c.context.as_ref())
1970 .and_then(|c| c.solution_id.clone())
1971 .or_else(|| {
1972 platform
1973 .as_ref()
1974 .and_then(|p| p.storage.as_ref())
1975 .and_then(|s| s.context.as_ref())
1976 .map(|c| c.solution_id.clone())
1977 });
1978
1979 let timeline = TimelineContext {
1980 platform: platform.clone(),
1981 state: customer_state.clone(),
1982 };
1983 let membership = MembershipContext {
1984 platform: platform.clone(),
1985 state: customer_state.clone(),
1986 solution_id,
1987 };
1988 let consent = ConsentContext {
1989 platform: platform.clone(),
1990 state: customer_state.clone(),
1991 };
1992
1993 let person_mutations = Arc::new(StdMutex::new(Vec::new()));
1994 let person_ctx = PersonContext::new(person.as_ref(), person_mutations.clone());
1995 let session_events: Arc<StdMutex<Vec<EmittedBusinessEvent>>> =
1996 Arc::new(StdMutex::new(Vec::new()));
1997 let app_for_emit = self.clone();
1998 let emit_events = session_events.clone();
1999 let emit: Arc<dyn Fn(EmittedBusinessEvent) -> Result<()> + Send + Sync> = Arc::new(
2000 move |event: EmittedBusinessEvent| {
2001 let event_type = event.event_type.trim().to_string();
2002 if !is_business_event_type(&event_type) {
2003 return Err(anyhow!(
2004 "ctx.emit() requires a Business Event such as quotation.created, not a capability; got \"{}\"",
2005 event.event_type
2006 ));
2007 }
2008 let declared = {
2009 let map = app_for_emit
2010 .inner
2011 .business_events
2012 .read()
2013 .expect("business_events");
2014 if !map.is_empty() && !map.contains_key(&event_type) {
2015 return Err(anyhow!(
2016 "ctx.emit(\"{event_type}\") is not declared via app.business_event()"
2017 ));
2018 }
2019 map.get(&event_type).cloned()
2020 };
2021 let durable = normalize_emitted_event(event, declared.as_ref())
2022 .map_err(|e| anyhow!(e.to_string()))?;
2023 app_for_emit.inner.outbox.put(durable.clone())?;
2024 emit_events.lock().expect("events").push(durable);
2025 app_for_emit.schedule_flush();
2026 Ok(())
2027 },
2028 );
2029
2030 let ctx = ToolContext {
2031 identity: identity_value,
2032 parameters: parameters.clone(),
2033 conversation: Conversation {
2034 id: conversation_id.clone(),
2035 },
2036 channel: channel.clone(),
2037 authentication,
2038 auth_response,
2039 customer: current_customer,
2040 customer_api: Some(customer_api),
2041 timeline,
2042 membership,
2043 consent,
2044 platform: platform.clone(),
2045 settings: settings.clone(),
2046 install_settings: settings.clone(),
2047 trace_id,
2048 person: person_ctx,
2049 storage: build_storage_context(platform.clone()),
2050 emit,
2051 };
2052
2053 let before_hooks = self.inner.before_hooks.read().expect("before_hooks").clone();
2054 for hook in &before_hooks {
2055 if let Err(e) = hook(ctx.clone()).await {
2056 return map_invoke_error(
2057 e,
2058 self,
2059 &tool_name,
2060 ¶meters,
2061 conversation_id.clone(),
2062 identity.clone(),
2063 channel.clone(),
2064 platform.clone(),
2065 person.clone(),
2066 settings.clone(),
2067 )
2068 .await;
2069 }
2070 }
2071
2072 let handler = registration.handler.clone();
2073 let middlewares = self.inner.middlewares.read().expect("middlewares").clone();
2074 let run_result = run_middlewares(middlewares, ctx.clone(), handler).await;
2075
2076 let output = match run_result {
2077 Ok(v) => v,
2078 Err(e) => {
2079 return map_invoke_error(
2080 e,
2081 self,
2082 &tool_name,
2083 ¶meters,
2084 conversation_id,
2085 identity,
2086 channel,
2087 platform,
2088 person,
2089 settings,
2090 )
2091 .await;
2092 }
2093 };
2094
2095 let after_hooks = self.inner.after_hooks.read().expect("after_hooks").clone();
2096 let mut output = output;
2097 for hook in &after_hooks {
2098 match hook(ctx.clone(), output).await {
2099 Ok(v) => output = v,
2100 Err(e) => {
2101 return map_invoke_error(
2102 e,
2103 self,
2104 &tool_name,
2105 ¶meters,
2106 conversation_id,
2107 identity,
2108 channel,
2109 platform,
2110 person,
2111 settings,
2112 )
2113 .await;
2114 }
2115 }
2116 }
2117
2118 let auth = {
2119 let map = self.inner.auth_by_conversation.lock().await;
2120 map.get(&conversation_id)
2121 .filter(|v| v.expires_at_epoch_ms > Utc::now().timestamp_millis())
2122 .map(|v| v.auth.clone())
2123 };
2124 let mutations = person_mutations.lock().expect("mutations").clone();
2125 let events = self.merge_pending_events(&session_events.lock().expect("events"));
2126
2127 QefroResponse::Result {
2128 output,
2129 authentication_context: auth,
2130 person_mutations: if mutations.is_empty() {
2131 None
2132 } else {
2133 Some(mutations)
2134 },
2135 events: if events.is_empty() { None } else { Some(events) },
2136 }
2137 }
2138
2139 fn merge_pending_events(&self, session: &[EmittedBusinessEvent]) -> Vec<EmittedBusinessEvent> {
2140 let mut by_id: HashMap<String, EmittedBusinessEvent> = HashMap::new();
2141 for event in self.inner.outbox.pending() {
2142 if let Some(id) = event.event_id.clone() {
2143 by_id.insert(id, event);
2144 }
2145 }
2146 for event in session {
2147 if let Some(id) = event.event_id.clone() {
2148 by_id.insert(id, event.clone());
2149 }
2150 }
2151 by_id.into_values().collect()
2152 }
2153
2154 fn schedule_flush(&self) {
2155 if self.inner.event_ingest_url.is_none() {
2156 return;
2157 }
2158 let app = self.clone();
2159 tokio::spawn(async move {
2160 let mut in_flight = app.inner.flush_in_flight.lock().await;
2161 if *in_flight {
2162 return;
2163 }
2164 *in_flight = true;
2165 drop(in_flight);
2166 app.flush_outbox().await;
2167 *app.inner.flush_in_flight.lock().await = false;
2168 });
2169 }
2170
2171 async fn flush_outbox(&self) {
2172 let Some(url) = self.inner.event_ingest_url.clone() else {
2173 return;
2174 };
2175 for event in self.inner.outbox.pending() {
2176 if self.post_ingest(&url, &event).await {
2177 if let Some(id) = event.event_id.as_deref() {
2178 self.inner.outbox.ack(id);
2179 }
2180 }
2181 }
2182 }
2183
2184 async fn post_ingest(&self, url: &str, event: &EmittedBusinessEvent) -> bool {
2185 let body = json!({
2186 "name": event.event_type,
2187 "event_type": event.event_type,
2188 "event_id": event.event_id,
2189 "payload": {
2190 "customer": event.customer.clone().unwrap_or_else(|| json!({})),
2191 "data": event.data.clone().unwrap_or_else(|| json!({})),
2192 "event_version": event.version.unwrap_or(1),
2193 "external_event_id": event.event_id,
2194 "timestamp": event.timestamp,
2195 }
2196 });
2197 let body_str = body.to_string();
2198 let ts = Utc::now().timestamp();
2199 let payload = format!("v1:{ts}:{body_str}");
2200 let mut mac = HmacSha256::new_from_slice(self.inner.config.signing_secret.as_bytes())
2201 .expect("HMAC accepts any key length");
2202 mac.update(payload.as_bytes());
2203 let signature = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
2204 let res = reqwest::Client::new()
2205 .post(url)
2206 .header("content-type", "application/json")
2207 .header("x-qefro-signature", signature)
2208 .header("x-qefro-timestamp", ts.to_string())
2209 .body(body_str)
2210 .send()
2211 .await;
2212 match res {
2213 Ok(r) => r.status().is_success() || r.status().as_u16() == 409,
2214 Err(_) => false,
2215 }
2216 }
2217
2218 async fn take_pending(&self, token: &str) -> Option<PendingInvocation> {
2219 self.prune_expired_pending().await;
2220 self.inner.pending.lock().await.remove(token)
2221 }
2222
2223 async fn prune_expired_pending(&self) {
2224 let now = now_ms();
2225 let mut map = self.inner.pending.lock().await;
2226 map.retain(|_, p| now.saturating_sub(p.created_at_ms) <= 15 * 60 * 1000);
2227 }
2228
2229 async fn consume_auth_outcome(
2230 &self,
2231 outcome: AuthOutcome,
2232 conversation_id: String,
2233 customer_state: Option<Arc<Mutex<CustomerState>>>,
2234 pending_tool: Option<&str>,
2235 pending_parameters: Option<Value>,
2236 pending_identity: Option<Value>,
2237 pending_channel: Option<String>,
2238 ) -> Result<Value> {
2239 match outcome {
2240 AuthOutcome::Success { customer, auth } => {
2241 let ttl = auth.expires_in.unwrap_or(900).max(1);
2242 self.inner.auth_by_conversation.lock().await.insert(
2243 conversation_id,
2244 StoredAuth {
2245 customer: customer.clone(),
2246 auth,
2247 expires_at_epoch_ms: Utc::now().timestamp_millis() + ttl * 1000,
2248 },
2249 );
2250 if let Some(state) = customer_state {
2251 let mut s = state.lock().await;
2252 s.current = Some(customer.clone());
2253 s.lookup_completed = true;
2254 }
2255 Ok(customer)
2256 }
2257 AuthOutcome::Challenge(challenge) => {
2258 if let (Some(tool), Some(parameters)) = (pending_tool, pending_parameters) {
2259 let resume_token = Uuid::new_v4().to_string();
2260 self.inner.pending.lock().await.insert(
2261 resume_token.clone(),
2262 PendingInvocation {
2263 tool: tool.to_string(),
2264 conversation_id,
2265 parameters,
2266 identity: pending_identity,
2267 channel: pending_channel,
2268 platform: None,
2269 person: None,
2270 settings: None,
2271 created_at_ms: now_ms(),
2272 },
2273 );
2274 let _ = resume_token;
2275 }
2276 Err(ChallengeSignal { challenge }.into())
2277 }
2278 AuthOutcome::Denied => Err(anyhow!("denied")),
2279 AuthOutcome::NotFound => Err(anyhow!("customer_not_found")),
2280 }
2281 }
2282
2283 pub async fn require_authentication(
2284 &self,
2285 conversation_id: String,
2286 outcome: AuthOutcome,
2287 tool: &str,
2288 parameters: Value,
2289 identity: Option<Value>,
2290 channel: Option<String>,
2291 ) -> std::result::Result<Value, QefroResponse> {
2292 match outcome {
2293 AuthOutcome::Success { customer, auth } => {
2294 let ttl = auth.expires_in.unwrap_or(900).max(1);
2295 self.inner.auth_by_conversation.lock().await.insert(
2296 conversation_id,
2297 StoredAuth {
2298 customer: customer.clone(),
2299 auth,
2300 expires_at_epoch_ms: Utc::now().timestamp_millis() + ttl * 1000,
2301 },
2302 );
2303 Ok(customer)
2304 }
2305 AuthOutcome::Challenge(challenge) => {
2306 let resume_token = Uuid::new_v4().to_string();
2307 self.inner.pending.lock().await.insert(
2308 resume_token.clone(),
2309 PendingInvocation {
2310 tool: tool.to_string(),
2311 conversation_id,
2312 parameters,
2313 identity,
2314 channel,
2315 platform: None,
2316 person: None,
2317 settings: None,
2318 created_at_ms: now_ms(),
2319 },
2320 );
2321 Err(QefroResponse::Challenge {
2322 resume_token,
2323 challenge,
2324 })
2325 }
2326 AuthOutcome::Denied => Err(QefroResponse::Error {
2327 code: "denied".into(),
2328 message: "Authentication denied".into(),
2329 }),
2330 AuthOutcome::NotFound => Err(QefroResponse::Error {
2331 code: "customer_not_found".into(),
2332 message: "Customer not found".into(),
2333 }),
2334 }
2335 }
2336}
2337
2338fn now_ms() -> i64 {
2339 SystemTime::now()
2340 .duration_since(UNIX_EPOCH)
2341 .map(|d| d.as_millis() as i64)
2342 .unwrap_or(0)
2343}
2344
2345async fn map_invoke_error(
2346 e: anyhow::Error,
2347 app: &Qefro,
2348 tool_name: &str,
2349 parameters: &Value,
2350 conversation_id: String,
2351 identity: Option<Value>,
2352 channel: Option<String>,
2353 platform: Option<PlatformCapabilities>,
2354 person: Option<Value>,
2355 settings: Option<Value>,
2356) -> QefroResponse {
2357 if let Some(signal) = e.downcast_ref::<ChallengeSignal>() {
2358 let resume_token = Uuid::new_v4().to_string();
2359 app.inner.pending.lock().await.insert(
2360 resume_token.clone(),
2361 PendingInvocation {
2362 tool: tool_name.to_string(),
2363 conversation_id,
2364 parameters: parameters.clone(),
2365 identity,
2366 channel,
2367 platform,
2368 person,
2369 settings,
2370 created_at_ms: now_ms(),
2371 },
2372 );
2373 return QefroResponse::Challenge {
2374 resume_token,
2375 challenge: signal.challenge.clone(),
2376 };
2377 }
2378
2379 let message = e.to_string();
2380 if message == "denied" {
2381 return QefroResponse::Error {
2382 code: "denied".into(),
2383 message: "Authentication denied".into(),
2384 };
2385 }
2386 if message == "customer_not_found" {
2387 return QefroResponse::Error {
2388 code: "customer_not_found".into(),
2389 message: "Customer not found".into(),
2390 };
2391 }
2392 if message == "person_not_found" {
2393 return QefroResponse::Error {
2394 code: "person_not_found".into(),
2395 message: "No Customer Hub Person is linked to this conversation.".into(),
2396 };
2397 }
2398 if message == "customer_provider_missing" {
2399 return QefroResponse::Error {
2400 code: "configuration_error".into(),
2401 message: "Tool requires customer provider. Configure app.customer(...) first.".into(),
2402 };
2403 }
2404
2405 QefroResponse::Error {
2406 code: "internal_error".into(),
2407 message,
2408 }
2409}
2410
2411async fn run_middlewares(
2412 middlewares: Vec<MiddlewareFn>,
2413 ctx: ToolContext,
2414 handler: ToolHandler,
2415) -> Result<Value> {
2416 fn dispatch(
2417 i: usize,
2418 middlewares: Arc<Vec<MiddlewareFn>>,
2419 ctx: ToolContext,
2420 handler: ToolHandler,
2421 ) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>> {
2422 Box::pin(async move {
2423 if i == middlewares.len() {
2424 return handler(ctx).await;
2425 }
2426 let mw = middlewares[i].clone();
2427 let mws = middlewares.clone();
2428 let h = handler.clone();
2429 let next: NextFn = Box::new(move |c| dispatch(i + 1, mws, c, h));
2430 mw(ctx, next).await
2431 })
2432 }
2433
2434 dispatch(0, Arc::new(middlewares), ctx, handler).await
2435}
2436
2437fn header_str<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
2438 headers.get(key).and_then(|v| v.to_str().ok())
2439}
2440
2441fn protocol_response_headers(app: &Qefro) -> HeaderMap {
2442 use axum::http::HeaderValue;
2443 let mut headers = HeaderMap::new();
2444 let proto = HeaderValue::from_str(&app.inner.config.protocol_version)
2445 .unwrap_or_else(|_| HeaderValue::from_static("1"));
2446 headers.insert("X-Qefro-Protocol", proto.clone());
2447 headers.insert("X-Qefro-Protocol-Version", proto);
2448 headers.insert("X-Qefro-SDK", HeaderValue::from_static(SDK_NAME));
2449 headers.insert(
2450 "X-Qefro-Version",
2451 HeaderValue::from_static(SDK_VERSION),
2452 );
2453 headers
2454}
2455
2456async fn http_handler(
2457 State(app): State<Qefro>,
2458 headers: HeaderMap,
2459 body: Bytes,
2460) -> impl IntoResponse {
2461 let mut response_headers = protocol_response_headers(&app);
2462 let body_str = String::from_utf8_lossy(&body);
2463 let (status, resp) = app.handle_raw(&body_str, &headers).await;
2464 response_headers.insert(
2465 axum::http::header::CONTENT_TYPE,
2466 "application/json".parse().unwrap(),
2467 );
2468 (status, response_headers, Json(resp))
2469}
2470
2471#[cfg(test)]
2472mod tests {
2473 use super::*;
2474
2475 #[test]
2476 fn normalize_lookup_dedupes() {
2477 let lookup = ToolLookup {
2478 by: Some("Email".into()),
2479 required: vec!["phone".into(), "email".into()],
2480 };
2481 assert_eq!(
2482 normalize_lookup(Some(&lookup)),
2483 vec!["phone".to_string(), "email".to_string()]
2484 );
2485 }
2486
2487 #[test]
2488 fn signature_roundtrip() {
2489 let app = Qefro::new(QefroConfig::new("secret"));
2490 let body = r#"{"protocol_version":"1","request_id":"00000000-0000-0000-0000-000000000001","type":"ping"}"#;
2491 let ts = Utc::now().timestamp();
2492 let payload = format!("v1:{ts}:{body}");
2493 let mut mac = HmacSha256::new_from_slice(b"secret").unwrap();
2494 mac.update(payload.as_bytes());
2495 let sig = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
2496 assert!(app.verify_signature(&sig, ts, body));
2497 assert!(!app.verify_signature("v1=deadbeef", ts, body));
2498 }
2499
2500 #[tokio::test]
2501 async fn tools_list_includes_lookup() {
2502 let app = Qefro::new(QefroConfig::new("secret"));
2503 app.tool(
2504 ToolMetadata {
2505 name: "orders".into(),
2506 lookup: Some(ToolLookup {
2507 by: Some("email".into()),
2508 required: vec![],
2509 }),
2510 ..Default::default()
2511 },
2512 |_ctx| async move { Ok(json!({})) },
2513 );
2514
2515 let resp = app
2516 .handle(QefroRequest {
2517 protocol_version: "1".into(),
2518 request_id: Uuid::new_v4().to_string(),
2519 request_type: "tools.list".into(),
2520 organization_id: None,
2521 conversation_id: None,
2522 channel: None,
2523 identity: None,
2524 tool: None,
2525 parameters: None,
2526 authentication: None,
2527 resume_token: None,
2528 challenge_response: None,
2529 person: None,
2530 platform: None,
2531 settings: None,
2532 })
2533 .await;
2534
2535 match resp {
2536 QefroResponse::ToolsList { tools, .. } => {
2537 assert_eq!(tools.len(), 1);
2538 assert_eq!(
2539 tools[0].lookup.as_ref().unwrap().required,
2540 vec!["email".to_string()]
2541 );
2542 }
2543 other => panic!("unexpected {other:?}"),
2544 }
2545 }
2546
2547 fn capabilities_request() -> QefroRequest {
2548 QefroRequest {
2549 protocol_version: "1".into(),
2550 request_id: Uuid::new_v4().to_string(),
2551 request_type: "capabilities.list".into(),
2552 organization_id: None,
2553 conversation_id: None,
2554 channel: None,
2555 identity: None,
2556 tool: None,
2557 parameters: None,
2558 authentication: None,
2559 resume_token: None,
2560 challenge_response: None,
2561 person: None,
2562 platform: None,
2563 settings: None,
2564 }
2565 }
2566
2567 fn order_lookup_metadata() -> BusinessFlowMetadata {
2568 BusinessFlowMetadata {
2569 id: "order_lookup".into(),
2570 name: Some("Order Lookup".into()),
2571 description: Some("Lookup customer orders".into()),
2572 category: Some("crm".into()),
2573 tags: vec!["customer".into(), "orders".into()],
2574 intent: vec!["track order".into(), "where is my order".into()],
2575 inputs: vec!["email".into()],
2576 outputs: vec!["customer".into(), "orders".into()],
2577 ..Default::default()
2578 }
2579 }
2580
2581 #[tokio::test]
2582 async fn capabilities_list_advertises_flows() {
2583 let app = Qefro::new(QefroConfig::new("secret"));
2584 app.tool(
2585 ToolMetadata {
2586 name: "lookup_customer".into(),
2587 ..Default::default()
2588 },
2589 |_ctx| async move { Ok(json!({})) },
2590 );
2591 app.flow(order_lookup_metadata())
2592 .expect("flow registers")
2593 .ask("email", "email", "Please enter your email.")
2594 .tool("lookup", "lookup_customer")
2595 .complete("done", None)
2596 .expect("flow builds without error");
2597
2598 let resp = app.handle(capabilities_request()).await;
2599 let value = serde_json::to_value(&resp).expect("serialize");
2600 assert_eq!(value["type"], "capabilities.list");
2601 assert_eq!(value["flows"].as_array().unwrap().len(), 1);
2602 let flow = &value["flows"][0];
2603 assert_eq!(flow["metadata"]["id"], "order_lookup");
2605 assert_eq!(flow["metadata"]["version"], 1);
2606 assert!(flow["metadata"]["version"].is_number());
2607 assert_eq!(flow["steps"][0]["id"], "email");
2609 assert_eq!(flow["steps"][0]["type"], "ask");
2610 assert_eq!(flow["steps"][0]["config"]["field"], "email");
2611 assert_eq!(flow["steps"][1]["type"], "tool");
2612 assert_eq!(flow["steps"][1]["config"]["tool_ref"], "lookup_customer");
2613 assert_eq!(flow["steps"][2]["type"], "complete");
2614 assert!(flow["steps"][2]["config"].is_object());
2615 }
2616
2617 #[test]
2618 fn duplicate_flow_id_returns_error_no_panic() {
2619 let app = Qefro::new(QefroConfig::new("secret"));
2620 app.flow(order_lookup_metadata()).expect("first registers");
2621 let err = app.flow(order_lookup_metadata()).err().unwrap();
2622 assert_eq!(err, FlowError::DuplicateFlowId("order_lookup".into()));
2623 }
2624
2625 #[test]
2626 fn empty_flow_id_returns_error() {
2627 let app = Qefro::new(QefroConfig::new("secret"));
2628 let err = app
2629 .flow(BusinessFlowMetadata {
2630 id: " ".into(),
2631 ..Default::default()
2632 })
2633 .err()
2634 .unwrap();
2635 assert_eq!(err, FlowError::EmptyFlowId);
2636 }
2637
2638 #[tokio::test]
2639 async fn capabilities_list_includes_event_triggers_and_handlers() {
2640 let app = Qefro::new(QefroConfig::new("secret"));
2641 app.flow(BusinessFlowMetadata {
2642 id: "abandoned_cart".into(),
2643 name: Some("Abandoned cart".into()),
2644 trigger: Some(FlowTrigger::Event {
2645 event: "shopify.cart.abandoned".into(),
2646 when: None,
2647 }),
2648 ..Default::default()
2649 })
2650 .expect("flow")
2651 .delay("wait", 60)
2652 .complete("done", None)
2653 .expect("build");
2654
2655 app.event(EventHandlerDefinition {
2656 name: "shopify.cart.abandoned".into(),
2657 description: Some("cart abandoned".into()),
2658 cron: None,
2659 })
2660 .expect("event");
2661 app.schedule(EventHandlerDefinition {
2662 name: "nightly_sync".into(),
2663 description: None,
2664 cron: Some("0 2 * * *".into()),
2665 })
2666 .expect("schedule");
2667
2668 let resp = app.handle(capabilities_request()).await;
2669 let value = serde_json::to_value(&resp).expect("serialize");
2670 assert_eq!(value["type"], "capabilities.list");
2671 assert_eq!(
2672 value["flows"][0]["metadata"]["trigger"]["type"],
2673 "event"
2674 );
2675 assert_eq!(
2676 value["flows"][0]["metadata"]["trigger"]["event"],
2677 "shopify.cart.abandoned"
2678 );
2679 assert_eq!(value["events"][0]["name"], "shopify.cart.abandoned");
2680 assert_eq!(value["schedules"][0]["cron"], "0 2 * * *");
2681 }
2682
2683 #[tokio::test]
2684 async fn capabilities_list_includes_marketing_metadata() {
2685 let app = Qefro::new(QefroConfig::new("secret"));
2686 app.marketing(MarketingDefinition {
2687 version: Some(1),
2688 audiences: vec![MarketingAudience {
2689 id: "vip".into(),
2690 label: "VIP".into(),
2691 description: None,
2692 source: "customer_hub".into(),
2693 customer_hub: None,
2694 app_query: None,
2695 static_filter: None,
2696 }],
2697 variables: vec![],
2698 actions: vec![],
2699 landing_pages: vec![],
2700 channels: vec![
2701 MarketingChannel {
2702 id: "whatsapp".into(),
2703 provider: Some("meta".into()),
2704 label: None,
2705 enabled: Some(true),
2706 },
2707 MarketingChannel {
2708 id: "email".into(),
2709 provider: Some("sendgrid".into()),
2710 label: None,
2711 enabled: Some(true),
2712 },
2713 ],
2714 })
2715 .expect("marketing");
2716
2717 let resp = app.handle(capabilities_request()).await;
2718 let value = serde_json::to_value(&resp).expect("serialize");
2719 assert_eq!(value["type"], "capabilities.list");
2720 assert_eq!(value["marketing"]["version"], 1);
2721 assert_eq!(value["marketing"]["metadata"]["audiences"][0]["id"], "vip");
2722 assert_eq!(
2723 value["marketing"]["metadata"]["channels"][0]["provider"],
2724 "meta"
2725 );
2726 assert!(value["marketing"].get("audiences").is_none());
2727 }
2728
2729 #[tokio::test]
2730 async fn duplicate_step_id_surfaced_and_flow_excluded() {
2731 let app = Qefro::new(QefroConfig::new("secret"));
2732 let result = app
2733 .flow(order_lookup_metadata())
2734 .expect("flow registers")
2735 .ask("email", "email", "Please enter your email.")
2736 .tool("email", "lookup_customer") .complete("done", None);
2738 assert_eq!(
2739 result.unwrap_err(),
2740 FlowError::DuplicateStepId {
2741 flow: "order_lookup".into(),
2742 step: "email".into()
2743 }
2744 );
2745
2746 let resp = app.handle(capabilities_request()).await;
2748 let value = serde_json::to_value(&resp).expect("serialize");
2749 assert_eq!(value["flows"].as_array().unwrap().len(), 0);
2750 }
2751
2752 #[tokio::test]
2753 async fn complete_step_allows_branch_terminal_before_complete() {
2754 let app = Qefro::new(QefroConfig::new("secret"));
2755 app.flow(order_lookup_metadata())
2756 .expect("flow registers")
2757 .ask("ask", "order_id", "Order id?")
2758 .tool("lookup", "order_status_check")
2759 .condition(
2760 "branch",
2761 "order_status_check.found == true",
2762 Some("ok".into()),
2763 Some("missing".into()),
2764 )
2765 .complete_step("missing", Some("Not found.".into()))
2766 .complete("ok", Some("Found.".into()))
2767 .expect("flow builds with a mid-chain complete_step");
2768
2769 let resp = app.handle(capabilities_request()).await;
2770 let value = serde_json::to_value(&resp).expect("serialize");
2771 let steps = value["flows"][0]["steps"].as_array().unwrap();
2772 let kinds: Vec<&str> = steps.iter().map(|s| s["type"].as_str().unwrap()).collect();
2773 assert_eq!(kinds, ["ask", "tool", "condition", "complete", "complete"]);
2774 }
2775
2776 #[tokio::test]
2777 async fn tools_list_unchanged_alongside_flows() {
2778 let app = Qefro::new(QefroConfig::new("secret"));
2779 app.tool(
2780 ToolMetadata {
2781 name: "lookup_customer".into(),
2782 ..Default::default()
2783 },
2784 |_ctx| async move { Ok(json!({})) },
2785 );
2786 app.flow(order_lookup_metadata())
2787 .expect("flow registers")
2788 .tool("lookup", "lookup_customer")
2789 .complete("done", None)
2790 .expect("flow builds");
2791
2792 let resp = app
2793 .handle(QefroRequest {
2794 protocol_version: "1".into(),
2795 request_id: Uuid::new_v4().to_string(),
2796 request_type: "tools.list".into(),
2797 organization_id: None,
2798 conversation_id: None,
2799 channel: None,
2800 identity: None,
2801 tool: None,
2802 parameters: None,
2803 authentication: None,
2804 resume_token: None,
2805 challenge_response: None,
2806 person: None,
2807 platform: None,
2808 settings: None,
2809 })
2810 .await;
2811 let value = serde_json::to_value(&resp).expect("serialize");
2812 assert_eq!(value["type"], "tools.list");
2814 assert!(value.get("flows").is_none());
2815 assert_eq!(value["tools"].as_array().unwrap().len(), 1);
2816 }
2817
2818 #[test]
2819 fn flow_step_roundtrips_through_wire_shape() {
2820 let step = FlowStep {
2821 id: "lookup".into(),
2822 kind: FlowStepKind::Tool {
2823 tool_ref: "lookup_customer".into(),
2824 },
2825 };
2826 let value = serde_json::to_value(&step).unwrap();
2827 assert_eq!(value["id"], "lookup");
2828 assert_eq!(value["type"], "tool");
2829 assert_eq!(value["config"]["tool_ref"], "lookup_customer");
2830 let back: FlowStep = serde_json::from_value(value).unwrap();
2831 assert_eq!(back, step);
2832 }
2833
2834 #[tokio::test]
2835 async fn customer_hub_resolve_soft_skips_when_disabled() {
2836 let _guard = customer_hub::HUB_ENV_LOCK
2837 .lock()
2838 .unwrap_or_else(|e| e.into_inner());
2839 std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "false");
2840 std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "true");
2841 let app = Qefro::new(QefroConfig::new("secret"));
2842 app.tool(
2843 ToolMetadata {
2844 name: "hub_probe".into(),
2845 auth: ToolAuthMode::None,
2846 ..Default::default()
2847 },
2848 |ctx| async move {
2849 let api = ctx.customer_api().unwrap();
2850 let out = api.resolve(Some(json!({"phone_number": "+1"}))).await?;
2851 Ok(json!({ "customer": out }))
2852 },
2853 );
2854 let resp = app
2855 .handle(QefroRequest {
2856 protocol_version: "1".into(),
2857 request_id: Uuid::new_v4().to_string(),
2858 request_type: "tool.invoke".into(),
2859 organization_id: None,
2860 conversation_id: None,
2861 channel: None,
2862 identity: None,
2863 tool: Some("hub_probe".into()),
2864 parameters: Some(json!({})),
2865 authentication: None,
2866 resume_token: None,
2867 challenge_response: None,
2868 person: None,
2869 platform: None,
2870 settings: None,
2871 })
2872 .await;
2873 let value = serde_json::to_value(&resp).unwrap();
2874 assert_eq!(value["type"], "result");
2875 assert!(value["output"]["customer"].is_null());
2876 std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
2877 std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
2878 }
2879
2880 #[tokio::test]
2881 async fn customer_hub_person_seed_exposes_properties() {
2882 let app = Qefro::new(QefroConfig::new("secret"));
2883 app.tool(
2884 ToolMetadata {
2885 name: "who".into(),
2886 auth: ToolAuthMode::None,
2887 ..Default::default()
2888 },
2889 |ctx| async move {
2890 let api = ctx.customer_api().unwrap();
2891 Ok(json!({
2892 "id": api.id().await,
2893 "phone_number": api.phone_number().await,
2894 "display_name": api.display_name().await,
2895 }))
2896 },
2897 );
2898 let resp = app
2899 .handle(QefroRequest {
2900 protocol_version: "1".into(),
2901 request_id: Uuid::new_v4().to_string(),
2902 request_type: "tool.invoke".into(),
2903 organization_id: None,
2904 conversation_id: None,
2905 channel: None,
2906 identity: None,
2907 tool: Some("who".into()),
2908 parameters: Some(json!({})),
2909 authentication: None,
2910 resume_token: None,
2911 challenge_response: None,
2912 person: Some(json!({
2913 "id": "cust-1",
2914 "phone": "+1999",
2915 "name": "Sam",
2916 })),
2917 platform: None,
2918 settings: None,
2919 })
2920 .await;
2921 let value = serde_json::to_value(&resp).unwrap();
2922 assert_eq!(value["output"]["id"], "cust-1");
2923 assert_eq!(value["output"]["phone_number"], "+1999");
2924 assert_eq!(value["output"]["display_name"], "Sam");
2925 }
2926
2927 #[tokio::test]
2928 async fn customer_hub_timeline_noop_when_optional_no_customer() {
2929 let _guard = customer_hub::HUB_ENV_LOCK
2930 .lock()
2931 .unwrap_or_else(|e| e.into_inner());
2932 std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "true");
2933 std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "true");
2934 let app = Qefro::new(QefroConfig::new("secret"));
2935 app.tool(
2936 ToolMetadata {
2937 name: "hub_side".into(),
2938 auth: ToolAuthMode::None,
2939 ..Default::default()
2940 },
2941 |ctx| async move {
2942 ctx.timeline
2943 .append(json!({"event_type": "x.y"}))
2944 .await?;
2945 ctx.membership.attach(None).await?;
2946 ctx.consent
2947 .grant(json!({"purpose": "marketing"}))
2948 .await?;
2949 Ok(json!({"ok": true}))
2950 },
2951 );
2952 let resp = app
2953 .handle(QefroRequest {
2954 protocol_version: "1".into(),
2955 request_id: Uuid::new_v4().to_string(),
2956 request_type: "tool.invoke".into(),
2957 organization_id: None,
2958 conversation_id: None,
2959 channel: None,
2960 identity: None,
2961 tool: Some("hub_side".into()),
2962 parameters: Some(json!({})),
2963 authentication: None,
2964 resume_token: None,
2965 challenge_response: None,
2966 person: None,
2967 platform: None,
2968 settings: None,
2969 })
2970 .await;
2971 let value = serde_json::to_value(&resp).unwrap();
2972 assert_eq!(value["type"], "result");
2973 assert_eq!(value["output"]["ok"], true);
2974 std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
2975 std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
2976 }
2977
2978 #[test]
2979 fn stable_event_id_unique_per_type() {
2980 assert_eq!(
2981 stable_event_id("quotation.created", "Q-1001").unwrap(),
2982 "quotation.created:Q-1001"
2983 );
2984 assert_eq!(
2985 stable_event_id("order.created", "Q-1001").unwrap(),
2986 "order.created:Q-1001"
2987 );
2988 }
2989
2990 #[tokio::test]
2991 async fn ctx_emit_persists_versioned_event() {
2992 let dir = std::env::temp_dir().join(format!("qefro-outbox-{}", Uuid::new_v4()));
2993 let mut config = QefroConfig::new("secret");
2994 config.event_outbox_dir = Some(dir.to_string_lossy().into());
2995 let app = Qefro::new(config);
2996 app.business_event(BusinessEventDefinition {
2997 event_type: "quotation.created".into(),
2998 version: 1,
2999 label: Some("Quotation created".into()),
3000 description: None,
3001 schema: None,
3002 })
3003 .expect("business event");
3004 app.tool(
3005 ToolMetadata {
3006 name: "createQuotation".into(),
3007 auth: ToolAuthMode::None,
3008 ..Default::default()
3009 },
3010 |ctx| async move {
3011 ctx.emit(EmittedBusinessEvent {
3012 event_type: "quotation.created".into(),
3013 version: None,
3014 event_id: Some("Q-1001".into()),
3015 customer: None,
3016 data: Some(json!({ "amount": 125000 })),
3017 timestamp: None,
3018 })?;
3019 Ok(json!({ "id": "Q-1001" }))
3020 },
3021 );
3022 let resp = app
3023 .handle(QefroRequest {
3024 protocol_version: "1".into(),
3025 request_id: "r-emit".into(),
3026 request_type: "tool.invoke".into(),
3027 organization_id: None,
3028 conversation_id: Some("c1".into()),
3029 channel: None,
3030 identity: None,
3031 tool: Some("createQuotation".into()),
3032 parameters: Some(json!({})),
3033 authentication: None,
3034 resume_token: None,
3035 challenge_response: None,
3036 person: None,
3037 platform: None,
3038 settings: None,
3039 })
3040 .await;
3041 let value = serde_json::to_value(&resp).unwrap();
3042 assert_eq!(value["type"], "result");
3043 assert_eq!(value["events"][0]["event_type"], "quotation.created");
3044 assert_eq!(value["events"][0]["event_id"], "quotation.created:Q-1001");
3045 assert_eq!(value["events"][0]["version"], 1);
3046
3047 let caps = app.handle(capabilities_request()).await;
3048 let caps = serde_json::to_value(&caps).unwrap();
3049 assert_eq!(caps["business_events"][0]["version"], 1);
3050 let _ = std::fs::remove_dir_all(dir);
3051 }
3052
3053 #[tokio::test]
3054 async fn person_mutations_and_trace() {
3055 let app = Qefro::new(QefroConfig::new("secret"));
3056 app.tool(
3057 ToolMetadata {
3058 name: "tag_person".into(),
3059 auth: ToolAuthMode::None,
3060 ..Default::default()
3061 },
3062 |ctx| async move {
3063 ctx.person.tag("vip", None)?;
3064 Ok(json!({
3065 "trace": ctx.trace_id,
3066 "id": ctx.person.require()?.get("id"),
3067 }))
3068 },
3069 );
3070 let resp = app
3071 .handle_with_trace(
3072 QefroRequest {
3073 protocol_version: "1".into(),
3074 request_id: "x".into(),
3075 request_type: "tool.invoke".into(),
3076 organization_id: None,
3077 conversation_id: None,
3078 channel: None,
3079 identity: None,
3080 tool: Some("tag_person".into()),
3081 parameters: Some(json!({})),
3082 authentication: None,
3083 resume_token: None,
3084 challenge_response: None,
3085 person: Some(json!({ "id": "p1", "name": "Ada" })),
3086 platform: None,
3087 settings: None,
3088 },
3089 Some("trace-1".into()),
3090 )
3091 .await;
3092 let value = serde_json::to_value(&resp).unwrap();
3093 assert_eq!(value["type"], "result");
3094 assert_eq!(value["output"]["trace"], "trace-1");
3095 assert_eq!(value["output"]["id"], "p1");
3096 assert_eq!(value["person_mutations"][0]["op"], "tag");
3097 assert_eq!(value["person_mutations"][0]["name"], "vip");
3098 }
3099
3100 #[test]
3101 fn flow_person_hub_steps() {
3102 let app = Qefro::new(QefroConfig::new("secret"));
3103 app.flow(BusinessFlowMetadata {
3104 id: "welcome".into(),
3105 trigger: Some(on_person_created(None)),
3106 ..Default::default()
3107 })
3108 .unwrap()
3109 .message("hi", "Hi {{person.name}}")
3110 .tag("vip", "vip", None)
3111 .assign("sales", "sales", None)
3112 .activity("note", "note.logged", None, None)
3113 .complete("done", None)
3114 .unwrap();
3115 let flows = app.list_registered_flows();
3116 let types: Vec<&str> = flows[0]
3117 .steps
3118 .iter()
3119 .map(|s| match s.kind {
3120 FlowStepKind::Message { .. } => "message",
3121 FlowStepKind::Tag { .. } => "tag",
3122 FlowStepKind::Assign { .. } => "assign",
3123 FlowStepKind::Activity { .. } => "activity",
3124 FlowStepKind::Complete { .. } => "complete",
3125 _ => "other",
3126 })
3127 .collect();
3128 assert_eq!(types, vec!["message", "tag", "assign", "activity", "complete"]);
3129 }
3130}