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 DiscordGateway {
59 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 #[allow(dead_code)]
91 pub async fn send_with_buttons(
92 &self,
93 channel_id: &str,
94 text: &str,
95 buttons: &[(String, String)], ) -> anyhow::Result<String> {
97 let components = vec![serde_json::json!({
98 "type": 1, "components": buttons.iter().map(|(label, custom_id)| {
100 serde_json::json!({
101 "type": 2, "style": 1, "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#[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 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#[derive(Deserialize)]
228struct GatewayPayload {
229 op: u64,
230 #[serde(default)]
231 d: Value,
232 s: Option<u64>,
233 t: Option<String>,
234}
235
236struct 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
266async 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); 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
329async 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 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 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 let mut hb_ticker = tokio::time::interval(Duration::from_millis(hb_interval_ms));
398 hb_ticker.tick().await; 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); }
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 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 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 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
530async 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
546async 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 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 if data["author"]["bot"].as_bool().unwrap_or(false) {
578 return Ok(());
579 }
580
581 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 let interaction_type = data["type"].as_u64().unwrap_or(0);
617 if interaction_type == 3 {
618 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 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
691fn parse_heartbeat_interval(value: &Value) -> u64 {
696 value.as_u64().filter(|&v| v > 0).unwrap_or(41_250)
697}
698
699#[cfg(test)]
702mod tests {
703 use super::*;
704 use tokio::sync::mpsc;
705
706 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 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); 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, "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, "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 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 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}