Skip to main content

agentmux_srv/messaging/discord/
gateway.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Discord Gateway WebSocket client.
5//!
6//! Protocol flow: connect → HELLO → IDENTIFY (or RESUME) → READY → event loop.
7//!
8//! On disconnect:
9//!   - If we have a stored session_id + last_seq, attempt RESUME using the
10//!     `resume_gateway_url` from the prior READY payload.
11//!   - On INVALID_SESSION(resumable=false) or RECONNECT, drop session and re-IDENTIFY.
12//!
13//! Heartbeat:
14//!   - HELLO provides `heartbeat_interval` in milliseconds.
15//!   - We send HEARTBEAT every interval, tracking ACK. If the prior heartbeat
16//!     was not ACK'd, the connection is a zombie — close and reconnect.
17
18use std::sync::{Arc, Mutex};
19use std::time::Duration;
20
21use futures_util::{SinkExt, StreamExt};
22use serde_json::Value;
23use tokio::sync::mpsc;
24use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message};
25
26use crate::backend::reactive::handler::get_global_handler;
27use crate::backend::reactive::types::InjectionRequest;
28use crate::messaging::{BridgeHealth, BridgeStatus, OutboundMsg};
29
30use super::rest;
31use super::types::{
32    opcode, GatewayPayload, HeartbeatPayload, IdentifyData, IdentifyPayload, IdentifyProperties,
33    MessageCreate, ReadyEvent, ResumeData, ResumePayload, INTENTS,
34};
35
36const GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json";
37const RECONNECT_DELAY_SECS: u64 = 5;
38const MAX_RECONNECT_DELAY_SECS: u64 = 60;
39
40#[derive(Clone)]
41struct Session {
42    session_id: String,
43    resume_url: String,
44    last_seq: u64,
45}
46
47pub async fn run_gateway_loop(
48    token: String,
49    channel_id: String,
50    target_agent: Option<String>,
51    http: reqwest::Client,
52    mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
53    health: Arc<Mutex<BridgeHealth>>,
54) {
55    let mut delay_secs = RECONNECT_DELAY_SECS;
56    let mut session: Option<Session> = None;
57
58    loop {
59        {
60            let mut h = health.lock().unwrap();
61            h.status = BridgeStatus::Connecting;
62        }
63
64        let connect_url = session
65            .as_ref()
66            .map(|s| {
67                // Append gateway params if the resume URL doesn't include them.
68                if s.resume_url.contains("?v=") {
69                    s.resume_url.clone()
70                } else {
71                    format!("{}?v=10&encoding=json", s.resume_url)
72                }
73            })
74            .unwrap_or_else(|| GATEWAY_URL.to_string());
75
76        tracing::info!("discord_bridge: connecting to {}", connect_url);
77        let session_start = std::time::Instant::now();
78
79        match run_session(
80            &token,
81            &channel_id,
82            &target_agent,
83            &connect_url,
84            &http,
85            &mut outbound_rx,
86            &health,
87            session.take(),
88        )
89        .await
90        {
91            Ok(new_session) => {
92                session = new_session;
93                if session_start.elapsed().as_secs() > 30 {
94                    delay_secs = RECONNECT_DELAY_SECS;
95                }
96                tracing::info!("discord_bridge: session ended cleanly");
97            }
98            Err(e) => {
99                session = None;
100                if session_start.elapsed().as_secs() > 30 {
101                    delay_secs = RECONNECT_DELAY_SECS;
102                }
103                tracing::warn!("discord_bridge: session error: {e}");
104                {
105                    let mut h = health.lock().unwrap();
106                    h.status = BridgeStatus::Error;
107                    h.error = Some(e);
108                    h.reconnect_count += 1;
109                }
110            }
111        }
112
113        {
114            let mut h = health.lock().unwrap();
115            if h.status != BridgeStatus::Error {
116                h.status = BridgeStatus::Disconnected;
117            }
118        }
119
120        tokio::time::sleep(Duration::from_secs(delay_secs)).await;
121        delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
122    }
123}
124
125/// Run a single Gateway session. Returns:
126///   Ok(Some(session)) — clean disconnect, can attempt RESUME
127///   Ok(None)          — clean disconnect, session is invalid (re-IDENTIFY)
128///   Err(e)            — unexpected error, will re-IDENTIFY after backoff
129async fn run_session(
130    token: &str,
131    channel_id: &str,
132    target_agent: &Option<String>,
133    connect_url: &str,
134    http: &reqwest::Client,
135    outbound_rx: &mut mpsc::UnboundedReceiver<OutboundMsg>,
136    health: &Arc<Mutex<BridgeHealth>>,
137    resume_session: Option<Session>,
138) -> Result<Option<Session>, String> {
139    use tokio_tungstenite::tungstenite::http::Request;
140
141    let request = Request::builder()
142        .uri(connect_url)
143        .body(())
144        .map_err(|e| format!("build ws request: {e}"))?;
145
146    let (ws_stream, _) = connect_async_tls_with_config(request, None, false, None)
147        .await
148        .map_err(|e| format!("ws connect: {e}"))?;
149
150    let (mut write, mut read) = ws_stream.split();
151
152    // Heartbeat: starts as pending, replaced with a timed sleep on HELLO.
153    // Using Box<dyn Future> avoids pinning complexity inside the select! arms.
154    let mut hb_sleep: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> =
155        Box::pin(std::future::pending());
156    let mut hb_interval_ms: u64 = 0;
157    let mut last_hb_acked = true;
158
159    let mut session: Option<Session> = None;
160    let mut identified = false;
161
162    loop {
163        tokio::select! {
164            // Heartbeat tick
165            _ = &mut hb_sleep, if hb_interval_ms > 0 => {
166                if !last_hb_acked {
167                    tracing::warn!("discord_bridge: heartbeat zombie — reconnecting");
168                    let _ = write.send(Message::Close(None)).await;
169                    return Ok(session);
170                }
171                let seq = session.as_ref().map(|s| s.last_seq);
172                let payload = serde_json::to_string(&HeartbeatPayload {
173                    op: opcode::HEARTBEAT,
174                    d: seq,
175                })
176                .unwrap_or_default();
177                if let Err(e) = write.send(Message::Text(payload.into())).await {
178                    return Err(format!("heartbeat send: {e}"));
179                }
180                last_hb_acked = false;
181                hb_sleep = Box::pin(tokio::time::sleep(Duration::from_millis(hb_interval_ms)));
182            }
183
184            // Outbound message from agent → Discord REST
185            msg = outbound_rx.recv() => {
186                match msg {
187                    None => return Ok(session),
188                    Some(outbound) => {
189                        let ch = if outbound.channel_id.is_empty() {
190                            channel_id
191                        } else {
192                            outbound.channel_id.as_str()
193                        };
194                        if let Err(e) = rest::send_message(http, token, ch, &outbound).await {
195                            tracing::warn!("discord_bridge: send failed: {e}");
196                        }
197                    }
198                }
199            }
200
201            // Incoming Gateway frame
202            frame = read.next() => {
203                match frame {
204                    None => return Ok(session),
205                    Some(Err(e)) => return Err(format!("ws recv: {e}")),
206                    Some(Ok(Message::Text(text))) => {
207                        let payload: GatewayPayload = match serde_json::from_str(&text) {
208                            Ok(p) => p,
209                            Err(e) => {
210                                tracing::warn!("discord_bridge: parse error: {e}");
211                                continue;
212                            }
213                        };
214
215                        // Track sequence number on every dispatch
216                        if let (Some(seq), Some(sess)) = (payload.seq, session.as_mut()) {
217                            if seq > sess.last_seq {
218                                sess.last_seq = seq;
219                            }
220                        }
221
222                        match payload.opcode {
223                            opcode::HELLO => {
224                                hb_interval_ms = payload.data
225                                    .as_ref()
226                                    .and_then(|d| d.get("heartbeat_interval"))
227                                    .and_then(Value::as_u64)
228                                    .unwrap_or(41_250);
229
230                                // First heartbeat fires after one full interval
231                                hb_sleep = Box::pin(tokio::time::sleep(
232                                    Duration::from_millis(hb_interval_ms),
233                                ));
234                                last_hb_acked = true;
235
236                                if let Some(prev) = resume_session.as_ref().filter(|_| !identified) {
237                                    let payload = serde_json::to_string(&ResumePayload {
238                                        op: opcode::RESUME,
239                                        d: ResumeData {
240                                            token: token.to_string(),
241                                            session_id: prev.session_id.clone(),
242                                            seq: prev.last_seq,
243                                        },
244                                    })
245                                    .unwrap_or_default();
246                                    write.send(Message::Text(payload.into()))
247                                        .await
248                                        .map_err(|e| format!("resume send: {e}"))?;
249                                    session = Some(prev.clone());
250                                } else {
251                                    let payload = serde_json::to_string(&IdentifyPayload {
252                                        op: opcode::IDENTIFY,
253                                        d: IdentifyData {
254                                            token: token.to_string(),
255                                            intents: INTENTS,
256                                            properties: IdentifyProperties {
257                                                os: std::env::consts::OS.to_string(),
258                                                browser: "agentmux".to_string(),
259                                                device: "agentmux".to_string(),
260                                            },
261                                        },
262                                    })
263                                    .unwrap_or_default();
264                                    write.send(Message::Text(payload.into()))
265                                        .await
266                                        .map_err(|e| format!("identify send: {e}"))?;
267                                }
268                                identified = true;
269                            }
270
271                            opcode::HEARTBEAT_ACK => {
272                                last_hb_acked = true;
273                                let now = unix_secs();
274                                let mut h = health.lock().unwrap();
275                                h.status = BridgeStatus::Connected;
276                                h.error = None;
277                                h.last_event_at = Some(now);
278                            }
279
280                            opcode::HEARTBEAT => {
281                                // Server-initiated heartbeat request
282                                let seq = session.as_ref().map(|s| s.last_seq);
283                                let payload = serde_json::to_string(&HeartbeatPayload {
284                                    op: opcode::HEARTBEAT,
285                                    d: seq,
286                                })
287                                .unwrap_or_default();
288                                let _ = write.send(Message::Text(payload.into())).await;
289                            }
290
291                            opcode::RECONNECT => {
292                                tracing::info!("discord_bridge: server requested reconnect (RESUME eligible)");
293                                let _ = write.send(Message::Close(None)).await;
294                                return Ok(session);
295                            }
296
297                            opcode::INVALID_SESSION => {
298                                let resumable = payload.data
299                                    .as_ref()
300                                    .and_then(Value::as_bool)
301                                    .unwrap_or(false);
302                                tracing::warn!(
303                                    "discord_bridge: invalid session (resumable={resumable})"
304                                );
305                                let _ = write.send(Message::Close(None)).await;
306                                return Ok(if resumable { session } else { None });
307                            }
308
309                            opcode::DISPATCH => {
310                                handle_dispatch(
311                                    payload.event_type.as_deref(),
312                                    payload.data,
313                                    channel_id,
314                                    target_agent,
315                                    &mut session,
316                                    health,
317                                );
318                            }
319
320                            op => {
321                                tracing::debug!("discord_bridge: unhandled opcode {op}");
322                            }
323                        }
324                    }
325
326                    Some(Ok(Message::Ping(data))) => {
327                        let _ = write.send(Message::Pong(data)).await;
328                    }
329                    Some(Ok(Message::Close(_))) => return Ok(session),
330                    Some(Ok(_)) => {}
331                }
332            }
333        }
334    }
335}
336
337fn handle_dispatch(
338    event_type: Option<&str>,
339    data: Option<Value>,
340    channel_id: &str,
341    target_agent: &Option<String>,
342    session: &mut Option<Session>,
343    health: &Arc<Mutex<BridgeHealth>>,
344) {
345    match event_type {
346        Some("READY") => {
347            if let Some(data) = data {
348                if let Ok(ready) = serde_json::from_value::<ReadyEvent>(data) {
349                    let username = ready
350                        .user
351                        .as_ref()
352                        .map(|u| u.username.as_str())
353                        .unwrap_or("unknown");
354                    tracing::info!(
355                        "discord_bridge: READY as {}, session {}",
356                        username,
357                        ready.session_id
358                    );
359                    *session = Some(Session {
360                        session_id: ready.session_id.clone(),
361                        resume_url: ready.resume_gateway_url.clone(),
362                        last_seq: 0,
363                    });
364                    let mut h = health.lock().unwrap();
365                    h.status = BridgeStatus::Connected;
366                    h.error = None;
367                }
368            }
369        }
370
371        Some("RESUMED") => {
372            tracing::info!("discord_bridge: session RESUMED");
373            let mut h = health.lock().unwrap();
374            h.status = BridgeStatus::Connected;
375            h.error = None;
376        }
377
378        Some("MESSAGE_CREATE") => {
379            let data = match data {
380                Some(d) => d,
381                None => return,
382            };
383            let msg: MessageCreate = match serde_json::from_value(data) {
384                Ok(m) => m,
385                Err(e) => {
386                    tracing::warn!("discord_bridge: parse MESSAGE_CREATE: {e}");
387                    return;
388                }
389            };
390
391            // Ignore bot messages (including our own bot)
392            if msg.author.bot.unwrap_or(false) {
393                return;
394            }
395
396            // Only process messages from the configured channel
397            if msg.channel_id != channel_id {
398                return;
399            }
400
401            {
402                let mut h = health.lock().unwrap();
403                h.last_event_at = Some(unix_secs());
404            }
405
406            let Some(target) = target_agent else {
407                tracing::debug!(
408                    "discord_bridge: message from {} (no target agent configured)",
409                    msg.author.username
410                );
411                return;
412            };
413
414            let envelope = format!("[Discord @{}]: {}", msg.author.username, msg.content);
415            let handler = get_global_handler();
416            let req = InjectionRequest {
417                target_agent: target.clone(),
418                message: envelope,
419                source_agent: Some("discord".to_string()),
420                request_id: Some(msg.id.clone()),
421                priority: None,
422                jekt_tier: None,
423                delivery_tier: Some("wan".to_string()),
424                forward_hops: 0,
425                ..Default::default()
426            };
427            let result = handler.inject_message(req);
428            if result.success {
429                tracing::debug!(
430                    "discord_bridge: injected msg from {} to agent {}",
431                    msg.author.username,
432                    target
433                );
434            } else {
435                tracing::warn!(
436                    "discord_bridge: inject to agent {} failed: {:?}",
437                    target,
438                    result.error
439                );
440            }
441        }
442
443        Some(t) => {
444            tracing::debug!("discord_bridge: unhandled event {t}");
445        }
446        None => {}
447    }
448}
449
450fn unix_secs() -> u64 {
451    agentmux_common::time::now_secs_u64()
452}