Skip to main content

orch/channels/
discord_ws.rs

1//! Discord Gateway websocket client.
2//!
3//! Delivers real-time events from Discord's Gateway API (wss://gateway.discord.gg)
4//! replacing the HTTP polling approach in `discord.rs`.
5//!
6//! Protocol flow:
7//!   connect → Hello → Identify → Ready → receive MESSAGE_CREATE events
8//!   heartbeat every `heartbeat_interval` ms to keep the connection alive
9//!   on disconnect: resume with session_id+seq, or re-identify from scratch
10
11use super::{Channel, IncomingMessage, OutgoingMessage};
12use anyhow::Context;
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use futures::{SinkExt, StreamExt};
16use reqwest::Client;
17use serde::Deserialize;
18use serde_json::Value;
19use std::time::Duration;
20use tokio::sync::mpsc;
21use tokio_tungstenite::{connect_async, tungstenite::Message};
22
23// ── Gateway opcodes ──────────────────────────────────────────────────────────
24
25const OP_DISPATCH: u64 = 0;
26const OP_HEARTBEAT: u64 = 1;
27const OP_IDENTIFY: u64 = 2;
28const OP_RESUME: u64 = 6;
29const OP_RECONNECT: u64 = 7;
30const OP_INVALID_SESSION: u64 = 9;
31const OP_HELLO: u64 = 10;
32const OP_HEARTBEAT_ACK: u64 = 11;
33
34/// Gateway intents bitmask.
35/// GUILDS(1) | GUILD_MESSAGES(512) | MESSAGE_CONTENT(32768)
36const GATEWAY_INTENTS: u64 = 1 | (1 << 9) | (1 << 15);
37
38const DEFAULT_GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json";
39
40/// Timeout for websocket send operations.
41/// Must be less than heartbeat interval to fail fast before zombie detection.
42const SEND_TIMEOUT_SECS: u64 = 10;
43
44// ── Public struct ────────────────────────────────────────────────────────────
45
46/// Discord Gateway websocket client.
47///
48/// Receives real-time events via wss:// and sends messages via REST.
49/// Supports sharding via `shard_id`/`shard_count` (both read from config).
50pub struct DiscordGateway {
51    token: String,
52    client: Client,
53    channel_id: Option<String>,
54    shard_id: u64,
55    shard_count: u64,
56}
57
58impl std::fmt::Debug for DiscordGateway {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("DiscordGateway")
61            .field("channel_id", &self.channel_id)
62            .field("shard_id", &self.shard_id)
63            .field("shard_count", &self.shard_count)
64            .finish_non_exhaustive()
65    }
66}
67
68impl DiscordGateway {
69    /// Create a new Gateway client.
70    ///
71    /// `shard_id` and `shard_count` follow Discord sharding conventions.
72    /// For a single-shard bot, use `shard_id = 0, shard_count = 1`.
73    pub fn new(
74        token: String,
75        channel_id: Option<String>,
76        shard_id: u64,
77        shard_count: u64,
78    ) -> anyhow::Result<Self> {
79        let client = Client::builder()
80            .timeout(Duration::from_secs(30))
81            .build()
82            .context("failed to build HTTP client")?;
83
84        Ok(Self {
85            token,
86            client,
87            channel_id,
88            shard_id,
89            shard_count,
90        })
91    }
92
93    fn api_url(endpoint: &str) -> String {
94        format!("https://discord.com/api/v10{endpoint}")
95    }
96
97    /// Send a message with action row buttons to a specific channel.
98    ///
99    /// Returns the message ID of the sent message.
100    pub async fn send_with_buttons(
101        &self,
102        channel_id: &str,
103        text: &str,
104        buttons: &[(String, String)], // (label, custom_id)
105    ) -> anyhow::Result<String> {
106        let components = vec![serde_json::json!({
107            "type": 1, // ActionRow
108            "components": buttons.iter().map(|(label, custom_id)| {
109                serde_json::json!({
110                    "type": 2, // Button
111                    "style": 1, // Primary
112                    "label": label,
113                    "custom_id": custom_id
114                })
115            }).collect::<Vec<_>>()
116        })];
117
118        let body = serde_json::json!({
119            "content": text,
120            "components": components
121        });
122
123        let url = Self::api_url(&format!("/channels/{channel_id}/messages"));
124        let response = self
125            .client
126            .post(&url)
127            .header("Authorization", format!("Bot {}", self.token))
128            .json(&body)
129            .send()
130            .await?;
131
132        if !response.status().is_success() {
133            let body = response.text().await.unwrap_or_default();
134            anyhow::bail!("discord API error: {body}");
135        }
136
137        let result: serde_json::Value = response.json().await?;
138        Ok(result["id"].as_str().unwrap_or("").to_string())
139    }
140
141    async fn send_message(&self, channel_id: &str, content: &str) -> anyhow::Result<()> {
142        let url = Self::api_url(&format!("/channels/{channel_id}/messages"));
143        let resp = self
144            .client
145            .post(&url)
146            .header("Authorization", format!("Bot {}", self.token))
147            .json(&serde_json::json!({ "content": content }))
148            .send()
149            .await?;
150
151        if !resp.status().is_success() {
152            anyhow::bail!(
153                "discord send failed: {}",
154                resp.text().await.unwrap_or_default()
155            );
156        }
157        Ok(())
158    }
159}
160
161// ── Channel impl ─────────────────────────────────────────────────────────────
162
163#[async_trait]
164impl Channel for DiscordGateway {
165    fn name(&self) -> &'static str {
166        "discord"
167    }
168
169    async fn start(&self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>> {
170        let (tx, rx) = mpsc::channel(64);
171        let token = self.token.clone();
172        let shard_id = self.shard_id;
173        let shard_count = self.shard_count;
174        let client = self.client.clone();
175
176        tracing::info!(shard_id, shard_count, "discord gateway starting");
177
178        tokio::spawn(async move {
179            run_gateway(token, shard_id, shard_count, tx, client).await;
180        });
181
182        Ok(rx)
183    }
184
185    async fn send(&self, msg: &OutgoingMessage) -> anyhow::Result<()> {
186        let target_channel = msg
187            .topic_id
188            .as_deref()
189            .or(self.channel_id.as_deref())
190            .ok_or_else(|| anyhow::anyhow!("no target channel for discord message"))?;
191
192        // If metadata carries buttons, send as an interactive message with an ActionRow.
193        if let Some(buttons_val) = msg.metadata.get("buttons")
194            && let Some(buttons_arr) = buttons_val.as_array()
195        {
196            let buttons: Vec<(String, String)> = buttons_arr
197                .iter()
198                .filter_map(|b| {
199                    let text = b["text"].as_str()?.to_string();
200                    let custom_id = b["callback_data"].as_str()?.to_string();
201                    Some((text, custom_id))
202                })
203                .collect();
204            if !buttons.is_empty() {
205                self.send_with_buttons(target_channel, &msg.body, &buttons)
206                    .await?;
207                return Ok(());
208            }
209        }
210
211        self.send_message(target_channel, &msg.body).await
212    }
213
214    async fn health_check(&self) -> anyhow::Result<()> {
215        let resp = self
216            .client
217            .get(Self::api_url("/users/@me"))
218            .header("Authorization", format!("Bot {}", self.token))
219            .send()
220            .await?;
221
222        if !resp.status().is_success() {
223            anyhow::bail!(
224                "discord health check failed: {}",
225                resp.text().await.unwrap_or_default()
226            );
227        }
228        tracing::info!("discord gateway health check passed");
229        Ok(())
230    }
231}
232
233// ── Gateway protocol ─────────────────────────────────────────────────────────
234
235/// Parsed Gateway payload (op + d + s + t).
236#[derive(Deserialize)]
237struct GatewayPayload {
238    op: u64,
239    #[serde(default)]
240    d: Value,
241    s: Option<u64>,
242    t: Option<String>,
243}
244
245/// Mutable session state shared across reconnect cycles.
246struct GatewayState {
247    session_id: Option<String>,
248    last_seq: Option<u64>,
249    resume_url: Option<String>,
250}
251
252impl GatewayState {
253    fn new() -> Self {
254        Self {
255            session_id: None,
256            last_seq: None,
257            resume_url: None,
258        }
259    }
260
261    fn clear(&mut self) {
262        self.session_id = None;
263        self.last_seq = None;
264        self.resume_url = None;
265    }
266
267    fn ws_url(&self) -> String {
268        self.resume_url.as_deref().map_or_else(
269            || DEFAULT_GATEWAY_URL.to_string(),
270            |u| format!("{}/?v=10&encoding=json", u.trim_end_matches('/')),
271        )
272    }
273}
274
275/// Main gateway loop — connects, handles protocol, reconnects on error.
276async fn run_gateway(
277    token: String,
278    shard_id: u64,
279    shard_count: u64,
280    tx: mpsc::Sender<IncomingMessage>,
281    client: Client,
282) {
283    let mut state = GatewayState::new();
284    let mut backoff = Duration::from_secs(1);
285
286    loop {
287        let ws_url = state.ws_url();
288        tracing::debug!(url = %ws_url, "connecting to discord gateway");
289
290        match tokio::time::timeout(Duration::from_secs(30), connect_async(&ws_url)).await {
291            Ok(Ok((ws, _))) => {
292                backoff = Duration::from_secs(1); // reset on success
293
294                let result =
295                    handle_connection(ws, &token, shard_id, shard_count, &mut state, &tx, &client)
296                        .await;
297
298                match result {
299                    Ok(true) => {
300                        tracing::info!("discord gateway: disconnected (will resume)");
301                    }
302                    Ok(false) => {
303                        tracing::info!(
304                            "discord gateway: disconnected (not resumable), re-identifying"
305                        );
306                        state.clear();
307                    }
308                    Err(e) => {
309                        tracing::warn!(?e, "discord gateway: connection error");
310                    }
311                }
312            }
313            Ok(Err(e)) => {
314                tracing::warn!(
315                    ?e,
316                    backoff_secs = backoff.as_secs(),
317                    "discord gateway: connect failed"
318                );
319            }
320            Err(_) => {
321                tracing::warn!(
322                    backoff_secs = backoff.as_secs(),
323                    "discord gateway: connect timed out"
324                );
325            }
326        }
327
328        if tx.is_closed() {
329            tracing::debug!("discord gateway: receiver closed, shutting down");
330            return;
331        }
332
333        tokio::time::sleep(backoff).await;
334        backoff = (backoff * 2).min(Duration::from_secs(60));
335    }
336}
337
338/// Handle a single websocket connection lifecycle.
339///
340/// Returns `Ok(true)` if the session can be resumed, `Ok(false)` if we
341/// need to re-identify (invalid session or clean disconnect).
342async fn handle_connection(
343    ws: tokio_tungstenite::WebSocketStream<
344        tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
345    >,
346    token: &str,
347    shard_id: u64,
348    shard_count: u64,
349    state: &mut GatewayState,
350    tx: &mpsc::Sender<IncomingMessage>,
351    client: &Client,
352) -> anyhow::Result<bool> {
353    let (mut write, mut read) = ws.split();
354
355    // ── Step 1: Hello ────────────────────────────────────────────────────────
356    let text = tokio::time::timeout(Duration::from_secs(15), next_text_message(&mut read))
357        .await
358        .map_err(|_| anyhow::anyhow!("discord gateway: Hello timed out"))??;
359    let hello: GatewayPayload = serde_json::from_str(&text)?;
360    anyhow::ensure!(
361        hello.op == OP_HELLO,
362        "expected Hello (op=10), got op={}",
363        hello.op
364    );
365
366    let hb_interval_ms = parse_heartbeat_interval(&hello.d["heartbeat_interval"]);
367    tracing::debug!(hb_interval_ms, "discord gateway: Hello received");
368
369    // ── Step 2: Identify or Resume ───────────────────────────────────────────
370    let payload = if let (Some(sid), Some(seq)) = (state.session_id.as_deref(), state.last_seq) {
371        tracing::info!(session_id = %sid, seq, "discord gateway: sending Resume");
372        serde_json::json!({
373            "op": OP_RESUME,
374            "d": {
375                "token": token,
376                "session_id": sid,
377                "seq": seq,
378            }
379        })
380    } else {
381        tracing::info!(shard_id, shard_count, "discord gateway: sending Identify");
382        serde_json::json!({
383            "op": OP_IDENTIFY,
384            "d": {
385                "token": token,
386                "intents": GATEWAY_INTENTS,
387                "properties": {
388                    "os": std::env::consts::OS,
389                    "browser": "orch",
390                    "device": "orch",
391                },
392                "shard": [shard_id, shard_count],
393            }
394        })
395    };
396
397    tokio::time::timeout(
398        Duration::from_secs(SEND_TIMEOUT_SECS),
399        write.send(Message::Text(payload.to_string())),
400    )
401    .await
402    .map_err(|_| anyhow::anyhow!("identify send timed out"))?
403    .map_err(|e| anyhow::anyhow!("identify send failed: {e}"))?;
404
405    // ── Step 3: Event loop ───────────────────────────────────────────────────
406    let mut hb_ticker = tokio::time::interval(Duration::from_millis(hb_interval_ms));
407    hb_ticker.tick().await; // consume the immediate first tick
408    let mut ack_received = true;
409
410    loop {
411        tokio::select! {
412            raw = read.next() => {
413                let msg = match raw {
414                    Some(Ok(m)) => m,
415                    Some(Err(e)) => return Err(e.into()),
416                    None => {
417                        tracing::debug!("discord gateway: stream closed");
418                        return Ok(true); // try to resume
419                    }
420                };
421
422                match msg {
423                    Message::Text(text) => {
424                        let payload: GatewayPayload = match serde_json::from_str(&text) {
425                            Ok(p) => p,
426                            Err(e) => {
427                                tracing::warn!(?e, "discord gateway: failed to parse payload");
428                                continue;
429                            }
430                        };
431
432                        if let Some(seq) = payload.s {
433                            state.last_seq = Some(seq);
434                        }
435
436                        match payload.op {
437                            OP_DISPATCH => {
438                                if let Err(e) = handle_dispatch(
439                                    payload.t.as_deref(),
440                                    &payload.d,
441                                    &mut state.session_id,
442                                    &mut state.resume_url,
443                                    tx,
444                                    client,
445                                )
446                                .await
447                                {
448                                    tracing::warn!(?e, "discord gateway: dispatch error");
449                                }
450                                if tx.is_closed() {
451                                    return Ok(false);
452                                }
453                            }
454                            OP_HEARTBEAT => {
455                                // Server explicitly requested a heartbeat
456                                let d = state
457                                    .last_seq
458                                    .map_or(Value::Null, |s| serde_json::json!(s));
459                                let hb_payload = Message::Text(
460                                    serde_json::json!({"op": OP_HEARTBEAT, "d": d}).to_string(),
461                                );
462                                tokio::time::timeout(Duration::from_secs(SEND_TIMEOUT_SECS), write.send(hb_payload))
463                                    .await
464                                    .map_err(|_| anyhow::anyhow!("server heartbeat send timed out"))?
465                                    .map_err(|e| anyhow::anyhow!("server heartbeat send failed: {e}"))?;
466                            }
467                            OP_HEARTBEAT_ACK => {
468                                ack_received = true;
469                            }
470                            OP_RECONNECT => {
471                                tracing::info!("discord gateway: server requested reconnect");
472                                return Ok(true);
473                            }
474                            OP_INVALID_SESSION => {
475                                let resumable = payload.d.as_bool().unwrap_or(false);
476                                tracing::info!(resumable, "discord gateway: invalid session");
477                                if !resumable {
478                                    state.clear();
479                                    // Brief delay before re-identifying to avoid rate limiting
480                                    tokio::time::sleep(Duration::from_secs(5)).await;
481                                }
482                                return Ok(resumable);
483                            }
484                            op => {
485                                tracing::debug!(op, "discord gateway: unhandled opcode");
486                            }
487                        }
488                    }
489                    Message::Close(frame) => {
490                        let code = frame.as_ref().map_or(0, |f| u16::from(f.code));
491                        // Non-resumable Discord Gateway close codes per Discord docs
492                        let resumable = !matches!(code, 4004 | 4010 | 4011 | 4013 | 4014);
493                        if resumable {
494                            tracing::info!(code, ?frame, "discord gateway: close frame, will attempt resume");
495                        } else {
496                            state.clear();
497                            tracing::error!(
498                                code,
499                                "discord gateway: non-resumable close code — check bot token and intent configuration"
500                            );
501                        }
502                        return Ok(resumable);
503                    }
504                    Message::Ping(data) => {
505                        tokio::time::timeout(Duration::from_secs(SEND_TIMEOUT_SECS), write.send(Message::Pong(data)))
506                            .await
507                            .map_err(|_| anyhow::anyhow!("pong send timed out"))?
508                            .map_err(|e| anyhow::anyhow!("pong send failed: {e}"))?;
509                    }
510                    _ => {}
511                }
512            }
513
514            _ = hb_ticker.tick() => {
515                if !ack_received {
516                    tracing::warn!(
517                        "discord gateway: heartbeat not acknowledged (zombie connection), reconnecting"
518                    );
519                    return Ok(true);
520                }
521                ack_received = false;
522                let d = state
523                    .last_seq
524                    .map_or(Value::Null, |s| serde_json::json!(s));
525                let hb_payload = Message::Text(
526                    serde_json::json!({"op": OP_HEARTBEAT, "d": d}).to_string(),
527                );
528                tokio::time::timeout(Duration::from_secs(SEND_TIMEOUT_SECS), write.send(hb_payload))
529                    .await
530                    .map_err(|_| anyhow::anyhow!("periodic heartbeat send timed out"))?
531                    .map_err(|e| anyhow::anyhow!("periodic heartbeat send failed: {e}"))?;
532            }
533        }
534    }
535}
536
537/// Read the next text frame from the stream, skipping non-text control frames.
538async fn next_text_message<S>(read: &mut S) -> anyhow::Result<String>
539where
540    S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
541{
542    loop {
543        match read.next().await {
544            Some(Ok(Message::Text(t))) => return Ok(t),
545            Some(Ok(_)) => {}
546            Some(Err(e)) => return Err(e.into()),
547            None => anyhow::bail!("stream closed before receiving expected message"),
548        }
549    }
550}
551
552/// Handle a DISPATCH event (op=0).
553async fn handle_dispatch(
554    event_type: Option<&str>,
555    data: &Value,
556    session_id: &mut Option<String>,
557    resume_url: &mut Option<String>,
558    tx: &mpsc::Sender<IncomingMessage>,
559    client: &Client,
560) -> anyhow::Result<()> {
561    match event_type {
562        Some("READY") => {
563            if let Some(sid) = data["session_id"].as_str() {
564                *session_id = Some(sid.to_string());
565            }
566            if let Some(url) = data["resume_gateway_url"].as_str() {
567                *resume_url = Some(url.to_string());
568            }
569            let username = data["user"]["username"].as_str().unwrap_or("unknown");
570            tracing::info!(username, "discord gateway: ready");
571        }
572        Some("RESUMED") => {
573            tracing::info!("discord gateway: session resumed");
574        }
575        Some("MESSAGE_CREATE") => {
576            // Validate channel_id is present and non-empty — it's required for routing/replies.
577            let Some(msg_channel_id) = data["channel_id"].as_str().filter(|s| !s.is_empty()) else {
578                tracing::warn!("discord MESSAGE_CREATE missing channel_id, skipping");
579                return Ok(());
580            };
581
582            // Skip messages from bots (avoid reacting to ourselves)
583            if data["author"]["bot"].as_bool().unwrap_or(false) {
584                return Ok(());
585            }
586
587            // Validate message id is present and non-empty. If malformed, skip the event.
588            let Some(id_str) = data["id"].as_str().filter(|s| !s.is_empty()) else {
589                tracing::warn!("discord MESSAGE_CREATE missing message id, skipping");
590                return Ok(());
591            };
592
593            let id = id_str.to_string();
594            let author = data["author"]["username"]
595                .as_str()
596                .unwrap_or("unknown")
597                .to_string();
598            let body = data["content"].as_str().unwrap_or("").to_string();
599            let timestamp = data["timestamp"]
600                .as_str()
601                .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
602                .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc));
603
604            let incoming = IncomingMessage {
605                channel: "discord".to_string(),
606                id,
607                thread_id: msg_channel_id.to_string(),
608                author,
609                body,
610                timestamp,
611                metadata: serde_json::json!({}),
612                topic_id: Some(msg_channel_id.to_string()),
613            };
614
615            tx.send(incoming)
616                .await
617                .map_err(|_| anyhow::anyhow!("receiver closed"))?;
618        }
619        Some("INTERACTION_CREATE") => {
620            // Handle message component interactions (e.g. button clicks)
621            let interaction_type = data["type"].as_u64().unwrap_or(0);
622            if interaction_type == 3 {
623                // MESSAGE_COMPONENT
624                let Some(interaction_id) = data["id"].as_str().filter(|s| !s.is_empty()) else {
625                    tracing::warn!("ignoring INTERACTION_CREATE with missing id");
626                    return Ok(());
627                };
628                let Some(interaction_token) = data["token"].as_str().filter(|s| !s.is_empty())
629                else {
630                    tracing::warn!("ignoring INTERACTION_CREATE with missing token");
631                    return Ok(());
632                };
633                let interaction_id = interaction_id.to_string();
634                let interaction_token = interaction_token.to_string();
635                let Some(inter_channel_id) = data["channel_id"].as_str().filter(|s| !s.is_empty())
636                else {
637                    tracing::warn!("ignoring INTERACTION_CREATE with missing channel_id");
638                    return Ok(());
639                };
640                let inter_channel_id = inter_channel_id.to_string();
641                let custom_id = data["data"]["custom_id"]
642                    .as_str()
643                    .filter(|s| !s.is_empty())
644                    .unwrap_or("button_click")
645                    .to_string();
646                let author = data["member"]["user"]["username"]
647                    .as_str()
648                    .or_else(|| data["user"]["username"].as_str())
649                    .unwrap_or("unknown")
650                    .to_string();
651
652                // Acknowledge the interaction (type 6 = DEFERRED_UPDATE_MESSAGE)
653                let callback_url = format!(
654                    "https://discord.com/api/v10/interactions/{interaction_id}/{interaction_token}/callback"
655                );
656                let http_client = client.clone();
657                if let Err(e) = http_client
658                    .post(&callback_url)
659                    .header("Content-Type", "application/json")
660                    .json(&serde_json::json!({ "type": 6 }))
661                    .send()
662                    .await
663                {
664                    tracing::warn!(interaction_id, error = %e, "failed to acknowledge Discord interaction");
665                }
666
667                let incoming = IncomingMessage {
668                    channel: "discord".to_string(),
669                    id: interaction_id.clone(),
670                    thread_id: inter_channel_id.clone(),
671                    author,
672                    body: custom_id.clone(),
673                    timestamp: Utc::now(),
674                    metadata: serde_json::json!({
675                        "interaction_id": interaction_id,
676                        "interaction_token": interaction_token,
677                        "custom_id": custom_id,
678                    }),
679                    topic_id: Some(inter_channel_id),
680                };
681
682                tx.send(incoming)
683                    .await
684                    .map_err(|_| anyhow::anyhow!("receiver closed"))?;
685            }
686        }
687        Some(t) => {
688            tracing::debug!(event_type = %t, "discord gateway: unhandled event");
689        }
690        None => {}
691    }
692    Ok(())
693}
694
695// ── Heartbeat interval parsing ───────────────────────────────────────────────
696
697/// Parse heartbeat interval from Discord gateway Hello payload.
698/// Rejects zero or invalid values and falls back to Discord's default (41.25 seconds).
699fn parse_heartbeat_interval(value: &Value) -> u64 {
700    value.as_u64().filter(|&v| v > 0).unwrap_or(41_250)
701}
702
703// ── Tests ────────────────────────────────────────────────────────────────────
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use tokio::sync::mpsc;
709
710    /// Build a reqwest::Client that cannot make real network calls.
711    /// All external hosts resolve to 127.0.0.1:1 and connections time out in 1ms.
712    fn no_network_client() -> Client {
713        Client::builder()
714            .connect_timeout(std::time::Duration::from_millis(1))
715            .resolve("discord.com", "127.0.0.1:1".parse().unwrap())
716            .build()
717            .unwrap()
718    }
719
720    #[tokio::test]
721    async fn handle_dispatch_message_create_sends_to_channel() {
722        let (tx, mut rx) = mpsc::channel(10);
723        let data = serde_json::json!({
724            "id": "123456789",
725            "channel_id": "987654321",
726            "author": {"username": "testuser", "bot": false},
727            "content": "hello world",
728            "timestamp": "2024-01-01T00:00:00+00:00",
729        });
730
731        let mut session_id = None;
732        let mut resume_url = None;
733        let client = no_network_client();
734
735        handle_dispatch(
736            Some("MESSAGE_CREATE"),
737            &data,
738            &mut session_id,
739            &mut resume_url,
740            &tx,
741            &client,
742        )
743        .await
744        .expect("dispatch should succeed for valid MESSAGE_CREATE");
745
746        let msg = rx
747            .recv()
748            .await
749            .expect("should receive MESSAGE_CREATE event from dispatch");
750        assert_eq!(msg.id, "123456789");
751        assert_eq!(msg.thread_id, "987654321");
752        assert_eq!(msg.topic_id, Some("987654321".to_string()));
753        assert_eq!(msg.author, "testuser");
754        assert_eq!(msg.body, "hello world");
755        assert_eq!(msg.channel, "discord");
756    }
757
758    #[tokio::test]
759    async fn handle_dispatch_skips_bot_messages() {
760        let (tx, mut rx) = mpsc::channel(10);
761        let data = serde_json::json!({
762            "id": "111",
763            "channel_id": "222",
764            "author": {"username": "botuser", "bot": true},
765            "content": "I am a bot",
766            "timestamp": "2024-01-01T00:00:00+00:00",
767        });
768
769        let mut session_id = None;
770        let mut resume_url = None;
771        let client = no_network_client();
772
773        handle_dispatch(
774            Some("MESSAGE_CREATE"),
775            &data,
776            &mut session_id,
777            &mut resume_url,
778            &tx,
779            &client,
780        )
781        .await
782        .expect("dispatch should succeed for bot message (just skipping send)");
783
784        // Nothing should arrive
785        assert!(rx.try_recv().is_err());
786    }
787
788    #[tokio::test]
789    async fn handle_dispatch_accepts_any_channel() {
790        let (tx, mut rx) = mpsc::channel(10);
791        let data = serde_json::json!({
792            "id": "999",
793            "channel_id": "any-channel-id",
794            "author": {"username": "user", "bot": false},
795            "content": "hello from any channel",
796            "timestamp": "2024-01-01T00:00:00+00:00",
797        });
798
799        let mut session_id = None;
800        let mut resume_url = None;
801        let client = no_network_client();
802
803        handle_dispatch(
804            Some("MESSAGE_CREATE"),
805            &data,
806            &mut session_id,
807            &mut resume_url,
808            &tx,
809            &client,
810        )
811        .await
812        .expect("dispatch should succeed for MESSAGE_CREATE from any channel");
813
814        let msg = rx
815            .recv()
816            .await
817            .expect("should receive message from any channel");
818        assert_eq!(msg.body, "hello from any channel");
819        assert_eq!(msg.thread_id, "any-channel-id");
820        assert_eq!(msg.topic_id, Some("any-channel-id".to_string()));
821    }
822
823    #[tokio::test]
824    async fn handle_dispatch_ready_sets_session_and_resume_url() {
825        let (tx, _rx) = mpsc::channel(10);
826        let data = serde_json::json!({
827            "session_id": "abc123",
828            "resume_gateway_url": "wss://us-east1.discord.gg",
829            "user": {"username": "mybot"},
830        });
831
832        let mut session_id = None;
833        let mut resume_url = None;
834        let client = no_network_client();
835
836        handle_dispatch(
837            Some("READY"),
838            &data,
839            &mut session_id,
840            &mut resume_url,
841            &tx,
842            &client,
843        )
844        .await
845        .expect("dispatch should succeed for READY event");
846
847        assert_eq!(session_id.as_deref(), Some("abc123"));
848        assert_eq!(resume_url.as_deref(), Some("wss://us-east1.discord.gg"));
849    }
850
851    #[tokio::test]
852    async fn handle_dispatch_accepts_messages_from_any_channel() {
853        let (tx, mut rx) = mpsc::channel(10);
854        let data = serde_json::json!({
855            "id": "42",
856            "channel_id": "random-channel",
857            "author": {"username": "user", "bot": false},
858            "content": "open message",
859            "timestamp": "2024-01-01T00:00:00+00:00",
860        });
861
862        let mut session_id = None;
863        let mut resume_url = None;
864        let client = no_network_client();
865
866        handle_dispatch(
867            Some("MESSAGE_CREATE"),
868            &data,
869            &mut session_id,
870            &mut resume_url,
871            &tx,
872            &client,
873        )
874        .await
875        .expect("dispatch should succeed for open channel MESSAGE_CREATE");
876
877        let msg = rx
878            .recv()
879            .await
880            .expect("should receive message from random channel");
881        assert_eq!(msg.id, "42");
882        assert_eq!(msg.thread_id, "random-channel");
883        assert_eq!(msg.topic_id, Some("random-channel".to_string()));
884    }
885
886    #[tokio::test]
887    async fn handle_dispatch_receiver_closed_returns_error() {
888        let (tx, rx) = mpsc::channel(1);
889        drop(rx); // close receiver
890
891        let data = serde_json::json!({
892            "id": "1",
893            "channel_id": "ch",
894            "author": {"username": "u", "bot": false},
895            "content": "msg",
896            "timestamp": "2024-01-01T00:00:00+00:00",
897        });
898
899        let mut session_id = None;
900        let mut resume_url = None;
901        let client = Client::new();
902
903        let result = handle_dispatch(
904            Some("MESSAGE_CREATE"),
905            &data,
906            &mut session_id,
907            &mut resume_url,
908            &tx,
909            &client,
910        )
911        .await;
912
913        assert!(result.is_err());
914        assert!(result.unwrap_err().to_string().contains("receiver closed"));
915    }
916
917    #[tokio::test]
918    async fn handle_dispatch_interaction_create_button_click() {
919        let (tx, mut rx) = mpsc::channel(10);
920        let data = serde_json::json!({
921            "type": 3, // MESSAGE_COMPONENT
922            "id": "interaction-123",
923            "token": "interaction-token-abc",
924            "channel_id": "chan-456",
925            "data": {
926                "custom_id": "select_project:owner/repo"
927            },
928            "member": {
929                "user": {"username": "clicker"}
930            }
931        });
932
933        let mut session_id = None;
934        let mut resume_url = None;
935        let client = no_network_client();
936
937        handle_dispatch(
938            Some("INTERACTION_CREATE"),
939            &data,
940            &mut session_id,
941            &mut resume_url,
942            &tx,
943            &client,
944        )
945        .await
946        .expect("dispatch should succeed for INTERACTION_CREATE");
947
948        let msg = rx
949            .recv()
950            .await
951            .expect("should receive INTERACTION_CREATE event from dispatch");
952        assert_eq!(msg.channel, "discord");
953        assert_eq!(msg.id, "interaction-123");
954        assert_eq!(msg.thread_id, "chan-456");
955        assert_eq!(msg.topic_id, Some("chan-456".to_string()));
956        assert_eq!(msg.body, "select_project:owner/repo");
957        assert_eq!(msg.author, "clicker");
958        assert_eq!(
959            msg.metadata["custom_id"].as_str(),
960            Some("select_project:owner/repo")
961        );
962        assert_eq!(
963            msg.metadata["interaction_id"].as_str(),
964            Some("interaction-123")
965        );
966        assert_eq!(
967            msg.metadata["interaction_token"].as_str(),
968            Some("interaction-token-abc")
969        );
970    }
971
972    #[tokio::test]
973    async fn handle_dispatch_interaction_create_ignores_non_component() {
974        let (tx, mut rx) = mpsc::channel(10);
975        let data = serde_json::json!({
976            "type": 1, // PING, not MESSAGE_COMPONENT
977            "id": "interaction-999",
978            "token": "tok",
979            "channel_id": "ch",
980        });
981
982        let mut session_id = None;
983        let mut resume_url = None;
984        let client = no_network_client();
985
986        handle_dispatch(
987            Some("INTERACTION_CREATE"),
988            &data,
989            &mut session_id,
990            &mut resume_url,
991            &tx,
992            &client,
993        )
994        .await
995        .expect("dispatch should succeed for non-component INTERACTION_CREATE");
996
997        // Should not produce a message for non-component interactions
998        assert!(rx.try_recv().is_err());
999    }
1000
1001    #[test]
1002    fn parse_heartbeat_interval_rejects_zero() {
1003        let zero = serde_json::json!(0);
1004        assert_eq!(parse_heartbeat_interval(&zero), 41_250);
1005    }
1006
1007    #[test]
1008    fn parse_heartbeat_interval_accepts_valid() {
1009        let valid = serde_json::json!(50_000);
1010        assert_eq!(parse_heartbeat_interval(&valid), 50_000);
1011    }
1012
1013    #[test]
1014    fn parse_heartbeat_interval_defaults_on_null() {
1015        let null = serde_json::json!(null);
1016        assert_eq!(parse_heartbeat_interval(&null), 41_250);
1017    }
1018
1019    #[test]
1020    fn parse_heartbeat_interval_defaults_on_missing() {
1021        let object = serde_json::json!({});
1022        assert_eq!(
1023            parse_heartbeat_interval(&object["heartbeat_interval"]),
1024            41_250
1025        );
1026    }
1027
1028    #[test]
1029    fn parse_heartbeat_interval_can_create_valid_duration() {
1030        // Verify that the parsed interval can be used to create a tokio interval
1031        // without panicking. This is the real-world requirement: the value must
1032        // be valid for Duration::from_millis().
1033        let zero = serde_json::json!(0);
1034        let interval_ms = parse_heartbeat_interval(&zero);
1035        let duration = std::time::Duration::from_millis(interval_ms);
1036        assert!(duration.as_millis() > 0, "duration must be non-zero");
1037    }
1038}