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