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()
}