greentic_runner_host/runner/
adapt_slack.rs

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}