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}