Skip to main content

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