1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3use futures_util::{SinkExt, StreamExt};
4use parking_lot::Mutex;
5use reqwest::multipart::{Form, Part};
6use serde_json::json;
7use std::collections::HashMap;
8use std::fmt::Write as _;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use tokio_tungstenite::tungstenite::Message;
12use uuid::Uuid;
13
14pub struct DiscordChannel {
16 bot_token: String,
17 guild_id: Option<String>,
18 allowed_users: Vec<String>,
19 listen_to_bots: bool,
20 mention_only: bool,
21 typing_handles: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
22 proxy_url: Option<String>,
24 transcription: Option<crate::config::TranscriptionConfig>,
27 transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
28 stream_mode: crate::config::StreamMode,
30 draft_update_interval_ms: u64,
32 multi_message_delay_ms: u64,
34 last_draft_edit: Mutex<HashMap<String, std::time::Instant>>,
36 multi_message_sent_len: Mutex<HashMap<String, usize>>,
38 multi_message_thread_ts: Mutex<HashMap<String, Option<String>>>,
40 approval_registry: Option<Arc<crate::gateway::approval_registry::ApprovalRegistry>>,
43 gateway_port: u16,
45}
46
47impl DiscordChannel {
48 pub fn new(
49 bot_token: String,
50 guild_id: Option<String>,
51 allowed_users: Vec<String>,
52 listen_to_bots: bool,
53 mention_only: bool,
54 ) -> Self {
55 Self {
56 bot_token,
57 guild_id,
58 allowed_users,
59 listen_to_bots,
60 mention_only,
61 typing_handles: Mutex::new(HashMap::new()),
62 proxy_url: None,
63 transcription: None,
64 transcription_manager: None,
65 stream_mode: crate::config::StreamMode::Off,
66 draft_update_interval_ms: 1000,
67 multi_message_delay_ms: 800,
68 last_draft_edit: Mutex::new(HashMap::new()),
69 multi_message_sent_len: Mutex::new(HashMap::new()),
70 multi_message_thread_ts: Mutex::new(HashMap::new()),
71 approval_registry: None,
72 gateway_port: 42617,
73 }
74 }
75
76 pub fn with_approval_registry(
78 mut self,
79 registry: Arc<crate::gateway::approval_registry::ApprovalRegistry>,
80 gateway_port: u16,
81 ) -> Self {
82 self.approval_registry = Some(registry);
83 self.gateway_port = gateway_port;
84 self
85 }
86
87 pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
89 self.proxy_url = proxy_url;
90 self
91 }
92
93 pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
95 if !config.enabled {
96 return self;
97 }
98 match super::transcription::TranscriptionManager::new(&config) {
99 Ok(m) => {
100 self.transcription_manager = Some(std::sync::Arc::new(m));
101 self.transcription = Some(config);
102 }
103 Err(e) => {
104 tracing::warn!(
105 "transcription manager init failed, voice transcription disabled: {e}"
106 );
107 }
108 }
109 self
110 }
111
112 pub fn with_streaming(
114 mut self,
115 stream_mode: crate::config::StreamMode,
116 draft_update_interval_ms: u64,
117 multi_message_delay_ms: u64,
118 ) -> Self {
119 self.stream_mode = stream_mode;
120 self.draft_update_interval_ms = draft_update_interval_ms;
121 self.multi_message_delay_ms = multi_message_delay_ms;
122 self
123 }
124
125 fn http_client(&self) -> reqwest::Client {
126 crate::config::build_channel_proxy_client("channel.discord", self.proxy_url.as_deref())
127 }
128
129 fn is_user_allowed(&self, user_id: &str) -> bool {
133 self.allowed_users.iter().any(|u| u == "*" || u == user_id)
134 }
135
136 fn bot_user_id_from_token(token: &str) -> Option<String> {
137 let part = token.split('.').next()?;
139 base64_decode(part)
140 }
141}
142
143async fn process_attachments(
149 attachments: &[serde_json::Value],
150 client: &reqwest::Client,
151) -> String {
152 let mut parts: Vec<String> = Vec::new();
153 for att in attachments {
154 let ct = att
155 .get("content_type")
156 .and_then(|v| v.as_str())
157 .unwrap_or("");
158 let name = att
159 .get("filename")
160 .and_then(|v| v.as_str())
161 .unwrap_or("file");
162 let Some(url) = att.get("url").and_then(|v| v.as_str()) else {
163 tracing::warn!(name, "discord: attachment has no url, skipping");
164 continue;
165 };
166 if ct.starts_with("text/") {
167 match client.get(url).send().await {
168 Ok(resp) if resp.status().is_success() => {
169 if let Ok(text) = resp.text().await {
170 parts.push(format!("[{name}]\n{text}"));
171 }
172 }
173 Ok(resp) => {
174 tracing::warn!(name, status = %resp.status(), "discord attachment fetch failed");
175 }
176 Err(e) => {
177 tracing::warn!(name, error = %e, "discord attachment fetch error");
178 }
179 }
180 } else {
181 tracing::debug!(
182 name,
183 content_type = ct,
184 "discord: skipping unsupported attachment type"
185 );
186 }
187 }
188 parts.join("\n---\n")
189}
190
191const DISCORD_AUDIO_EXTENSIONS: &[&str] = &[
193 "flac", "mp3", "mpeg", "mpga", "mp4", "m4a", "ogg", "oga", "opus", "wav", "webm",
194];
195
196fn is_discord_audio_attachment(content_type: &str, filename: &str) -> bool {
198 if content_type.starts_with("audio/") {
199 return true;
200 }
201 if let Some(ext) = filename.rsplit('.').next() {
202 return DISCORD_AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str());
203 }
204 false
205}
206
207async fn transcribe_discord_audio_attachments(
212 attachments: &[serde_json::Value],
213 client: &reqwest::Client,
214 manager: &super::transcription::TranscriptionManager,
215) -> String {
216 let mut parts: Vec<String> = Vec::new();
217 for att in attachments {
218 let ct = att
219 .get("content_type")
220 .and_then(|v| v.as_str())
221 .unwrap_or("");
222 let name = att
223 .get("filename")
224 .and_then(|v| v.as_str())
225 .unwrap_or("file");
226
227 if !is_discord_audio_attachment(ct, name) {
228 continue;
229 }
230
231 let Some(url) = att.get("url").and_then(|v| v.as_str()) else {
232 continue;
233 };
234
235 let audio_data = match client.get(url).send().await {
236 Ok(resp) if resp.status().is_success() => match resp.bytes().await {
237 Ok(bytes) => bytes.to_vec(),
238 Err(e) => {
239 tracing::warn!(name, error = %e, "discord: failed to read audio attachment bytes");
240 continue;
241 }
242 },
243 Ok(resp) => {
244 tracing::warn!(name, status = %resp.status(), "discord: audio attachment download failed");
245 continue;
246 }
247 Err(e) => {
248 tracing::warn!(name, error = %e, "discord: audio attachment fetch error");
249 continue;
250 }
251 };
252
253 match manager.transcribe(&audio_data, name).await {
254 Ok(text) => {
255 let trimmed = text.trim();
256 if !trimmed.is_empty() {
257 tracing::info!(
258 "Discord: transcribed audio attachment {} ({} chars)",
259 name,
260 trimmed.len()
261 );
262 parts.push(format!("[Voice] {trimmed}"));
263 }
264 }
265 Err(e) => {
266 tracing::warn!(name, error = %e, "discord: voice transcription failed");
267 }
268 }
269 }
270 parts.join("\n")
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274enum DiscordAttachmentKind {
275 Image,
276 Document,
277 Video,
278 Audio,
279 Voice,
280}
281
282impl DiscordAttachmentKind {
283 fn from_marker(kind: &str) -> Option<Self> {
284 match kind.trim().to_ascii_uppercase().as_str() {
285 "IMAGE" | "PHOTO" => Some(Self::Image),
286 "DOCUMENT" | "FILE" => Some(Self::Document),
287 "VIDEO" => Some(Self::Video),
288 "AUDIO" => Some(Self::Audio),
289 "VOICE" => Some(Self::Voice),
290 _ => None,
291 }
292 }
293
294 fn marker_name(&self) -> &'static str {
295 match self {
296 Self::Image => "IMAGE",
297 Self::Document => "DOCUMENT",
298 Self::Video => "VIDEO",
299 Self::Audio => "AUDIO",
300 Self::Voice => "VOICE",
301 }
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306struct DiscordAttachment {
307 kind: DiscordAttachmentKind,
308 target: String,
309}
310
311fn parse_attachment_markers(message: &str) -> (String, Vec<DiscordAttachment>) {
312 let mut cleaned = String::with_capacity(message.len());
313 let mut attachments = Vec::new();
314 let mut cursor = 0usize;
315
316 while let Some(rel_start) = message[cursor..].find('[') {
317 let start = cursor + rel_start;
318 cleaned.push_str(&message[cursor..start]);
319
320 let Some(rel_end) = message[start..].find(']') else {
321 cleaned.push_str(&message[start..]);
322 cursor = message.len();
323 break;
324 };
325 let end = start + rel_end;
326 let marker_text = &message[start + 1..end];
327
328 let parsed = marker_text.split_once(':').and_then(|(kind, target)| {
329 let kind = DiscordAttachmentKind::from_marker(kind)?;
330 let target = target.trim();
331 if target.is_empty() {
332 return None;
333 }
334 Some(DiscordAttachment {
335 kind,
336 target: target.to_string(),
337 })
338 });
339
340 if let Some(attachment) = parsed {
341 attachments.push(attachment);
342 } else {
343 cleaned.push_str(&message[start..=end]);
344 }
345
346 cursor = end + 1;
347 }
348
349 if cursor < message.len() {
350 cleaned.push_str(&message[cursor..]);
351 }
352
353 (cleaned.trim().to_string(), attachments)
354}
355
356fn classify_outgoing_attachments(
357 attachments: &[DiscordAttachment],
358) -> (Vec<PathBuf>, Vec<String>, Vec<String>) {
359 let mut local_files = Vec::new();
360 let mut remote_urls = Vec::new();
361 let mut unresolved_markers = Vec::new();
362
363 for attachment in attachments {
364 let target = attachment.target.trim();
365 if target.starts_with("https://") || target.starts_with("http://") {
366 remote_urls.push(target.to_string());
367 continue;
368 }
369
370 let path = Path::new(target);
371 if path.exists() && path.is_file() {
372 local_files.push(path.to_path_buf());
373 continue;
374 }
375
376 unresolved_markers.push(format!("[{}:{}]", attachment.kind.marker_name(), target));
377 }
378
379 (local_files, remote_urls, unresolved_markers)
380}
381
382fn with_inline_attachment_urls(
383 content: &str,
384 remote_urls: &[String],
385 unresolved_markers: &[String],
386) -> String {
387 let mut lines = Vec::new();
388 if !content.trim().is_empty() {
389 lines.push(content.trim().to_string());
390 }
391 if !remote_urls.is_empty() {
392 lines.extend(remote_urls.iter().cloned());
393 }
394 if !unresolved_markers.is_empty() {
395 lines.extend(unresolved_markers.iter().cloned());
396 }
397 lines.join("\n")
398}
399
400async fn send_discord_message_json(
401 client: &reqwest::Client,
402 bot_token: &str,
403 recipient: &str,
404 content: &str,
405) -> anyhow::Result<()> {
406 let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
407 let body = json!({ "content": content });
408
409 let resp = client
410 .post(&url)
411 .header("Authorization", format!("Bot {bot_token}"))
412 .json(&body)
413 .send()
414 .await?;
415
416 if !resp.status().is_success() {
417 let status = resp.status();
418 let err = resp
419 .text()
420 .await
421 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
422 anyhow::bail!("Discord send message failed ({status}): {err}");
423 }
424
425 Ok(())
426}
427
428async fn send_discord_message_with_files(
429 client: &reqwest::Client,
430 bot_token: &str,
431 recipient: &str,
432 content: &str,
433 files: &[PathBuf],
434) -> anyhow::Result<()> {
435 let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
436
437 let mut form = Form::new().text("payload_json", json!({ "content": content }).to_string());
438
439 for (idx, path) in files.iter().enumerate() {
440 let bytes = tokio::fs::read(path).await.map_err(|error| {
441 anyhow::anyhow!(
442 "Discord attachment read failed for '{}': {error}",
443 path.display()
444 )
445 })?;
446 let filename = path
447 .file_name()
448 .and_then(|name| name.to_str())
449 .unwrap_or("attachment.bin")
450 .to_string();
451 form = form.part(
452 format!("files[{idx}]"),
453 Part::bytes(bytes).file_name(filename),
454 );
455 }
456
457 let resp = client
458 .post(&url)
459 .header("Authorization", format!("Bot {bot_token}"))
460 .multipart(form)
461 .send()
462 .await?;
463
464 if !resp.status().is_success() {
465 let status = resp.status();
466 let err = resp
467 .text()
468 .await
469 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
470 anyhow::bail!("Discord send message with files failed ({status}): {err}");
471 }
472
473 Ok(())
474}
475
476async fn send_discord_message_json_with_id(
478 client: &reqwest::Client,
479 bot_token: &str,
480 recipient: &str,
481 content: &str,
482) -> anyhow::Result<String> {
483 let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
484 let body = json!({ "content": content });
485
486 let resp = client
487 .post(&url)
488 .header("Authorization", format!("Bot {bot_token}"))
489 .json(&body)
490 .send()
491 .await?;
492
493 if !resp.status().is_success() {
494 let status = resp.status();
495 let err = resp
496 .text()
497 .await
498 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
499 anyhow::bail!("Discord send message failed ({status}): {err}");
500 }
501
502 let resp_json: serde_json::Value = resp.json().await?;
503 resp_json
504 .get("id")
505 .and_then(|v| v.as_str())
506 .map(|s| s.to_string())
507 .ok_or_else(|| anyhow::anyhow!("Discord send response missing 'id' field"))
508}
509
510async fn edit_discord_message(
515 client: &reqwest::Client,
516 bot_token: &str,
517 channel_id: &str,
518 message_id: &str,
519 content: &str,
520) -> anyhow::Result<()> {
521 let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}");
522 let body = json!({ "content": content });
523
524 let resp = client
525 .patch(&url)
526 .header("Authorization", format!("Bot {bot_token}"))
527 .json(&body)
528 .send()
529 .await?;
530
531 if resp.status().as_u16() == 429 {
532 tracing::debug!("Discord edit message rate-limited (429), skipping update");
533 return Ok(());
534 }
535
536 if !resp.status().is_success() {
537 let status = resp.status();
538 let err = resp
539 .text()
540 .await
541 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
542 anyhow::bail!("Discord edit message failed ({status}): {err}");
543 }
544
545 Ok(())
546}
547
548async fn delete_discord_message(
553 client: &reqwest::Client,
554 bot_token: &str,
555 channel_id: &str,
556 message_id: &str,
557) -> anyhow::Result<()> {
558 let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}");
559
560 let resp = client
561 .delete(&url)
562 .header("Authorization", format!("Bot {bot_token}"))
563 .send()
564 .await?;
565
566 if resp.status().as_u16() == 429 {
567 tracing::debug!("Discord delete message rate-limited (429), skipping");
568 return Ok(());
569 }
570
571 if !resp.status().is_success() {
572 let status = resp.status();
573 let err = resp
574 .text()
575 .await
576 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
577 anyhow::bail!("Discord delete message failed ({status}): {err}");
578 }
579
580 Ok(())
581}
582
583const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
584
585const DISCORD_MAX_MESSAGE_LENGTH: usize = 2000;
589const DISCORD_ACK_REACTIONS: &[&str] = &["⚡️", "🦀", "🙌", "💪", "👌", "👀", "👣"];
590
591fn split_message_for_discord(message: &str) -> Vec<String> {
594 if message.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH {
595 return vec![message.to_string()];
596 }
597
598 let mut chunks = Vec::new();
599 let mut remaining = message;
600
601 while !remaining.is_empty() {
602 let hard_split = remaining
605 .char_indices()
606 .nth(DISCORD_MAX_MESSAGE_LENGTH)
607 .map_or(remaining.len(), |(idx, _)| idx);
608
609 let chunk_end = if hard_split == remaining.len() {
610 hard_split
611 } else {
612 let search_area = &remaining[..hard_split];
614
615 if let Some(pos) = search_area.rfind('\n') {
617 if search_area[..pos].chars().count() >= DISCORD_MAX_MESSAGE_LENGTH / 2 {
619 pos + 1
620 } else {
621 search_area.rfind(' ').map_or(hard_split, |space| space + 1)
623 }
624 } else if let Some(pos) = search_area.rfind(' ') {
625 pos + 1
626 } else {
627 hard_split
629 }
630 };
631
632 chunks.push(remaining[..chunk_end].to_string());
633 remaining = &remaining[chunk_end..];
634 }
635
636 chunks
637}
638
639fn split_message_for_discord_multi(content: &str, max_len: usize) -> Vec<String> {
644 if content.is_empty() {
645 return vec![];
646 }
647
648 let mut segments: Vec<String> = Vec::new();
650 let mut current = String::new();
651 let mut in_fence = false;
652
653 for line in content.lines() {
654 let trimmed = line.trim_start();
655 if trimmed.starts_with("```") {
656 in_fence = !in_fence;
657 }
658
659 if line.is_empty() && !in_fence && !current.is_empty() {
661 segments.push(current.trim_end().to_string());
662 current.clear();
663 continue;
664 }
665
666 if !current.is_empty() {
667 current.push('\n');
668 }
669 current.push_str(line);
670 }
671 if !current.is_empty() {
672 segments.push(current.trim_end().to_string());
673 }
674
675 let mut chunks: Vec<String> = Vec::new();
677
678 for segment in segments {
679 if segment.chars().count() > max_len {
680 let sub_chunks = split_message_for_discord(&segment);
683 chunks.extend(sub_chunks);
684 } else {
685 chunks.push(segment);
686 }
687 }
688
689 if chunks.is_empty() {
690 vec![content.to_string()]
691 } else {
692 chunks
693 }
694}
695
696fn pick_uniform_index(len: usize) -> usize {
697 debug_assert!(len > 0);
698 let upper = len as u64;
699 let reject_threshold = (u64::MAX / upper) * upper;
700
701 loop {
702 let value = rand::random::<u64>();
703 if value < reject_threshold {
704 #[allow(clippy::cast_possible_truncation)]
705 return (value % upper) as usize;
706 }
707 }
708}
709
710fn random_discord_ack_reaction() -> &'static str {
711 DISCORD_ACK_REACTIONS[pick_uniform_index(DISCORD_ACK_REACTIONS.len())]
712}
713
714fn encode_emoji_for_discord(emoji: &str) -> String {
720 if emoji.contains(':') {
721 return emoji.to_string();
722 }
723
724 let mut encoded = String::new();
725 for byte in emoji.as_bytes() {
726 let _ = write!(encoded, "%{byte:02X}");
727 }
728 encoded
729}
730
731fn discord_reaction_url(channel_id: &str, message_id: &str, emoji: &str) -> String {
732 let raw_id = message_id.strip_prefix("discord_").unwrap_or(message_id);
733 let encoded_emoji = encode_emoji_for_discord(emoji);
734 format!(
735 "https://discord.com/api/v10/channels/{channel_id}/messages/{raw_id}/reactions/{encoded_emoji}/@me"
736 )
737}
738
739fn mention_tags(bot_user_id: &str) -> [String; 2] {
740 [format!("<@{bot_user_id}>"), format!("<@!{bot_user_id}>")]
741}
742
743fn contains_bot_mention(content: &str, bot_user_id: &str) -> bool {
744 let tags = mention_tags(bot_user_id);
745 content.contains(&tags[0]) || content.contains(&tags[1])
746}
747
748fn normalize_incoming_content(
749 content: &str,
750 mention_only: bool,
751 bot_user_id: &str,
752) -> Option<String> {
753 if content.is_empty() {
754 return None;
755 }
756
757 if mention_only && !contains_bot_mention(content, bot_user_id) {
758 return None;
759 }
760
761 let mut normalized = content.to_string();
762 if mention_only {
763 for tag in mention_tags(bot_user_id) {
764 normalized = normalized.replace(&tag, " ");
765 }
766 }
767
768 let normalized = normalized.trim().to_string();
769 if normalized.is_empty() {
770 return None;
771 }
772
773 Some(normalized)
774}
775
776#[allow(clippy::cast_possible_truncation)]
778fn base64_decode(input: &str) -> Option<String> {
779 let padded = match input.len() % 4 {
780 2 => format!("{input}=="),
781 3 => format!("{input}="),
782 _ => input.to_string(),
783 };
784
785 let mut bytes = Vec::new();
786 let chars: Vec<u8> = padded.bytes().collect();
787
788 for chunk in chars.chunks(4) {
789 if chunk.len() < 4 {
790 break;
791 }
792
793 let mut v = [0usize; 4];
794 for (i, &b) in chunk.iter().enumerate() {
795 if b == b'=' {
796 v[i] = 0;
797 } else {
798 v[i] = BASE64_ALPHABET.iter().position(|&a| a == b)?;
799 }
800 }
801
802 bytes.push(((v[0] << 2) | (v[1] >> 4)) as u8);
803 if chunk[2] != b'=' {
804 bytes.push((((v[1] & 0xF) << 4) | (v[2] >> 2)) as u8);
805 }
806 if chunk[3] != b'=' {
807 bytes.push((((v[2] & 0x3) << 6) | v[3]) as u8);
808 }
809 }
810
811 String::from_utf8(bytes).ok()
812}
813
814#[async_trait]
815impl Channel for DiscordChannel {
816 fn name(&self) -> &str {
817 "discord"
818 }
819
820 async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
821 let raw_content = super::strip_tool_call_tags(&message.content);
822 let (cleaned_content, parsed_attachments) = parse_attachment_markers(&raw_content);
823 let (mut local_files, remote_urls, unresolved_markers) =
824 classify_outgoing_attachments(&parsed_attachments);
825
826 if !unresolved_markers.is_empty() {
827 tracing::warn!(
828 unresolved = ?unresolved_markers,
829 "discord: unresolved attachment markers were sent as plain text"
830 );
831 }
832
833 if local_files.len() > 10 {
835 tracing::warn!(
836 count = local_files.len(),
837 "discord: truncating local attachment upload list to 10 files"
838 );
839 local_files.truncate(10);
840 }
841
842 let content =
843 with_inline_attachment_urls(&cleaned_content, &remote_urls, &unresolved_markers);
844
845 if self.stream_mode == crate::config::StreamMode::MultiMessage {
848 let chunks = split_message_for_discord_multi(&content, DISCORD_MAX_MESSAGE_LENGTH);
849 let client = self.http_client();
850
851 for (i, chunk) in chunks.iter().enumerate() {
852 if i == 0 && !local_files.is_empty() {
853 send_discord_message_with_files(
854 &client,
855 &self.bot_token,
856 &message.recipient,
857 chunk,
858 &local_files,
859 )
860 .await?;
861 } else {
862 send_discord_message_json(&client, &self.bot_token, &message.recipient, chunk)
863 .await?;
864 }
865
866 if i < chunks.len() - 1 {
867 if message
869 .cancellation_token
870 .as_ref()
871 .is_some_and(|t| t.is_cancelled())
872 {
873 tracing::debug!(
874 "MultiMessage delivery interrupted after chunk {}/{}",
875 i + 1,
876 chunks.len()
877 );
878 break;
879 }
880 tokio::time::sleep(std::time::Duration::from_millis(
881 self.multi_message_delay_ms,
882 ))
883 .await;
884 }
885 }
886
887 return Ok(());
888 }
889
890 let chunks = split_message_for_discord(&content);
892 let client = self.http_client();
893
894 for (i, chunk) in chunks.iter().enumerate() {
895 if i == 0 && !local_files.is_empty() {
896 send_discord_message_with_files(
897 &client,
898 &self.bot_token,
899 &message.recipient,
900 chunk,
901 &local_files,
902 )
903 .await?;
904 } else {
905 send_discord_message_json(&client, &self.bot_token, &message.recipient, chunk)
906 .await?;
907 }
908
909 if i < chunks.len() - 1 {
910 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
911 }
912 }
913
914 Ok(())
915 }
916
917 #[allow(clippy::too_many_lines)]
918 async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
919 let bot_user_id = Self::bot_user_id_from_token(&self.bot_token).unwrap_or_default();
920
921 let gw_resp: serde_json::Value = self
923 .http_client()
924 .get("https://discord.com/api/v10/gateway/bot")
925 .header("Authorization", format!("Bot {}", self.bot_token))
926 .send()
927 .await?
928 .json()
929 .await?;
930
931 let gw_url = gw_resp
932 .get("url")
933 .and_then(|u| u.as_str())
934 .unwrap_or("wss://gateway.discord.gg");
935
936 let ws_url = format!("{gw_url}/?v=10&encoding=json");
937 tracing::info!("Discord: connecting to gateway...");
938
939 let (ws_stream, _) = crate::config::ws_connect_with_proxy(
940 &ws_url,
941 "channel.discord",
942 self.proxy_url.as_deref(),
943 )
944 .await?;
945 let (mut write, mut read) = ws_stream.split();
946
947 let hello = read.next().await.ok_or(anyhow::anyhow!("No hello"))??;
949 let hello_data: serde_json::Value = serde_json::from_str(&hello.to_string())?;
950 let heartbeat_interval = hello_data
951 .get("d")
952 .and_then(|d| d.get("heartbeat_interval"))
953 .and_then(serde_json::Value::as_u64)
954 .unwrap_or(41250);
955
956 let identify = json!({
958 "op": 2,
959 "d": {
960 "token": self.bot_token,
961 "intents": 37377, "properties": {
963 "os": "linux",
964 "browser": "construct",
965 "device": "construct"
966 }
967 }
968 });
969 write
970 .send(Message::Text(identify.to_string().into()))
971 .await?;
972
973 tracing::info!("Discord: connected and identified");
974
975 let mut sequence: i64 = -1;
978
979 let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
982 let hb_interval = heartbeat_interval;
983 tokio::spawn(async move {
984 let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval));
985 loop {
986 interval.tick().await;
987 if hb_tx.send(()).await.is_err() {
988 break;
989 }
990 }
991 });
992
993 let guild_filter = self.guild_id.clone();
994
995 loop {
996 tokio::select! {
997 _ = hb_rx.recv() => {
998 let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
999 let hb = json!({"op": 1, "d": d});
1000 if write.send(Message::Text(hb.to_string().into())).await.is_err() {
1001 break;
1002 }
1003 }
1004 msg = read.next() => {
1005 let msg = match msg {
1006 Some(Ok(Message::Text(t))) => t,
1007 Some(Ok(Message::Ping(payload))) => {
1008 if write.send(Message::Pong(payload)).await.is_err() {
1009 tracing::warn!("Discord: pong send failed, reconnecting");
1010 break;
1011 }
1012 continue;
1013 }
1014 Some(Ok(Message::Close(_))) | None => break,
1015 Some(Err(e)) => {
1016 tracing::warn!("Discord: websocket read error: {e}, reconnecting");
1017 break;
1018 }
1019 _ => continue,
1020 };
1021
1022 let event: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
1023 Ok(e) => e,
1024 Err(_) => continue,
1025 };
1026
1027 if let Some(s) = event.get("s").and_then(serde_json::Value::as_i64) {
1029 sequence = s;
1030 }
1031
1032 let op = event.get("op").and_then(serde_json::Value::as_u64).unwrap_or(0);
1033
1034 match op {
1035 1 => {
1037 let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
1038 let hb = json!({"op": 1, "d": d});
1039 if write.send(Message::Text(hb.to_string().into())).await.is_err() {
1040 break;
1041 }
1042 continue;
1043 }
1044 7 => {
1046 tracing::warn!("Discord: received Reconnect (op 7), closing for restart");
1047 break;
1048 }
1049 9 => {
1051 tracing::warn!("Discord: received Invalid Session (op 9), closing for restart");
1052 break;
1053 }
1054 _ => {}
1055 }
1056
1057 let event_type = event.get("t").and_then(|t| t.as_str()).unwrap_or("");
1059 if event_type != "MESSAGE_CREATE" {
1060 continue;
1061 }
1062
1063 let Some(d) = event.get("d") else {
1064 continue;
1065 };
1066
1067 let author_id = d.get("author").and_then(|a| a.get("id")).and_then(|i| i.as_str()).unwrap_or("");
1069 if author_id == bot_user_id {
1070 continue;
1071 }
1072
1073 if !self.listen_to_bots && d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) {
1075 continue;
1076 }
1077
1078 if !self.is_user_allowed(author_id) {
1080 tracing::warn!("Discord: ignoring message from unauthorized user: {author_id}");
1081 continue;
1082 }
1083
1084 if let Some(ref gid) = guild_filter {
1086 let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str);
1087 if let Some(g) = msg_guild {
1089 if g != gid {
1090 continue;
1091 }
1092 }
1093 }
1094
1095 let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
1096 let is_dm = d.get("guild_id").is_none();
1100 let effective_mention_only = self.mention_only && !is_dm;
1101 let Some(clean_content) =
1102 normalize_incoming_content(content, effective_mention_only, &bot_user_id)
1103 else {
1104 continue;
1105 };
1106
1107 let channel_id_for_check = d
1119 .get("channel_id")
1120 .and_then(|c| c.as_str())
1121 .unwrap_or("");
1122 let reply_to_message_id = d
1123 .get("message_reference")
1124 .and_then(|r| r.get("message_id"))
1125 .and_then(|v| v.as_str());
1126 if let Some(ref registry) = self.approval_registry {
1127 if let Some((run_id, is_approve, feedback)) = registry.match_discord_keyword(
1128 channel_id_for_check,
1129 Some(channel_id_for_check),
1130 reply_to_message_id,
1131 &clean_content,
1132 )
1133 {
1134 if let Some(pending) = registry.try_claim(&run_id) {
1136 let port = self.gateway_port;
1137 let bot_token = self.bot_token.clone();
1138 let author_username = d
1139 .get("author")
1140 .and_then(|a| a.get("username"))
1141 .and_then(|u| u.as_str())
1142 .unwrap_or("unknown")
1143 .to_string();
1144 let reply_channel = channel_id_for_check.to_string();
1145
1146 tokio::spawn(async move {
1147 let url = format!(
1149 "http://127.0.0.1:{port}/api/workflows/runs/{run_id}/approve"
1150 );
1151 let payload = serde_json::json!({
1152 "approved": is_approve,
1153 "feedback": feedback,
1154 });
1155 let client = reqwest::Client::new();
1156 match client.post(&url).json(&payload).send().await {
1157 Ok(resp) if resp.status().is_success() => {
1158 tracing::info!(
1159 run_id = %run_id,
1160 approved = %is_approve,
1161 "Discord: workflow approval processed"
1162 );
1163 }
1164 Ok(resp) => {
1165 let status = resp.status();
1166 tracing::warn!(
1167 run_id = %run_id,
1168 %status,
1169 "Discord: workflow approval endpoint returned error"
1170 );
1171 }
1172 Err(e) => {
1173 tracing::warn!(
1174 run_id = %run_id,
1175 error = %e,
1176 "Discord: failed to call workflow approval endpoint"
1177 );
1178 }
1179 }
1180
1181 let confirm_msg = if is_approve {
1183 format!(
1184 "✅ Workflow `{}` approved by @{}",
1185 pending.workflow_name, author_username
1186 )
1187 } else if feedback.is_empty() {
1188 format!(
1189 "❌ Workflow `{}` rejected by @{}",
1190 pending.workflow_name, author_username
1191 )
1192 } else {
1193 format!(
1194 "❌ Workflow `{}` rejected by @{}. Feedback: {}",
1195 pending.workflow_name, author_username, feedback
1196 )
1197 };
1198 let notify_url = format!(
1199 "https://discord.com/api/v10/channels/{reply_channel}/messages"
1200 );
1201 let _ = reqwest::Client::new()
1202 .post(¬ify_url)
1203 .header(
1204 "Authorization",
1205 format!("Bot {bot_token}"),
1206 )
1207 .json(&serde_json::json!({ "content": confirm_msg }))
1208 .send()
1209 .await;
1210 });
1211
1212 continue;
1214 }
1215 }
1216 }
1217 let attachment_text = {
1220 let atts = d
1221 .get("attachments")
1222 .and_then(|a| a.as_array())
1223 .cloned()
1224 .unwrap_or_default();
1225 let client = self.http_client();
1226 let mut text_parts = process_attachments(&atts, &client).await;
1227
1228 if let Some(ref transcription_manager) = self.transcription_manager {
1230 let voice_text = transcribe_discord_audio_attachments(
1231 &atts,
1232 &client,
1233 transcription_manager,
1234 )
1235 .await;
1236 if !voice_text.is_empty() {
1237 if text_parts.is_empty() {
1238 text_parts = voice_text;
1239 } else {
1240 text_parts = format!("{text_parts}
1241 {voice_text}");
1242 }
1243 }
1244 }
1245
1246 text_parts
1247 };
1248 let final_content = if attachment_text.is_empty() {
1249 clean_content
1250 } else {
1251 format!("{clean_content}\n\n[Attachments]\n{attachment_text}")
1252 };
1253
1254 let message_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
1255 let channel_id = d
1256 .get("channel_id")
1257 .and_then(|c| c.as_str())
1258 .unwrap_or("")
1259 .to_string();
1260
1261 if !message_id.is_empty() && !channel_id.is_empty() {
1262 let reaction_channel = DiscordChannel::new(
1263 self.bot_token.clone(),
1264 self.guild_id.clone(),
1265 self.allowed_users.clone(),
1266 self.listen_to_bots,
1267 self.mention_only,
1268 );
1269 let reaction_channel_id = channel_id.clone();
1270 let reaction_message_id = message_id.to_string();
1271 let reaction_emoji = random_discord_ack_reaction().to_string();
1272 tokio::spawn(async move {
1273 if let Err(err) = reaction_channel
1274 .add_reaction(
1275 &reaction_channel_id,
1276 &reaction_message_id,
1277 &reaction_emoji,
1278 )
1279 .await
1280 {
1281 tracing::debug!(
1282 "Discord: failed to add ACK reaction for message {reaction_message_id}: {err}"
1283 );
1284 }
1285 });
1286 }
1287
1288 let channel_msg = ChannelMessage {
1289 id: if message_id.is_empty() {
1290 Uuid::new_v4().to_string()
1291 } else {
1292 format!("discord_{message_id}")
1293 },
1294 sender: author_id.to_string(),
1295 reply_target: if channel_id.is_empty() {
1296 author_id.to_string()
1297 } else {
1298 channel_id.clone()
1299 },
1300 content: final_content,
1301 channel: "discord".to_string(),
1302 timestamp: std::time::SystemTime::now()
1303 .duration_since(std::time::UNIX_EPOCH)
1304 .unwrap_or_default()
1305 .as_secs(),
1306 thread_ts: None,
1307 interruption_scope_id: None,
1308 attachments: vec![],
1309 };
1310
1311 if tx.send(channel_msg).await.is_err() {
1312 break;
1313 }
1314 }
1315 }
1316 }
1317
1318 Ok(())
1319 }
1320
1321 async fn health_check(&self) -> bool {
1322 self.http_client()
1323 .get("https://discord.com/api/v10/users/@me")
1324 .header("Authorization", format!("Bot {}", self.bot_token))
1325 .send()
1326 .await
1327 .map(|r| r.status().is_success())
1328 .unwrap_or(false)
1329 }
1330
1331 async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
1332 self.stop_typing(recipient).await?;
1333
1334 let client = self.http_client();
1335 let token = self.bot_token.clone();
1336 let channel_id = recipient.to_string();
1337
1338 let handle = tokio::spawn(async move {
1339 let url = format!("https://discord.com/api/v10/channels/{channel_id}/typing");
1340 loop {
1341 let _ = client
1342 .post(&url)
1343 .header("Authorization", format!("Bot {token}"))
1344 .send()
1345 .await;
1346 tokio::time::sleep(std::time::Duration::from_secs(8)).await;
1347 }
1348 });
1349
1350 let mut guard = self.typing_handles.lock();
1351 guard.insert(recipient.to_string(), handle);
1352
1353 Ok(())
1354 }
1355
1356 async fn stop_typing(&self, recipient: &str) -> anyhow::Result<()> {
1357 let mut guard = self.typing_handles.lock();
1358 if let Some(handle) = guard.remove(recipient) {
1359 handle.abort();
1360 }
1361 Ok(())
1362 }
1363
1364 fn supports_draft_updates(&self) -> bool {
1365 self.stream_mode != crate::config::StreamMode::Off
1366 }
1367
1368 fn supports_multi_message_streaming(&self) -> bool {
1369 self.stream_mode == crate::config::StreamMode::MultiMessage
1370 }
1371
1372 fn multi_message_delay_ms(&self) -> u64 {
1373 self.multi_message_delay_ms
1374 }
1375
1376 async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
1377 use crate::config::StreamMode;
1378 match self.stream_mode {
1379 StreamMode::Off => Ok(None),
1380 StreamMode::Partial => {
1381 let initial_text = if message.content.is_empty() {
1382 "...".to_string()
1383 } else {
1384 message.content.clone()
1385 };
1386
1387 let client = self.http_client();
1388 let msg_id = send_discord_message_json_with_id(
1389 &client,
1390 &self.bot_token,
1391 &message.recipient,
1392 &initial_text,
1393 )
1394 .await?;
1395
1396 self.last_draft_edit
1397 .lock()
1398 .insert(message.recipient.clone(), std::time::Instant::now());
1399
1400 Ok(Some(msg_id))
1401 }
1402 StreamMode::MultiMessage => {
1403 self.multi_message_sent_len.lock().clear();
1406 self.multi_message_thread_ts
1407 .lock()
1408 .insert(message.recipient.clone(), message.thread_ts.clone());
1409 Ok(Some("multi_message_synthetic".to_string()))
1410 }
1411 }
1412 }
1413
1414 async fn update_draft(
1415 &self,
1416 recipient: &str,
1417 message_id: &str,
1418 text: &str,
1419 ) -> anyhow::Result<()> {
1420 use crate::config::StreamMode;
1421 match self.stream_mode {
1422 StreamMode::Off => Ok(()),
1423 StreamMode::Partial => {
1424 {
1426 let last_edits = self.last_draft_edit.lock();
1427 if let Some(last_time) = last_edits.get(recipient) {
1428 let elapsed_ms =
1429 u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX);
1430 if elapsed_ms < self.draft_update_interval_ms {
1431 return Ok(());
1432 }
1433 }
1434 }
1435
1436 let display_text = if text.len() > DISCORD_MAX_MESSAGE_LENGTH {
1438 let mut end = 0;
1439 for (idx, ch) in text.char_indices() {
1440 let next = idx + ch.len_utf8();
1441 if next > DISCORD_MAX_MESSAGE_LENGTH {
1442 break;
1443 }
1444 end = next;
1445 }
1446 &text[..end]
1447 } else {
1448 text
1449 };
1450
1451 let client = self.http_client();
1452 match edit_discord_message(
1453 &client,
1454 &self.bot_token,
1455 recipient,
1456 message_id,
1457 display_text,
1458 )
1459 .await
1460 {
1461 Ok(()) => {
1462 self.last_draft_edit
1463 .lock()
1464 .insert(recipient.to_string(), std::time::Instant::now());
1465 }
1466 Err(e) => {
1467 tracing::debug!("Discord draft update failed: {e}");
1468 }
1469 }
1470
1471 Ok(())
1472 }
1473 StreamMode::MultiMessage => {
1474 let (paragraph, thread_ts) = {
1477 let thread_ts = self
1478 .multi_message_thread_ts
1479 .lock()
1480 .get(recipient)
1481 .cloned()
1482 .flatten();
1483 let mut sent_map = self.multi_message_sent_len.lock();
1484 let sent_so_far = sent_map.get(recipient).copied().unwrap_or(0);
1485
1486 if text.len() < sent_so_far {
1488 sent_map.insert(recipient.to_string(), 0);
1489 return Ok(());
1490 }
1491 if text.len() == sent_so_far {
1492 return Ok(());
1493 }
1494
1495 let new_text = &text[sent_so_far..];
1496 let mut scan_pos = 0;
1497 let mut in_fence = false;
1498 let bytes = new_text.as_bytes();
1499 let mut found_paragraph = None;
1500
1501 while scan_pos < bytes.len() {
1502 let ch = bytes[scan_pos];
1503
1504 if ch == b'`'
1505 && scan_pos + 2 < bytes.len()
1506 && bytes[scan_pos + 1] == b'`'
1507 && bytes[scan_pos + 2] == b'`'
1508 && (scan_pos == 0 || bytes[scan_pos - 1] == b'\n')
1509 {
1510 in_fence = !in_fence;
1511 }
1512
1513 if !in_fence
1514 && ch == b'\n'
1515 && scan_pos + 1 < bytes.len()
1516 && bytes[scan_pos + 1] == b'\n'
1517 {
1518 let paragraph = new_text[..scan_pos].trim().to_string();
1519 let consumed = scan_pos + 2;
1520 *sent_map.entry(recipient.to_string()).or_insert(0) += consumed;
1521 if !paragraph.is_empty() {
1522 found_paragraph = Some(paragraph);
1523 }
1524 break;
1525 }
1526
1527 scan_pos += 1;
1528 }
1529 (found_paragraph, thread_ts)
1531 };
1532
1533 if let Some(paragraph) = paragraph {
1534 let msg = SendMessage::new(¶graph, recipient).in_thread(thread_ts.clone());
1535 if let Err(e) = self.send(&msg).await {
1536 tracing::debug!("Discord multi-message paragraph send failed: {e}");
1537 }
1538 if self.multi_message_delay_ms > 0 {
1539 tokio::time::sleep(std::time::Duration::from_millis(
1540 self.multi_message_delay_ms,
1541 ))
1542 .await;
1543 }
1544 return self.update_draft(recipient, message_id, text).await;
1546 }
1547
1548 Ok(())
1549 }
1550 }
1551 }
1552
1553 async fn finalize_draft(
1554 &self,
1555 recipient: &str,
1556 message_id: &str,
1557 text: &str,
1558 ) -> anyhow::Result<()> {
1559 if self.stream_mode == crate::config::StreamMode::MultiMessage {
1560 let thread_ts = self
1562 .multi_message_thread_ts
1563 .lock()
1564 .remove(recipient)
1565 .flatten();
1566 let sent_so_far = self
1567 .multi_message_sent_len
1568 .lock()
1569 .remove(recipient)
1570 .unwrap_or(0);
1571 if text.len() > sent_so_far {
1572 let remaining = text[sent_so_far..].trim().to_string();
1573 if !remaining.is_empty() {
1574 let msg = SendMessage::new(&remaining, recipient).in_thread(thread_ts);
1575 if let Err(e) = self.send(&msg).await {
1576 tracing::debug!("Discord multi-message final flush failed: {e}");
1577 }
1578 }
1579 }
1580 return Ok(());
1581 }
1582
1583 let _ = self.stop_typing(recipient).await;
1585 self.last_draft_edit.lock().remove(recipient);
1586
1587 let text = &super::strip_tool_call_tags(text);
1588 let (cleaned_content, parsed_attachments) = parse_attachment_markers(text);
1589 let (mut local_files, remote_urls, unresolved_markers) =
1590 classify_outgoing_attachments(&parsed_attachments);
1591 let content =
1592 with_inline_attachment_urls(&cleaned_content, &remote_urls, &unresolved_markers);
1593
1594 let client = self.http_client();
1595
1596 if !local_files.is_empty() {
1598 let _ = delete_discord_message(&client, &self.bot_token, recipient, message_id).await;
1599
1600 if local_files.len() > 10 {
1601 local_files.truncate(10);
1602 }
1603 let chunks = split_message_for_discord(&content);
1604 for (i, chunk) in chunks.iter().enumerate() {
1605 if i == 0 {
1606 send_discord_message_with_files(
1607 &client,
1608 &self.bot_token,
1609 recipient,
1610 chunk,
1611 &local_files,
1612 )
1613 .await?;
1614 } else {
1615 send_discord_message_json(&client, &self.bot_token, recipient, chunk).await?;
1616 }
1617 if i < chunks.len() - 1 {
1618 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1619 }
1620 }
1621 return Ok(());
1622 }
1623
1624 if content.chars().count() > DISCORD_MAX_MESSAGE_LENGTH {
1626 let _ = delete_discord_message(&client, &self.bot_token, recipient, message_id).await;
1627
1628 let chunks = split_message_for_discord(&content);
1629 for (i, chunk) in chunks.iter().enumerate() {
1630 send_discord_message_json(&client, &self.bot_token, recipient, chunk).await?;
1631 if i < chunks.len() - 1 {
1632 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1633 }
1634 }
1635 return Ok(());
1636 }
1637
1638 if let Err(e) =
1640 edit_discord_message(&client, &self.bot_token, recipient, message_id, &content).await
1641 {
1642 tracing::warn!("Discord finalize_draft edit failed: {e}; falling back to delete+send");
1643 let _ = delete_discord_message(&client, &self.bot_token, recipient, message_id).await;
1644 send_discord_message_json(&client, &self.bot_token, recipient, &content).await?;
1645 }
1646
1647 Ok(())
1648 }
1649
1650 async fn cancel_draft(&self, recipient: &str, message_id: &str) -> anyhow::Result<()> {
1651 if self.stream_mode == crate::config::StreamMode::MultiMessage {
1652 self.multi_message_sent_len.lock().remove(recipient);
1653 self.multi_message_thread_ts.lock().remove(recipient);
1654 return Ok(());
1655 }
1656
1657 let _ = self.stop_typing(recipient).await;
1658 self.last_draft_edit.lock().remove(recipient);
1659
1660 let client = self.http_client();
1661 if let Err(e) =
1662 delete_discord_message(&client, &self.bot_token, recipient, message_id).await
1663 {
1664 tracing::debug!("Discord cancel_draft delete failed: {e}");
1665 }
1666
1667 Ok(())
1668 }
1669
1670 async fn add_reaction(
1671 &self,
1672 channel_id: &str,
1673 message_id: &str,
1674 emoji: &str,
1675 ) -> anyhow::Result<()> {
1676 let url = discord_reaction_url(channel_id, message_id, emoji);
1677
1678 let resp = self
1679 .http_client()
1680 .put(&url)
1681 .header("Authorization", format!("Bot {}", self.bot_token))
1682 .header("Content-Length", "0")
1683 .send()
1684 .await?;
1685
1686 if !resp.status().is_success() {
1687 let status = resp.status();
1688 let err = resp
1689 .text()
1690 .await
1691 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1692 anyhow::bail!("Discord add reaction failed ({status}): {err}");
1693 }
1694
1695 Ok(())
1696 }
1697
1698 async fn remove_reaction(
1699 &self,
1700 channel_id: &str,
1701 message_id: &str,
1702 emoji: &str,
1703 ) -> anyhow::Result<()> {
1704 let url = discord_reaction_url(channel_id, message_id, emoji);
1705
1706 let resp = self
1707 .http_client()
1708 .delete(&url)
1709 .header("Authorization", format!("Bot {}", self.bot_token))
1710 .send()
1711 .await?;
1712
1713 if !resp.status().is_success() {
1714 let status = resp.status();
1715 let err = resp
1716 .text()
1717 .await
1718 .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1719 anyhow::bail!("Discord remove reaction failed ({status}): {err}");
1720 }
1721
1722 Ok(())
1723 }
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728 use super::*;
1729
1730 #[test]
1731 fn discord_channel_name() {
1732 let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
1733 assert_eq!(ch.name(), "discord");
1734 }
1735
1736 #[test]
1737 fn base64_decode_bot_id() {
1738 let decoded = base64_decode("MTIzNDU2");
1740 assert_eq!(decoded, Some("123456".to_string()));
1741 }
1742
1743 #[test]
1744 fn bot_user_id_extraction() {
1745 let token = "MTIzNDU2.fake.hmac";
1747 let id = DiscordChannel::bot_user_id_from_token(token);
1748 assert_eq!(id, Some("123456".to_string()));
1749 }
1750
1751 #[test]
1752 fn empty_allowlist_denies_everyone() {
1753 let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
1754 assert!(!ch.is_user_allowed("12345"));
1755 assert!(!ch.is_user_allowed("anyone"));
1756 }
1757
1758 #[test]
1759 fn wildcard_allows_everyone() {
1760 let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()], false, false);
1761 assert!(ch.is_user_allowed("12345"));
1762 assert!(ch.is_user_allowed("anyone"));
1763 }
1764
1765 #[test]
1766 fn specific_allowlist_filters() {
1767 let ch = DiscordChannel::new(
1768 "fake".into(),
1769 None,
1770 vec!["111".into(), "222".into()],
1771 false,
1772 false,
1773 );
1774 assert!(ch.is_user_allowed("111"));
1775 assert!(ch.is_user_allowed("222"));
1776 assert!(!ch.is_user_allowed("333"));
1777 assert!(!ch.is_user_allowed("unknown"));
1778 }
1779
1780 #[test]
1781 fn allowlist_is_exact_match_not_substring() {
1782 let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false);
1783 assert!(!ch.is_user_allowed("1111"));
1784 assert!(!ch.is_user_allowed("11"));
1785 assert!(!ch.is_user_allowed("0111"));
1786 }
1787
1788 #[test]
1789 fn allowlist_empty_string_user_id() {
1790 let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false);
1791 assert!(!ch.is_user_allowed(""));
1792 }
1793
1794 #[test]
1795 fn allowlist_with_wildcard_and_specific() {
1796 let ch = DiscordChannel::new(
1797 "fake".into(),
1798 None,
1799 vec!["111".into(), "*".into()],
1800 false,
1801 false,
1802 );
1803 assert!(ch.is_user_allowed("111"));
1804 assert!(ch.is_user_allowed("anyone_else"));
1805 }
1806
1807 #[test]
1808 fn allowlist_case_sensitive() {
1809 let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()], false, false);
1810 assert!(ch.is_user_allowed("ABC"));
1811 assert!(!ch.is_user_allowed("abc"));
1812 assert!(!ch.is_user_allowed("Abc"));
1813 }
1814
1815 #[test]
1816 fn base64_decode_empty_string() {
1817 let decoded = base64_decode("");
1818 assert_eq!(decoded, Some(String::new()));
1819 }
1820
1821 #[test]
1822 fn base64_decode_invalid_chars() {
1823 let decoded = base64_decode("!!!!");
1824 assert!(decoded.is_none());
1825 }
1826
1827 #[test]
1828 fn bot_user_id_from_empty_token() {
1829 let id = DiscordChannel::bot_user_id_from_token("");
1830 assert_eq!(id, Some(String::new()));
1831 }
1832
1833 #[test]
1834 fn contains_bot_mention_supports_plain_and_nick_forms() {
1835 assert!(contains_bot_mention("hi <@12345>", "12345"));
1836 assert!(contains_bot_mention("hi <@!12345>", "12345"));
1837 assert!(!contains_bot_mention("hi <@99999>", "12345"));
1838 }
1839
1840 #[test]
1841 fn normalize_incoming_content_requires_mention_when_enabled() {
1842 let cleaned = normalize_incoming_content("hello there", true, "12345");
1843 assert!(cleaned.is_none());
1844 }
1845
1846 #[test]
1847 fn normalize_incoming_content_strips_mentions_and_trims() {
1848 let cleaned = normalize_incoming_content(" <@!12345> run status ", true, "12345");
1849 assert_eq!(cleaned.as_deref(), Some("run status"));
1850 }
1851
1852 #[test]
1853 fn normalize_incoming_content_rejects_empty_after_strip() {
1854 let cleaned = normalize_incoming_content("<@12345>", true, "12345");
1855 assert!(cleaned.is_none());
1856 }
1857
1858 #[test]
1861 fn mention_only_dm_bypasses_mention_gate() {
1862 let mention_only = true;
1865 let is_dm = true;
1866 let effective = mention_only && !is_dm;
1867 let cleaned = normalize_incoming_content("hello without mention", effective, "12345");
1868 assert_eq!(cleaned.as_deref(), Some("hello without mention"));
1869 }
1870
1871 #[test]
1872 fn mention_only_guild_message_without_mention_is_rejected() {
1873 let mention_only = true;
1876 let is_dm = false;
1877 let effective = mention_only && !is_dm;
1878 let cleaned = normalize_incoming_content("hello without mention", effective, "12345");
1879 assert!(cleaned.is_none());
1880 }
1881
1882 #[test]
1883 fn mention_only_guild_message_with_mention_passes_and_strips() {
1884 let mention_only = true;
1887 let is_dm = false;
1888 let effective = mention_only && !is_dm;
1889 let cleaned = normalize_incoming_content("<@12345> run status", effective, "12345");
1890 assert_eq!(cleaned.as_deref(), Some("run status"));
1891 }
1892
1893 #[test]
1896 fn split_empty_message() {
1897 let chunks = split_message_for_discord("");
1898 assert_eq!(chunks, vec![""]);
1899 }
1900
1901 #[test]
1902 fn split_short_message_under_limit() {
1903 let msg = "Hello, world!";
1904 let chunks = split_message_for_discord(msg);
1905 assert_eq!(chunks, vec![msg]);
1906 }
1907
1908 #[test]
1909 fn split_message_exactly_2000_chars() {
1910 let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
1911 let chunks = split_message_for_discord(&msg);
1912 assert_eq!(chunks.len(), 1);
1913 assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
1914 }
1915
1916 #[test]
1917 fn split_message_just_over_limit() {
1918 let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
1919 let chunks = split_message_for_discord(&msg);
1920 assert_eq!(chunks.len(), 2);
1921 assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
1922 assert_eq!(chunks[1].chars().count(), 1);
1923 }
1924
1925 #[test]
1926 fn split_very_long_message() {
1927 let msg = "word ".repeat(2000); let chunks = split_message_for_discord(&msg);
1929 assert_eq!(chunks.len(), 5);
1931 assert!(
1932 chunks
1933 .iter()
1934 .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH)
1935 );
1936 let reconstructed = chunks.concat();
1938 assert_eq!(reconstructed, msg);
1939 }
1940
1941 #[test]
1942 fn split_prefer_newline_break() {
1943 let msg = format!("{}\n{}", "a".repeat(1500), "b".repeat(500));
1944 let chunks = split_message_for_discord(&msg);
1945 assert_eq!(chunks.len(), 2);
1947 assert!(chunks[0].ends_with('\n'));
1948 assert!(chunks[1].starts_with('b'));
1949 }
1950
1951 #[test]
1952 fn split_prefer_space_break() {
1953 let msg = format!("{} {}", "a".repeat(1500), "b".repeat(600));
1954 let chunks = split_message_for_discord(&msg);
1955 assert_eq!(chunks.len(), 2);
1956 }
1957
1958 #[test]
1959 fn split_without_good_break_points_hard_split() {
1960 let msg = "a".repeat(5000);
1962 let chunks = split_message_for_discord(&msg);
1963 assert_eq!(chunks.len(), 3);
1964 assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
1965 assert_eq!(chunks[1].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
1966 assert_eq!(chunks[2].chars().count(), 1000);
1967 }
1968
1969 #[test]
1970 fn split_multiple_breaks() {
1971 let part1 = "a".repeat(900);
1973 let part2 = "b".repeat(900);
1974 let part3 = "c".repeat(900);
1975 let msg = format!("{part1}\n{part2}\n{part3}");
1976 let chunks = split_message_for_discord(&msg);
1977 assert_eq!(chunks.len(), 2);
1979 assert!(chunks[0].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
1980 assert!(chunks[1].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
1981 }
1982
1983 #[test]
1984 fn split_preserves_content() {
1985 let original = "Hello world! This is a test message with some content. ".repeat(200);
1986 let chunks = split_message_for_discord(&original);
1987 let reconstructed = chunks.concat();
1988 assert_eq!(reconstructed, original);
1989 }
1990
1991 #[test]
1992 fn split_unicode_content() {
1993 let msg = "🦀 Rust is awesome! ".repeat(500);
1995 let chunks = split_message_for_discord(&msg);
1996 for chunk in &chunks {
1998 assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
1999 assert!(chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
2000 }
2001 let reconstructed = chunks.concat();
2003 assert_eq!(reconstructed, msg);
2004 }
2005
2006 #[test]
2007 fn split_newline_too_close_to_end() {
2008 let msg = format!("{}\n{}", "a".repeat(1900), "b".repeat(500));
2010 let chunks = split_message_for_discord(&msg);
2011 assert_eq!(chunks.len(), 2);
2013 }
2014
2015 #[test]
2016 fn split_multibyte_only_content_without_panics() {
2017 let msg = "🦀".repeat(2500);
2018 let chunks = split_message_for_discord(&msg);
2019 assert_eq!(chunks.len(), 2);
2020 assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
2021 assert_eq!(chunks[1].chars().count(), 500);
2022 let reconstructed = chunks.concat();
2023 assert_eq!(reconstructed, msg);
2024 }
2025
2026 #[test]
2027 fn split_chunks_always_within_discord_limit() {
2028 let msg = "x".repeat(12_345);
2029 let chunks = split_message_for_discord(&msg);
2030 assert!(
2031 chunks
2032 .iter()
2033 .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH)
2034 );
2035 }
2036
2037 #[test]
2038 fn split_message_with_multiple_newlines() {
2039 let msg = "Line 1\nLine 2\nLine 3\n".repeat(1000);
2040 let chunks = split_message_for_discord(&msg);
2041 assert!(chunks.len() > 1);
2042 let reconstructed = chunks.concat();
2043 assert_eq!(reconstructed, msg);
2044 }
2045
2046 #[test]
2047 fn typing_handles_start_empty() {
2048 let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
2049 let guard = ch.typing_handles.lock();
2050 assert!(guard.is_empty());
2051 }
2052
2053 #[tokio::test]
2054 async fn start_typing_sets_handle() {
2055 let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
2056 let _ = ch.start_typing("123456").await;
2057 let guard = ch.typing_handles.lock();
2058 assert!(guard.contains_key("123456"));
2059 }
2060
2061 #[tokio::test]
2062 async fn stop_typing_clears_handle() {
2063 let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
2064 let _ = ch.start_typing("123456").await;
2065 let _ = ch.stop_typing("123456").await;
2066 let guard = ch.typing_handles.lock();
2067 assert!(!guard.contains_key("123456"));
2068 }
2069
2070 #[tokio::test]
2071 async fn stop_typing_is_idempotent() {
2072 let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
2073 assert!(ch.stop_typing("123456").await.is_ok());
2074 assert!(ch.stop_typing("123456").await.is_ok());
2075 }
2076
2077 #[tokio::test]
2078 async fn concurrent_typing_handles_are_independent() {
2079 let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
2080 let _ = ch.start_typing("111").await;
2081 let _ = ch.start_typing("222").await;
2082 {
2083 let guard = ch.typing_handles.lock();
2084 assert_eq!(guard.len(), 2);
2085 assert!(guard.contains_key("111"));
2086 assert!(guard.contains_key("222"));
2087 }
2088 let _ = ch.stop_typing("111").await;
2090 let guard = ch.typing_handles.lock();
2091 assert_eq!(guard.len(), 1);
2092 assert!(guard.contains_key("222"));
2093 }
2094
2095 #[test]
2098 fn encode_emoji_unicode_percent_encodes() {
2099 let encoded = encode_emoji_for_discord("\u{1F440}");
2100 assert_eq!(encoded, "%F0%9F%91%80");
2101 }
2102
2103 #[test]
2104 fn encode_emoji_checkmark() {
2105 let encoded = encode_emoji_for_discord("\u{2705}");
2106 assert_eq!(encoded, "%E2%9C%85");
2107 }
2108
2109 #[test]
2110 fn encode_emoji_custom_guild_emoji_passthrough() {
2111 let encoded = encode_emoji_for_discord("custom_emoji:123456789");
2112 assert_eq!(encoded, "custom_emoji:123456789");
2113 }
2114
2115 #[test]
2116 fn encode_emoji_simple_ascii_char() {
2117 let encoded = encode_emoji_for_discord("A");
2118 assert_eq!(encoded, "%41");
2119 }
2120
2121 #[test]
2122 fn random_discord_ack_reaction_is_from_pool() {
2123 for _ in 0..128 {
2124 let emoji = random_discord_ack_reaction();
2125 assert!(DISCORD_ACK_REACTIONS.contains(&emoji));
2126 }
2127 }
2128
2129 #[test]
2130 fn discord_reaction_url_encodes_emoji_and_strips_prefix() {
2131 let url = discord_reaction_url("123", "discord_456", "👀");
2132 assert_eq!(
2133 url,
2134 "https://discord.com/api/v10/channels/123/messages/456/reactions/%F0%9F%91%80/@me"
2135 );
2136 }
2137
2138 #[test]
2141 fn discord_message_id_format_includes_discord_prefix() {
2142 let message_id = "123456789012345678";
2144 let expected_id = format!("discord_{message_id}");
2145 assert_eq!(expected_id, "discord_123456789012345678");
2146 }
2147
2148 #[test]
2149 fn discord_message_id_is_deterministic() {
2150 let message_id = "123456789012345678";
2152 let id1 = format!("discord_{message_id}");
2153 let id2 = format!("discord_{message_id}");
2154 assert_eq!(id1, id2);
2155 }
2156
2157 #[test]
2158 fn discord_message_id_different_message_different_id() {
2159 let id1 = "discord_123456789012345678".to_string();
2161 let id2 = "discord_987654321098765432".to_string();
2162 assert_ne!(id1, id2);
2163 }
2164
2165 #[test]
2166 fn discord_message_id_uses_snowflake_id() {
2167 let message_id = "123456789012345678"; let id = format!("discord_{message_id}");
2170 assert!(id.starts_with("discord_"));
2171 assert!(message_id.chars().all(|c| c.is_ascii_digit()));
2173 }
2174
2175 #[test]
2176 fn discord_message_id_fallback_to_uuid_on_empty() {
2177 let message_id = "";
2179 let id = if message_id.is_empty() {
2180 format!("discord_{}", uuid::Uuid::new_v4())
2181 } else {
2182 format!("discord_{message_id}")
2183 };
2184 assert!(id.starts_with("discord_"));
2185 assert!(id.contains('-'));
2187 }
2188
2189 #[test]
2195 fn split_message_code_block_at_boundary() {
2196 let mut msg = String::new();
2198 msg.push_str("```rust\n");
2199 msg.push_str(&"x".repeat(1990));
2200 msg.push_str("\n```\nMore text after code block");
2201 let parts = split_message_for_discord(&msg);
2202 assert!(
2203 parts.len() >= 2,
2204 "code block spanning boundary should split"
2205 );
2206 for part in &parts {
2207 assert!(
2208 part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
2209 "each part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
2210 part.len()
2211 );
2212 }
2213 }
2214
2215 #[test]
2216 fn split_message_single_long_word_exceeds_limit() {
2217 let long_word = "a".repeat(2500);
2219 let parts = split_message_for_discord(&long_word);
2220 assert!(parts.len() >= 2, "word exceeding limit must be split");
2221 for part in &parts {
2222 assert!(
2223 part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
2224 "hard-split part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
2225 part.len()
2226 );
2227 }
2228 let reassembled: String = parts.join("");
2230 assert_eq!(reassembled, long_word);
2231 }
2232
2233 #[test]
2234 fn split_message_exactly_at_limit_no_split() {
2235 let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
2236 let parts = split_message_for_discord(&msg);
2237 assert_eq!(parts.len(), 1, "message exactly at limit should not split");
2238 assert_eq!(parts[0].len(), DISCORD_MAX_MESSAGE_LENGTH);
2239 }
2240
2241 #[test]
2242 fn split_message_one_over_limit_splits() {
2243 let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
2244 let parts = split_message_for_discord(&msg);
2245 assert!(parts.len() >= 2, "message 1 char over limit must split");
2246 }
2247
2248 #[test]
2249 fn split_message_many_short_lines() {
2250 let msg: String = (0..500).fold(String::new(), |mut acc, i| {
2252 let _ = writeln!(acc, "line {i}");
2253 acc
2254 });
2255 let parts = split_message_for_discord(&msg);
2256 for part in &parts {
2257 assert!(
2258 part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
2259 "short-line batch must be <= limit"
2260 );
2261 }
2262 let reassembled: String = parts.join("");
2264 assert_eq!(reassembled.trim(), msg.trim());
2265 }
2266
2267 #[test]
2268 fn split_message_only_whitespace() {
2269 let msg = " \n\n\t ";
2270 let parts = split_message_for_discord(msg);
2271 assert!(parts.len() <= 1);
2273 }
2274
2275 #[test]
2276 fn split_message_emoji_at_boundary() {
2277 let mut msg = "a".repeat(1998);
2279 msg.push_str("🎉🎊"); let parts = split_message_for_discord(&msg);
2281 for part in &parts {
2282 assert!(
2284 part.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH,
2285 "emoji boundary split must respect limit"
2286 );
2287 }
2288 }
2289
2290 #[test]
2291 fn split_message_consecutive_newlines_at_boundary() {
2292 let mut msg = "a".repeat(1995);
2293 msg.push_str("\n\n\n\n\n");
2294 msg.push_str(&"b".repeat(100));
2295 let parts = split_message_for_discord(&msg);
2296 for part in &parts {
2297 assert!(part.len() <= DISCORD_MAX_MESSAGE_LENGTH);
2298 }
2299 }
2300
2301 #[tokio::test]
2304 async fn process_attachments_empty_list_returns_empty() {
2305 let client = reqwest::Client::new();
2306 let result = process_attachments(&[], &client).await;
2307 assert!(result.is_empty());
2308 }
2309
2310 #[tokio::test]
2311 async fn process_attachments_skips_unsupported_types() {
2312 let client = reqwest::Client::new();
2313 let attachments = vec![serde_json::json!({
2314 "url": "https://cdn.discordapp.com/attachments/123/456/doc.pdf",
2315 "filename": "doc.pdf",
2316 "content_type": "application/pdf"
2317 })];
2318 let result = process_attachments(&attachments, &client).await;
2319 assert!(result.is_empty());
2320 }
2321
2322 #[test]
2323 fn parse_attachment_markers_extracts_supported_markers() {
2324 let input = "Report\n[IMAGE:https://example.com/a.png]\n[DOCUMENT:/tmp/a.pdf]";
2325 let (cleaned, attachments) = parse_attachment_markers(input);
2326
2327 assert_eq!(cleaned, "Report");
2328 assert_eq!(attachments.len(), 2);
2329 assert_eq!(attachments[0].kind, DiscordAttachmentKind::Image);
2330 assert_eq!(attachments[0].target, "https://example.com/a.png");
2331 assert_eq!(attachments[1].kind, DiscordAttachmentKind::Document);
2332 assert_eq!(attachments[1].target, "/tmp/a.pdf");
2333 }
2334
2335 #[test]
2336 fn parse_attachment_markers_keeps_invalid_marker_text() {
2337 let input = "Hello [NOT_A_MARKER:foo] world";
2338 let (cleaned, attachments) = parse_attachment_markers(input);
2339
2340 assert_eq!(cleaned, input);
2341 assert!(attachments.is_empty());
2342 }
2343
2344 #[test]
2345 fn classify_outgoing_attachments_splits_local_remote_and_unresolved() {
2346 let temp = tempfile::tempdir().expect("tempdir");
2347 let file_path = temp.path().join("image.png");
2348 std::fs::write(&file_path, b"fake").expect("write fixture");
2349
2350 let attachments = vec![
2351 DiscordAttachment {
2352 kind: DiscordAttachmentKind::Image,
2353 target: file_path.to_string_lossy().to_string(),
2354 },
2355 DiscordAttachment {
2356 kind: DiscordAttachmentKind::Image,
2357 target: "https://example.com/remote.png".to_string(),
2358 },
2359 DiscordAttachment {
2360 kind: DiscordAttachmentKind::Video,
2361 target: "/tmp/does-not-exist.mp4".to_string(),
2362 },
2363 ];
2364
2365 let (locals, remotes, unresolved) = classify_outgoing_attachments(&attachments);
2366 assert_eq!(locals.len(), 1);
2367 assert_eq!(locals[0], file_path);
2368 assert_eq!(remotes, vec!["https://example.com/remote.png".to_string()]);
2369 assert_eq!(
2370 unresolved,
2371 vec!["[VIDEO:/tmp/does-not-exist.mp4]".to_string()]
2372 );
2373 }
2374
2375 #[test]
2376 fn with_inline_attachment_urls_appends_urls_and_unresolved_markers() {
2377 let content = "Done";
2378 let remote_urls = vec!["https://example.com/a.png".to_string()];
2379 let unresolved = vec!["[IMAGE:/tmp/missing.png]".to_string()];
2380
2381 let rendered = with_inline_attachment_urls(content, &remote_urls, &unresolved);
2382 assert_eq!(
2383 rendered,
2384 "Done\nhttps://example.com/a.png\n[IMAGE:/tmp/missing.png]"
2385 );
2386 }
2387
2388 #[test]
2391 fn supports_draft_updates_respects_stream_mode() {
2392 use crate::config::StreamMode;
2393
2394 let off = DiscordChannel::new("t".into(), None, vec![], false, false);
2395 assert!(!off.supports_draft_updates());
2396
2397 let partial = DiscordChannel::new("t".into(), None, vec![], false, false).with_streaming(
2398 StreamMode::Partial,
2399 750,
2400 800,
2401 );
2402 assert!(partial.supports_draft_updates());
2403 assert_eq!(partial.draft_update_interval_ms, 750);
2404
2405 let multi = DiscordChannel::new("t".into(), None, vec![], false, false).with_streaming(
2406 StreamMode::MultiMessage,
2407 1000,
2408 600,
2409 );
2410 assert!(multi.supports_draft_updates());
2411 assert_eq!(multi.multi_message_delay_ms, 600);
2412 }
2413
2414 #[tokio::test]
2415 async fn send_draft_returns_none_when_not_partial() {
2416 use crate::channels::traits::SendMessage;
2417 use crate::config::StreamMode;
2418
2419 let off = DiscordChannel::new("t".into(), None, vec![], false, false);
2420 let msg = SendMessage::new("hello", "123");
2421 assert!(off.send_draft(&msg).await.unwrap().is_none());
2422
2423 let multi = DiscordChannel::new("t".into(), None, vec![], false, false).with_streaming(
2424 StreamMode::MultiMessage,
2425 1000,
2426 800,
2427 );
2428 assert_eq!(
2430 multi.send_draft(&msg).await.unwrap().as_deref(),
2431 Some("multi_message_synthetic")
2432 );
2433 }
2434
2435 #[tokio::test]
2436 async fn update_draft_rate_limit_short_circuits() {
2437 use crate::config::StreamMode;
2438
2439 let ch = DiscordChannel::new("t".into(), None, vec![], false, false).with_streaming(
2440 StreamMode::Partial,
2441 60_000,
2442 800,
2443 );
2444
2445 ch.last_draft_edit
2447 .lock()
2448 .insert("chan".to_string(), std::time::Instant::now());
2449
2450 let result = ch.update_draft("chan", "fake_msg_id", "new text").await;
2452 assert!(result.is_ok());
2453 }
2454
2455 #[tokio::test]
2456 async fn cancel_draft_cleans_up_tracking() {
2457 use crate::config::StreamMode;
2458
2459 let ch = DiscordChannel::new("t".into(), None, vec![], false, false).with_streaming(
2460 StreamMode::Partial,
2461 1000,
2462 800,
2463 );
2464
2465 ch.last_draft_edit
2466 .lock()
2467 .insert("chan".to_string(), std::time::Instant::now());
2468
2469 let _ = ch.cancel_draft("chan", "fake_msg_id").await;
2472 assert!(!ch.last_draft_edit.lock().contains_key("chan"));
2473 }
2474
2475 #[test]
2478 fn split_message_for_discord_multi_splits_at_paragraphs() {
2479 let content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
2480 let chunks = split_message_for_discord_multi(content, 2000);
2481 assert_eq!(chunks.len(), 3);
2482 assert_eq!(chunks[0], "First paragraph.");
2483 assert_eq!(chunks[1], "Second paragraph.");
2484 assert_eq!(chunks[2], "Third paragraph.");
2485 }
2486
2487 #[test]
2488 fn split_message_for_discord_multi_single_paragraph() {
2489 let content = "Just one paragraph with no breaks.";
2490 let chunks = split_message_for_discord_multi(content, 2000);
2491 assert_eq!(chunks.len(), 1);
2492 assert_eq!(chunks[0], content);
2493 }
2494
2495 #[test]
2496 fn split_message_for_discord_multi_respects_max_len() {
2497 let long_para = "a ".repeat(1100); let chunks = split_message_for_discord_multi(&long_para, 2000);
2500 assert!(chunks.len() > 1, "should split oversized paragraph");
2501 for chunk in &chunks {
2502 assert!(
2503 chunk.chars().count() <= 2000,
2504 "chunk exceeds max: {}",
2505 chunk.chars().count()
2506 );
2507 }
2508 }
2509
2510 #[test]
2511 fn split_message_for_discord_multi_preserves_code_fences() {
2512 let content =
2513 "Before.\n\n```rust\nfn main() {\n\n println!(\"hello\");\n}\n```\n\nAfter.";
2514 let chunks = split_message_for_discord_multi(content, 2000);
2515 assert_eq!(chunks.len(), 3);
2517 assert_eq!(chunks[0], "Before.");
2518 assert!(chunks[1].contains("```rust"));
2519 assert!(chunks[1].contains("println!"));
2520 assert!(chunks[1].contains("```"));
2521 assert_eq!(chunks[2], "After.");
2522 }
2523
2524 #[test]
2525 fn split_message_for_discord_multi_empty_input() {
2526 let chunks = split_message_for_discord_multi("", 2000);
2527 assert!(chunks.is_empty());
2528 }
2529}