Skip to main content

systemprompt_api/routes/messaging/
mod.rs

1//! Platform-agnostic dispatch for chat-platform inbound messages.
2//!
3//! Slack and Teams differ only at their edges — request verification, payload
4//! shape, and reply rendering. Everything between (identity, authorization,
5//! deterministic conversation context, per-user A2A token minting, the blocking
6//! `message/send` through the proxy, and reply extraction) is identical and
7//! lives here once. A per-platform route normalizes its wire payload into a
8//! [`MessagingInbound`] and calls [`dispatch_messaging`]; the returned
9//! [`DispatchOutcome`] is rendered back into the platform's UI by the route.
10//!
11//! The pipeline is **synchronous, spawned**: the route acks the platform within
12//! its timeout, then a spawned task runs this blocking dispatch and posts the
13//! reply. There is no responder job and no dispatch-state table — a stable
14//! [`ContextId`] (derived from the conversation) ties multi-turn history
15//! together instead.
16//!
17//! Copyright (c) systemprompt.io — Business Source License 1.1.
18//! See <https://systemprompt.io> for licensing details.
19
20mod 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/// A surface-agnostic inbound message ready for dispatch. Per-platform routes
47/// build this from their normalized payload; the dispatch core never sees a
48/// Slack- or Teams-specific type.
49#[derive(Debug, Clone)]
50pub struct MessagingInbound {
51    /// Stable platform tag (`"slack"` / `"teams"`), used in the derived
52    /// `ContextId` and the authz context kind.
53    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    /// The authz target — the workspace/tenant entity, config-seedable from
61    /// `allowed_roles`.
62    pub entity: EntityRef,
63    pub reply: ReplyTarget,
64}
65
66#[derive(Debug, Clone)]
67pub enum DispatchOutcome {
68    /// Authorized; the agent's reply text (may be empty if the agent produced
69    /// no message).
70    Replied(String),
71    /// Authorization denied; the reason is surfaced as an ephemeral refusal.
72    Denied(String),
73}
74
75/// Failures along the dispatch pipeline. This is an internal system surface;
76/// messages are deliberately descriptive for operator debugging.
77#[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    /// The reply-surface rendering of a dispatch failure. Production keeps the
91    /// message opaque; under `test-api` the error text is appended, because the
92    /// spawned pipeline's `tracing::error!` is invisible to an asserting test
93    /// and an opaque card makes a CI failure unreproducible.
94    #[must_use]
95    pub fn user_message(&self) -> String {
96        let opaque = "Sorry — something went wrong handling that.";
97        if cfg!(feature = "test-api") {
98            format!("{opaque} ({self})")
99        } else {
100            opaque.to_owned()
101        }
102    }
103}
104
105/// Run the shared inbound pipeline.
106///
107/// Links the platform sender to a governed identity, authorizes against the
108/// workspace/tenant entity, mints a per-user A2A token, dispatches a blocking
109/// `message/send` through the proxy, and returns the agent's reply.
110pub async fn dispatch_messaging(
111    ctx: &AppContext,
112    inbound: MessagingInbound,
113) -> Result<DispatchOutcome, MessagingError> {
114    let user = resolve_or_link_user(ctx, &inbound.issuer, &inbound.external_user_id).await?;
115    let authed = authenticated_user(&user)?;
116
117    let context_id =
118        ContextId::derived_from_messaging(inbound.platform, &inbound.org_id, &inbound.channel_id);
119
120    let authz = AuthzRequest {
121        entity: inbound.entity.clone(),
122        user_id: user.id.clone(),
123        roles: user.roles.clone(),
124        attributes: std::collections::BTreeMap::new(),
125        trace_id: TraceId::generate(),
126        session_id: None,
127        context: AuthzContext::extension(
128            format!("{}.message", inbound.platform),
129            json!({ "channel": inbound.channel_id }),
130        ),
131        context_id: Some(context_id.clone()),
132        task_id: None,
133        act_chain: Vec::new(),
134    };
135    if let AuthzDecision::Deny { reason, policy } = ctx.authz_hook().evaluate(authz).await {
136        return Ok(DispatchOutcome::Denied(format!("{policy}: {reason}")));
137    }
138
139    let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
140    let token = mint_a2a_token(ctx, &authed, &session_id)?;
141
142    let request = build_a2a_request(&inbound, &authed, &session_id, &token, &context_id)?;
143    let reply = run_agent(ctx, inbound.agent_name.as_str(), request).await?;
144    Ok(DispatchOutcome::Replied(reply))
145}
146
147#[cfg(feature = "test-api")]
148pub mod test_api {
149    use systemprompt_agent::models::a2a::Task;
150
151    #[must_use]
152    pub fn reply_text(task: Option<&Task>) -> String {
153        super::a2a::reply_text(task)
154    }
155}