agentmux_srv/messaging/discord/
gateway.rs1use 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 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
125async 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 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 _ = &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 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 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 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 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 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 if msg.author.bot.unwrap_or(false) {
393 return;
394 }
395
396 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}