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