Skip to main content

agentmux_srv/messaging/slack/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Slack messaging bridge — Socket Mode WebSocket receive + Web API send.
5//!
6//! Lifecycle:
7//!   1. `SlackBridge::init_global(config, http)` — call once at startup.
8//!   2. The Socket Mode loop (inbound) and the outbound send loop run in the
9//!      background as two independent tokio tasks joined by
10//!      `socket::run_bridge` — see that module's doc comment for why they're
11//!      split rather than interleaved (mirrors `telegram/poller.rs`).
12//!   3. `SlackBridge::get()` returns the singleton for HTTP handler use.
13//!   4. `bridge.send(msg)` enqueues a message for the REST client.
14//!   5. `bridge.health()` returns the current connection status.
15//!
16//! Unlike Discord's fixed Gateway URL, Slack's Socket Mode WS URL is
17//! ephemeral and single-use — a fresh URL is fetched via
18//! `apps.connections.open` before every connection attempt (initial connect
19//! and every reconnect), inside the spawned background task. `init_global`
20//! itself does no network I/O and returns immediately, matching Discord's
21//! and Telegram's synchronous, non-blocking init.
22
23mod socket;
24pub mod rest;
25pub mod types;
26
27use std::sync::{Arc, Mutex, OnceLock};
28
29use tokio::sync::mpsc;
30
31use crate::messaging::{BridgeHealth, OutboundMsg};
32
33// ── Config ─────────────────────────────────────────────────────────────────
34
35#[derive(Debug, Clone)]
36pub struct SlackConfig {
37    /// Bot token (`xoxb-...`) — used for Web API calls (chat.postMessage).
38    pub bot_token: String,
39    /// App-level token (`xapp-...`) — used only for apps.connections.open.
40    pub app_token: String,
41    /// Default channel ID — inbound filter + default outbound target.
42    pub channel_id: String,
43    /// Agent ID to inject inbound Slack messages into via the reactive bus.
44    /// If None, inbound messages are logged but not forwarded.
45    pub target_agent: Option<String>,
46}
47
48// ── Bridge ─────────────────────────────────────────────────────────────────
49
50pub struct SlackBridge {
51    outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
52    health: Arc<Mutex<BridgeHealth>>,
53}
54
55static GLOBAL_BRIDGE: OnceLock<SlackBridge> = OnceLock::new();
56
57impl SlackBridge {
58    /// Initialize the global Slack bridge and start the Socket Mode +
59    /// outbound-send background tasks. No-op if already initialized.
60    pub fn init_global(config: SlackConfig, http: reqwest::Client) {
61        // Startup sanity check (spec §9b) — the two tokens have very
62        // different blast radii (app-level token can only open Socket Mode
63        // connections; bot token can actually read/post), so a swap
64        // produces confusing auth errors. Warn, don't hard-fail.
65        if !config.app_token.starts_with("xapp-") {
66            tracing::warn!(
67                "slack_bridge: messaging:slack:app_token does not start with 'xapp-' — check for a swapped bot/app token"
68            );
69        }
70        if !config.bot_token.starts_with("xoxb-") {
71            tracing::warn!(
72                "slack_bridge: messaging:slack:bot_token does not start with 'xoxb-' — check for a swapped bot/app token"
73            );
74        }
75
76        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
77        let health = Arc::new(Mutex::new(BridgeHealth::connecting("slack")));
78
79        let bridge = SlackBridge {
80            outbound_tx,
81            health: health.clone(),
82        };
83
84        if GLOBAL_BRIDGE.set(bridge).is_err() {
85            return; // already initialized
86        }
87
88        let app_token = config.app_token.clone();
89        let bot_token = config.bot_token.clone();
90        let channel_id = config.channel_id.clone();
91        let target_agent = config.target_agent.clone();
92
93        tokio::spawn(async move {
94            socket::run_bridge(
95                app_token,
96                bot_token,
97                channel_id,
98                target_agent,
99                http,
100                outbound_rx,
101                health,
102            )
103            .await;
104        });
105
106        tracing::info!(
107            "slack_bridge: initialized (channel={}, target={:?})",
108            config.channel_id,
109            config.target_agent
110        );
111    }
112
113    pub fn get() -> Option<&'static SlackBridge> {
114        GLOBAL_BRIDGE.get()
115    }
116
117    /// Enqueue a message for delivery via Slack's chat.postMessage.
118    /// Returns error if the bridge task has exited (should not happen in normal operation).
119    pub fn send(&self, msg: OutboundMsg) -> Result<(), String> {
120        self.outbound_tx
121            .send(msg)
122            .map_err(|_| "slack_bridge: outbound channel closed".to_string())
123    }
124
125    pub fn health(&self) -> BridgeHealth {
126        self.health.lock().unwrap().clone()
127    }
128}
129
130impl crate::messaging::MessagingBridge for SlackBridge {
131    fn health(&self) -> BridgeHealth {
132        SlackBridge::health(self)
133    }
134}