Skip to main content

agentmux_srv/messaging/discord/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Discord messaging bridge — Gateway WebSocket + REST send.
5//!
6//! Lifecycle:
7//!   1. `DiscordBridge::init_global(config, http)` — call once at startup.
8//!   2. The gateway loop runs in a background tokio task (auto-reconnects).
9//!   3. `DiscordBridge::get()` returns the singleton for HTTP handler use.
10//!   4. `bridge.send(msg)` enqueues a message for the REST client.
11//!   5. `bridge.health()` returns the current connection status.
12
13mod gateway;
14pub mod rest;
15pub mod types;
16
17use std::sync::{Arc, Mutex, OnceLock};
18
19use tokio::sync::mpsc;
20
21use crate::messaging::{BridgeHealth, OutboundMsg};
22
23// ── Config ─────────────────────────────────────────────────────────────────
24
25#[derive(Debug, Clone)]
26pub struct DiscordConfig {
27    /// Discord bot token (obtain from discord.com/developers/applications).
28    pub token: String,
29    /// Default channel ID — inbound messages are filtered to this channel;
30    /// outbound messages target it when `OutboundMsg.channel_id` is empty.
31    pub channel_id: String,
32    /// Agent ID to inject inbound Discord messages into via the reactive bus.
33    /// If None, inbound messages are logged but not forwarded.
34    pub target_agent: Option<String>,
35    /// Guild ID for guild-scoped slash command registration (Phase 2).
36    #[allow(dead_code)]
37    pub guild_id: Option<String>,
38}
39
40// ── Bridge ─────────────────────────────────────────────────────────────────
41
42pub struct DiscordBridge {
43    outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
44    health: Arc<Mutex<BridgeHealth>>,
45}
46
47static GLOBAL_BRIDGE: OnceLock<DiscordBridge> = OnceLock::new();
48
49impl DiscordBridge {
50    /// Initialize the global Discord bridge and start the Gateway background task.
51    /// No-op if already initialized.
52    pub fn init_global(config: DiscordConfig, http: reqwest::Client) {
53        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
54        let health = Arc::new(Mutex::new(BridgeHealth::connecting("discord")));
55
56        let bridge = DiscordBridge {
57            outbound_tx,
58            health: health.clone(),
59        };
60
61        if GLOBAL_BRIDGE.set(bridge).is_err() {
62            return; // already initialized
63        }
64
65        let token = config.token.clone();
66        let channel_id = config.channel_id.clone();
67        let target_agent = config.target_agent.clone();
68
69        tokio::spawn(async move {
70            gateway::run_gateway_loop(token, channel_id, target_agent, http, outbound_rx, health)
71                .await;
72        });
73
74        tracing::info!(
75            "discord_bridge: initialized (channel={}, target={:?})",
76            config.channel_id,
77            config.target_agent
78        );
79    }
80
81    pub fn get() -> Option<&'static DiscordBridge> {
82        GLOBAL_BRIDGE.get()
83    }
84
85    /// Enqueue a message for delivery via Discord REST.
86    /// Returns error if the bridge task has exited (should not happen in normal operation).
87    pub fn send(&self, msg: OutboundMsg) -> Result<(), String> {
88        self.outbound_tx
89            .send(msg)
90            .map_err(|_| "discord_bridge: outbound channel closed".to_string())
91    }
92
93    pub fn health(&self) -> BridgeHealth {
94        self.health.lock().unwrap().clone()
95    }
96}
97
98impl crate::messaging::MessagingBridge for DiscordBridge {
99    fn health(&self) -> BridgeHealth {
100        DiscordBridge::health(self)
101    }
102}