Skip to main content

car_messaging/
slack_adapter.rs

1//! Slack approval-transport adapter (Unit 4) — the maximally-different SECOND
2//! channel that proves the channel-agnostic seam holds.
3//!
4//! Where the iMessage adapter is poll-based with a text-code grammar and a
5//! per-approval `CodeMap`, the Slack adapter is **push-based** (Socket Mode
6//! WebSocket) with **Block Kit buttons** that carry the `approval_id`
7//! DIRECTLY — there is NO `CodeMap` and NO text-code grammar on the Slack
8//! side. The two channels share ONLY the channel-agnostic [`ApprovalCore`]
9//! (resolve by `approval_id`) and the per-channel
10//! [`MessagingConfigStore`] (allowlist + pairing-code primitive). Neither
11//! fakes the other's model.
12//!
13//! ## Mockable transport seam (MC-12 / MC-13)
14//!
15//! The Slack outbound (`chat.postMessage`) and inbound (Socket Mode event
16//! source) sit behind [`SlackTransport`]. The production impl
17//! ([`RealSlackTransport`]) drives `tokio-tungstenite` + `reqwest`; tests
18//! substitute a mock (see `tests/slack_mock.rs`) so the e2e + pairing round-
19//! trips run with NO live Slack — exactly the `SpySender`/`MessageSender`
20//! pattern the iMessage gates use.
21//!
22//! ## The two inbound shapes (and ONLY these two)
23//!
24//! Slack inbound recognizes EXACTLY TWO message shapes and ignores everything
25//! else (MC-6 anti-injection by construction — the parse output is a closed
26//! set with NO config-mutation arm):
27//!
28//! 1. **Approve/Deny button interaction** (`block_actions`) — `action_id`
29//!    names the verb, `value` carries the `approval_id`. Routes to
30//!    [`ApprovalCore::resolve`] with the Slack transport principal.
31//! 2. **Pairing-code DM** (`message.im`) — `user` is the member id, `text` is
32//!    the code. Routes to
33//!    [`MessagingConfigStore::validate_and_consume_pairing_code_for`] — the
34//!    ONLY allowlist-bind edge reachable from a Slack inbound event (MC-13:
35//!    code-proven pairing). A wrong code binds nothing; a bare member id with
36//!    no code binds nothing.
37//!
38//! ## Token in keychain, never plaintext (MC-9)
39//!
40//! The bot token (`xoxb-`) and app-level token (`xapp-`) are stored via
41//! [`car_secrets::SecretStore::put`] (OS keychain). The Slack
42//! [`car_server_types::channel::ChannelConfig`] on disk holds only a token *reference*
43//! (a key name resolved against [`car_secrets::DEFAULT_SERVICE`]), never the
44//! bearer value — so no `xoxb`/`xapp` literal ever lands in `messaging.json`.
45//! The outbound transport fetches the bot token via
46//! [`car_secrets::SecretStore::get`] at use-time.
47
48use async_trait::async_trait;
49
50use crate::messaging_config::{MessagingConfigStore, PairingOutcome};
51use car_proto::approval_summary::{approval_summary, sanitize_line};
52use car_proto::HostApprovalRequest;
53use car_server_types::approval_core::ApprovalCore;
54use car_server_types::channel::{CancelSignal, ChannelId, InboundChannel, InboundSink};
55
56/// The system-raised principal the Slack adapter supplies to
57/// [`ApprovalCore::resolve`]. The literal lives HERE (in the Slack adapter),
58/// passed INTO the channel-agnostic core — it must NOT appear in
59/// `approval_core.rs` (MC-3 edge), mirroring the iMessage adapter's
60/// `imessage-transport` principal.
61pub const SLACK_PRINCIPAL: &str = "slack-transport";
62
63/// The `action_id` on the Approve button (Block Kit). A click delivers this
64/// in `payload.actions[0].action_id`.
65pub const APPROVE_ACTION_ID: &str = "approve_request";
66/// The `action_id` on the Deny button.
67pub const DENY_ACTION_ID: &str = "deny_request";
68
69/// The resolution verb the Slack transport supplies on an approve — matches
70/// the gate's `approve_label` so an inbound approve reads exactly like a
71/// CarHost click (same as the iMessage adapter's `APPROVE`).
72const APPROVE: &str = "approve";
73/// The resolution verb on a deny.
74const DENY: &str = "deny";
75
76// ===================================================================
77// Keychain token reference (MC-9)
78// ===================================================================
79
80/// The keychain key (under [`car_secrets::DEFAULT_SERVICE`]) the Slack bot
81/// token (`xoxb-`) is stored at. The on-disk Slack config holds this *name*,
82/// never the bearer value.
83pub const SLACK_BOT_TOKEN_KEY: &str = "SLACK_BOT_TOKEN";
84/// The keychain key the Slack app-level token (`xapp-`) is stored at.
85pub const SLACK_APP_TOKEN_KEY: &str = "SLACK_APP_TOKEN";
86
87/// Provision the Slack tokens into the OS keychain (MC-9). The bot token and
88/// app-level token are written via [`car_secrets::SecretStore::put`] — the
89/// daemon never persists them into `messaging.json`. This is the host-gated
90/// provisioning write path (called from the host/local-auth-gated config
91/// surface, NEVER from an inbound message).
92///
93/// Returns the keychain key NAMES the Slack config should reference (a ref,
94/// not the bearer value).
95pub fn provision_slack_tokens(
96    store: &car_secrets::SecretStore,
97    bot_token: &str,
98    app_token: &str,
99) -> Result<SlackTokenRefs, String> {
100    use car_secrets::{SecretRef, DEFAULT_SERVICE};
101    store
102        .put(
103            &SecretRef::new(DEFAULT_SERVICE, SLACK_BOT_TOKEN_KEY),
104            bot_token,
105        )
106        .map_err(|e| format!("store bot token: {e}"))?;
107    store
108        .put(
109            &SecretRef::new(DEFAULT_SERVICE, SLACK_APP_TOKEN_KEY),
110            app_token,
111        )
112        .map_err(|e| format!("store app token: {e}"))?;
113    Ok(SlackTokenRefs {
114        bot_token_key: SLACK_BOT_TOKEN_KEY.to_string(),
115        app_token_key: SLACK_APP_TOKEN_KEY.to_string(),
116    })
117}
118
119/// Keychain key REFERENCES for the Slack tokens — what the on-disk config
120/// carries instead of the bearer values (MC-9). The actual `xoxb-`/`xapp-`
121/// strings live only in the OS keychain.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct SlackTokenRefs {
124    /// Keychain key for the bot token (`xoxb-`).
125    pub bot_token_key: String,
126    /// Keychain key for the app-level token (`xapp-`).
127    pub app_token_key: String,
128}
129
130/// Fetch a secret from the OS keychain by its reference key (MC-9 read side).
131/// Generic get-by-ref under [`car_secrets::DEFAULT_SERVICE`] — used for the bot
132/// token AND the app-level token at use-time, so a secret is never read out of
133/// `messaging.json` as a value.
134pub fn fetch_secret_by_ref(store: &car_secrets::SecretStore, key: &str) -> Result<String, String> {
135    use car_secrets::{SecretRef, DEFAULT_SERVICE};
136    store
137        .get(&SecretRef::new(DEFAULT_SERVICE, key))
138        .map_err(|e| format!("fetch secret {key}: {e}"))
139}
140
141// ===================================================================
142// The mockable transport seam (MC-12 / MC-13)
143// ===================================================================
144
145/// One inbound Slack event the adapter acts on. **Closed set** — the
146/// transport yields exactly one of these (or `Ignore`), so there is NO
147/// inbound→config-mutation edge by construction (MC-6). A config-mutation-
148/// shaped Slack message has no variant here and falls through to `Ignore`.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum SlackInboundEvent {
151    /// An Approve/Deny button click. `action_id` is the verb, `value` carries
152    /// the `approval_id` directly (NO CodeMap). `user` is the clicker.
153    ButtonInteraction {
154        /// `payload.actions[0].action_id` — [`APPROVE_ACTION_ID`] /
155        /// [`DENY_ACTION_ID`].
156        action_id: String,
157        /// `payload.actions[0].value` — the `approval_id` (no grammar).
158        value: String,
159        /// `payload.user.id` — the Slack member who clicked.
160        user: String,
161    },
162    /// A DM to the bot (`message.im`). `user` is the member id, `text` is the
163    /// (possibly-pairing) code. The ONLY shape that can bind a handle — and
164    /// only via the constant-time pairing-code match.
165    PairingDm {
166        /// `event.user` — the member id to bind on a code match.
167        user: String,
168        /// `event.text` — the candidate pairing code.
169        text: String,
170    },
171    /// Anything else (the bot's own echo, channel chatter, an unknown
172    /// interaction). Ignored — never a config mutation.
173    Ignore,
174}
175
176/// The Slack transport seam (MC-12 / MC-13). The production impl drives a
177/// Socket Mode WebSocket + the Web API; tests substitute a mock that captures
178/// outbound posts and injects inbound events.
179///
180/// Object-safe (`#[async_trait]`) so the adapter holds `Arc<dyn
181/// SlackTransport>` and the mock and real impls are interchangeable.
182#[async_trait]
183pub trait SlackTransport: Send + Sync {
184    /// Post a Block Kit approval message carrying Approve/Deny buttons whose
185    /// `value` is the `approval_id`. `text` is the human-readable summary (also
186    /// carries the shared fan-out code for MC-8 equality). Returns the posted
187    /// message `ts` on success.
188    async fn post_message(
189        &self,
190        channel: &str,
191        text: &str,
192        approval_id: &str,
193    ) -> Result<String, String>;
194
195    /// Block until the next inbound Slack event (button click or DM), or
196    /// `None` when the source is exhausted / cancelled. The adapter ACKs and
197    /// dispatches each returned event. The transport is responsible for the
198    /// Socket Mode ACK contract and for suppressing the bot's own echo
199    /// (returning [`SlackInboundEvent::Ignore`] or skipping it).
200    async fn next_event(&self) -> Option<SlackInboundEvent>;
201}
202
203/// Build the Block Kit body the outbound post renders. Pure function so the
204/// mock and the real transport agree on the wire shape, and tests can assert
205/// the buttons carry the `approval_id` (MC-12). The `value` on BOTH buttons is
206/// the raw `approval_id` — NO code grammar.
207pub fn build_block_kit_message(text: &str, approval_id: &str) -> serde_json::Value {
208    serde_json::json!({
209        "text": text,
210        "blocks": [
211            {
212                "type": "section",
213                "text": { "type": "mrkdwn", "text": text }
214            },
215            {
216                "type": "actions",
217                "block_id": "approval_actions",
218                "elements": [
219                    {
220                        "type": "button",
221                        "action_id": APPROVE_ACTION_ID,
222                        "text": { "type": "plain_text", "text": "Approve", "emoji": true },
223                        "style": "primary",
224                        "value": approval_id
225                    },
226                    {
227                        "type": "button",
228                        "action_id": DENY_ACTION_ID,
229                        "text": { "type": "plain_text", "text": "Deny", "emoji": true },
230                        "style": "danger",
231                        "value": approval_id
232                    }
233                ]
234            }
235        ]
236    })
237}
238
239// ===================================================================
240// The Slack adapter
241// ===================================================================
242
243/// In-process Slack adapter. UNCONDITIONAL (no `#[cfg(target_os=...)]` — MC-11):
244/// Slack is cross-platform, the whole point of the second channel proving the
245/// seam holds without macOS. Holds the channel-agnostic [`ApprovalCore`]
246/// (resolve by `approval_id`), the per-channel [`MessagingConfigStore`]
247/// (allowlist + pairing), the [`SlackTransport`] seam, and the configured
248/// Slack channel id to post into.
249pub struct SlackAdapter {
250    /// Channel-agnostic approval semantics. The `"slack-transport"` principal
251    /// is supplied by this adapter at each `core.resolve(...)` call — it is not
252    /// baked into the core (MC-3).
253    core: ApprovalCore,
254    /// Per-channel config + pairing store (the Slack section).
255    config: MessagingConfigStore,
256    /// The mockable transport seam (Socket Mode + Web API in prod; a mock in
257    /// tests).
258    transport: std::sync::Arc<dyn SlackTransport>,
259    /// The Slack channel/DM id the outbound prompt posts into.
260    channel: String,
261}
262
263impl SlackAdapter {
264    /// Build a Slack adapter over the shared host, config store, transport
265    /// seam, and the configured Slack channel id.
266    pub fn new(
267        host: std::sync::Arc<car_server_types::host::HostState>,
268        config: MessagingConfigStore,
269        transport: std::sync::Arc<dyn SlackTransport>,
270        channel: impl Into<String>,
271    ) -> Self {
272        Self {
273            core: ApprovalCore::new(host),
274            config,
275            transport,
276            channel: channel.into(),
277        }
278    }
279
280    /// Post ONE Block Kit approval prompt for `approval_id` (carrying the
281    /// shared fan-out `code` in the text for MC-8 equality) to the configured
282    /// Slack channel. The button `value` carries the `approval_id` directly.
283    /// Used by the fan-out outbound path (Unit 5) and by the e2e test.
284    ///
285    /// Gating: a disabled Slack channel is a silent no-op (zero posts) — the
286    /// enabled-flag wall, same as iMessage.
287    pub async fn post_prompt(&self, approval: &HostApprovalRequest, code: &str) {
288        let approval_id = approval.id.as_str();
289        if !self
290            .config
291            .is_enabled_for(ChannelId::Slack)
292            .unwrap_or(false)
293        {
294            return;
295        }
296        // No post-channel configured ⇒ posting to `channel:""` is a guaranteed
297        // `channel_not_found` every tick (plus a keychain token fetch per doomed
298        // tick + log spam). Early-return instead. The single missing-channel
299        // warning is logged once at boot (see the orchestrator's Slack adapter
300        // build); here we just no-op.
301        if self.channel.is_empty() {
302            return;
303        }
304        let text = slack_prompt_text(approval, code);
305        if let Err(e) = self
306            .transport
307            .post_message(&self.channel, &text, approval_id)
308            .await
309        {
310            tracing::warn!(approval_id = %approval_id, error = %e, "slack approval prompt post failed");
311        }
312    }
313
314    /// Dispatch one inbound Slack event. The closed set guarantees the only
315    /// effects are: (1) resolve a known approval by id (button), or (2)
316    /// validate-and-consume a pairing code (DM). NO config-mutation edge
317    /// (MC-6). The enabled-flag wall applies — a disabled channel does zero
318    /// inbound work.
319    pub async fn handle_event(&self, event: &SlackInboundEvent) {
320        if !self
321            .config
322            .is_enabled_for(ChannelId::Slack)
323            .unwrap_or(false)
324        {
325            return;
326        }
327        match event {
328            SlackInboundEvent::ButtonInteraction {
329                action_id,
330                value,
331                user,
332            } => {
333                self.handle_button(action_id, value, user).await;
334            }
335            SlackInboundEvent::PairingDm { user, text } => {
336                // The ONLY inbound-reachable allowlist bind (MC-13): a
337                // constant-time match of the locally-minted Slack code binds
338                // this member id. A wrong code (or no active code) binds
339                // nothing; a bare member id with no code never reaches a setter
340                // (it parses to `Ignore` upstream / fails the pairing match).
341                let _outcome: PairingOutcome = self
342                    .config
343                    .validate_and_consume_pairing_code_for(ChannelId::Slack, user, text)
344                    .unwrap_or(PairingOutcome::Rejected);
345            }
346            SlackInboundEvent::Ignore => {}
347        }
348    }
349
350    /// Resolve the approval named by a button's `value` (the `approval_id`),
351    /// clicked by `user` (the Slack member id). Approve vs Deny is read from the
352    /// `action_id`. A bogus / already-resolved `approval_id` no-ops via the
353    /// untouched first-writer-wins guard — no panic, nothing resolves, no second
354    /// event (MC-12 edge).
355    ///
356    /// BEFORE resolving, the two trust gates the iMessage inbound path enforces
357    /// are applied here too (the Slack button path must NOT be a back door
358    /// around pairing + eligibility):
359    ///
360    /// 1. **Allowlist (SC-7 wall):** the clicker must be on the Slack channel's
361    ///    allowlist (the paired approver). A non-allowlisted member of the
362    ///    configured channel clicking a button is a silent no-op — no resolve,
363    ///    no event. The allowlist is the whole point of pairing; without this
364    ///    check it is inert on Slack (any channel member could resolve).
365    /// 2. **Eligibility (MC-7):** the named `approval_id` must be a pending,
366    ///    eligible system-level row ([`ApprovalCore::is_id_eligible_pending`]).
367    ///    This excludes `ws.method:`-prefixed blocking-gate rows (high-risk
368    ///    methods the user never acked) and session-owned (`client_id: Some`)
369    ///    rows — exactly like the iMessage path. A crafted `block_actions`
370    ///    payload whose `value` names such a row resolves NOTHING.
371    async fn handle_button(&self, action_id: &str, approval_id: &str, user: &str) {
372        let resolution = if action_id == APPROVE_ACTION_ID {
373            APPROVE
374        } else if action_id == DENY_ACTION_ID {
375            DENY
376        } else {
377            // Unknown action_id — not one of our two buttons. Ignore.
378            return;
379        };
380        // Gate 1 — allowlist (SC-7). Drop a non-allowlisted clicker BEFORE any
381        // resolve. `is_allowlisted_for` errs only on a malformed config file;
382        // treat an error as "not allowlisted" (fail closed).
383        if !self
384            .config
385            .is_allowlisted_for(ChannelId::Slack, user)
386            .unwrap_or(false)
387        {
388            return;
389        }
390        // Gate 2 — eligibility (MC-7). Only a pending, eligible system-level row
391        // is resolvable from a channel transport. A `ws.method:` blocking-gate
392        // row or a session-owned (`client_id: Some`) row is NOT eligible, so a
393        // Slack button can never resolve one.
394        if !self.core.is_id_eligible_pending(approval_id).await {
395            return;
396        }
397        // Past both gates: resolve by approval_id directly through the
398        // channel-agnostic core. The core's first-writer-wins guard makes an
399        // already-resolved id a safe no-op (returns Error/StillPending, emits
400        // nothing). `SLACK_PRINCIPAL` is the audit principal.
401        let _ = self
402            .core
403            .resolve(SLACK_PRINCIPAL, approval_id, resolution)
404            .await;
405    }
406
407    /// The shared core (so the fan-out coordinator can query eligibility
408    /// through the same `Arc<HostState>`).
409    pub fn core(&self) -> &ApprovalCore {
410        &self.core
411    }
412}
413
414/// The Slack adapter implements the channel-agnostic [`InboundChannel`] seam:
415/// it names [`ChannelId::Slack`] and OWNS its push-based inbound loop, pulling
416/// events off the [`SlackTransport`] and dispatching each. UNCONDITIONAL — no
417/// cfg gate (MC-11).
418///
419/// Note on the `sink`: like the iMessage adapter, the Slack adapter is its own
420/// delivery path. Its inbound is button-clicks (resolve by `approval_id`) and
421/// pairing DMs (the code primitive) — neither maps to the `handle_id`+`body`
422/// `InboundMessage` sink shape, so the passed sink is unused here (the seam's
423/// `sink` param exists for a hypothetical adapter whose delivery is externally
424/// supplied; Slack, like iMessage, delivers internally and honestly does not
425/// fake an `InboundMessage`).
426#[async_trait]
427impl InboundChannel for SlackAdapter {
428    fn channel(&self) -> ChannelId {
429        ChannelId::Slack
430    }
431
432    async fn run(&self, _sink: &dyn InboundSink, mut cancel: CancelSignal) {
433        loop {
434            // Stop promptly on cancel without waiting for the next event.
435            tokio::select! {
436                _ = cancel.changed() => {
437                    if *cancel.borrow() {
438                        break;
439                    }
440                }
441                event = self.transport.next_event() => {
442                    match event {
443                        Some(ev) => self.handle_event(&ev).await,
444                        None => break, // source exhausted / closed
445                    }
446                }
447            }
448        }
449    }
450}
451
452/// Build the Slack prompt text: the action summary plus the shared fan-out
453/// code (MC-8: the SAME code that iMessage's text grammar carries, so both
454/// sinks render one shared code minted once). The Slack APPROVE/DENY mechanism
455/// is the buttons (value = approval_id); the code in the text is for fan-out
456/// equality + human readability, not a resolve grammar.
457pub fn slack_prompt_text(approval: &HostApprovalRequest, code: &str) -> String {
458    // Sanitised for the same reason as the iMessage body — see `outbound_body`.
459    let mut text = format!("*Approval needed:* {}\n", sanitize_line(&approval.action));
460    // Same payload the dashboard renders, bounded for a third-party transport
461    // (`car_proto::approval_summary`). Buttons that resolve an action the approver
462    // cannot see collect a click, not a decision.
463    if let Some(summary) = approval_summary(approval) {
464        text.push_str(&summary);
465        text.push('\n');
466    }
467    text.push_str(&format!("(ref `{code}`) — use the buttons below."));
468    text
469}
470
471// ===================================================================
472// Production transport: Socket Mode (tokio-tungstenite) + Web API (reqwest)
473// ===================================================================
474
475/// Production [`SlackTransport`] — UNCONDITIONAL (no cfg gate, MC-11). Drives:
476///
477/// - **Outbound** `chat.postMessage` via `reqwest`, fetching the bot token
478///   (`xoxb-`) from the OS keychain by its reference key at use-time (MC-9 — the
479///   token is never read out of `messaging.json`).
480/// - **Inbound** Socket Mode: opens a WebSocket via `apps.connections.open`
481///   (using the app-level token `xapp-`, also keychain-fetched), reads frames,
482///   ACKs each `events_api`/`interactive` envelope within the ~3s window, and
483///   maps the two recognized shapes to [`SlackInboundEvent`]. The bot's own
484///   echo is suppressed (`subtype == "bot_message"` / `bot_id` present).
485///
486/// The reconnect loop (resilient backoff, `apps.connections.open` per
487/// connection, `disconnect`/`warning` handling) follows the Socket Mode
488/// reference. `next_event` pulls from an internal channel the background
489/// connection task feeds.
490pub struct RealSlackTransport {
491    secrets: car_secrets::SecretStore,
492    bot_token_key: String,
493    app_token_key: String,
494    http: reqwest::Client,
495    /// Inbound events the background Socket Mode task feeds; `next_event`
496    /// receives off this.
497    inbound_rx: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<SlackInboundEvent>>,
498    inbound_tx: tokio::sync::mpsc::Sender<SlackInboundEvent>,
499}
500
501impl RealSlackTransport {
502    /// Build the production transport. `bot_token_key`/`app_token_key` are the
503    /// keychain REFERENCE keys (from [`SlackTokenRefs`]) — the bearer values
504    /// live only in the OS keychain (MC-9).
505    pub fn new(bot_token_key: impl Into<String>, app_token_key: impl Into<String>) -> Self {
506        let (inbound_tx, inbound_rx) = tokio::sync::mpsc::channel(64);
507        Self {
508            secrets: car_secrets::SecretStore::new(),
509            bot_token_key: bot_token_key.into(),
510            app_token_key: app_token_key.into(),
511            http: reqwest::Client::new(),
512            inbound_rx: tokio::sync::Mutex::new(inbound_rx),
513            inbound_tx,
514        }
515    }
516
517    /// Open a Socket Mode WebSocket URL via `apps.connections.open` (uses the
518    /// app-level token, keychain-fetched at use-time).
519    async fn open_socket_url(&self) -> Result<String, String> {
520        let app_token = fetch_secret_by_ref(&self.secrets, &self.app_token_key)?;
521        let resp: serde_json::Value = self
522            .http
523            .post("https://slack.com/api/apps.connections.open")
524            .bearer_auth(app_token)
525            .header("Content-Length", "0")
526            .send()
527            .await
528            .map_err(|e| format!("apps.connections.open: {e}"))?
529            .json()
530            .await
531            .map_err(|e| format!("apps.connections.open decode: {e}"))?;
532        parse_socket_url_response(&resp)
533    }
534
535    /// Spawn the background Socket Mode reconnect loop. Each connection reads
536    /// frames, ACKs, maps the two recognized shapes, and forwards them to
537    /// `inbound_tx`. Runs until `cancel` flips. Per the reference: resilient
538    /// backoff, one `apps.connections.open` per connection, `disconnect`/error
539    /// → reconnect.
540    pub fn spawn_socket_loop(self: std::sync::Arc<Self>, mut cancel: CancelSignal) {
541        tokio::spawn(async move {
542            // A connection that stayed up at least this long is treated as
543            // "healthy" — only then is the backoff reset to 1s. Resetting on
544            // EVERY `Ok(url)` (the prior behavior) meant a connection that
545            // failed instantly still cleared the backoff, so a persistently
546            // broken endpoint produced a tight 1s open→fail→open loop instead of
547            // escalating. The threshold sits comfortably above the Socket Mode
548            // ACK/hello round-trip.
549            const HEALTHY_UP: std::time::Duration = std::time::Duration::from_secs(5);
550            let mut backoff = std::time::Duration::from_secs(1);
551            loop {
552                if *cancel.borrow() {
553                    break;
554                }
555                match self.open_socket_url().await {
556                    Ok(url) => {
557                        let started = std::time::Instant::now();
558                        if let Err(e) = self.run_one_connection(&url, &mut cancel).await {
559                            tracing::warn!(error = %e, "slack socket connection ended");
560                        }
561                        if started.elapsed() >= HEALTHY_UP {
562                            // The connection stayed up — reset to the base delay.
563                            backoff = std::time::Duration::from_secs(1);
564                        } else {
565                            // It fell over fast — back off + sleep before the
566                            // next open so we don't spin.
567                            tokio::select! {
568                                _ = tokio::time::sleep(backoff) => {}
569                                _ = cancel.changed() => {}
570                            }
571                            backoff = (backoff * 2).min(std::time::Duration::from_secs(30));
572                        }
573                    }
574                    Err(e) => {
575                        tracing::warn!(error = %e, "slack apps.connections.open failed");
576                        tokio::select! {
577                            _ = tokio::time::sleep(backoff) => {}
578                            _ = cancel.changed() => {}
579                        }
580                        backoff = (backoff * 2).min(std::time::Duration::from_secs(30));
581                    }
582                }
583            }
584        });
585    }
586
587    /// Run a single Socket Mode WebSocket connection: ACK every
588    /// `events_api`/`interactive` envelope, map the two recognized shapes, and
589    /// forward to the inbound channel. Returns on disconnect/close/error/cancel.
590    async fn run_one_connection(
591        &self,
592        wss_url: &str,
593        cancel: &mut CancelSignal,
594    ) -> Result<(), String> {
595        use futures_util::{SinkExt, StreamExt};
596        use tokio_tungstenite::{connect_async, tungstenite::Message};
597
598        let (ws_stream, _resp) = connect_async(wss_url)
599            .await
600            .map_err(|e| format!("ws connect: {e}"))?;
601        let (mut write, mut read) = ws_stream.split();
602
603        loop {
604            tokio::select! {
605                _ = cancel.changed() => {
606                    if *cancel.borrow() { return Ok(()); }
607                }
608                frame = read.next() => {
609                    let Some(frame) = frame else { return Ok(()); };
610                    let msg = frame.map_err(|e| format!("ws read: {e}"))?;
611                    let text = match msg {
612                        Message::Text(t) => t,
613                        Message::Close(_) => return Ok(()),
614                        _ => continue,
615                    };
616                    let envelope: serde_json::Value = match serde_json::from_str(&text) {
617                        Ok(v) => v,
618                        Err(_) => continue,
619                    };
620                    let ev_type = envelope["type"].as_str().unwrap_or("");
621                    // ACK within ~3s — before any business logic. The frame
622                    // shape (`{envelope_id}`) is built by the shared pure
623                    // `build_ack_frame` the wire-parse gate asserts.
624                    if matches!(ev_type, "events_api" | "interactive" | "slash_commands") {
625                        if let Some(ack) = build_ack_frame(&envelope) {
626                            let _ = write.send(Message::Text(ack.to_string().into())).await;
627                        }
628                    }
629                    if ev_type == "disconnect" {
630                        return Ok(()); // reconnect in the outer loop
631                    }
632                    // Route through the SINGLE shared wire parser. Only the two
633                    // closed shapes are forwarded; `Ignore` (bot echo, non-im,
634                    // unknown action, hello) is dropped here (MC-6 boundary).
635                    match parse_socket_frame(&envelope) {
636                        ev @ (SlackInboundEvent::ButtonInteraction { .. }
637                        | SlackInboundEvent::PairingDm { .. }) => {
638                            let _ = self.inbound_tx.send(ev).await;
639                        }
640                        SlackInboundEvent::Ignore => {}
641                    }
642                }
643            }
644        }
645    }
646}
647
648/// Resolve the WebSocket URL out of an `apps.connections.open` response body.
649/// Pure function so the reconnect-spin guard is unit-testable without a live
650/// HTTP call: an `ok:false` body, or an `ok:true` body whose `url` is
651/// absent/empty, both yield `Err` (NOT `Ok("")`). Returning `Ok("")` would
652/// route through the success arm of [`RealSlackTransport::spawn_socket_loop`]
653/// (resetting backoff), then `connect_async("")` fails instantly — a tight,
654/// no-sleep open→fail→open spin. Routing it as `Err` sends it through the
655/// backoff/sleep arm instead.
656pub fn parse_socket_url_response(resp: &serde_json::Value) -> Result<String, String> {
657    if resp["ok"].as_bool() != Some(true) {
658        return Err(format!(
659            "apps.connections.open failed: {}",
660            resp["error"].as_str().unwrap_or("unknown")
661        ));
662    }
663    resp["url"]
664        .as_str()
665        .filter(|u| !u.is_empty())
666        .map(|u| u.to_string())
667        .ok_or_else(|| "apps.connections.open returned ok with no url".to_string())
668}
669
670/// Build the Socket Mode ACK frame for an envelope. Pure function so the real
671/// transport and the wire-parse tests agree on the shape: the 3s-window ACK
672/// echoes ONLY the `envelope_id` back as the frame body (the minimal,
673/// no-response-payload ACK). Returns `None` when the envelope has no
674/// `envelope_id` (a frame that needs no ACK, e.g. `hello`/`disconnect`).
675pub fn build_ack_frame(envelope: &serde_json::Value) -> Option<serde_json::Value> {
676    let eid = envelope["envelope_id"].as_str()?;
677    Some(serde_json::json!({ "envelope_id": eid }))
678}
679
680/// Parse a top-level Socket Mode envelope (the WS text frame's JSON) into a
681/// [`SlackInboundEvent`]. This is the SINGLE wire-parse entry point the real
682/// Socket Mode loop and the tests share: it routes by the envelope `type`
683/// (`interactive` → block_actions; `events_api` → message.im) and yields
684/// [`SlackInboundEvent::Ignore`] for everything else (the bot's own echo, a
685/// non-DM channel message, an unknown action, a `hello`/`disconnect` frame).
686///
687/// The closed-set output is the MC-6 anti-injection boundary BY CONSTRUCTION:
688/// a hostile envelope — even one whose DM body is literally a config-mutation
689/// payload — can only ever produce a `ButtonInteraction`, a `PairingDm`, or
690/// `Ignore`. There is NO arm that manufactures a config mutation.
691pub fn parse_socket_frame(envelope: &serde_json::Value) -> SlackInboundEvent {
692    match envelope["type"].as_str() {
693        Some("interactive") => {
694            parse_interactive(&envelope["payload"]).unwrap_or(SlackInboundEvent::Ignore)
695        }
696        Some("events_api") => {
697            parse_events_api(&envelope["payload"]).unwrap_or(SlackInboundEvent::Ignore)
698        }
699        _ => SlackInboundEvent::Ignore,
700    }
701}
702
703/// Parse a Socket Mode `interactive` (block_actions) payload into a
704/// [`SlackInboundEvent::ButtonInteraction`], or `None` if it is not one of our
705/// two buttons. `pub` so the wire-parse gate (`mc_slack_wire_parse`) drives the
706/// REAL parser, not a pre-built enum — the MC-6 boundary lives in this code.
707pub fn parse_interactive(payload: &serde_json::Value) -> Option<SlackInboundEvent> {
708    if payload["type"].as_str()? != "block_actions" {
709        return None;
710    }
711    let action = payload["actions"].as_array()?.first()?;
712    let action_id = action["action_id"].as_str()?.to_string();
713    if action_id != APPROVE_ACTION_ID && action_id != DENY_ACTION_ID {
714        return None;
715    }
716    let value = action["value"].as_str()?.to_string();
717    let user = payload["user"]["id"].as_str().unwrap_or("").to_string();
718    Some(SlackInboundEvent::ButtonInteraction {
719        action_id,
720        value,
721        user,
722    })
723}
724
725/// Parse a Socket Mode `events_api` payload into a
726/// [`SlackInboundEvent::PairingDm`], or `None` if it is not a member DM (the
727/// bot's own echo is suppressed: `subtype == "bot_message"` or `bot_id`
728/// present). `pub` so the wire-parse + MC-6 gates exercise the REAL parser:
729/// a non-`im` channel message or a bot echo yields `None` ⇒ `Ignore`.
730pub fn parse_events_api(payload: &serde_json::Value) -> Option<SlackInboundEvent> {
731    let event = &payload["event"];
732    if event["type"].as_str()? != "message" {
733        return None;
734    }
735    if event["channel_type"].as_str() != Some("im") {
736        return None; // only DMs to the bot
737    }
738    // Suppress the bot's own echo.
739    if event["subtype"].as_str() == Some("bot_message") || event.get("bot_id").is_some() {
740        return None;
741    }
742    let user = event["user"].as_str()?.to_string();
743    let text = event["text"].as_str().unwrap_or("").trim().to_string();
744    if user.is_empty() || text.is_empty() {
745        return None;
746    }
747    Some(SlackInboundEvent::PairingDm { user, text })
748}
749
750#[async_trait]
751impl SlackTransport for RealSlackTransport {
752    async fn post_message(
753        &self,
754        channel: &str,
755        text: &str,
756        approval_id: &str,
757    ) -> Result<String, String> {
758        // MC-9: fetch the bot token from the keychain by ref at use-time.
759        let bot_token = fetch_secret_by_ref(&self.secrets, &self.bot_token_key)?;
760        let mut body = build_block_kit_message(text, approval_id);
761        body["channel"] = serde_json::Value::String(channel.to_string());
762        let resp: serde_json::Value = self
763            .http
764            .post("https://slack.com/api/chat.postMessage")
765            .bearer_auth(bot_token)
766            .json(&body)
767            .send()
768            .await
769            .map_err(|e| format!("chat.postMessage: {e}"))?
770            .json()
771            .await
772            .map_err(|e| format!("chat.postMessage decode: {e}"))?;
773        if resp["ok"].as_bool() != Some(true) {
774            return Err(format!(
775                "chat.postMessage failed: {}",
776                resp["error"].as_str().unwrap_or("unknown")
777            ));
778        }
779        Ok(resp["ts"].as_str().unwrap_or("").to_string())
780    }
781
782    async fn next_event(&self) -> Option<SlackInboundEvent> {
783        self.inbound_rx.lock().await.recv().await
784    }
785}