systemprompt_api/routes/messaging/
mod.rs1mod a2a;
21pub mod identity;
22
23use std::sync::LazyLock;
24
25use serde_json::json;
26use systemprompt_identifiers::{AgentName, ContextId, SessionId, TraceId};
27use systemprompt_runtime::AppContext;
28use systemprompt_security::authz::{AuthzContext, AuthzDecision, AuthzRequest, EntityRef};
29
30use a2a::{authenticated_user, build_a2a_request, mint_a2a_token, run_agent};
31use identity::resolve_or_link_user;
32
33static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
34
35#[must_use]
36pub fn http_client() -> reqwest::Client {
37 CLIENT.clone()
38}
39
40#[derive(Debug, Clone)]
41pub enum ReplyTarget {
42 Channel { id: String },
43 Url { url: String },
44}
45
46#[derive(Debug, Clone)]
50pub struct MessagingInbound {
51 pub platform: &'static str,
54 pub issuer: String,
55 pub org_id: String,
56 pub channel_id: String,
57 pub external_user_id: String,
58 pub text: String,
59 pub agent_name: AgentName,
60 pub entity: EntityRef,
63 pub reply: ReplyTarget,
64}
65
66#[derive(Debug, Clone)]
67pub enum DispatchOutcome {
68 Replied(String),
71 Denied(String),
73}
74
75#[derive(Debug, thiserror::Error)]
78pub enum MessagingError {
79 #[error("identity resolution failed: {0}")]
80 Identity(String),
81 #[error("token minting failed: {0}")]
82 Token(String),
83 #[error("agent dispatch failed: {0}")]
84 Dispatch(String),
85 #[error("malformed agent response: {0}")]
86 Response(String),
87}
88
89impl MessagingError {
90 #[must_use]
95 pub fn user_message(&self) -> String {
96 #[cfg(feature = "test-api")]
97 return format!("Sorry — something went wrong handling that. ({self})");
98 #[cfg(not(feature = "test-api"))]
99 "Sorry — something went wrong handling that.".to_owned()
100 }
101}
102
103pub async fn dispatch_messaging(
109 ctx: &AppContext,
110 inbound: MessagingInbound,
111) -> Result<DispatchOutcome, MessagingError> {
112 let user = resolve_or_link_user(ctx, &inbound.issuer, &inbound.external_user_id).await?;
113 let authed = authenticated_user(&user)?;
114
115 let context_id =
116 ContextId::derived_from_messaging(inbound.platform, &inbound.org_id, &inbound.channel_id);
117
118 let authz = AuthzRequest {
119 entity: inbound.entity.clone(),
120 user_id: user.id.clone(),
121 roles: user.roles.clone(),
122 attributes: std::collections::BTreeMap::new(),
123 trace_id: TraceId::generate(),
124 session_id: None,
125 context: AuthzContext::extension(
126 format!("{}.message", inbound.platform),
127 json!({ "channel": inbound.channel_id }),
128 ),
129 context_id: Some(context_id.clone()),
130 task_id: None,
131 act_chain: Vec::new(),
132 };
133 if let AuthzDecision::Deny { reason, policy } = ctx.authz_hook().evaluate(authz).await {
134 return Ok(DispatchOutcome::Denied(format!("{policy}: {reason}")));
135 }
136
137 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
138 let token = mint_a2a_token(ctx, &authed, &session_id)?;
139
140 let request = build_a2a_request(&inbound, &authed, &session_id, &token, &context_id)?;
141 let reply = run_agent(ctx, inbound.agent_name.as_str(), request).await?;
142 Ok(DispatchOutcome::Replied(reply))
143}
144
145#[cfg(feature = "test-api")]
146pub mod test_api {
147 use systemprompt_agent::models::a2a::Task;
148
149 #[must_use]
150 pub fn reply_text(task: Option<&Task>) -> String {
151 super::a2a::reply_text(task)
152 }
153}