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::client::SlackClient;
use systemprompt_slack::events::{EventsApiEnvelope, InteractionPayload, SlashCommand};
use crate::routes::messaging::{
DispatchOutcome, MessagingInbound, ReplyTarget, dispatch_messaging, http_client,
};
use verify::{bot_token, 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(),
},
};
spawn_reply(ctx, inbound, bot_token(&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 },
),
};
spawn_reply(ctx, inbound, bot_token(&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,
};
spawn_reply(ctx, inbound, bot_token(&app));
StatusCode::OK.into_response()
}
fn spawn_reply(ctx: AppContext, inbound: MessagingInbound, bot_token: Option<String>) {
tokio::spawn(async move {
let (text, ephemeral) = match dispatch_messaging(&ctx, inbound.clone()).await {
Ok(DispatchOutcome::Replied(reply)) => (non_empty(reply), false),
Ok(DispatchOutcome::Denied(reason)) => (format!("⛔ {reason}"), true),
Err(err) => {
tracing::error!(error = %err, "slack dispatch failed");
(
"Sorry — something went wrong handling that.".to_owned(),
true,
)
},
};
let blocks = systemprompt_slack::blockkit::render_blocks(&text);
let result = match &inbound.reply {
ReplyTarget::Channel { id } => {
let Some(token) = bot_token else {
tracing::warn!(channel = %id, "no slack bot token configured; cannot post reply");
return;
};
SlackClient::new(http_client(), token)
.post_message(id, blocks)
.await
},
ReplyTarget::Url { url } => {
SlackClient::new(http_client(), String::new())
.respond(url, blocks, ephemeral)
.await
},
};
if let Err(err) = result {
tracing::error!(error = %err, "failed to post slack reply");
}
});
}
fn non_empty(text: String) -> String {
if text.trim().is_empty() {
"_(no response)_".to_owned()
} else {
text
}
}
fn parse_form(body: &[u8]) -> HashMap<String, String> {
url::form_urlencoded::parse(body)
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
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()
}
#[cfg(feature = "test-api")]
pub mod test_api {
use std::collections::HashMap;
use systemprompt_slack::events::SlashCommand;
#[must_use]
pub fn parse_form(body: &[u8]) -> HashMap<String, String> {
super::parse_form(body)
}
#[must_use]
pub fn slash_command_from_form<S: std::hash::BuildHasher>(
form: &HashMap<String, String, S>,
) -> Option<SlashCommand> {
super::slash_command_from_form(form)
}
}