1use std::collections::HashSet;
2
3use anyhow::Result;
4use axum::body::Body;
5use axum::extract::Form;
6use axum::http::{HeaderMap, Request, StatusCode};
7use axum::response::{IntoResponse, Response};
8use chrono::{DateTime, Utc};
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use sha2::Sha256;
13
14use crate::engine::runtime::IngressEnvelope;
15use crate::ingress::{
16 CanonicalAttachment, CanonicalButton, ProviderIds, build_canonical_payload,
17 canonical_session_key, default_metadata, empty_entities,
18};
19use crate::provider_core_only;
20use crate::routing::TenantRuntimeHandle;
21use crate::runner::ingress_util::{collect_body, mark_processed};
22
23type HmacSha256 = Hmac<Sha256>;
24
25pub async fn events(
26 TenantRuntimeHandle { tenant, runtime }: TenantRuntimeHandle,
27 request: Request<Body>,
28) -> Result<Response, StatusCode> {
29 if provider_core_only::is_enabled() {
30 tracing::warn!("provider-core only mode enabled; blocking slack events webhook");
31 return Err(StatusCode::NOT_IMPLEMENTED);
32 }
33
34 let (parts, body) = request.into_parts();
35 let headers = parts.headers;
36 let bytes = collect_body(body).await?;
37 verify_slack_signature(&headers, &bytes)?;
38
39 let raw_value: Value = serde_json::from_slice(&bytes).map_err(|_| StatusCode::BAD_REQUEST)?;
40 let payload: SlackEventEnvelope =
41 serde_json::from_value(raw_value.clone()).map_err(|_| StatusCode::BAD_REQUEST)?;
42
43 if payload.payload_type == "url_verification" {
44 if let Some(challenge) = payload.challenge {
45 let response = axum::Json(json!({ "challenge": challenge })).into_response();
46 return Ok(response);
47 }
48 return Err(StatusCode::BAD_REQUEST);
49 }
50
51 let event = payload.event.as_ref().ok_or(StatusCode::BAD_REQUEST)?;
52
53 if payload
54 .event_id
55 .as_deref()
56 .is_some_and(|event_id| mark_processed(runtime.webhook_cache(), event_id))
57 {
58 return Ok(StatusCode::OK.into_response());
59 }
60
61 let flow = runtime
62 .engine()
63 .flow_by_type("messaging")
64 .ok_or(StatusCode::NOT_FOUND)?;
65
66 let mapped = map_slack_event(&tenant, &payload, event, &raw_value)?;
67 let envelope = IngressEnvelope {
68 tenant,
69 env: None,
70 flow_id: flow.id.clone(),
71 flow_type: Some(flow.flow_type.clone()),
72 action: Some("messaging".into()),
73 session_hint: Some(mapped.session_key.clone()),
74 provider: Some("slack".into()),
75 channel: mapped
76 .provider_ids
77 .channel_id
78 .clone()
79 .or_else(|| mapped.provider_ids.conversation_id.clone()),
80 conversation: mapped.provider_ids.conversation_id.clone(),
81 user: mapped.provider_ids.user_id.clone(),
82 activity_id: mapped
83 .provider_ids
84 .message_id
85 .clone()
86 .or_else(|| mapped.provider_ids.event_id.clone()),
87 timestamp: Some(mapped.timestamp.to_rfc3339()),
88 payload: mapped.payload,
89 metadata: None,
90 }
91 .canonicalize();
92
93 runtime
94 .state_machine()
95 .handle(envelope)
96 .await
97 .map_err(|err| {
98 tracing::error!(error = %err, "slack flow execution failed");
99 StatusCode::BAD_GATEWAY
100 })?;
101 Ok(StatusCode::OK.into_response())
102}
103
104pub async fn interactive(
105 TenantRuntimeHandle { tenant, runtime }: TenantRuntimeHandle,
106 headers: HeaderMap,
107 Form(body): Form<SlackInteractiveForm>,
108) -> Result<impl IntoResponse, StatusCode> {
109 if provider_core_only::is_enabled() {
110 tracing::warn!("provider-core only mode enabled; blocking slack interactive webhook");
111 return Err(StatusCode::NOT_IMPLEMENTED);
112 }
113
114 if let Some(secret) = signing_secret() {
115 let timestamp = headers
116 .get("X-Slack-Request-Timestamp")
117 .and_then(|value| value.to_str().ok())
118 .ok_or(StatusCode::UNAUTHORIZED)?;
119 let sig = headers
120 .get("X-Slack-Signature")
121 .and_then(|value| value.to_str().ok())
122 .ok_or(StatusCode::UNAUTHORIZED)?;
123 let base_string = format!("v0:{timestamp}:payload={}", body.payload);
124 if !verify_hmac(&secret, &base_string, sig) {
125 return Err(StatusCode::UNAUTHORIZED);
126 }
127 }
128
129 let raw_value: Value =
130 serde_json::from_str(&body.payload).map_err(|_| StatusCode::BAD_REQUEST)?;
131 let payload: SlackInteractivePayload =
132 serde_json::from_value(raw_value.clone()).map_err(|_| StatusCode::BAD_REQUEST)?;
133
134 let flow = runtime
135 .engine()
136 .flow_by_type("messaging")
137 .ok_or(StatusCode::NOT_FOUND)?;
138
139 let mapped = map_slack_interactive(&tenant, &payload, &raw_value)?;
140 if mapped
141 .provider_ids
142 .event_id
143 .as_deref()
144 .is_some_and(|dedupe| mark_processed(runtime.webhook_cache(), dedupe))
145 {
146 return Ok(StatusCode::OK);
147 }
148
149 let envelope = IngressEnvelope {
150 tenant,
151 env: None,
152 flow_id: flow.id.clone(),
153 flow_type: Some(flow.flow_type.clone()),
154 action: Some("messaging".into()),
155 session_hint: Some(mapped.session_key.clone()),
156 provider: Some("slack".into()),
157 channel: mapped.provider_ids.channel_id.clone(),
158 conversation: mapped.provider_ids.conversation_id.clone(),
159 user: mapped.provider_ids.user_id.clone(),
160 activity_id: mapped.provider_ids.event_id.clone(),
161 timestamp: Some(mapped.timestamp.to_rfc3339()),
162 payload: mapped.payload,
163 metadata: None,
164 }
165 .canonicalize();
166
167 runtime
168 .state_machine()
169 .handle(envelope)
170 .await
171 .map_err(|err| {
172 tracing::error!(error = %err, "slack interactive flow failed");
173 StatusCode::BAD_GATEWAY
174 })?;
175 Ok(StatusCode::OK)
176}
177
178fn map_slack_event(
179 tenant: &str,
180 payload: &SlackEventEnvelope,
181 event: &SlackEvent,
182 raw: &Value,
183) -> Result<MappedCanonical, StatusCode> {
184 let provider_ids = ProviderIds {
185 workspace_id: payload.team_id.clone(),
186 channel_id: event.channel.clone(),
187 thread_id: event.thread_ts.clone(),
188 conversation_id: event.thread_ts.clone().or(event.channel.clone()),
189 user_id: event.user.clone(),
190 message_id: event.ts.clone(),
191 event_id: payload.event_id.clone(),
192 ..ProviderIds::default()
193 };
194 if provider_ids.user_id.is_none() {
195 return Err(StatusCode::BAD_REQUEST);
196 }
197 let session_key = canonical_session_key(tenant, "slack", &provider_ids);
198 let timestamp = parse_slack_timestamp(event.ts.as_deref(), payload.event_time)?;
199 let mut attachments = map_slack_files(event.files.as_deref());
200 let buttons = buttons_from_event(event);
201 let mut scopes = base_scopes(!attachments.is_empty());
202 if !buttons.is_empty() {
203 scopes.insert("buttons".into());
204 }
205 let scopes_vec: Vec<String> = scopes.into_iter().collect();
206 let payload_value = build_canonical_payload(
207 tenant,
208 "slack",
209 &provider_ids,
210 session_key.clone(),
211 &scopes_vec,
212 timestamp,
213 event.locale.clone(),
214 event.text.clone(),
215 {
216 let mut vals = Vec::new();
217 std::mem::swap(&mut vals, &mut attachments);
218 vals
219 },
220 buttons,
221 empty_entities(),
222 default_metadata(),
223 json!({"type": event.event_type, "subtype": event.subtype}),
224 raw.clone(),
225 );
226 Ok(MappedCanonical {
227 provider_ids,
228 session_key,
229 timestamp,
230 payload: payload_value,
231 })
232}
233
234fn map_slack_interactive(
235 tenant: &str,
236 payload: &SlackInteractivePayload,
237 raw: &Value,
238) -> Result<MappedCanonical, StatusCode> {
239 let provider_ids = ProviderIds {
240 workspace_id: payload.team.as_ref().map(|team| team.id.clone()),
241 channel_id: payload.channel.as_ref().map(|c| c.id.clone()),
242 conversation_id: payload.channel.as_ref().map(|c| c.id.clone()),
243 user_id: payload.user.as_ref().map(|u| u.id.clone()),
244 event_id: payload.trigger_id.clone().or(payload.action_ts.clone()),
245 ..ProviderIds::default()
246 };
247 if provider_ids.user_id.is_none() {
248 return Err(StatusCode::BAD_REQUEST);
249 }
250 let session_key = canonical_session_key(tenant, "slack", &provider_ids);
251 let timestamp = parse_slack_timestamp(payload.action_ts.as_deref(), None)?;
252 let buttons = payload
253 .actions
254 .iter()
255 .map(|action| {
256 CanonicalButton {
257 id: action.action_id.clone(),
258 title: action
259 .text
260 .as_ref()
261 .and_then(|text| text.get("text").and_then(Value::as_str))
262 .unwrap_or("Button")
263 .to_string(),
264 payload: action
265 .value
266 .clone()
267 .or_else(|| {
268 action
269 .selected_option
270 .as_ref()
271 .and_then(|opt| opt.value.clone())
272 })
273 .unwrap_or_default(),
274 }
275 .into_value()
276 })
277 .collect::<Vec<_>>();
278 let scopes = vec!["chat".to_string(), "buttons".to_string()];
279 let payload_value = build_canonical_payload(
280 tenant,
281 "slack",
282 &provider_ids,
283 session_key.clone(),
284 &scopes,
285 timestamp,
286 None,
287 payload.message.as_ref().and_then(|msg| msg.text.clone()),
288 Vec::new(),
289 buttons,
290 empty_entities(),
291 default_metadata(),
292 json!({"type": payload.payload_type}),
293 raw.clone(),
294 );
295 Ok(MappedCanonical {
296 provider_ids,
297 session_key,
298 timestamp,
299 payload: payload_value,
300 })
301}
302
303fn buttons_from_event(event: &SlackEvent) -> Vec<Value> {
304 let mut buttons = Vec::new();
305 if let Some(blocks) = &event.blocks {
306 for block in blocks {
307 if let Some(elements) = block.get("elements").and_then(Value::as_array) {
308 for elem in elements {
309 if elem.get("type") == Some(&Value::String("button".into())) {
310 let id = elem
311 .get("action_id")
312 .and_then(Value::as_str)
313 .unwrap_or_default();
314 let title = elem
315 .get("text")
316 .and_then(|text| text.get("text"))
317 .and_then(Value::as_str)
318 .unwrap_or("Button");
319 let payload = elem
320 .get("value")
321 .and_then(Value::as_str)
322 .unwrap_or("")
323 .to_string();
324 buttons.push(
325 CanonicalButton {
326 id: id.to_string(),
327 title: title.to_string(),
328 payload,
329 }
330 .into_value(),
331 );
332 }
333 }
334 }
335 }
336 }
337 buttons
338}
339
340fn map_slack_files(files: Option<&[SlackFile]>) -> Vec<Value> {
341 files
342 .into_iter()
343 .flat_map(|items| items.iter())
344 .map(|file| {
345 CanonicalAttachment {
346 attachment_type: infer_slack_attachment_type(file),
347 name: file.name.clone(),
348 mime: file.mimetype.clone(),
349 size: file.size.map(|s| s as u64),
350 url: file.url_private.clone(),
351 data_inline_b64: None,
352 }
353 .into_value()
354 })
355 .collect()
356}
357
358fn infer_slack_attachment_type(file: &SlackFile) -> String {
359 match file.mimetype.as_deref().unwrap_or("") {
360 mime if mime.starts_with("image/") => "image".into(),
361 mime if mime.starts_with("audio/") => "audio".into(),
362 mime if mime.starts_with("video/") => "video".into(),
363 _ => "file".into(),
364 }
365}
366
367fn base_scopes(has_attachments: bool) -> HashSet<String> {
368 let mut scopes = HashSet::new();
369 scopes.insert("chat".into());
370 if has_attachments {
371 scopes.insert("attachments".into());
372 }
373 scopes
374}
375
376fn parse_slack_timestamp(
377 ts: Option<&str>,
378 fallback: Option<i64>,
379) -> Result<DateTime<Utc>, StatusCode> {
380 if let Some(seconds) = ts
381 .and_then(|value| value.split_once('.'))
382 .and_then(|(secs, _)| secs.parse::<i64>().ok())
383 {
384 return DateTime::from_timestamp(seconds, 0).ok_or(StatusCode::BAD_REQUEST);
385 }
386 if let Some(seconds) = fallback {
387 return DateTime::from_timestamp(seconds, 0).ok_or(StatusCode::BAD_REQUEST);
388 }
389 Ok(Utc::now())
390}
391
392fn verify_slack_signature(headers: &HeaderMap, body: &[u8]) -> Result<(), StatusCode> {
393 if let Some(secret) = signing_secret() {
394 let timestamp = headers
395 .get("X-Slack-Request-Timestamp")
396 .and_then(|value| value.to_str().ok())
397 .ok_or(StatusCode::UNAUTHORIZED)?;
398 let signature = headers
399 .get("X-Slack-Signature")
400 .and_then(|value| value.to_str().ok())
401 .ok_or(StatusCode::UNAUTHORIZED)?;
402 let base_string = format!("v0:{timestamp}:{}", String::from_utf8_lossy(body));
403 if !verify_hmac(&secret, &base_string, signature) {
404 return Err(StatusCode::UNAUTHORIZED);
405 }
406 }
407 Ok(())
408}
409
410fn signing_secret() -> Option<String> {
411 std::env::var("SLACK_SIGNING_SECRET").ok()
412}
413
414fn verify_hmac(secret: &str, base_string: &str, signature: &str) -> bool {
415 let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
416 Ok(mac) => mac,
417 Err(_) => return false,
418 };
419 mac.update(base_string.as_bytes());
420 let expected = format!("v0={}", hex::encode(mac.finalize().into_bytes()));
421 subtle_equals(&expected, signature)
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use serde_json::json;
428
429 #[test]
430 fn slack_event_maps_to_canonical_payload() {
431 let raw = json!({
432 "type": "event_callback",
433 "team_id": "T123",
434 "event_id": "Ev01ABC",
435 "event_time": 1731315600,
436 "event": {
437 "type": "message",
438 "user": "U456",
439 "text": "Hi",
440 "ts": "1731315600.000100",
441 "channel": "C789",
442 "thread_ts": "1731315600.000100"
443 }
444 });
445 let envelope: SlackEventEnvelope = serde_json::from_value(raw.clone()).unwrap();
446 let event = envelope.event.as_ref().unwrap();
447 let mapped = map_slack_event("demo", &envelope, event, &raw).unwrap();
448 assert_eq!(mapped.session_key, "demo:slack:1731315600.000100:U456");
449 assert_eq!(mapped.provider_ids.workspace_id.as_deref(), Some("T123"));
450 assert_eq!(
451 mapped.provider_ids.thread_id.as_deref(),
452 Some("1731315600.000100")
453 );
454 let canonical = mapped.payload;
455 assert_eq!(canonical["provider"], json!("slack"));
456 assert_eq!(
457 canonical["session"]["key"],
458 json!("demo:slack:1731315600.000100:U456")
459 );
460 assert_eq!(canonical["text"], json!("Hi"));
461 assert_eq!(canonical["attachments"], json!([]));
462 assert_eq!(canonical["buttons"], json!([]));
463 }
464}
465
466fn subtle_equals(a: &str, b: &str) -> bool {
467 if a.len() != b.len() {
468 return false;
469 }
470 let mut diff = 0u8;
471 for (x, y) in a.as_bytes().iter().zip(b.as_bytes()) {
472 diff |= x ^ y;
473 }
474 diff == 0
475}
476
477struct MappedCanonical {
478 provider_ids: ProviderIds,
479 session_key: String,
480 timestamp: DateTime<Utc>,
481 payload: Value,
482}
483
484#[derive(Deserialize)]
485struct SlackEventEnvelope {
486 #[serde(rename = "type")]
487 payload_type: String,
488 #[allow(dead_code)]
489 #[serde(default)]
490 token: Option<String>,
491 #[serde(default)]
492 challenge: Option<String>,
493 #[serde(default)]
494 team_id: Option<String>,
495 #[serde(default)]
496 event_id: Option<String>,
497 #[serde(default)]
498 event_time: Option<i64>,
499 #[serde(default)]
500 event: Option<SlackEvent>,
501}
502
503#[derive(Deserialize)]
504struct SlackEvent {
505 #[serde(rename = "type")]
506 event_type: String,
507 #[serde(default)]
508 subtype: Option<String>,
509 #[serde(default)]
510 user: Option<String>,
511 #[serde(default)]
512 text: Option<String>,
513 #[serde(default)]
514 channel: Option<String>,
515 #[serde(default)]
516 thread_ts: Option<String>,
517 #[serde(default)]
518 ts: Option<String>,
519 #[serde(default)]
520 files: Option<Vec<SlackFile>>,
521 #[serde(default)]
522 blocks: Option<Vec<Value>>,
523 #[serde(default)]
524 locale: Option<String>,
525}
526
527#[derive(Deserialize)]
528struct SlackFile {
529 #[allow(dead_code)]
530 #[serde(default)]
531 id: Option<String>,
532 #[serde(default)]
533 name: Option<String>,
534 #[serde(default)]
535 mimetype: Option<String>,
536 #[serde(default)]
537 size: Option<i64>,
538 #[serde(rename = "url_private")]
539 #[serde(default)]
540 url_private: Option<String>,
541}
542
543#[derive(Deserialize)]
544pub struct SlackInteractiveForm {
545 pub payload: String,
546}
547
548#[derive(Deserialize, Serialize)]
549struct SlackInteractivePayload {
550 #[serde(rename = "type")]
551 payload_type: String,
552 #[serde(default)]
553 team: Option<SlackTeam>,
554 #[serde(default)]
555 channel: Option<SlackChannel>,
556 user: Option<SlackUser>,
557 #[serde(default)]
558 actions: Vec<SlackAction>,
559 #[serde(default)]
560 trigger_id: Option<String>,
561 #[serde(default)]
562 action_ts: Option<String>,
563 #[serde(default)]
564 message: Option<SlackMessageRef>,
565}
566
567#[derive(Deserialize, Serialize)]
568struct SlackTeam {
569 id: String,
570}
571
572#[derive(Deserialize, Serialize)]
573struct SlackChannel {
574 id: String,
575}
576
577#[derive(Deserialize, Serialize)]
578struct SlackUser {
579 id: String,
580}
581
582#[derive(Deserialize, Serialize)]
583struct SlackMessageRef {
584 #[serde(default)]
585 text: Option<String>,
586}
587
588#[derive(Deserialize, Serialize)]
589struct SlackAction {
590 action_id: String,
591 #[serde(default)]
592 value: Option<String>,
593 #[serde(default)]
594 selected_option: Option<SlackSelectedOption>,
595 #[serde(default)]
596 text: Option<Value>,
597}
598
599#[derive(Deserialize, Serialize)]
600struct SlackSelectedOption {
601 #[serde(default)]
602 value: Option<String>,
603}