1use 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
30const 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
41const GATEWAY_INTENTS: u64 = 1 | (1 << 9) | (1 << 15);
44
45const DEFAULT_GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json";
46
47const SEND_TIMEOUT_SECS: u64 = 10;
50
51pub 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 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 pub async fn send_with_buttons(
108 &self,
109 channel_id: &str,
110 text: &str,
111 buttons: &[(String, String)], ) -> anyhow::Result<String> {
113 let components = vec![serde_json::json!({
114 "type": 1, "components": buttons.iter().map(|(label, custom_id)| {
116 serde_json::json!({
117 "type": 2, "style": 1, "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#[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 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#[derive(Deserialize)]
244struct GatewayPayload {
245 op: u64,
246 #[serde(default)]
247 d: Value,
248 s: Option<u64>,
249 t: Option<String>,
250}
251
252struct 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
282async 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); 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
345async 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 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 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 let mut hb_ticker = time::interval(Duration::from_millis(hb_interval_ms));
412 hb_ticker.tick().await; 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); }
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 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 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 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
542async 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
557async 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 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 if data["author"]["bot"].as_bool().unwrap_or(false) {
589 return Ok(());
590 }
591
592 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 let interaction_type = data["type"].as_u64().unwrap_or(0);
627 if interaction_type == 3 {
628 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 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
700fn parse_heartbeat_interval(value: &Value) -> u64 {
705 value.as_u64().filter(|&v| v > 0).unwrap_or(41_250)
706}
707
708#[cfg(test)]
711mod tests {
712 use super::*;
713 use tokio::sync::mpsc;
714
715 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 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); 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, "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, "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 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 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}