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