1mod customer_hub;
7
8pub use customer_hub::{
9 env_flag_true, hub_call, hub_customer_from_person, is_customer_hub_enabled,
10 is_customer_hub_optional, pick_identity, read_identity_phone, seed_from_person,
11 ConsentContext, CustomerState, MembershipContext, PlatformCapabilities,
12 PlatformCustomerBinding, PlatformCustomerContext, PlatformStorageBinding,
13 PlatformStorageContext, TimelineContext,
14};
15
16use std::collections::HashMap;
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::{Arc, RwLock};
20
21use anyhow::{anyhow, Result};
22use async_trait::async_trait;
23use axum::body::Bytes;
24use axum::extract::State;
25use axum::http::{HeaderMap, StatusCode};
26use axum::response::IntoResponse;
27use axum::routing::post;
28use axum::{Json, Router};
29use chrono::Utc;
30use hmac::{Hmac, Mac};
31use serde::{Deserialize, Serialize};
32use serde_json::{json, Value};
33use sha2::Sha256;
34use subtle::ConstantTimeEq;
35use tokio::sync::Mutex;
36use uuid::Uuid;
37
38type HmacSha256 = Hmac<Sha256>;
39
40pub const SDK_NAME: &str = "qefro-backend-sdk";
42pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
44
45type ToolHandler = Arc<dyn Fn(ToolContext) -> ToolFuture + Send + Sync>;
46type ToolFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send>>;
47type BeforeHook = Arc<dyn Fn(ToolContext) -> HookFuture + Send + Sync>;
48type AfterHook = Arc<dyn Fn(ToolContext, Value) -> AfterFuture + Send + Sync>;
49type HookFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
50type AfterFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send>>;
51type MiddlewareFn = Arc<
52 dyn Fn(ToolContext, NextFn) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
53 + Send
54 + Sync,
55>;
56type NextFn = Box<dyn FnOnce(ToolContext) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>> + Send>;
57
58#[derive(Debug, Clone)]
59pub struct QefroConfig {
60 pub signing_secret: String,
61 pub protocol_version: String,
62 pub max_timestamp_skew_secs: i64,
63 pub endpoint_path: String,
64}
65
66impl QefroConfig {
67 pub fn new(signing_secret: impl Into<String>) -> Self {
68 Self {
69 signing_secret: signing_secret.into(),
70 protocol_version: "1".into(),
71 max_timestamp_skew_secs: 300,
72 endpoint_path: "/qefro".into(),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
78#[serde(rename_all = "snake_case")]
79pub enum ToolAuthMode {
80 None,
81 #[default]
82 Optional,
83 Required,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
88pub struct ToolLookup {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub by: Option<String>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub required: Vec<String>,
95}
96
97pub fn normalize_lookup(lookup: Option<&ToolLookup>) -> Vec<String> {
99 let Some(lookup) = lookup else {
100 return Vec::new();
101 };
102 let mut seen = std::collections::HashSet::new();
103 let mut out = Vec::new();
104 for item in lookup
105 .required
106 .iter()
107 .cloned()
108 .chain(lookup.by.iter().cloned())
109 {
110 let key = item.trim().to_ascii_lowercase();
111 if key.is_empty() || !seen.insert(key.clone()) {
112 continue;
113 }
114 out.push(key);
115 }
116 out
117}
118
119fn normalized_lookup_field(lookup: Option<&ToolLookup>) -> Option<ToolLookup> {
120 let attrs = normalize_lookup(lookup);
121 if attrs.is_empty() {
122 None
123 } else {
124 Some(ToolLookup {
125 by: None,
126 required: attrs,
127 })
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132pub struct ToolMetadata {
133 pub name: String,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub description: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub input_schema: Option<Value>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub authentication_methods: Vec<String>,
140 #[serde(default)]
141 pub auth: ToolAuthMode,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub permissions: Vec<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub timeout: Option<u64>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub default_auth_method: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub lookup: Option<ToolLookup>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct RegisteredTool {
155 pub name: String,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub description: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub input_schema: Option<Value>,
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub authentication_methods: Vec<String>,
162 #[serde(default)]
163 pub auth: ToolAuthMode,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub permissions: Vec<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub timeout: Option<u64>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub lookup: Option<ToolLookup>,
170}
171
172fn default_flow_version() -> u32 {
177 1
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
184pub struct BusinessFlowMetadata {
185 pub id: String,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub name: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub description: Option<String>,
190 #[serde(default = "default_flow_version")]
192 pub version: u32,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub category: Option<String>,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub tags: Vec<String>,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub intent: Vec<String>,
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub inputs: Vec<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub outputs: Vec<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub trigger: Option<FlowTrigger>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum FlowTrigger {
216 Conversation,
217 Event {
218 event: String,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 when: Option<String>,
221 },
222 Schedule { cron: String },
223 Webhook {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 name: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 when: Option<String>,
228 },
229}
230
231impl FlowTrigger {
232 pub fn normalize(self) -> Result<Self, FlowError> {
233 match self {
234 FlowTrigger::Conversation => Ok(FlowTrigger::Conversation),
235 FlowTrigger::Event { event, when } => {
236 let event = event.trim().to_string();
237 if event.is_empty() {
238 return Err(FlowError::InvalidTrigger(
239 "trigger.type=event requires a non-empty event name".into(),
240 ));
241 }
242 if !event.contains('.') {
243 return Err(FlowError::InvalidTrigger(
244 "trigger.event must be namespaced (e.g. shopify.order.created)".into(),
245 ));
246 }
247 let when = when
248 .map(|w| w.trim().to_string())
249 .filter(|w| !w.is_empty());
250 Ok(FlowTrigger::Event { event, when })
251 }
252 FlowTrigger::Schedule { cron } => {
253 let cron = cron.trim().to_string();
254 if cron.is_empty() {
255 return Err(FlowError::InvalidTrigger(
256 "trigger.type=schedule requires a non-empty cron expression".into(),
257 ));
258 }
259 Ok(FlowTrigger::Schedule { cron })
260 }
261 FlowTrigger::Webhook { name, when } => {
262 let name = name
263 .map(|n| n.trim().to_string())
264 .filter(|n| !n.is_empty());
265 let when = when
266 .map(|w| w.trim().to_string())
267 .filter(|w| !w.is_empty());
268 Ok(FlowTrigger::Webhook { name, when })
269 }
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276pub struct EventHandlerDefinition {
277 pub name: String,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub description: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub cron: Option<String>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288#[serde(tag = "type", content = "config", rename_all = "snake_case")]
289pub enum FlowStepKind {
290 Ask {
291 field: String,
292 prompt: String,
293 },
294 Tool {
295 tool_ref: String,
297 },
298 Challenge {
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 message: Option<String>,
301 },
302 Upload {
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 field: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 prompt: Option<String>,
307 #[serde(default, skip_serializing_if = "Vec::is_empty")]
308 accept: Vec<String>,
309 },
310 Condition {
311 when: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 then: Option<String>,
314 #[serde(rename = "else", default, skip_serializing_if = "Option::is_none")]
315 else_step: Option<String>,
316 },
317 Delay {
318 duration_seconds: u64,
319 },
320 Approval {
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 prompt: Option<String>,
323 },
324 Complete {
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 message: Option<String>,
327 },
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
332pub struct FlowStep {
333 pub id: String,
334 #[serde(flatten)]
335 pub kind: FlowStepKind,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub struct BusinessFlow {
341 pub metadata: BusinessFlowMetadata,
342 pub steps: Vec<FlowStep>,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum FlowError {
349 EmptyFlowId,
350 DuplicateFlowId(String),
351 EmptyStepId { flow: String },
352 DuplicateStepId { flow: String, step: String },
353 InvalidTrigger(String),
354 EmptyHandlerName(&'static str),
355 DuplicateHandler { kind: &'static str, name: String },
356}
357
358impl std::fmt::Display for FlowError {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 match self {
361 FlowError::EmptyFlowId => write!(f, "flow() requires a non-empty metadata.id"),
362 FlowError::DuplicateFlowId(id) => write!(f, "flow \"{id}\" is already registered"),
363 FlowError::EmptyStepId { flow } => {
364 write!(f, "flow \"{flow}\": every step requires a non-empty id")
365 }
366 FlowError::DuplicateStepId { flow, step } => {
367 write!(f, "flow \"{flow}\": duplicate step id \"{step}\"")
368 }
369 FlowError::InvalidTrigger(msg) => write!(f, "{msg}"),
370 FlowError::EmptyHandlerName(kind) => {
371 write!(f, "{kind}() requires a non-empty name")
372 }
373 FlowError::DuplicateHandler { kind, name } => {
374 write!(f, "{kind} \"{name}\" is already registered")
375 }
376 }
377 }
378}
379
380impl std::error::Error for FlowError {}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct AuthenticationContextPayload {
384 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
385 pub credential_type: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub access_token: Option<String>,
388 #[serde(skip_serializing_if = "Option::is_none")]
389 pub credential: Option<String>,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 pub refresh_token: Option<String>,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 pub expires_in: Option<i64>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 pub customer_id: Option<String>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct ChallengePayload {
400 #[serde(rename = "type")]
401 pub challenge_type: String,
402 pub message: String,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub destination_hint: Option<String>,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub login_url: Option<String>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct QefroRequest {
411 pub protocol_version: String,
412 pub request_id: Uuid,
413 #[serde(rename = "type")]
414 pub request_type: String,
415 pub organization_id: Option<Uuid>,
416 pub conversation_id: Option<Uuid>,
417 pub channel: Option<String>,
418 pub identity: Option<Value>,
419 pub tool: Option<String>,
420 pub parameters: Option<Value>,
421 pub authentication: Option<Value>,
422 pub resume_token: Option<String>,
423 pub challenge_response: Option<String>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub person: Option<Value>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub platform: Option<PlatformCapabilities>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(tag = "type", rename_all = "snake_case")]
434pub enum QefroResponse {
435 Pong {
436 protocol_version: String,
437 sdk_version: String,
438 },
439 #[serde(rename = "tools.list")]
440 ToolsList {
441 tools: Vec<RegisteredTool>,
442 protocol_version: String,
443 sdk_version: String,
444 },
445 #[serde(rename = "capabilities.list")]
446 CapabilitiesList {
447 tools: Vec<RegisteredTool>,
448 flows: Vec<BusinessFlow>,
449 #[serde(default, skip_serializing_if = "Vec::is_empty")]
450 events: Vec<EventHandlerDefinition>,
451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
452 webhooks: Vec<EventHandlerDefinition>,
453 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 schedules: Vec<EventHandlerDefinition>,
455 protocol_version: String,
456 sdk_version: String,
457 sdk_name: String,
458 },
459 Result {
460 output: Value,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 authentication_context: Option<AuthenticationContextPayload>,
463 },
464 Challenge {
465 resume_token: String,
466 challenge: ChallengePayload,
467 },
468 Error {
469 code: String,
470 message: String,
471 },
472}
473
474#[derive(Debug, Clone)]
475struct PendingInvocation {
476 tool: String,
477 conversation_id: Uuid,
478 parameters: Value,
479 identity: Option<Value>,
480 channel: Option<String>,
481 platform: Option<PlatformCapabilities>,
482 person: Option<Value>,
483}
484
485#[derive(Debug, Clone)]
486struct StoredAuth {
487 customer: Value,
488 auth: AuthenticationContextPayload,
489 expires_at_epoch_ms: i64,
490}
491
492#[derive(Debug, Clone)]
493pub struct Conversation {
494 pub id: Uuid,
495}
496
497#[derive(Clone)]
498pub struct ToolContext {
499 pub identity: Value,
500 pub parameters: Value,
501 pub conversation: Conversation,
502 pub channel: Option<String>,
503 pub authentication: Option<Value>,
504 pub auth_response: Option<String>,
505 pub customer: Option<Value>,
507 customer_api: Option<CustomerApi>,
508 pub timeline: TimelineContext,
510 pub membership: MembershipContext,
512 pub consent: ConsentContext,
514 pub platform: Option<PlatformCapabilities>,
516}
517
518impl ToolContext {
519 pub fn customer_api(&self) -> Option<&CustomerApi> {
521 self.customer_api.as_ref()
522 }
523
524 pub fn raise_challenge(challenge: ChallengePayload) -> Result<Value> {
526 Err(ChallengeSignal { challenge }.into())
527 }
528}
529
530#[derive(Debug, Clone)]
532pub struct ChallengeSignal {
533 pub challenge: ChallengePayload,
534}
535
536impl std::fmt::Display for ChallengeSignal {
537 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538 write!(f, "{}", self.challenge.message)
539 }
540}
541
542impl std::error::Error for ChallengeSignal {}
543
544#[derive(Debug, Clone)]
545pub enum AuthOutcome {
546 Success {
547 customer: Value,
548 auth: AuthenticationContextPayload,
549 },
550 Challenge(ChallengePayload),
551 Denied,
552 NotFound,
553}
554
555#[derive(Debug, Clone)]
557pub struct AuthBuilder {
558 pub response: Option<String>,
559}
560
561impl AuthBuilder {
562 pub fn new(response: Option<String>) -> Self {
563 Self { response }
564 }
565
566 pub fn success(
567 &self,
568 customer: Value,
569 mut auth: AuthenticationContextPayload,
570 ) -> AuthOutcome {
571 if auth.customer_id.is_none() {
572 auth.customer_id = customer
573 .get("id")
574 .and_then(|v| v.as_str())
575 .map(str::to_string);
576 }
577 AuthOutcome::Success { customer, auth }
578 }
579
580 pub fn denied(&self) -> AuthOutcome {
581 AuthOutcome::Denied
582 }
583
584 pub fn not_found(&self) -> AuthOutcome {
585 AuthOutcome::NotFound
586 }
587
588 pub fn email_otp(&self, email: &str, message: Option<&str>) -> AuthOutcome {
589 AuthOutcome::Challenge(ChallengePayload {
590 challenge_type: "email_otp".into(),
591 message: message
592 .unwrap_or("Enter the OTP sent to your email.")
593 .into(),
594 destination_hint: Some(mask(email)),
595 login_url: None,
596 })
597 }
598
599 pub fn sms_otp(&self, phone: &str, message: Option<&str>) -> AuthOutcome {
600 AuthOutcome::Challenge(ChallengePayload {
601 challenge_type: "sms_otp".into(),
602 message: message
603 .unwrap_or("Enter the OTP sent to your phone.")
604 .into(),
605 destination_hint: Some(mask(phone)),
606 login_url: None,
607 })
608 }
609
610 pub fn login(&self, url: &str, message: Option<&str>) -> AuthOutcome {
611 AuthOutcome::Challenge(ChallengePayload {
612 challenge_type: "login".into(),
613 message: message
614 .unwrap_or("Please continue in your login page.")
615 .into(),
616 destination_hint: None,
617 login_url: Some(url.into()),
618 })
619 }
620
621 pub fn custom(&self, challenge: ChallengePayload) -> AuthOutcome {
622 AuthOutcome::Challenge(challenge)
623 }
624}
625
626fn mask(value: &str) -> String {
627 if value.len() <= 4 {
628 return value.to_string();
629 }
630 format!("{}***{}", &value[..2], &value[value.len() - 2..])
631}
632
633#[derive(Debug, Clone)]
634pub struct CustomerLookupContext {
635 pub identity: Value,
636 pub parameters: Value,
637 pub conversation: Conversation,
638 pub channel: Option<String>,
639}
640
641#[derive(Debug, Clone)]
642pub struct CustomerAuthorizeContext {
643 pub customer: Value,
644 pub method: Option<String>,
645 pub response: Option<String>,
646 pub identity: Value,
647 pub parameters: Value,
648 pub conversation: Conversation,
649 pub channel: Option<String>,
650}
651
652#[async_trait]
653pub trait CustomerProvider: Send + Sync {
654 async fn lookup(&self, ctx: &CustomerLookupContext) -> Result<Option<Value>>;
655 async fn authorize(&self, ctx: &CustomerAuthorizeContext) -> Result<AuthOutcome>;
656}
657
658#[derive(Clone)]
659struct ToolRegistration {
660 metadata: ToolMetadata,
661 handler: ToolHandler,
662}
663
664#[derive(Clone)]
665struct FlowRegistration {
666 metadata: BusinessFlowMetadata,
667 steps: Vec<FlowStep>,
668 error: Option<FlowError>,
671}
672
673pub struct FlowBuilder {
677 inner: Arc<Inner>,
678 flow_id: String,
679}
680
681impl FlowBuilder {
682 pub fn ask(self, id: impl Into<String>, field: impl Into<String>, prompt: impl Into<String>) -> Self {
683 self.push(
684 id.into(),
685 FlowStepKind::Ask {
686 field: field.into(),
687 prompt: prompt.into(),
688 },
689 )
690 }
691
692 pub fn tool(self, id: impl Into<String>, tool_ref: impl Into<String>) -> Self {
693 self.push(
694 id.into(),
695 FlowStepKind::Tool {
696 tool_ref: tool_ref.into(),
697 },
698 )
699 }
700
701 pub fn challenge(self, id: impl Into<String>, message: Option<String>) -> Self {
702 self.push(id.into(), FlowStepKind::Challenge { message })
703 }
704
705 pub fn upload(
706 self,
707 id: impl Into<String>,
708 field: Option<String>,
709 prompt: Option<String>,
710 accept: Vec<String>,
711 ) -> Self {
712 self.push(
713 id.into(),
714 FlowStepKind::Upload {
715 field,
716 prompt,
717 accept,
718 },
719 )
720 }
721
722 pub fn condition(
723 self,
724 id: impl Into<String>,
725 when: impl Into<String>,
726 then: Option<String>,
727 else_step: Option<String>,
728 ) -> Self {
729 self.push(
730 id.into(),
731 FlowStepKind::Condition {
732 when: when.into(),
733 then,
734 else_step,
735 },
736 )
737 }
738
739 pub fn delay(self, id: impl Into<String>, duration_seconds: u64) -> Self {
740 self.push(id.into(), FlowStepKind::Delay { duration_seconds })
741 }
742
743 pub fn approval(self, id: impl Into<String>, prompt: Option<String>) -> Self {
744 self.push(id.into(), FlowStepKind::Approval { prompt })
745 }
746
747 pub fn complete_step(self, id: impl Into<String>, message: Option<String>) -> Self {
751 self.push(id.into(), FlowStepKind::Complete { message })
752 }
753
754 #[must_use = "handle the FlowError so malformed flows fail fast at startup"]
756 pub fn complete(self, id: impl Into<String>, message: Option<String>) -> Result<(), FlowError> {
757 let flow_id = self.flow_id.clone();
758 let inner = self.inner.clone();
759 let _ = self.push(id.into(), FlowStepKind::Complete { message });
760 let flows = inner.flows.read().expect("flows");
761 match flows.get(&flow_id).and_then(|r| r.error.clone()) {
762 Some(err) => Err(err),
763 None => Ok(()),
764 }
765 }
766
767 fn push(self, id: String, kind: FlowStepKind) -> Self {
768 let step_id = id.trim().to_string();
769 let mut flows = self.inner.flows.write().expect("flows");
770 if let Some(reg) = flows.get_mut(&self.flow_id) {
771 if reg.error.is_none() {
772 if step_id.is_empty() {
773 reg.error = Some(FlowError::EmptyStepId {
774 flow: self.flow_id.clone(),
775 });
776 } else if reg.steps.iter().any(|s| s.id == step_id) {
777 reg.error = Some(FlowError::DuplicateStepId {
778 flow: self.flow_id.clone(),
779 step: step_id.clone(),
780 });
781 } else {
782 reg.steps.push(FlowStep { id: step_id, kind });
783 }
784 }
785 }
786 drop(flows);
787 self
788 }
789}
790
791#[derive(Debug, Clone)]
792pub struct ListenOptions {
793 pub port: u16,
794 pub host: Option<String>,
795 pub path: Option<String>,
796}
797
798pub struct ListenHandle {
799 pub url: String,
800 shutdown: Option<tokio::sync::oneshot::Sender<()>>,
801 join: Option<tokio::task::JoinHandle<()>>,
802}
803
804impl ListenHandle {
805 pub async fn close(mut self) {
806 if let Some(tx) = self.shutdown.take() {
807 let _ = tx.send(());
808 }
809 if let Some(join) = self.join.take() {
810 let _ = join.await;
811 }
812 }
813}
814
815struct Inner {
816 config: QefroConfig,
817 tools: RwLock<HashMap<String, ToolRegistration>>,
818 flows: RwLock<HashMap<String, FlowRegistration>>,
819 events: RwLock<HashMap<String, EventHandlerDefinition>>,
820 webhooks: RwLock<HashMap<String, EventHandlerDefinition>>,
821 schedules: RwLock<HashMap<String, EventHandlerDefinition>>,
822 pending: Mutex<HashMap<String, PendingInvocation>>,
823 auth_by_conversation: Mutex<HashMap<Uuid, StoredAuth>>,
824 customer_provider: RwLock<Option<Arc<dyn CustomerProvider>>>,
825 middlewares: RwLock<Vec<MiddlewareFn>>,
826 before_hooks: RwLock<Vec<BeforeHook>>,
827 after_hooks: RwLock<Vec<AfterHook>>,
828}
829
830#[derive(Clone)]
836pub struct CustomerApi {
837 app: Qefro,
838 identity: Value,
839 parameters: Value,
840 conversation_id: Uuid,
841 channel: Option<String>,
842 auth_response: Option<String>,
843 state: Arc<Mutex<CustomerState>>,
844 platform: Option<PlatformCapabilities>,
845}
846
847impl CustomerApi {
848 async fn set_current(&self, customer: Option<Value>) -> Option<Value> {
849 let mut state = self.state.lock().await;
850 state.current = customer.clone();
851 state.lookup_completed = true;
852 customer
853 }
854
855 async fn require_current_id(&self) -> Result<String> {
856 let state = self.state.lock().await;
857 if let Some(id) = state
858 .current
859 .as_ref()
860 .and_then(|v| v.get("id"))
861 .and_then(|v| v.as_str())
862 .filter(|s| !s.is_empty())
863 {
864 return Ok(id.to_string());
865 }
866 Err(anyhow!("customer_not_found"))
867 }
868
869 pub async fn resolve(&self, input: Option<Value>) -> Result<Option<Value>> {
871 let identity = pick_identity(input.as_ref(), &self.identity);
872 let mut body = Value::Object(identity);
873 if let Some(obj) = body.as_object_mut() {
874 if let Some(ch) = self.channel.as_ref() {
875 obj.insert("channel".into(), json!(ch));
876 }
877 obj.insert("conversation_id".into(), json!(self.conversation_id.to_string()));
878 }
879 let out = hub_call(self.platform.as_ref(), "resolve", body).await?;
880 let hub = hub_customer_from_person(out.as_ref());
881 if hub.is_some() {
882 self.set_current(hub.clone()).await;
883 }
884 Ok(hub)
885 }
886
887 pub async fn lookup(&self, input: Option<Value>) -> Result<Option<Value>> {
889 let provider = self
890 .app
891 .inner
892 .customer_provider
893 .read()
894 .expect("customer_provider")
895 .clone();
896
897 if input.is_some() || provider.is_none() {
898 {
899 let state = self.state.lock().await;
900 if state.lookup_completed && input.is_none() && state.current.is_some() {
901 return Ok(state.current.clone());
902 }
903 }
904 let identity = pick_identity(input.as_ref(), &self.identity);
905 let mut body = Value::Object(identity);
906 if let Some(obj) = body.as_object_mut() {
907 if let Some(ch) = self.channel.as_ref() {
908 obj.insert("channel".into(), json!(ch));
909 }
910 obj.insert(
911 "conversation_id".into(),
912 json!(self.conversation_id.to_string()),
913 );
914 }
915 let out = hub_call(self.platform.as_ref(), "lookup", body).await?;
916 let hub = hub_customer_from_person(out.as_ref());
917 self.set_current(hub.clone()).await;
918 return Ok(hub);
919 }
920
921 {
922 let state = self.state.lock().await;
923 if state.lookup_completed {
924 return Ok(state.current.clone());
925 }
926 }
927
928 let customer = provider
929 .unwrap()
930 .lookup(&CustomerLookupContext {
931 identity: self.identity.clone(),
932 parameters: self.parameters.clone(),
933 conversation: Conversation {
934 id: self.conversation_id,
935 },
936 channel: self.channel.clone(),
937 })
938 .await?;
939
940 Ok(self.set_current(customer).await)
941 }
942
943 pub async fn lookup_by_phone(&self, phone: Option<&str>) -> Result<Option<Value>> {
944 let source = phone
945 .map(str::to_string)
946 .or_else(|| read_identity_phone(&self.identity));
947
948 let Some(source) = source else {
949 let mut state = self.state.lock().await;
950 state.lookup_completed = true;
951 state.current = None;
952 return Ok(None);
953 };
954
955 let provider = self
956 .app
957 .inner
958 .customer_provider
959 .read()
960 .expect("customer_provider")
961 .clone();
962
963 if provider.is_none() || is_customer_hub_enabled() {
964 return self
965 .lookup(Some(json!({
966 "phone_number": source,
967 "whatsapp_number": source,
968 })))
969 .await;
970 }
971
972 let mut identity = self.identity.clone();
973 if let Some(obj) = identity.as_object_mut() {
974 obj.insert("phone".into(), json!(source));
975 }
976
977 let customer = provider
978 .unwrap()
979 .lookup(&CustomerLookupContext {
980 identity,
981 parameters: self.parameters.clone(),
982 conversation: Conversation {
983 id: self.conversation_id,
984 },
985 channel: self.channel.clone(),
986 })
987 .await?;
988
989 Ok(self.set_current(customer).await)
990 }
991
992 pub async fn create(&self, input: Value) -> Result<Option<Value>> {
993 let identity = pick_identity(Some(&input), &self.identity);
994 let mut body = Value::Object(identity);
995 if let Some(obj) = body.as_object_mut() {
996 if let Some(ch) = self.channel.as_ref() {
997 obj.insert("channel".into(), json!(ch));
998 }
999 obj.insert(
1000 "conversation_id".into(),
1001 json!(self.conversation_id.to_string()),
1002 );
1003 }
1004 let out = hub_call(self.platform.as_ref(), "create", body).await?;
1005 let hub = hub_customer_from_person(out.as_ref());
1006 if hub.is_some() {
1007 self.set_current(hub.clone()).await;
1008 }
1009 Ok(hub)
1010 }
1011
1012 pub async fn update(&self, input: Value) -> Result<Option<Value>> {
1013 let id = input
1014 .get("id")
1015 .and_then(|v| v.as_str())
1016 .map(str::to_string)
1017 .or_else(|| {
1018 None
1020 });
1021 let id = match id {
1022 Some(id) => id,
1023 None => {
1024 let state = self.state.lock().await;
1025 match state
1026 .current
1027 .as_ref()
1028 .and_then(|v| v.get("id"))
1029 .and_then(|v| v.as_str())
1030 .map(str::to_string)
1031 {
1032 Some(id) => id,
1033 None if is_customer_hub_optional() => return Ok(None),
1034 None => return Err(anyhow!("customer_not_found")),
1035 }
1036 }
1037 };
1038 let identity = pick_identity(Some(&input), &self.identity);
1039 let mut body = Value::Object(identity);
1040 if let Some(obj) = body.as_object_mut() {
1041 obj.insert("id".into(), json!(id));
1042 }
1043 let out = hub_call(self.platform.as_ref(), "update", body).await?;
1044 let hub = hub_customer_from_person(out.as_ref());
1045 if hub.is_some() {
1046 self.set_current(hub.clone()).await;
1047 }
1048 Ok(hub)
1049 }
1050
1051 pub async fn note(&self, content: &str, options: Option<Value>) -> Result<()> {
1052 let trimmed = content.trim();
1053 if trimmed.is_empty() {
1054 return Err(anyhow!("customer_note_empty"));
1055 }
1056 let customer_id = match self.require_current_id().await {
1057 Ok(id) => id,
1058 Err(_err) if is_customer_hub_optional() => return Ok(()),
1059 Err(err) => return Err(err),
1060 };
1061 let author_id = options
1062 .as_ref()
1063 .and_then(|v| v.get("author_id"))
1064 .cloned()
1065 .unwrap_or(Value::Null);
1066 hub_call(
1067 self.platform.as_ref(),
1068 "note",
1069 json!({
1070 "customer_id": customer_id,
1071 "content": trimmed,
1072 "author_id": author_id,
1073 }),
1074 )
1075 .await?;
1076 Ok(())
1077 }
1078
1079 pub async fn tag(&self, name: &str, options: Option<Value>) -> Result<()> {
1080 let trimmed = name.trim();
1081 if trimmed.is_empty() {
1082 return Err(anyhow!("customer_tag_empty"));
1083 }
1084 let customer_id = match self.require_current_id().await {
1085 Ok(id) => id,
1086 Err(_err) if is_customer_hub_optional() => return Ok(()),
1087 Err(err) => return Err(err),
1088 };
1089 let color = options
1090 .as_ref()
1091 .and_then(|v| v.get("color"))
1092 .cloned()
1093 .unwrap_or(Value::Null);
1094 hub_call(
1095 self.platform.as_ref(),
1096 "tag",
1097 json!({
1098 "customer_id": customer_id,
1099 "name": trimmed,
1100 "color": color,
1101 }),
1102 )
1103 .await?;
1104 Ok(())
1105 }
1106
1107 pub async fn authorize(&self, method: Option<String>) -> Result<Value> {
1108 let provider = self
1109 .app
1110 .inner
1111 .customer_provider
1112 .read()
1113 .expect("customer_provider")
1114 .clone()
1115 .ok_or_else(|| anyhow!("customer_provider_missing"))?;
1116
1117 {
1118 let auth = self.app.inner.auth_by_conversation.lock().await;
1119 if let Some(existing) = auth.get(&self.conversation_id) {
1120 if existing.expires_at_epoch_ms > Utc::now().timestamp_millis() {
1121 let mut state = self.state.lock().await;
1122 state.current = Some(existing.customer.clone());
1123 state.lookup_completed = true;
1124 return Ok(existing.customer.clone());
1125 }
1126 }
1127 }
1128
1129 let customer = self
1130 .lookup(None)
1131 .await?
1132 .ok_or_else(|| anyhow!("customer_not_found"))?;
1133
1134 let outcome = provider
1135 .authorize(&CustomerAuthorizeContext {
1136 customer: customer.clone(),
1137 method,
1138 response: self.auth_response.clone(),
1139 identity: self.identity.clone(),
1140 parameters: self.parameters.clone(),
1141 conversation: Conversation {
1142 id: self.conversation_id,
1143 },
1144 channel: self.channel.clone(),
1145 })
1146 .await?;
1147
1148 self.app
1149 .consume_auth_outcome(
1150 outcome,
1151 self.conversation_id,
1152 Some(self.state.clone()),
1153 None,
1154 None,
1155 None,
1156 None,
1157 )
1158 .await
1159 }
1160
1161 pub async fn get(&self) -> Option<Value> {
1162 self.state.lock().await.current.clone()
1163 }
1164
1165 pub async fn require(&self) -> Result<Value> {
1166 self.get()
1167 .await
1168 .ok_or_else(|| anyhow!("customer_not_found"))
1169 }
1170
1171 pub async fn id(&self) -> Option<String> {
1173 self.get()
1174 .await
1175 .and_then(|v| v.get("id")?.as_str().map(str::to_string))
1176 }
1177
1178 pub async fn phone_number(&self) -> Option<String> {
1179 self.get().await.and_then(|v| {
1180 v.get("phone_number")?
1181 .as_str()
1182 .map(str::to_string)
1183 .or_else(|| v.get("phone")?.as_str().map(str::to_string))
1184 })
1185 }
1186
1187 pub async fn whatsapp_number(&self) -> Option<String> {
1188 self.get()
1189 .await
1190 .and_then(|v| v.get("whatsapp_number")?.as_str().map(str::to_string))
1191 }
1192
1193 pub async fn display_name(&self) -> Option<String> {
1194 self.get().await.and_then(|v| {
1195 v.get("display_name")?
1196 .as_str()
1197 .map(str::to_string)
1198 .or_else(|| v.get("name")?.as_str().map(str::to_string))
1199 })
1200 }
1201}
1202
1203#[derive(Clone)]
1204pub struct Qefro {
1205 inner: Arc<Inner>,
1206}
1207
1208impl Qefro {
1209 pub fn new(config: QefroConfig) -> Self {
1210 Self {
1211 inner: Arc::new(Inner {
1212 config,
1213 tools: RwLock::new(HashMap::new()),
1214 flows: RwLock::new(HashMap::new()),
1215 events: RwLock::new(HashMap::new()),
1216 webhooks: RwLock::new(HashMap::new()),
1217 schedules: RwLock::new(HashMap::new()),
1218 pending: Mutex::new(HashMap::new()),
1219 auth_by_conversation: Mutex::new(HashMap::new()),
1220 customer_provider: RwLock::new(None),
1221 middlewares: RwLock::new(Vec::new()),
1222 before_hooks: RwLock::new(Vec::new()),
1223 after_hooks: RwLock::new(Vec::new()),
1224 }),
1225 }
1226 }
1227
1228 pub fn customer<P>(&self, provider: P) -> &Self
1229 where
1230 P: CustomerProvider + 'static,
1231 {
1232 *self.inner.customer_provider.write().expect("customer_provider") =
1233 Some(Arc::new(provider));
1234 self
1235 }
1236
1237 pub fn tool<F, Fut>(&self, metadata: ToolMetadata, handler: F) -> &Self
1238 where
1239 F: Fn(ToolContext) -> Fut + Send + Sync + 'static,
1240 Fut: Future<Output = Result<Value>> + Send + 'static,
1241 {
1242 let name = metadata.name.clone();
1243 let lookup = normalized_lookup_field(metadata.lookup.as_ref());
1244 let metadata = ToolMetadata {
1245 lookup,
1246 ..metadata
1247 };
1248 let registration = ToolRegistration {
1249 metadata,
1250 handler: Arc::new(move |ctx| Box::pin(handler(ctx))),
1251 };
1252 self.inner
1253 .tools
1254 .write()
1255 .expect("tools")
1256 .insert(name, registration);
1257 self
1258 }
1259
1260 pub fn flow(&self, metadata: BusinessFlowMetadata) -> std::result::Result<FlowBuilder, FlowError> {
1265 let id = metadata.id.trim().to_string();
1266 if id.is_empty() {
1267 return Err(FlowError::EmptyFlowId);
1268 }
1269 {
1270 let flows = self.inner.flows.read().expect("flows");
1271 if flows.contains_key(&id) {
1272 return Err(FlowError::DuplicateFlowId(id));
1273 }
1274 }
1275 let version = if metadata.version == 0 { 1 } else { metadata.version };
1276 let trigger = match metadata.trigger.clone() {
1277 Some(t) => Some(t.normalize()?),
1278 None => None,
1279 };
1280 let metadata = BusinessFlowMetadata {
1281 id: id.clone(),
1282 version,
1283 trigger,
1284 ..metadata
1285 };
1286 self.inner.flows.write().expect("flows").insert(
1287 id.clone(),
1288 FlowRegistration {
1289 metadata,
1290 steps: Vec::new(),
1291 error: None,
1292 },
1293 );
1294 Ok(FlowBuilder {
1295 inner: self.inner.clone(),
1296 flow_id: id,
1297 })
1298 }
1299
1300 pub fn event(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1303 self.register_named_handler("event", &self.inner.events, def)
1304 }
1305
1306 pub fn webhook(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1308 self.register_named_handler("webhook", &self.inner.webhooks, def)
1309 }
1310
1311 pub fn schedule(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1313 let cron = def.cron.as_deref().unwrap_or("").trim();
1314 if cron.is_empty() {
1315 return Err(FlowError::InvalidTrigger(
1316 "schedule() requires a non-empty cron expression".into(),
1317 ));
1318 }
1319 self.register_named_handler("schedule", &self.inner.schedules, def)
1320 }
1321
1322 fn register_named_handler(
1323 &self,
1324 kind: &'static str,
1325 lock: &RwLock<HashMap<String, EventHandlerDefinition>>,
1326 def: EventHandlerDefinition,
1327 ) -> std::result::Result<&Self, FlowError> {
1328 let name = def.name.trim().to_string();
1329 if name.is_empty() {
1330 return Err(FlowError::EmptyHandlerName(kind));
1331 }
1332 let mut map = lock.write().expect(kind);
1333 if map.contains_key(&name) {
1334 return Err(FlowError::DuplicateHandler { kind, name });
1335 }
1336 map.insert(
1337 name.clone(),
1338 EventHandlerDefinition {
1339 name,
1340 description: def.description,
1341 cron: def.cron,
1342 },
1343 );
1344 Ok(self)
1345 }
1346
1347 fn list_named_handlers(
1348 lock: &RwLock<HashMap<String, EventHandlerDefinition>>,
1349 ) -> Vec<EventHandlerDefinition> {
1350 lock.read()
1351 .expect("handlers")
1352 .values()
1353 .cloned()
1354 .collect()
1355 }
1356
1357 fn list_registered_flows(&self) -> Vec<BusinessFlow> {
1360 let flows = self.inner.flows.read().expect("flows");
1361 flows
1362 .values()
1363 .filter_map(|reg| {
1364 if let Some(err) = ®.error {
1365 eprintln!("[qefro] skipping invalid flow \"{}\": {err}", reg.metadata.id);
1366 None
1367 } else {
1368 Some(BusinessFlow {
1369 metadata: reg.metadata.clone(),
1370 steps: reg.steps.clone(),
1371 })
1372 }
1373 })
1374 .collect()
1375 }
1376
1377 pub fn before<F, Fut>(&self, hook: F) -> &Self
1378 where
1379 F: Fn(ToolContext) -> Fut + Send + Sync + 'static,
1380 Fut: Future<Output = Result<()>> + Send + 'static,
1381 {
1382 let hook: BeforeHook = Arc::new(move |ctx| Box::pin(hook(ctx)));
1383 self.inner
1384 .before_hooks
1385 .write()
1386 .expect("before_hooks")
1387 .push(hook);
1388 self
1389 }
1390
1391 pub fn after<F, Fut>(&self, hook: F) -> &Self
1392 where
1393 F: Fn(ToolContext, Value) -> Fut + Send + Sync + 'static,
1394 Fut: Future<Output = Result<Value>> + Send + 'static,
1395 {
1396 let hook: AfterHook = Arc::new(move |ctx, out| Box::pin(hook(ctx, out)));
1397 self.inner
1398 .after_hooks
1399 .write()
1400 .expect("after_hooks")
1401 .push(hook);
1402 self
1403 }
1404
1405 pub fn use_middleware<F>(&self, middleware: F) -> &Self
1407 where
1408 F: Fn(ToolContext, NextFn) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
1409 + Send
1410 + Sync
1411 + 'static,
1412 {
1413 let mw: MiddlewareFn = Arc::new(middleware);
1414 self.inner.middlewares.write().expect("middlewares").push(mw);
1415 self
1416 }
1417
1418 pub fn verify_signature(&self, signature: &str, timestamp: i64, body: &str) -> bool {
1419 let now = Utc::now().timestamp();
1420 if (now - timestamp).abs() > self.inner.config.max_timestamp_skew_secs {
1421 return false;
1422 }
1423 let payload = format!("v1:{timestamp}:{body}");
1424 let mut mac = HmacSha256::new_from_slice(self.inner.config.signing_secret.as_bytes())
1425 .expect("HMAC accepts any key length");
1426 mac.update(payload.as_bytes());
1427 let expected = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
1428 let a = expected.as_bytes();
1429 let b = signature.as_bytes();
1430 a.len() == b.len() && bool::from(a.ct_eq(b))
1431 }
1432
1433 pub async fn handle(&self, request: QefroRequest) -> QefroResponse {
1435 if request.protocol_version != self.inner.config.protocol_version {
1436 return QefroResponse::Error {
1437 code: "protocol_mismatch".into(),
1438 message: "Unsupported protocol version".into(),
1439 };
1440 }
1441
1442 match request.request_type.as_str() {
1443 "ping" => QefroResponse::Pong {
1444 protocol_version: self.inner.config.protocol_version.clone(),
1445 sdk_version: SDK_VERSION.into(),
1446 },
1447 "tools.list" => {
1448 let tools = self.inner.tools.read().expect("tools");
1449 QefroResponse::ToolsList {
1450 tools: tools
1451 .values()
1452 .map(|r| RegisteredTool {
1453 name: r.metadata.name.clone(),
1454 description: r.metadata.description.clone(),
1455 input_schema: r.metadata.input_schema.clone(),
1456 authentication_methods: r.metadata.authentication_methods.clone(),
1457 auth: r.metadata.auth,
1458 permissions: r.metadata.permissions.clone(),
1459 timeout: r.metadata.timeout,
1460 lookup: r.metadata.lookup.clone(),
1461 })
1462 .collect(),
1463 protocol_version: self.inner.config.protocol_version.clone(),
1464 sdk_version: SDK_VERSION.into(),
1465 }
1466 }
1467 "capabilities.list" => {
1468 let tools = {
1469 let tools = self.inner.tools.read().expect("tools");
1470 tools
1471 .values()
1472 .map(|r| RegisteredTool {
1473 name: r.metadata.name.clone(),
1474 description: r.metadata.description.clone(),
1475 input_schema: r.metadata.input_schema.clone(),
1476 authentication_methods: r.metadata.authentication_methods.clone(),
1477 auth: r.metadata.auth,
1478 permissions: r.metadata.permissions.clone(),
1479 timeout: r.metadata.timeout,
1480 lookup: r.metadata.lookup.clone(),
1481 })
1482 .collect()
1483 };
1484 QefroResponse::CapabilitiesList {
1485 tools,
1486 flows: self.list_registered_flows(),
1487 events: Self::list_named_handlers(&self.inner.events),
1488 webhooks: Self::list_named_handlers(&self.inner.webhooks),
1489 schedules: Self::list_named_handlers(&self.inner.schedules),
1490 protocol_version: self.inner.config.protocol_version.clone(),
1491 sdk_version: SDK_VERSION.into(),
1492 sdk_name: SDK_NAME.into(),
1493 }
1494 }
1495 "tool.invoke" => {
1496 self.invoke(
1497 request.tool,
1498 request.parameters.unwrap_or_else(|| json!({})),
1499 request.conversation_id.unwrap_or_else(Uuid::new_v4),
1500 request.identity,
1501 request.channel,
1502 request.authentication,
1503 None,
1504 request.platform,
1505 request.person,
1506 )
1507 .await
1508 }
1509 "tool.resume" => {
1510 let Some(resume_token) = request.resume_token else {
1511 return QefroResponse::Error {
1512 code: "invalid_request".into(),
1513 message: "resume_token is required".into(),
1514 };
1515 };
1516 let Some(challenge_response) = request.challenge_response else {
1517 return QefroResponse::Error {
1518 code: "invalid_request".into(),
1519 message: "challenge_response is required".into(),
1520 };
1521 };
1522 let pending = {
1523 let mut map = self.inner.pending.lock().await;
1524 map.remove(&resume_token)
1525 };
1526 let Some(pending) = pending else {
1527 return QefroResponse::Error {
1528 code: "not_found".into(),
1529 message: "resume token not found or expired".into(),
1530 };
1531 };
1532 self.invoke(
1533 Some(pending.tool),
1534 pending.parameters,
1535 pending.conversation_id,
1536 pending.identity,
1537 pending.channel,
1538 request.authentication,
1539 Some(challenge_response),
1540 request.platform.or(pending.platform),
1541 request.person.or(pending.person),
1542 )
1543 .await
1544 }
1545 _ => QefroResponse::Error {
1546 code: "invalid_request".into(),
1547 message: "Unsupported request type".into(),
1548 },
1549 }
1550 }
1551
1552 pub async fn handle_raw(
1554 &self,
1555 body: &str,
1556 headers: &HeaderMap,
1557 ) -> (StatusCode, QefroResponse) {
1558 let signature = header_str(headers, "x-qefro-signature");
1559 let timestamp = header_str(headers, "x-qefro-timestamp")
1560 .and_then(|t| t.parse::<i64>().ok());
1561
1562 let protocol_header = header_str(headers, "x-qefro-protocol")
1563 .or_else(|| header_str(headers, "x-qefro-protocol-version"));
1564 if let Some(proto) = protocol_header {
1565 if proto != self.inner.config.protocol_version {
1566 return (
1567 StatusCode::BAD_REQUEST,
1568 QefroResponse::Error {
1569 code: "protocol_mismatch".into(),
1570 message: format!("Unsupported protocol version {proto}"),
1571 },
1572 );
1573 }
1574 }
1575
1576 match (signature, timestamp) {
1577 (Some(sig), Some(ts)) if self.verify_signature(sig, ts, body) => {}
1578 _ => {
1579 return (
1580 StatusCode::UNAUTHORIZED,
1581 QefroResponse::Error {
1582 code: "invalid_signature".into(),
1583 message: "Invalid Qefro signature".into(),
1584 },
1585 );
1586 }
1587 }
1588
1589 let request: QefroRequest = match serde_json::from_str(body) {
1590 Ok(r) => r,
1591 Err(e) => {
1592 return (
1593 StatusCode::BAD_REQUEST,
1594 QefroResponse::Error {
1595 code: "invalid_request".into(),
1596 message: e.to_string(),
1597 },
1598 );
1599 }
1600 };
1601
1602 (StatusCode::OK, self.handle(request).await)
1603 }
1604
1605 pub async fn listen(&self, options: ListenOptions) -> Result<ListenHandle> {
1607 let host = options
1608 .host
1609 .unwrap_or_else(|| "0.0.0.0".to_string());
1610 let path = options
1611 .path
1612 .unwrap_or_else(|| self.inner.config.endpoint_path.clone());
1613 let path = if path.starts_with('/') {
1614 path
1615 } else {
1616 format!("/{path}")
1617 };
1618
1619 let app_state = self.clone();
1620 let router = Router::new()
1621 .route(&path, post(http_handler))
1622 .with_state(app_state);
1623
1624 let addr = format!("{host}:{}", options.port);
1625 let listener = tokio::net::TcpListener::bind(&addr).await?;
1626 let url = format!("http://{host}:{}{path}", options.port);
1627
1628 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1629 let join = tokio::spawn(async move {
1630 let _ = axum::serve(listener, router)
1631 .with_graceful_shutdown(async {
1632 let _ = rx.await;
1633 })
1634 .await;
1635 });
1636
1637 Ok(ListenHandle {
1638 url,
1639 shutdown: Some(tx),
1640 join: Some(join),
1641 })
1642 }
1643
1644 async fn invoke(
1645 &self,
1646 tool: Option<String>,
1647 parameters: Value,
1648 conversation_id: Uuid,
1649 identity: Option<Value>,
1650 channel: Option<String>,
1651 authentication: Option<Value>,
1652 auth_response: Option<String>,
1653 platform: Option<PlatformCapabilities>,
1654 person: Option<Value>,
1655 ) -> QefroResponse {
1656 let Some(tool_name) = tool else {
1657 return QefroResponse::Error {
1658 code: "invalid_request".into(),
1659 message: "tool is required".into(),
1660 };
1661 };
1662
1663 let registration = {
1664 let tools = self.inner.tools.read().expect("tools");
1665 tools.get(&tool_name).cloned()
1666 };
1667 let Some(registration) = registration else {
1668 return QefroResponse::Error {
1669 code: "not_found".into(),
1670 message: format!("Unknown tool: {tool_name}"),
1671 };
1672 };
1673
1674 let identity_value = identity.clone().unwrap_or_else(|| json!({}));
1675 let customer_state = Arc::new(Mutex::new(CustomerState::default()));
1676
1677 {
1678 let auth = self.inner.auth_by_conversation.lock().await;
1679 if let Some(stored) = auth.get(&conversation_id) {
1680 if stored.expires_at_epoch_ms > Utc::now().timestamp_millis() {
1681 let mut state = customer_state.lock().await;
1682 state.current = Some(stored.customer.clone());
1683 state.lookup_completed = true;
1684 }
1685 }
1686 }
1687
1688 if let Some(ref person_val) = person {
1690 if let Some(seeded) = seed_from_person(person_val) {
1691 let mut state = customer_state.lock().await;
1692 state.current = Some(seeded);
1693 state.lookup_completed = true;
1694 }
1695 }
1696
1697 let customer_api = CustomerApi {
1698 app: self.clone(),
1699 identity: identity_value.clone(),
1700 parameters: parameters.clone(),
1701 conversation_id,
1702 channel: channel.clone(),
1703 auth_response: auth_response.clone(),
1704 state: customer_state.clone(),
1705 platform: platform.clone(),
1706 };
1707
1708 let mut current_customer = customer_state.lock().await.current.clone();
1709
1710 if registration.metadata.auth == ToolAuthMode::Required {
1711 match customer_api
1712 .authorize(registration.metadata.default_auth_method.clone())
1713 .await
1714 {
1715 Ok(customer) => current_customer = Some(customer),
1716 Err(e) => {
1717 return map_invoke_error(
1718 e,
1719 self,
1720 &tool_name,
1721 ¶meters,
1722 conversation_id,
1723 identity.clone(),
1724 channel.clone(),
1725 platform.clone(),
1726 person.clone(),
1727 )
1728 .await
1729 }
1730 }
1731 }
1732
1733 let solution_id = platform
1734 .as_ref()
1735 .and_then(|p| p.customer.as_ref())
1736 .and_then(|c| c.context.as_ref())
1737 .and_then(|c| c.solution_id.clone())
1738 .or_else(|| {
1739 platform
1740 .as_ref()
1741 .and_then(|p| p.storage.as_ref())
1742 .and_then(|s| s.context.as_ref())
1743 .map(|c| c.solution_id.clone())
1744 });
1745
1746 let timeline = TimelineContext {
1747 platform: platform.clone(),
1748 state: customer_state.clone(),
1749 };
1750 let membership = MembershipContext {
1751 platform: platform.clone(),
1752 state: customer_state.clone(),
1753 solution_id,
1754 };
1755 let consent = ConsentContext {
1756 platform: platform.clone(),
1757 state: customer_state.clone(),
1758 };
1759
1760 let ctx = ToolContext {
1761 identity: identity_value,
1762 parameters: parameters.clone(),
1763 conversation: Conversation {
1764 id: conversation_id,
1765 },
1766 channel: channel.clone(),
1767 authentication,
1768 auth_response,
1769 customer: current_customer,
1770 customer_api: Some(customer_api),
1771 timeline,
1772 membership,
1773 consent,
1774 platform: platform.clone(),
1775 };
1776
1777 let before_hooks = self.inner.before_hooks.read().expect("before_hooks").clone();
1778 for hook in &before_hooks {
1779 if let Err(e) = hook(ctx.clone()).await {
1780 return map_invoke_error(
1781 e,
1782 self,
1783 &tool_name,
1784 ¶meters,
1785 conversation_id,
1786 identity.clone(),
1787 channel.clone(),
1788 platform.clone(),
1789 person.clone(),
1790 )
1791 .await;
1792 }
1793 }
1794
1795 let handler = registration.handler.clone();
1796 let middlewares = self.inner.middlewares.read().expect("middlewares").clone();
1797 let run_result = run_middlewares(middlewares, ctx.clone(), handler).await;
1798
1799 let output = match run_result {
1800 Ok(v) => v,
1801 Err(e) => {
1802 return map_invoke_error(
1803 e,
1804 self,
1805 &tool_name,
1806 ¶meters,
1807 conversation_id,
1808 identity,
1809 channel,
1810 platform,
1811 person,
1812 )
1813 .await;
1814 }
1815 };
1816
1817 let after_hooks = self.inner.after_hooks.read().expect("after_hooks").clone();
1818 let mut output = output;
1819 for hook in &after_hooks {
1820 match hook(ctx.clone(), output).await {
1821 Ok(v) => output = v,
1822 Err(e) => {
1823 return map_invoke_error(
1824 e,
1825 self,
1826 &tool_name,
1827 ¶meters,
1828 conversation_id,
1829 identity,
1830 channel,
1831 platform,
1832 person,
1833 )
1834 .await;
1835 }
1836 }
1837 }
1838
1839 let auth = {
1840 let map = self.inner.auth_by_conversation.lock().await;
1841 map.get(&conversation_id)
1842 .filter(|v| v.expires_at_epoch_ms > Utc::now().timestamp_millis())
1843 .map(|v| v.auth.clone())
1844 };
1845
1846 QefroResponse::Result {
1847 output,
1848 authentication_context: auth,
1849 }
1850 }
1851
1852 async fn consume_auth_outcome(
1853 &self,
1854 outcome: AuthOutcome,
1855 conversation_id: Uuid,
1856 customer_state: Option<Arc<Mutex<CustomerState>>>,
1857 pending_tool: Option<&str>,
1859 pending_parameters: Option<Value>,
1860 pending_identity: Option<Value>,
1861 pending_channel: Option<String>,
1862 ) -> Result<Value> {
1863 match outcome {
1864 AuthOutcome::Success { customer, auth } => {
1865 let ttl = auth.expires_in.unwrap_or(900).max(1);
1866 self.inner.auth_by_conversation.lock().await.insert(
1867 conversation_id,
1868 StoredAuth {
1869 customer: customer.clone(),
1870 auth,
1871 expires_at_epoch_ms: Utc::now().timestamp_millis() + ttl * 1000,
1872 },
1873 );
1874 if let Some(state) = customer_state {
1875 let mut s = state.lock().await;
1876 s.current = Some(customer.clone());
1877 s.lookup_completed = true;
1878 }
1879 Ok(customer)
1880 }
1881 AuthOutcome::Challenge(challenge) => {
1882 if let (Some(tool), Some(parameters)) = (pending_tool, pending_parameters) {
1883 let resume_token = Uuid::new_v4().to_string();
1884 self.inner.pending.lock().await.insert(
1885 resume_token.clone(),
1886 PendingInvocation {
1887 tool: tool.to_string(),
1888 conversation_id,
1889 parameters,
1890 identity: pending_identity,
1891 channel: pending_channel,
1892 platform: None,
1893 person: None,
1894 },
1895 );
1896 let _ = resume_token;
1899 }
1900 Err(ChallengeSignal { challenge }.into())
1901 }
1902 AuthOutcome::Denied => Err(anyhow!("denied")),
1903 AuthOutcome::NotFound => Err(anyhow!("customer_not_found")),
1904 }
1905 }
1906
1907 pub async fn require_authentication(
1908 &self,
1909 conversation_id: Uuid,
1910 outcome: AuthOutcome,
1911 tool: &str,
1912 parameters: Value,
1913 identity: Option<Value>,
1914 channel: Option<String>,
1915 ) -> std::result::Result<Value, QefroResponse> {
1916 match outcome {
1917 AuthOutcome::Success { customer, auth } => {
1918 let ttl = auth.expires_in.unwrap_or(900).max(1);
1919 self.inner.auth_by_conversation.lock().await.insert(
1920 conversation_id,
1921 StoredAuth {
1922 customer: customer.clone(),
1923 auth,
1924 expires_at_epoch_ms: Utc::now().timestamp_millis() + ttl * 1000,
1925 },
1926 );
1927 Ok(customer)
1928 }
1929 AuthOutcome::Challenge(challenge) => {
1930 let resume_token = Uuid::new_v4().to_string();
1931 self.inner.pending.lock().await.insert(
1932 resume_token.clone(),
1933 PendingInvocation {
1934 tool: tool.to_string(),
1935 conversation_id,
1936 parameters,
1937 identity,
1938 channel,
1939 platform: None,
1940 person: None,
1941 },
1942 );
1943 Err(QefroResponse::Challenge {
1944 resume_token,
1945 challenge,
1946 })
1947 }
1948 AuthOutcome::Denied => Err(QefroResponse::Error {
1949 code: "denied".into(),
1950 message: "Authentication denied".into(),
1951 }),
1952 AuthOutcome::NotFound => Err(QefroResponse::Error {
1953 code: "customer_not_found".into(),
1954 message: "Customer not found".into(),
1955 }),
1956 }
1957 }
1958}
1959
1960async fn map_invoke_error(
1961 e: anyhow::Error,
1962 app: &Qefro,
1963 tool_name: &str,
1964 parameters: &Value,
1965 conversation_id: Uuid,
1966 identity: Option<Value>,
1967 channel: Option<String>,
1968 platform: Option<PlatformCapabilities>,
1969 person: Option<Value>,
1970) -> QefroResponse {
1971 if let Some(signal) = e.downcast_ref::<ChallengeSignal>() {
1972 let resume_token = Uuid::new_v4().to_string();
1973 app.inner.pending.lock().await.insert(
1974 resume_token.clone(),
1975 PendingInvocation {
1976 tool: tool_name.to_string(),
1977 conversation_id,
1978 parameters: parameters.clone(),
1979 identity,
1980 channel,
1981 platform,
1982 person,
1983 },
1984 );
1985 return QefroResponse::Challenge {
1986 resume_token,
1987 challenge: signal.challenge.clone(),
1988 };
1989 }
1990
1991 let message = e.to_string();
1992 if message == "denied" {
1993 return QefroResponse::Error {
1994 code: "denied".into(),
1995 message: "Authentication denied".into(),
1996 };
1997 }
1998 if message == "customer_not_found" {
1999 return QefroResponse::Error {
2000 code: "customer_not_found".into(),
2001 message: "Customer not found".into(),
2002 };
2003 }
2004 if message == "customer_provider_missing" {
2005 return QefroResponse::Error {
2006 code: "configuration_error".into(),
2007 message: "Tool requires customer provider. Configure app.customer(...) first.".into(),
2008 };
2009 }
2010
2011 QefroResponse::Error {
2012 code: "internal_error".into(),
2013 message,
2014 }
2015}
2016
2017async fn run_middlewares(
2018 middlewares: Vec<MiddlewareFn>,
2019 ctx: ToolContext,
2020 handler: ToolHandler,
2021) -> Result<Value> {
2022 fn dispatch(
2023 i: usize,
2024 middlewares: Arc<Vec<MiddlewareFn>>,
2025 ctx: ToolContext,
2026 handler: ToolHandler,
2027 ) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>> {
2028 Box::pin(async move {
2029 if i == middlewares.len() {
2030 return handler(ctx).await;
2031 }
2032 let mw = middlewares[i].clone();
2033 let mws = middlewares.clone();
2034 let h = handler.clone();
2035 let next: NextFn = Box::new(move |c| dispatch(i + 1, mws, c, h));
2036 mw(ctx, next).await
2037 })
2038 }
2039
2040 dispatch(0, Arc::new(middlewares), ctx, handler).await
2041}
2042
2043fn header_str<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
2044 headers.get(key).and_then(|v| v.to_str().ok())
2045}
2046
2047fn protocol_response_headers(app: &Qefro) -> HeaderMap {
2048 use axum::http::HeaderValue;
2049 let mut headers = HeaderMap::new();
2050 let proto = HeaderValue::from_str(&app.inner.config.protocol_version)
2051 .unwrap_or_else(|_| HeaderValue::from_static("1"));
2052 headers.insert("X-Qefro-Protocol", proto.clone());
2053 headers.insert("X-Qefro-Protocol-Version", proto);
2054 headers.insert("X-Qefro-SDK", HeaderValue::from_static(SDK_NAME));
2055 headers.insert(
2056 "X-Qefro-Version",
2057 HeaderValue::from_static(SDK_VERSION),
2058 );
2059 headers
2060}
2061
2062async fn http_handler(
2063 State(app): State<Qefro>,
2064 headers: HeaderMap,
2065 body: Bytes,
2066) -> impl IntoResponse {
2067 let mut response_headers = protocol_response_headers(&app);
2068 let body_str = String::from_utf8_lossy(&body);
2069 let (status, resp) = app.handle_raw(&body_str, &headers).await;
2070 response_headers.insert(
2071 axum::http::header::CONTENT_TYPE,
2072 "application/json".parse().unwrap(),
2073 );
2074 (status, response_headers, Json(resp))
2075}
2076
2077#[cfg(test)]
2078mod tests {
2079 use super::*;
2080
2081 #[test]
2082 fn normalize_lookup_dedupes() {
2083 let lookup = ToolLookup {
2084 by: Some("Email".into()),
2085 required: vec!["phone".into(), "email".into()],
2086 };
2087 assert_eq!(
2088 normalize_lookup(Some(&lookup)),
2089 vec!["phone".to_string(), "email".to_string()]
2090 );
2091 }
2092
2093 #[test]
2094 fn signature_roundtrip() {
2095 let app = Qefro::new(QefroConfig::new("secret"));
2096 let body = r#"{"protocol_version":"1","request_id":"00000000-0000-0000-0000-000000000001","type":"ping"}"#;
2097 let ts = Utc::now().timestamp();
2098 let payload = format!("v1:{ts}:{body}");
2099 let mut mac = HmacSha256::new_from_slice(b"secret").unwrap();
2100 mac.update(payload.as_bytes());
2101 let sig = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
2102 assert!(app.verify_signature(&sig, ts, body));
2103 assert!(!app.verify_signature("v1=deadbeef", ts, body));
2104 }
2105
2106 #[tokio::test]
2107 async fn tools_list_includes_lookup() {
2108 let app = Qefro::new(QefroConfig::new("secret"));
2109 app.tool(
2110 ToolMetadata {
2111 name: "orders".into(),
2112 lookup: Some(ToolLookup {
2113 by: Some("email".into()),
2114 required: vec![],
2115 }),
2116 ..Default::default()
2117 },
2118 |_ctx| async move { Ok(json!({})) },
2119 );
2120
2121 let resp = app
2122 .handle(QefroRequest {
2123 protocol_version: "1".into(),
2124 request_id: Uuid::new_v4(),
2125 request_type: "tools.list".into(),
2126 organization_id: None,
2127 conversation_id: None,
2128 channel: None,
2129 identity: None,
2130 tool: None,
2131 parameters: None,
2132 authentication: None,
2133 resume_token: None,
2134 challenge_response: None,
2135 person: None,
2136 platform: None,
2137 })
2138 .await;
2139
2140 match resp {
2141 QefroResponse::ToolsList { tools, .. } => {
2142 assert_eq!(tools.len(), 1);
2143 assert_eq!(
2144 tools[0].lookup.as_ref().unwrap().required,
2145 vec!["email".to_string()]
2146 );
2147 }
2148 other => panic!("unexpected {other:?}"),
2149 }
2150 }
2151
2152 fn capabilities_request() -> QefroRequest {
2153 QefroRequest {
2154 protocol_version: "1".into(),
2155 request_id: Uuid::new_v4(),
2156 request_type: "capabilities.list".into(),
2157 organization_id: None,
2158 conversation_id: None,
2159 channel: None,
2160 identity: None,
2161 tool: None,
2162 parameters: None,
2163 authentication: None,
2164 resume_token: None,
2165 challenge_response: None,
2166 person: None,
2167 platform: None,
2168 }
2169 }
2170
2171 fn order_lookup_metadata() -> BusinessFlowMetadata {
2172 BusinessFlowMetadata {
2173 id: "order_lookup".into(),
2174 name: Some("Order Lookup".into()),
2175 description: Some("Lookup customer orders".into()),
2176 category: Some("crm".into()),
2177 tags: vec!["customer".into(), "orders".into()],
2178 intent: vec!["track order".into(), "where is my order".into()],
2179 inputs: vec!["email".into()],
2180 outputs: vec!["customer".into(), "orders".into()],
2181 ..Default::default()
2182 }
2183 }
2184
2185 #[tokio::test]
2186 async fn capabilities_list_advertises_flows() {
2187 let app = Qefro::new(QefroConfig::new("secret"));
2188 app.tool(
2189 ToolMetadata {
2190 name: "lookup_customer".into(),
2191 ..Default::default()
2192 },
2193 |_ctx| async move { Ok(json!({})) },
2194 );
2195 app.flow(order_lookup_metadata())
2196 .expect("flow registers")
2197 .ask("email", "email", "Please enter your email.")
2198 .tool("lookup", "lookup_customer")
2199 .complete("done", None)
2200 .expect("flow builds without error");
2201
2202 let resp = app.handle(capabilities_request()).await;
2203 let value = serde_json::to_value(&resp).expect("serialize");
2204 assert_eq!(value["type"], "capabilities.list");
2205 assert_eq!(value["flows"].as_array().unwrap().len(), 1);
2206 let flow = &value["flows"][0];
2207 assert_eq!(flow["metadata"]["id"], "order_lookup");
2209 assert_eq!(flow["metadata"]["version"], 1);
2210 assert!(flow["metadata"]["version"].is_number());
2211 assert_eq!(flow["steps"][0]["id"], "email");
2213 assert_eq!(flow["steps"][0]["type"], "ask");
2214 assert_eq!(flow["steps"][0]["config"]["field"], "email");
2215 assert_eq!(flow["steps"][1]["type"], "tool");
2216 assert_eq!(flow["steps"][1]["config"]["tool_ref"], "lookup_customer");
2217 assert_eq!(flow["steps"][2]["type"], "complete");
2218 assert!(flow["steps"][2]["config"].is_object());
2219 }
2220
2221 #[test]
2222 fn duplicate_flow_id_returns_error_no_panic() {
2223 let app = Qefro::new(QefroConfig::new("secret"));
2224 app.flow(order_lookup_metadata()).expect("first registers");
2225 let err = app.flow(order_lookup_metadata()).err().unwrap();
2226 assert_eq!(err, FlowError::DuplicateFlowId("order_lookup".into()));
2227 }
2228
2229 #[test]
2230 fn empty_flow_id_returns_error() {
2231 let app = Qefro::new(QefroConfig::new("secret"));
2232 let err = app
2233 .flow(BusinessFlowMetadata {
2234 id: " ".into(),
2235 ..Default::default()
2236 })
2237 .err()
2238 .unwrap();
2239 assert_eq!(err, FlowError::EmptyFlowId);
2240 }
2241
2242 #[tokio::test]
2243 async fn capabilities_list_includes_event_triggers_and_handlers() {
2244 let app = Qefro::new(QefroConfig::new("secret"));
2245 app.flow(BusinessFlowMetadata {
2246 id: "abandoned_cart".into(),
2247 name: Some("Abandoned cart".into()),
2248 trigger: Some(FlowTrigger::Event {
2249 event: "shopify.cart.abandoned".into(),
2250 when: None,
2251 }),
2252 ..Default::default()
2253 })
2254 .expect("flow")
2255 .delay("wait", 60)
2256 .complete("done", None)
2257 .expect("build");
2258
2259 app.event(EventHandlerDefinition {
2260 name: "shopify.cart.abandoned".into(),
2261 description: Some("cart abandoned".into()),
2262 cron: None,
2263 })
2264 .expect("event");
2265 app.schedule(EventHandlerDefinition {
2266 name: "nightly_sync".into(),
2267 description: None,
2268 cron: Some("0 2 * * *".into()),
2269 })
2270 .expect("schedule");
2271
2272 let resp = app.handle(capabilities_request()).await;
2273 let value = serde_json::to_value(&resp).expect("serialize");
2274 assert_eq!(value["type"], "capabilities.list");
2275 assert_eq!(
2276 value["flows"][0]["metadata"]["trigger"]["type"],
2277 "event"
2278 );
2279 assert_eq!(
2280 value["flows"][0]["metadata"]["trigger"]["event"],
2281 "shopify.cart.abandoned"
2282 );
2283 assert_eq!(value["events"][0]["name"], "shopify.cart.abandoned");
2284 assert_eq!(value["schedules"][0]["cron"], "0 2 * * *");
2285 }
2286
2287 #[tokio::test]
2288 async fn duplicate_step_id_surfaced_and_flow_excluded() {
2289 let app = Qefro::new(QefroConfig::new("secret"));
2290 let result = app
2291 .flow(order_lookup_metadata())
2292 .expect("flow registers")
2293 .ask("email", "email", "Please enter your email.")
2294 .tool("email", "lookup_customer") .complete("done", None);
2296 assert_eq!(
2297 result.unwrap_err(),
2298 FlowError::DuplicateStepId {
2299 flow: "order_lookup".into(),
2300 step: "email".into()
2301 }
2302 );
2303
2304 let resp = app.handle(capabilities_request()).await;
2306 let value = serde_json::to_value(&resp).expect("serialize");
2307 assert_eq!(value["flows"].as_array().unwrap().len(), 0);
2308 }
2309
2310 #[tokio::test]
2311 async fn complete_step_allows_branch_terminal_before_complete() {
2312 let app = Qefro::new(QefroConfig::new("secret"));
2313 app.flow(order_lookup_metadata())
2314 .expect("flow registers")
2315 .ask("ask", "order_id", "Order id?")
2316 .tool("lookup", "order_status_check")
2317 .condition(
2318 "branch",
2319 "order_status_check.found == true",
2320 Some("ok".into()),
2321 Some("missing".into()),
2322 )
2323 .complete_step("missing", Some("Not found.".into()))
2324 .complete("ok", Some("Found.".into()))
2325 .expect("flow builds with a mid-chain complete_step");
2326
2327 let resp = app.handle(capabilities_request()).await;
2328 let value = serde_json::to_value(&resp).expect("serialize");
2329 let steps = value["flows"][0]["steps"].as_array().unwrap();
2330 let kinds: Vec<&str> = steps.iter().map(|s| s["type"].as_str().unwrap()).collect();
2331 assert_eq!(kinds, ["ask", "tool", "condition", "complete", "complete"]);
2332 }
2333
2334 #[tokio::test]
2335 async fn tools_list_unchanged_alongside_flows() {
2336 let app = Qefro::new(QefroConfig::new("secret"));
2337 app.tool(
2338 ToolMetadata {
2339 name: "lookup_customer".into(),
2340 ..Default::default()
2341 },
2342 |_ctx| async move { Ok(json!({})) },
2343 );
2344 app.flow(order_lookup_metadata())
2345 .expect("flow registers")
2346 .tool("lookup", "lookup_customer")
2347 .complete("done", None)
2348 .expect("flow builds");
2349
2350 let resp = app
2351 .handle(QefroRequest {
2352 protocol_version: "1".into(),
2353 request_id: Uuid::new_v4(),
2354 request_type: "tools.list".into(),
2355 organization_id: None,
2356 conversation_id: None,
2357 channel: None,
2358 identity: None,
2359 tool: None,
2360 parameters: None,
2361 authentication: None,
2362 resume_token: None,
2363 challenge_response: None,
2364 person: None,
2365 platform: None,
2366 })
2367 .await;
2368 let value = serde_json::to_value(&resp).expect("serialize");
2369 assert_eq!(value["type"], "tools.list");
2371 assert!(value.get("flows").is_none());
2372 assert_eq!(value["tools"].as_array().unwrap().len(), 1);
2373 }
2374
2375 #[test]
2376 fn flow_step_roundtrips_through_wire_shape() {
2377 let step = FlowStep {
2378 id: "lookup".into(),
2379 kind: FlowStepKind::Tool {
2380 tool_ref: "lookup_customer".into(),
2381 },
2382 };
2383 let value = serde_json::to_value(&step).unwrap();
2384 assert_eq!(value["id"], "lookup");
2385 assert_eq!(value["type"], "tool");
2386 assert_eq!(value["config"]["tool_ref"], "lookup_customer");
2387 let back: FlowStep = serde_json::from_value(value).unwrap();
2388 assert_eq!(back, step);
2389 }
2390
2391 #[tokio::test]
2392 async fn customer_hub_resolve_soft_skips_when_disabled() {
2393 let _guard = customer_hub::HUB_ENV_LOCK
2394 .lock()
2395 .unwrap_or_else(|e| e.into_inner());
2396 std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "false");
2397 std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "true");
2398 let app = Qefro::new(QefroConfig::new("secret"));
2399 app.tool(
2400 ToolMetadata {
2401 name: "hub_probe".into(),
2402 auth: ToolAuthMode::None,
2403 ..Default::default()
2404 },
2405 |ctx| async move {
2406 let api = ctx.customer_api().unwrap();
2407 let out = api.resolve(Some(json!({"phone_number": "+1"}))).await?;
2408 Ok(json!({ "customer": out }))
2409 },
2410 );
2411 let resp = app
2412 .handle(QefroRequest {
2413 protocol_version: "1".into(),
2414 request_id: Uuid::new_v4(),
2415 request_type: "tool.invoke".into(),
2416 organization_id: None,
2417 conversation_id: None,
2418 channel: None,
2419 identity: None,
2420 tool: Some("hub_probe".into()),
2421 parameters: Some(json!({})),
2422 authentication: None,
2423 resume_token: None,
2424 challenge_response: None,
2425 person: None,
2426 platform: None,
2427 })
2428 .await;
2429 let value = serde_json::to_value(&resp).unwrap();
2430 assert_eq!(value["type"], "result");
2431 assert!(value["output"]["customer"].is_null());
2432 std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
2433 std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
2434 }
2435
2436 #[tokio::test]
2437 async fn customer_hub_person_seed_exposes_properties() {
2438 let app = Qefro::new(QefroConfig::new("secret"));
2439 app.tool(
2440 ToolMetadata {
2441 name: "who".into(),
2442 auth: ToolAuthMode::None,
2443 ..Default::default()
2444 },
2445 |ctx| async move {
2446 let api = ctx.customer_api().unwrap();
2447 Ok(json!({
2448 "id": api.id().await,
2449 "phone_number": api.phone_number().await,
2450 "display_name": api.display_name().await,
2451 }))
2452 },
2453 );
2454 let resp = app
2455 .handle(QefroRequest {
2456 protocol_version: "1".into(),
2457 request_id: Uuid::new_v4(),
2458 request_type: "tool.invoke".into(),
2459 organization_id: None,
2460 conversation_id: None,
2461 channel: None,
2462 identity: None,
2463 tool: Some("who".into()),
2464 parameters: Some(json!({})),
2465 authentication: None,
2466 resume_token: None,
2467 challenge_response: None,
2468 person: Some(json!({
2469 "id": "cust-1",
2470 "phone": "+1999",
2471 "name": "Sam",
2472 })),
2473 platform: None,
2474 })
2475 .await;
2476 let value = serde_json::to_value(&resp).unwrap();
2477 assert_eq!(value["output"]["id"], "cust-1");
2478 assert_eq!(value["output"]["phone_number"], "+1999");
2479 assert_eq!(value["output"]["display_name"], "Sam");
2480 }
2481
2482 #[tokio::test]
2483 async fn customer_hub_timeline_noop_when_optional_no_customer() {
2484 let _guard = customer_hub::HUB_ENV_LOCK
2485 .lock()
2486 .unwrap_or_else(|e| e.into_inner());
2487 std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "true");
2488 std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "true");
2489 let app = Qefro::new(QefroConfig::new("secret"));
2490 app.tool(
2491 ToolMetadata {
2492 name: "hub_side".into(),
2493 auth: ToolAuthMode::None,
2494 ..Default::default()
2495 },
2496 |ctx| async move {
2497 ctx.timeline
2498 .append(json!({"event_type": "x.y"}))
2499 .await?;
2500 ctx.membership.attach(None).await?;
2501 ctx.consent
2502 .grant(json!({"purpose": "marketing"}))
2503 .await?;
2504 Ok(json!({"ok": true}))
2505 },
2506 );
2507 let resp = app
2508 .handle(QefroRequest {
2509 protocol_version: "1".into(),
2510 request_id: Uuid::new_v4(),
2511 request_type: "tool.invoke".into(),
2512 organization_id: None,
2513 conversation_id: None,
2514 channel: None,
2515 identity: None,
2516 tool: Some("hub_side".into()),
2517 parameters: Some(json!({})),
2518 authentication: None,
2519 resume_token: None,
2520 challenge_response: None,
2521 person: None,
2522 platform: None,
2523 })
2524 .await;
2525 let value = serde_json::to_value(&resp).unwrap();
2526 assert_eq!(value["type"], "result");
2527 assert_eq!(value["output"]["ok"], true);
2528 std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
2529 std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
2530 }
2531}