systemprompt-api 0.55.0

Axum-based HTTP server and API gateway for systemprompt.io AI governance infrastructure. Exposes governed agents, MCP, A2A, and admin endpoints with rate limiting and RBAC.
Documentation
//! Slack inbound HTTP surface.
//!
//! Three endpoints, one per Slack inbound surface: `/events` (Events API JSON),
//! `/commands` (slash commands, form-encoded), and `/interactivity` (Block Kit
//! interactions, a JSON `payload` form field). Each handler verifies the
//! request signature over the **raw** body, normalizes the payload, resolves
//! the agent from `services/slack/*.yaml`, acks Slack within its 3-second
//! timeout, and spawns the blocking
//! [`dispatch_messaging`](crate::routes::messaging::dispatch_messaging)
//! pipeline — whose reply is rendered to Block Kit and posted back via
//! `chat.postMessage` (events) or the captured `response_url`
//! (commands/interactivity).
//!
//! Config and secrets resolve on demand (the MCP-registry pattern): the app is
//! looked up by workspace id, and the signing secret / bot token are read from
//! the profile secret store. No `AppContext` wiring, no registry struct, no DB.
//!
//! When the app sets `authz.link_by_workspace_email`, the sender's profile is
//! read with `users.info` inside the spawned task — after the ack, never on the
//! 3-second path — so a confirmed workspace email can attach them to the
//! systemprompt account that already owns it.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

mod reply;
mod verify;

use std::collections::HashMap;

use axum::Router;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use systemprompt_runtime::AppContext;
use systemprompt_security::authz::EntityRef;
use systemprompt_slack::events::{EventsApiEnvelope, InteractionPayload, SlashCommand};
use systemprompt_traits::SenderIdentity;

use crate::routes::messaging::{MessagingInbound, ReplyTarget};

use reply::spawn_reply;
use verify::{resolve_app, verify_any_app, verify_app};

const ISSUER: &str = "https://slack.com";

pub fn slack_router() -> Router<AppContext> {
    Router::new()
        .route("/events", post(handle_events))
        .route("/commands", post(handle_commands))
        .route("/interactivity", post(handle_interactivity))
}

async fn handle_events(State(ctx): State<AppContext>, headers: HeaderMap, body: Bytes) -> Response {
    let Ok(envelope) = serde_json::from_slice::<EventsApiEnvelope>(&body) else {
        return StatusCode::BAD_REQUEST.into_response();
    };

    match envelope {
        EventsApiEnvelope::UrlVerification { challenge } => {
            if verify_any_app(&headers, &body) {
                challenge.into_response()
            } else {
                StatusCode::UNAUTHORIZED.into_response()
            }
        },
        EventsApiEnvelope::EventCallback { team_id, event } => {
            let Some(app) = resolve_app(team_id.as_str()) else {
                return StatusCode::OK.into_response();
            };
            if !verify_app(&app, &headers, &body) {
                return StatusCode::UNAUTHORIZED.into_response();
            }
            if event.bot_id.is_some() || !matches!(event.kind.as_str(), "message" | "app_mention") {
                return StatusCode::OK.into_response();
            }
            let (Some(channel), Some(user)) = (event.channel, event.user) else {
                return StatusCode::OK.into_response();
            };
            let Some(agent) = app.agent_for(channel.as_str()).cloned() else {
                return StatusCode::OK.into_response();
            };
            let inbound = MessagingInbound {
                platform: "slack",
                issuer: ISSUER.to_owned(),
                org_id: team_id.as_str().to_owned(),
                channel_id: channel.as_str().to_owned(),
                external_user_id: user.as_str().to_owned(),
                text: event.text.unwrap_or_default(),
                agent_name: agent,
                entity: EntityRef::SlackWorkspace(team_id),
                reply: ReplyTarget::Channel {
                    id: channel.as_str().to_owned(),
                },
                sender: SenderIdentity::Unlinked,
            };
            spawn_reply(ctx, inbound, &app);
            StatusCode::OK.into_response()
        },
    }
}

async fn handle_commands(
    State(ctx): State<AppContext>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let form = parse_form(&body);
    let Some(cmd) = slash_command_from_form(&form) else {
        return StatusCode::BAD_REQUEST.into_response();
    };
    let Some(app) = resolve_app(cmd.team_id.as_str()) else {
        return StatusCode::OK.into_response();
    };
    if !verify_app(&app, &headers, &body) {
        return StatusCode::UNAUTHORIZED.into_response();
    }
    let normalized = cmd.normalize();
    let Some(agent) = app.agent_for(&normalized.routing_key).cloned() else {
        return StatusCode::OK.into_response();
    };
    let inbound = MessagingInbound {
        platform: "slack",
        issuer: ISSUER.to_owned(),
        org_id: normalized.workspace_id.as_str().to_owned(),
        channel_id: normalized.channel_id.as_str().to_owned(),
        external_user_id: normalized.slack_user_id.as_str().to_owned(),
        text: normalized.text,
        agent_name: agent,
        entity: EntityRef::SlackWorkspace(normalized.workspace_id),
        reply: normalized.response_url.map_or_else(
            || ReplyTarget::Channel {
                id: normalized.channel_id.as_str().to_owned(),
            },
            |url| ReplyTarget::Url { url },
        ),
        sender: SenderIdentity::Unlinked,
    };
    spawn_reply(ctx, inbound, &app);
    StatusCode::OK.into_response()
}

async fn handle_interactivity(
    State(ctx): State<AppContext>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let form = parse_form(&body);
    let Some(payload_json) = form.get("payload") else {
        return StatusCode::BAD_REQUEST.into_response();
    };
    let payload: InteractionPayload = match serde_json::from_str(payload_json) {
        Ok(p) => p,
        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
    };
    let Some(app) = resolve_app(payload.team.id.as_str()) else {
        return StatusCode::OK.into_response();
    };
    if !verify_app(&app, &headers, &body) {
        return StatusCode::UNAUTHORIZED.into_response();
    }
    let channel_id = payload
        .channel
        .as_ref()
        .map_or_else(String::new, |c| c.id.as_str().to_owned());
    let routing_key = if channel_id.is_empty() {
        payload.team.id.as_str().to_owned()
    } else {
        channel_id.clone()
    };
    let Some(agent) = app.agent_for(&routing_key).cloned() else {
        return StatusCode::OK.into_response();
    };
    let text = payload
        .actions
        .first()
        .and_then(|a| a.value.clone())
        .unwrap_or_default();
    let reply = payload.response_url.clone().map_or_else(
        || ReplyTarget::Channel {
            id: channel_id.clone(),
        },
        |url| ReplyTarget::Url { url },
    );
    let inbound = MessagingInbound {
        platform: "slack",
        issuer: ISSUER.to_owned(),
        org_id: payload.team.id.as_str().to_owned(),
        channel_id,
        external_user_id: payload.user.id.as_str().to_owned(),
        text,
        agent_name: agent,
        entity: EntityRef::SlackWorkspace(payload.team.id),
        reply,
        sender: SenderIdentity::Unlinked,
    };
    spawn_reply(ctx, inbound, &app);
    StatusCode::OK.into_response()
}

pub fn parse_form(body: &[u8]) -> HashMap<String, String> {
    url::form_urlencoded::parse(body)
        .map(|(k, v)| (k.into_owned(), v.into_owned()))
        .collect()
}

pub fn slash_command_from_form<S: std::hash::BuildHasher>(
    form: &HashMap<String, String, S>,
) -> Option<SlashCommand> {
    let json = serde_json::json!({
        "command": form.get("command")?,
        "text": form.get("text").cloned().unwrap_or_default(),
        "user_id": form.get("user_id")?,
        "channel_id": form.get("channel_id")?,
        "team_id": form.get("team_id")?,
        "response_url": form.get("response_url")?,
    });
    serde_json::from_value(json).ok()
}