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