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}