Skip to main content

agentmux_srv/messaging/slack/
socket.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Slack Socket Mode connection lifecycle: `apps.connections.open`, the
5//! hello/events_api/disconnect-warning state machine, per-event ACK, the
6//! make-before-break reconnect-on-warning dance, and the 5-minute
7//! dead-connection timer — plus the outbound `chat.postMessage` sender.
8//!
9//! ## Two independent tasks, not one interleaved loop
10//!
11//! `run_bridge` joins the inbound socket loop (`run_socket_loop`) and the
12//! outbound send loop (`run_outbound_loop`) via `tokio::join!`, exactly like
13//! `telegram/poller.rs::run_poll_loop` joins its inbound/outbound loops —
14//! see that file's doc comment for the full review finding that motivated
15//! the split (an interleaved `tokio::select!` let an outbound
16//! backoff-sleep block inbound polling for as long as the backoff lasted).
17//! The same coupling would be worse for Slack: the inbound loop must ACK
18//! every `events_api` envelope within a hard 3-second deadline (spec §2.2),
19//! so anything that could delay servicing inbound frames — including an
20//! outbound rate-limit wait — must live on a fully separate task. The two
21//! loops share no state; outbound doesn't touch the WebSocket at all, it
22//! only calls `rest::post_message`.
23//!
24//! ## Make-before-break reconnect-on-warning (spec §2.3)
25//!
26//! This is entirely internal to `run_socket_loop`'s per-connection state
27//! (`run_session`), not a second top-level task — the dance is about
28//! managing one logical inbound connection's lifecycle, which is exactly
29//! what `run_socket_loop` already owns.
30//!
31//! `run_session` holds at most two live socket halves at once:
32//! - `primary: WsHalf` — the currently-active connection. All *new* inbound
33//!   processing (routing, ACKs for anything just arriving) happens here.
34//! - `old: Option<WsHalf>` — set only during the ~10s window between a
35//!   `disconnect: warning` and Slack force-closing that connection. Frames
36//!   still arriving here (Slack can route events to it right up until the
37//!   close) are drained and ACKed exactly like `primary`'s, just not
38//!   promoted to primary status.
39//!
40//! When a `warning` arrives on `primary`, we don't tear the connection down
41//! and reconnect — we kick off a *second*, independent connect (fetch a
42//! fresh URL via `apps.connections.open`, open a new WS, wait for its
43//! `hello`) while continuing to service `primary` unmodified. That connect
44//! runs as a pinned, boxed future (`cutover_connect`) polled as one more
45//! `tokio::select!` branch each loop iteration — the same technique
46//! `discord/gateway.rs` uses for its heartbeat-interval sleep (`hb_sleep`):
47//! a `Pin<Box<dyn Future>>` defaulting to `std::future::pending()` when
48//! there's nothing in flight, swapped in only when there's real async work
49//! to track. This is what makes the second connect genuinely concurrent
50//! with continuing to drain `primary` within a single task, no extra
51//! `tokio::spawn` required.
52//!
53//! Once that future resolves successfully (new socket reached `hello`), the
54//! *next* loop iteration promotes it: `old = Some(mem::replace(primary,
55//! new))`. From that point on, `primary`'s branch handles all new frames as
56//! usual, and a second `tokio::select!` branch (guarded by `old.is_some()`,
57//! built from the `next_frame` helper below so it evaluates to
58//! `std::future::pending()` — i.e. never fires — when `old` is `None`)
59//! keeps draining and ACKing `old` until it errors or closes, at which
60//! point it's dropped. A duplicate `warning` arriving while a cutover is
61//! already in flight is logged and ignored (`cutover_pending` guard) rather
62//! than launching a second concurrent connect.
63//!
64//! An unexpected close on `primary` with no prior warning (network blip,
65//! Slack silently dying, etc.) is the plain backoff path: `run_session`
66//! returns `Err`, and the outer `run_socket_loop` retry loop reconnects
67//! after an exponentially-increasing delay (capped at 60s, matching
68//! Discord's `RECONNECT_DELAY_SECS`/`MAX_RECONNECT_DELAY_SECS` constants),
69//! always fetching a fresh URL first (never retrying a stale one).
70
71use std::collections::HashMap;
72use std::sync::{Arc, Mutex};
73use std::time::{Duration, Instant};
74
75use futures_util::stream::{SplitSink, SplitStream};
76use futures_util::{SinkExt, StreamExt};
77use tokio::sync::mpsc;
78use tokio_tungstenite::{
79    connect_async_tls_with_config, tungstenite::Message, MaybeTlsStream, WebSocketStream,
80};
81
82use crate::backend::reactive::handler::get_global_handler;
83use crate::backend::reactive::types::InjectionRequest;
84use crate::messaging::{BridgeHealth, BridgeStatus, OutboundMsg};
85
86use super::rest;
87use super::types::{AckFrame, EventsApiPayload, SlackEvent, SocketEnvelope};
88
89const RECONNECT_DELAY_SECS: u64 = 5;
90const MAX_RECONNECT_DELAY_SECS: u64 = 60;
91
92/// Proactive dead-connection heuristic (spec §2.3): if no frame of any kind
93/// has arrived in this long, force a reconnect even though nothing signaled
94/// a close.
95const DEAD_CONN_TIMEOUT_SECS: u64 = 300;
96
97/// Minimum spacing between two outbound sends to the same channel (spec
98/// §2.6 — "~1 msg/s/channel", enforced with a bit of headroom).
99const CHANNEL_MIN_SPACING: Duration = Duration::from_millis(1100);
100
101/// Timeout waiting for the first frame (must be `hello`) after connecting.
102const HELLO_TIMEOUT_SECS: u64 = 10;
103
104type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
105type WsWrite = SplitSink<WsStream, Message>;
106type WsRead = SplitStream<WsStream>;
107
108struct WsHalf {
109    write: WsWrite,
110    read: WsRead,
111}
112
113/// Orchestrates the two independent tasks described in the module doc
114/// comment. Called once from `SlackBridge::init_global`'s spawned task.
115pub async fn run_bridge(
116    app_token: String,
117    bot_token: String,
118    channel_id: String,
119    target_agent: Option<String>,
120    http: reqwest::Client,
121    outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
122    health: Arc<Mutex<BridgeHealth>>,
123) {
124    let outbound_http = http.clone();
125    let default_channel = channel_id.clone();
126    tokio::join!(
127        run_socket_loop(app_token, channel_id, target_agent, http, health),
128        run_outbound_loop(outbound_http, bot_token, default_channel, outbound_rx),
129    );
130}
131
132// ── Inbound: Socket Mode connection lifecycle ───────────────────────────────
133
134/// Outer retry-forever loop: fetch a fresh WS URL, run a session until it
135/// ends (cleanly or with an error), back off, repeat. Every attempt —
136/// initial connect included — fetches a new URL via `apps.connections.open`
137/// (spec §2.1: the URL is single-use, there is no Discord-style constant
138/// Gateway URL to fall back to).
139async fn run_socket_loop(
140    app_token: String,
141    channel_id: String,
142    target_agent: Option<String>,
143    http: reqwest::Client,
144    health: Arc<Mutex<BridgeHealth>>,
145) {
146    let mut delay_secs = RECONNECT_DELAY_SECS;
147
148    loop {
149        {
150            let mut h = health.lock().unwrap();
151            h.status = BridgeStatus::Connecting;
152        }
153
154        let session_start = Instant::now();
155
156        match connect_and_run(&app_token, &channel_id, &target_agent, &http, &health).await {
157            Ok(()) => {
158                tracing::info!("slack_bridge: session ended cleanly");
159            }
160            Err(e) => {
161                tracing::warn!("slack_bridge: session error: {e}");
162                let mut h = health.lock().unwrap();
163                h.status = BridgeStatus::Error;
164                h.error = Some(e);
165                h.reconnect_count += 1;
166            }
167        }
168
169        if session_start.elapsed().as_secs() > 30 {
170            delay_secs = RECONNECT_DELAY_SECS;
171        }
172
173        {
174            let mut h = health.lock().unwrap();
175            if h.status != BridgeStatus::Error {
176                h.status = BridgeStatus::Disconnected;
177            }
178        }
179
180        tokio::time::sleep(Duration::from_secs(delay_secs)).await;
181        delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
182    }
183}
184
185async fn connect_and_run(
186    app_token: &str,
187    channel_id: &str,
188    target_agent: &Option<String>,
189    http: &reqwest::Client,
190    health: &Arc<Mutex<BridgeHealth>>,
191) -> Result<(), String> {
192    let mut primary = open_and_await_hello(app_token, http).await?;
193
194    {
195        let mut h = health.lock().unwrap();
196        h.status = BridgeStatus::Connected;
197        h.error = None;
198        h.last_event_at = Some(unix_secs());
199    }
200    tracing::info!("slack_bridge: connected (hello received)");
201
202    run_session(app_token, channel_id, target_agent, http, health, &mut primary).await
203}
204
205/// Fetches a fresh Socket Mode URL and connects, waiting for the mandatory
206/// first `hello` frame (spec §2.1, §4.1). Used both for the initial connect
207/// and for the cutover half of the make-before-break dance.
208async fn open_and_await_hello(app_token: &str, http: &reqwest::Client) -> Result<WsHalf, String> {
209    let url = rest::open_connection(http, app_token).await?;
210    tracing::debug!(
211        "slack_bridge: opened connection ({})",
212        rest::redact_ticket(&url)
213    );
214
215    let request = tokio_tungstenite::tungstenite::http::Request::builder()
216        .uri(&url)
217        .body(())
218        .map_err(|e| format!("slack_bridge: build ws request: {e}"))?;
219
220    let (ws_stream, _) = connect_async_tls_with_config(request, None, false, None)
221        .await
222        .map_err(|e| format!("slack_bridge: ws connect: {e}"))?;
223
224    let (write, mut read) = ws_stream.split();
225
226    match tokio::time::timeout(Duration::from_secs(HELLO_TIMEOUT_SECS), read.next()).await {
227        Ok(Some(Ok(Message::Text(text)))) => match serde_json::from_str::<SocketEnvelope>(&text) {
228            Ok(SocketEnvelope::Hello(_)) => Ok(WsHalf { write, read }),
229            Ok(_) => Err("slack_bridge: expected hello as first frame, got a different envelope type".to_string()),
230            Err(e) => Err(format!("slack_bridge: parse first frame: {e}")),
231        },
232        Ok(Some(Ok(_))) => Err("slack_bridge: expected hello as first frame, got a non-text frame".to_string()),
233        Ok(Some(Err(e))) => Err(format!("slack_bridge: ws recv error awaiting hello: {e}")),
234        Ok(None) => Err("slack_bridge: socket closed before hello".to_string()),
235        Err(_) => Err("slack_bridge: timed out waiting for hello".to_string()),
236    }
237}
238
239/// Await a frame on `old` if present, else never resolve. Lets the cutover
240/// socket's read half participate in the same `tokio::select!` as
241/// `primary`'s without needing a separate task — mirrors how
242/// `discord/gateway.rs` boxes an optional sleep future for its heartbeat.
243async fn next_frame(
244    old: &mut Option<WsHalf>,
245) -> Option<Result<Message, tokio_tungstenite::tungstenite::Error>> {
246    match old {
247        Some(half) => half.read.next().await,
248        None => std::future::pending().await,
249    }
250}
251
252enum FrameOutcome {
253    Continue,
254    WarningReceived,
255    Closed,
256}
257
258/// Runs one connection's session loop, holding at most two live socket
259/// halves (`primary` + optionally `old`, see module doc comment).
260///
261/// Returns `Ok(())` on a clean close (primary closed with no error) or
262/// `Err(e)` on anything reconnect-worthy (unexpected close, dead-connection
263/// timeout, ws send/recv error). Either way the caller (`run_socket_loop`)
264/// starts a fresh session with a freshly-fetched URL.
265async fn run_session(
266    app_token: &str,
267    channel_id: &str,
268    target_agent: &Option<String>,
269    http: &reqwest::Client,
270    health: &Arc<Mutex<BridgeHealth>>,
271    primary: &mut WsHalf,
272) -> Result<(), String> {
273    let mut old: Option<WsHalf> = None;
274    let mut cutover_pending = false;
275
276    let mut cutover_connect: std::pin::Pin<
277        Box<dyn std::future::Future<Output = Result<WsHalf, String>> + Send>,
278    > = Box::pin(std::future::pending());
279
280    let mut dead_conn_sleep = Box::pin(tokio::time::sleep(Duration::from_secs(
281        DEAD_CONN_TIMEOUT_SECS,
282    )));
283
284    loop {
285        tokio::select! {
286            // Proactive dead-connection heuristic (spec §2.3).
287            _ = &mut dead_conn_sleep => {
288                return Err(format!(
289                    "no frames received for {DEAD_CONN_TIMEOUT_SECS}s — treating connection as dead"
290                ));
291            }
292
293            // Cutover connect finished (success or failure).
294            result = &mut cutover_connect, if cutover_pending => {
295                cutover_pending = false;
296                cutover_connect = Box::pin(std::future::pending());
297                match result {
298                    Ok(new_half) => {
299                        tracing::info!("slack_bridge: cutover connection ready (hello received) — promoting to primary");
300                        let retiring = std::mem::replace(primary, new_half);
301                        old = Some(retiring);
302                        dead_conn_sleep = Box::pin(tokio::time::sleep(Duration::from_secs(DEAD_CONN_TIMEOUT_SECS)));
303                        let mut h = health.lock().unwrap();
304                        h.last_event_at = Some(unix_secs());
305                    }
306                    Err(e) => {
307                        // Stay on the current primary — it may still get
308                        // force-closed by Slack at the end of the warning
309                        // period, at which point the plain unexpected-close
310                        // path (primary.read.next() below) takes over.
311                        tracing::warn!("slack_bridge: cutover connect failed: {e} — staying on current primary until it closes");
312                    }
313                }
314            }
315
316            // Frame on the active (primary) socket.
317            frame = primary.read.next() => {
318                match frame {
319                    None => return Ok(()),
320                    Some(Err(e)) => return Err(format!("ws recv (primary): {e}")),
321                    Some(Ok(msg)) => {
322                        dead_conn_sleep = Box::pin(tokio::time::sleep(Duration::from_secs(DEAD_CONN_TIMEOUT_SECS)));
323                        let outcome = handle_frame(msg, &mut primary.write, channel_id, target_agent, health).await?;
324                        match outcome {
325                            FrameOutcome::Continue => {}
326                            FrameOutcome::Closed => return Ok(()),
327                            FrameOutcome::WarningReceived => {
328                                // Guard on `old.is_some()` too, not just
329                                // `cutover_pending`: a warning arriving on the
330                                // newly-promoted primary before the previous
331                                // retiring connection (`old`) has finished
332                                // draining must not start a second cutover —
333                                // its success arm below unconditionally
334                                // overwrites `old`, which would silently drop
335                                // the still-open first retiring connection
336                                // instead of finishing its drain, violating
337                                // "at most two live socket halves." Worst case
338                                // of ignoring it: Slack force-closes this
339                                // primary at the end of its warning window,
340                                // which falls through to the plain
341                                // unexpected-close error path below and
342                                // reconnects via the outer session retry loop
343                                // — the same degraded-but-safe fallback this
344                                // module already accepts when a cutover
345                                // connect attempt itself fails (see the `Err`
346                                // arm above).
347                                if cutover_pending || old.is_some() {
348                                    tracing::debug!(
349                                        "slack_bridge: disconnect warning while a cutover is already in flight or draining — ignoring"
350                                    );
351                                } else {
352                                    tracing::info!("slack_bridge: disconnect warning received — starting make-before-break reconnect");
353                                    cutover_pending = true;
354                                    let app_token = app_token.to_string();
355                                    let http = http.clone();
356                                    cutover_connect = Box::pin(async move { open_and_await_hello(&app_token, &http).await });
357                                }
358                            }
359                        }
360                    }
361                }
362            }
363
364            // Frame on the retiring (old) socket — only polled once a
365            // cutover has actually promoted a new primary.
366            frame = next_frame(&mut old), if old.is_some() => {
367                match frame {
368                    None | Some(Err(_)) => {
369                        tracing::debug!("slack_bridge: old socket closed/errored after cutover — discarding");
370                        old = None;
371                    }
372                    Some(Ok(msg)) => {
373                        // Best-effort: ACK/drain the old socket, but don't
374                        // let its errors tear down the (already-promoted)
375                        // primary session.
376                        if let Some(half) = old.as_mut() {
377                            match handle_frame(msg, &mut half.write, channel_id, target_agent, health).await {
378                                Ok(FrameOutcome::Closed) => {
379                                    tracing::info!("slack_bridge: old socket closed cleanly after cutover");
380                                    old = None;
381                                }
382                                Ok(_) => {}
383                                Err(e) => {
384                                    tracing::debug!("slack_bridge: old socket error after cutover (harmless — draining): {e}");
385                                    old = None;
386                                }
387                            }
388                        }
389                    }
390                }
391            }
392        }
393    }
394}
395
396/// Handles one WS frame: ACKs `events_api`/`slash_commands` envelopes
397/// immediately (before any parsing/routing — spec §2.2's hard 3-second
398/// requirement), then routes `events_api` payloads to the reactive bus.
399async fn handle_frame(
400    msg: Message,
401    write: &mut WsWrite,
402    channel_id: &str,
403    target_agent: &Option<String>,
404    health: &Arc<Mutex<BridgeHealth>>,
405) -> Result<FrameOutcome, String> {
406    match msg {
407        Message::Text(text) => {
408            let envelope: SocketEnvelope = match serde_json::from_str(&text) {
409                Ok(e) => e,
410                Err(e) => {
411                    tracing::warn!("slack_bridge: parse error: {e}");
412                    return Ok(FrameOutcome::Continue);
413                }
414            };
415
416            match envelope {
417                SocketEnvelope::Hello(_) => {
418                    tracing::debug!("slack_bridge: hello frame on an already-active socket");
419                    Ok(FrameOutcome::Continue)
420                }
421                SocketEnvelope::EventsApi(ev) => {
422                    ack_envelope(write, &ev.envelope_id).await?;
423                    route_event(ev.payload, channel_id, target_agent, health);
424                    Ok(FrameOutcome::Continue)
425                }
426                SocketEnvelope::SlashCommands(ev) => {
427                    // ACK required same as any other envelope (spec §2.5),
428                    // but slash commands are out of scope for this PR — no
429                    // further handling.
430                    ack_envelope(write, &ev.envelope_id).await?;
431                    tracing::debug!("slack_bridge: received slash_commands envelope (not handled — out of scope for this PR)");
432                    Ok(FrameOutcome::Continue)
433                }
434                SocketEnvelope::Disconnect(d) => {
435                    if d.reason.as_deref() == Some("warning") {
436                        Ok(FrameOutcome::WarningReceived)
437                    } else {
438                        tracing::info!("slack_bridge: disconnect frame (reason={:?})", d.reason);
439                        Ok(FrameOutcome::Closed)
440                    }
441                }
442                SocketEnvelope::Unknown => {
443                    tracing::debug!("slack_bridge: unhandled envelope type");
444                    Ok(FrameOutcome::Continue)
445                }
446            }
447        }
448        Message::Ping(data) => {
449            let _ = write.send(Message::Pong(data)).await;
450            Ok(FrameOutcome::Continue)
451        }
452        Message::Close(_) => Ok(FrameOutcome::Closed),
453        _ => Ok(FrameOutcome::Continue),
454    }
455}
456
457/// Sends `{"envelope_id": "..."}` back on the socket the envelope arrived
458/// on. Kept allocation-light and infallible-as-possible per spec §2.2's
459/// note that the ACK path should stay cheap and unconditional.
460async fn ack_envelope(write: &mut WsWrite, envelope_id: &str) -> Result<(), String> {
461    let ack = AckFrame {
462        envelope_id: envelope_id.to_string(),
463    };
464    let ack_json = serde_json::to_string(&ack).unwrap_or_default();
465    write
466        .send(Message::Text(ack_json.into()))
467        .await
468        .map_err(|e| format!("slack_bridge: ack send: {e}"))
469}
470
471/// True when a `SlackEvent` should be routed to the reactive bus: right
472/// event type, right channel, not self-authored. Pure/testable without a
473/// real socket.
474fn should_route(event: &SlackEvent, channel_id: &str) -> bool {
475    if event.bot_id.is_some() {
476        return false; // spec §9.4 — bot-message loop prevention
477    }
478    if event.type_ != "message" && event.type_ != "app_mention" {
479        return false;
480    }
481    event.channel.as_deref() == Some(channel_id) // spec §9.3 — channel allowlist
482}
483
484fn route_event(
485    payload: Option<EventsApiPayload>,
486    channel_id: &str,
487    target_agent: &Option<String>,
488    health: &Arc<Mutex<BridgeHealth>>,
489) {
490    let Some(event) = payload.and_then(|p| p.event) else {
491        return;
492    };
493
494    if !should_route(&event, channel_id) {
495        return;
496    }
497
498    {
499        let mut h = health.lock().unwrap();
500        h.last_event_at = Some(unix_secs());
501    }
502
503    let Some(target) = target_agent else {
504        tracing::debug!("slack_bridge: event in #{channel_id} (no target agent configured)");
505        return;
506    };
507
508    let user = event.user.as_deref().unwrap_or("unknown");
509    let text = event.text.as_deref().unwrap_or("");
510    let envelope = format!("[Slack #{channel_id} @{user}]: {text}");
511
512    let handler = get_global_handler();
513    let req = InjectionRequest {
514        target_agent: target.clone(),
515        message: envelope,
516        source_agent: Some("slack".to_string()),
517        request_id: event.ts.clone(),
518        priority: None,
519        jekt_tier: None,
520        delivery_tier: Some("wan".to_string()),
521        forward_hops: 0,
522        ..Default::default()
523    };
524    let result = handler.inject_message(req);
525    if result.success {
526        tracing::debug!("slack_bridge: injected msg from {user} to agent {target}");
527    } else {
528        tracing::warn!(
529            "slack_bridge: inject to agent {target} failed: {:?}",
530            result.error
531        );
532    }
533}
534
535// ── Outbound: chat.postMessage sender ───────────────────────────────────────
536
537async fn run_outbound_loop(
538    http: reqwest::Client,
539    bot_token: String,
540    default_channel_id: String,
541    mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
542) {
543    // Per-channel outbound rate limiting (spec §2.6, v1 scope: spacing +
544    // reactive 429 backoff only). Owned entirely by this task — never
545    // shared with the inbound loop.
546    let mut channel_last_sent: HashMap<String, Instant> = HashMap::new();
547    let mut channel_backoff_until: HashMap<String, Instant> = HashMap::new();
548    let mut channel_backoff_secs: HashMap<String, u64> = HashMap::new();
549
550    while let Some(msg) = outbound_rx.recv().await {
551        handle_outbound(
552            &http,
553            &bot_token,
554            &default_channel_id,
555            msg,
556            &mut channel_last_sent,
557            &mut channel_backoff_until,
558            &mut channel_backoff_secs,
559        )
560        .await;
561    }
562}
563
564/// Resolves an `OutboundMsg.channel_id` (empty → bridge default) into the
565/// channel id to send to. Pure/testable.
566fn resolve_channel_id(channel_id: &str, default_channel_id: &str) -> Option<String> {
567    if !channel_id.is_empty() {
568        return Some(channel_id.to_string());
569    }
570    if default_channel_id.is_empty() {
571        return None;
572    }
573    Some(default_channel_id.to_string())
574}
575
576/// Next backoff duration after a repeated failure, doubling and capped —
577/// same family used for reconnects (spec §2.6: "on repeated 429s apply the
578/// same exponential backoff used for reconnects"). Pure/testable.
579fn next_backoff_secs(current: u64) -> u64 {
580    (current * 2).min(MAX_RECONNECT_DELAY_SECS)
581}
582
583async fn handle_outbound(
584    http: &reqwest::Client,
585    bot_token: &str,
586    default_channel_id: &str,
587    msg: OutboundMsg,
588    channel_last_sent: &mut HashMap<String, Instant>,
589    channel_backoff_until: &mut HashMap<String, Instant>,
590    channel_backoff_secs: &mut HashMap<String, u64>,
591) {
592    let Some(channel_id) = resolve_channel_id(&msg.channel_id, default_channel_id) else {
593        tracing::warn!(
594            "slack_bridge: send dropped — no channel_id (empty channel_id and no messaging:slack:channel configured)"
595        );
596        return;
597    };
598
599    if let Some(until) = channel_backoff_until.get(&channel_id).copied() {
600        let now = Instant::now();
601        if now < until {
602            tokio::time::sleep(until - now).await;
603        }
604        channel_backoff_until.remove(&channel_id);
605    }
606
607    if let Some(last) = channel_last_sent.get(&channel_id) {
608        let elapsed = last.elapsed();
609        if elapsed < CHANNEL_MIN_SPACING {
610            tokio::time::sleep(CHANNEL_MIN_SPACING - elapsed).await;
611        }
612    }
613
614    match rest::post_message(http, bot_token, &channel_id, &msg).await {
615        Ok(()) => {
616            channel_last_sent.insert(channel_id.clone(), Instant::now());
617            channel_backoff_secs.remove(&channel_id);
618        }
619        Err(e) if e.retry_after.is_some() => {
620            // Spec §2.6: on 429, wait Retry-After then retry once.
621            let retry_after = e.retry_after.unwrap();
622            tracing::warn!(
623                "slack_bridge: rate limited sending to #{channel_id}, retry_after={retry_after}s — waiting then retrying once"
624            );
625            tokio::time::sleep(Duration::from_secs(retry_after)).await;
626
627            match rest::post_message(http, bot_token, &channel_id, &msg).await {
628                Ok(()) => {
629                    channel_last_sent.insert(channel_id.clone(), Instant::now());
630                    channel_backoff_secs.remove(&channel_id);
631                }
632                Err(e2) if e2.retry_after.is_some() => {
633                    // Still rate-limited after the single Retry-After wait —
634                    // apply exponential channel backoff.
635                    tracing::warn!(
636                        "slack_bridge: retry after rate limit still rate-limited for #{channel_id}: {e2}"
637                    );
638                    let current = channel_backoff_secs
639                        .get(&channel_id)
640                        .copied()
641                        .unwrap_or(RECONNECT_DELAY_SECS);
642                    channel_backoff_until.insert(channel_id.clone(), Instant::now() + Duration::from_secs(current));
643                    channel_backoff_secs.insert(channel_id, next_backoff_secs(current));
644                }
645                Err(e2) => {
646                    // A different, non-rate-limit failure (e.g.
647                    // channel_not_found, invalid_auth) — don't conflate it
648                    // with throttling by scheduling a rate-limit backoff;
649                    // just log it, matching the plain-failure arm below.
650                    tracing::warn!(
651                        "slack_bridge: retry after rate limit failed for #{channel_id} for an unrelated reason: {e2}"
652                    );
653                }
654            }
655        }
656        Err(e) => {
657            tracing::warn!("slack_bridge: send to #{channel_id} failed: {e}");
658        }
659    }
660}
661
662fn unix_secs() -> u64 {
663    agentmux_common::time::now_secs_u64()
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use super::super::types::SlackEvent;
670
671    fn event(type_: &str, channel: Option<&str>, bot_id: Option<&str>) -> SlackEvent {
672        SlackEvent {
673            type_: type_.to_string(),
674            channel: channel.map(str::to_string),
675            user: Some("U1".to_string()),
676            text: Some("hi".to_string()),
677            ts: Some("123.4".to_string()),
678            bot_id: bot_id.map(str::to_string),
679        }
680    }
681
682    #[test]
683    fn should_route_accepts_message_in_configured_channel() {
684        let e = event("message", Some("C1"), None);
685        assert!(should_route(&e, "C1"));
686    }
687
688    #[test]
689    fn should_route_accepts_app_mention() {
690        let e = event("app_mention", Some("C1"), None);
691        assert!(should_route(&e, "C1"));
692    }
693
694    #[test]
695    fn should_route_rejects_other_channel() {
696        let e = event("message", Some("C2"), None);
697        assert!(!should_route(&e, "C1"));
698    }
699
700    #[test]
701    fn should_route_rejects_missing_channel() {
702        let e = event("message", None, None);
703        assert!(!should_route(&e, "C1"));
704    }
705
706    #[test]
707    fn should_route_rejects_bot_authored_events() {
708        let e = event("message", Some("C1"), Some("B1"));
709        assert!(!should_route(&e, "C1"));
710    }
711
712    #[test]
713    fn should_route_rejects_unrelated_event_types() {
714        let e = event("channel_join", Some("C1"), None);
715        assert!(!should_route(&e, "C1"));
716    }
717
718    #[test]
719    fn resolve_channel_id_uses_explicit_channel_when_present() {
720        assert_eq!(resolve_channel_id("C1", "C_default"), Some("C1".to_string()));
721    }
722
723    #[test]
724    fn resolve_channel_id_falls_back_to_default_when_empty() {
725        assert_eq!(resolve_channel_id("", "C_default"), Some("C_default".to_string()));
726    }
727
728    #[test]
729    fn resolve_channel_id_none_when_both_empty() {
730        assert_eq!(resolve_channel_id("", ""), None);
731    }
732
733    #[test]
734    fn next_backoff_secs_doubles() {
735        assert_eq!(next_backoff_secs(5), 10);
736        assert_eq!(next_backoff_secs(10), 20);
737    }
738
739    #[test]
740    fn next_backoff_secs_caps_at_max() {
741        assert_eq!(next_backoff_secs(45), MAX_RECONNECT_DELAY_SECS);
742        assert_eq!(next_backoff_secs(60), MAX_RECONNECT_DELAY_SECS);
743    }
744
745    // ── Reconnect-on-warning state transition (no real socket needed) ──────
746
747    #[test]
748    fn duplicate_warning_while_cutover_pending_is_a_noop_decision() {
749        // Mirrors the guard in run_session's WarningReceived arm: a second
750        // warning while `cutover_pending` is true must not start a second
751        // concurrent connect. This is the pure boolean condition that
752        // guards that branch.
753        let cutover_pending = true;
754        let should_start_new_cutover = !cutover_pending;
755        assert!(!should_start_new_cutover);
756    }
757
758    #[test]
759    fn fresh_warning_when_not_pending_starts_cutover() {
760        let cutover_pending = false;
761        let should_start_new_cutover = !cutover_pending;
762        assert!(should_start_new_cutover);
763    }
764}