Skip to main content

construct/channels/
discord.rs

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
14/// Discord channel — connects via Gateway WebSocket for real-time messages
15pub 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    /// Per-channel proxy URL override.
23    proxy_url: Option<String>,
24    /// Voice transcription config — when set, audio attachments are
25    /// downloaded, transcribed, and their text inlined into the message.
26    transcription: Option<crate::config::TranscriptionConfig>,
27    transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
28    /// Streaming mode: Off, Partial (draft edits), or MultiMessage (paragraph splits).
29    stream_mode: crate::config::StreamMode,
30    /// Minimum interval (ms) between draft message edits (Partial mode only).
31    draft_update_interval_ms: u64,
32    /// Delay (ms) between sending each message chunk (MultiMessage mode only).
33    multi_message_delay_ms: u64,
34    /// Per-channel rate-limit tracking for draft edits.
35    last_draft_edit: Mutex<HashMap<String, std::time::Instant>>,
36    /// Tracks how much text has been sent in MultiMessage mode.
37    multi_message_sent_len: Mutex<HashMap<String, usize>>,
38    /// Thread context captured from `send_draft()` for MultiMessage paragraph delivery.
39    multi_message_thread_ts: Mutex<HashMap<String, Option<String>>>,
40    /// Registry of pending human approval requests — when set, enables keyword-based approval
41    /// intercept in `listen()`.
42    approval_registry: Option<Arc<crate::gateway::approval_registry::ApprovalRegistry>>,
43    /// Gateway port used to POST approval decisions to the local REST endpoint.
44    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    /// Configure the approval registry and gateway port for keyword-based approval intercept.
77    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    /// Set a per-channel proxy URL that overrides the global proxy config.
88    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
89        self.proxy_url = proxy_url;
90        self
91    }
92
93    /// Configure voice transcription for audio attachments.
94    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    /// Configure streaming mode for progressive draft updates or multi-message delivery.
113    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    /// Check if a Discord user ID is in the allowlist.
130    /// Empty list means deny everyone until explicitly configured.
131    /// `"*"` means allow everyone.
132    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        // Discord bot tokens are base64(bot_user_id).timestamp.hmac
138        let part = token.split('.').next()?;
139        base64_decode(part)
140    }
141}
142
143/// Process Discord message attachments and return a string to append to the
144/// agent message context.
145///
146/// Only `text/*` MIME types are fetched and inlined. All other types are
147/// silently skipped. Fetch errors are logged as warnings.
148async 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
191/// Audio file extensions accepted for voice transcription.
192const DISCORD_AUDIO_EXTENSIONS: &[&str] = &[
193    "flac", "mp3", "mpeg", "mpga", "mp4", "m4a", "ogg", "oga", "opus", "wav", "webm",
194];
195
196/// Check if a content type or filename indicates an audio file.
197fn 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
207/// Download and transcribe audio attachments from a Discord message.
208///
209/// Returns transcribed text blocks for any audio attachments found.
210/// Non-audio attachments and failures are silently skipped.
211async 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
476/// Send a message and return the Discord message ID from the response.
477async 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
510/// Edit an existing Discord message via PATCH.
511///
512/// Returns `Ok(())` on success. On HTTP 429 (rate limited), logs at debug
513/// level and returns `Ok(())` since skipping a mid-stream edit is harmless.
514async 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
548/// Delete a Discord message.
549///
550/// Returns `Ok(())` on success. On HTTP 429 (rate limited), logs at debug
551/// level and returns `Ok(())` since a stale message is cosmetic only.
552async 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
585/// Discord's maximum message length for regular messages.
586///
587/// Discord rejects longer payloads with `50035 Invalid Form Body`.
588const DISCORD_MAX_MESSAGE_LENGTH: usize = 2000;
589const DISCORD_ACK_REACTIONS: &[&str] = &["⚡️", "🦀", "🙌", "💪", "👌", "👀", "👣"];
590
591/// Split a message into chunks that respect Discord's 2000-character limit.
592/// Tries to split at word boundaries when possible.
593fn 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        // Find the byte offset for the 2000th character boundary.
603        // If there are fewer than 2000 chars left, we can emit the tail directly.
604        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            // Try to find a good break point (newline, then space)
613            let search_area = &remaining[..hard_split];
614
615            // Prefer splitting at newline
616            if let Some(pos) = search_area.rfind('\n') {
617                // Don't split if the newline is too close to the end
618                if search_area[..pos].chars().count() >= DISCORD_MAX_MESSAGE_LENGTH / 2 {
619                    pos + 1
620                } else {
621                    // Try space as fallback
622                    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 at the limit
628                hard_split
629            }
630        };
631
632        chunks.push(remaining[..chunk_end].to_string());
633        remaining = &remaining[chunk_end..];
634    }
635
636    chunks
637}
638
639/// Split a message into multiple logical chunks at paragraph boundaries for
640/// multi-message delivery. Respects code fences — never splits inside a
641/// fenced code block. Falls back to [`split_message_for_discord`] for any
642/// segment that exceeds `max_len`.
643fn split_message_for_discord_multi(content: &str, max_len: usize) -> Vec<String> {
644    if content.is_empty() {
645        return vec![];
646    }
647
648    // Gather paragraph-level segments, respecting code fences.
649    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 we hit a blank line outside a fence, that's a paragraph break.
660        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    // Now coalesce small segments and split oversized ones.
676    let mut chunks: Vec<String> = Vec::new();
677
678    for segment in segments {
679        if segment.chars().count() > max_len {
680            // This segment (possibly a large code fence) exceeds the limit.
681            // Fall back to the word-boundary splitter.
682            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
714/// URL-encode a Unicode emoji for use in Discord reaction API paths.
715///
716/// Discord's reaction endpoints accept raw Unicode emoji in the URL path,
717/// but they must be percent-encoded per RFC 3986. Custom guild emojis use
718/// the `name:id` format and are passed through unencoded.
719fn 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/// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion
777#[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        // Discord accepts max 10 files per message.
834        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        // MultiMessage mode: split at paragraph boundaries and send each as a
846        // separate message with a configurable delay between them.
847        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                    // Check cancellation between chunks so interruption stops delivery.
868                    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        // Default / Partial fallback: single chunked message delivery.
891        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        // Get Gateway URL
922        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        // Read Hello (opcode 10)
948        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        // Send Identify (opcode 2)
957        let identify = json!({
958            "op": 2,
959            "d": {
960                "token": self.bot_token,
961                "intents": 37377, // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT | DIRECT_MESSAGES
962                "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        // Track the last sequence number for heartbeats and resume.
976        // Only accessed in the select! loop below, so a plain i64 suffices.
977        let mut sequence: i64 = -1;
978
979        // Spawn heartbeat timer — sends a tick signal, actual heartbeat
980        // is assembled in the select! loop where `sequence` lives.
981        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                    // Track sequence number from all dispatch events
1028                    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                        // Op 1: Server requests an immediate heartbeat
1036                        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                        // Op 7: Reconnect
1045                        7 => {
1046                            tracing::warn!("Discord: received Reconnect (op 7), closing for restart");
1047                            break;
1048                        }
1049                        // Op 9: Invalid Session
1050                        9 => {
1051                            tracing::warn!("Discord: received Invalid Session (op 9), closing for restart");
1052                            break;
1053                        }
1054                        _ => {}
1055                    }
1056
1057                    // Only handle MESSAGE_CREATE (opcode 0, type "MESSAGE_CREATE")
1058                    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                    // Skip messages from the bot itself
1068                    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                    // Skip bot messages (unless listen_to_bots is enabled)
1074                    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                    // Sender validation
1079                    if !self.is_user_allowed(author_id) {
1080                        tracing::warn!("Discord: ignoring message from unauthorized user: {author_id}");
1081                        continue;
1082                    }
1083
1084                    // Guild filter
1085                    if let Some(ref gid) = guild_filter {
1086                        let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str);
1087                        // DMs have no guild_id — let them through; for guild messages, enforce the filter
1088                        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                    // DMs carry no guild_id in the Discord gateway payload. They are
1097                    // inherently private and implicitly addressed to the bot, so bypass
1098                    // the mention gate — requiring a @mention in a DM is never correct.
1099                    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                    // ── Human approval keyword intercept ─────────────────────
1108                    // Check BEFORE forwarding to the agent pipeline.
1109                    // We check against `clean_content` (mention-stripped, trimmed text)
1110                    // since approval keywords are standalone text commands.
1111                    //
1112                    // Scoping: the incoming message must be either in the thread
1113                    // we created for this approval, or a Discord reply to the
1114                    // original approval prompt. The registry enforces that —
1115                    // here we just extract the relevant IDs. Thread messages
1116                    // arrive with `channel_id` set to the thread's ID (threads
1117                    // are themselves channels in Discord's model).
1118                    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                            // Atomically claim — prevents duplicate handling.
1135                            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                                    // Call the gateway's approve endpoint on localhost.
1148                                    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                                    // Send confirmation message back to Discord.
1182                                    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(&notify_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                                // Don't forward to the agent pipeline.
1213                                continue;
1214                            }
1215                        }
1216                    }
1217                    // ── End keyword intercept ────────────────────────────────
1218
1219                    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                        // Transcribe audio attachments when transcription is configured
1229                        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                // No initial draft — paragraphs are sent as new messages.
1404                // Store thread context for paragraph delivery.
1405                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                // Rate-limit edits per channel.
1425                {
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                // UTF-8 safe truncation to Discord limit.
1437                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                // Track accumulated text and send new paragraphs at \n\n boundaries.
1475                // Extract paragraph (if any) under the lock, then drop it before async work.
1476                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                    // DraftEvent::Clear resets accumulated text — reset our counter.
1487                    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                    // Lock is dropped here at end of block.
1530                    (found_paragraph, thread_ts)
1531                };
1532
1533                if let Some(paragraph) = paragraph {
1534                    let msg = SendMessage::new(&paragraph, 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                    // Recurse to handle remaining text.
1545                    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            // Flush remaining buffered text.
1561            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        // Belt-and-suspenders: kill any typing handles for this channel.
1584        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        // Path 1: file attachments — delete draft and POST fresh message with files.
1597        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        // Path 2: text exceeds limit — delete draft and POST as chunked messages.
1625        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        // Path 3: simple case — edit in-place; fall back to delete + POST on failure.
1639        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        // "MTIzNDU2" decodes to "123456"
1739        let decoded = base64_decode("MTIzNDU2");
1740        assert_eq!(decoded, Some("123456".to_string()));
1741    }
1742
1743    #[test]
1744    fn bot_user_id_extraction() {
1745        // Token format: base64(user_id).timestamp.hmac
1746        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    // mention_only DM-bypass tests
1859
1860    #[test]
1861    fn mention_only_dm_bypasses_mention_gate() {
1862        // DMs (no guild_id) must pass through even when mention_only is true
1863        // and the message contains no @mention. Mirrors the listen call-site logic.
1864        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        // Guild messages (has guild_id, so is_dm = false) must still be rejected
1874        // when mention_only is true and the message contains no @mention.
1875        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        // Guild messages that do carry a @mention pass through and have the
1885        // mention tag stripped, consistent with pre-existing behaviour.
1886        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    // Message splitting tests
1894
1895    #[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); // 10000 characters (5 chars per "word ")
1928        let chunks = split_message_for_discord(&msg);
1929        // Should split into 5 chunks of <= 2000 chars
1930        assert_eq!(chunks.len(), 5);
1931        assert!(
1932            chunks
1933                .iter()
1934                .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH)
1935        );
1936        // Verify total content is preserved
1937        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        // Should split at the newline
1946        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        // No spaces or newlines - should hard split at 2000
1961        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        // Create a message with multiple newlines
1972        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        // Should split into 2 chunks (first two parts + third part)
1978        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        // Test with emoji and multi-byte characters
1994        let msg = "🦀 Rust is awesome! ".repeat(500);
1995        let chunks = split_message_for_discord(&msg);
1996        // All chunks should be valid UTF-8
1997        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        // Reconstruct and verify
2002        let reconstructed = chunks.concat();
2003        assert_eq!(reconstructed, msg);
2004    }
2005
2006    #[test]
2007    fn split_newline_too_close_to_end() {
2008        // If newline is in the first half, don't use it - use space instead or hard split
2009        let msg = format!("{}\n{}", "a".repeat(1900), "b".repeat(500));
2010        let chunks = split_message_for_discord(&msg);
2011        // Should split at newline since it's in the second half of the window
2012        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        // Stopping one does not affect the other
2089        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    // ── Emoji encoding for reactions ──────────────────────────────
2096
2097    #[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    // ── Message ID edge cases ─────────────────────────────────────
2139
2140    #[test]
2141    fn discord_message_id_format_includes_discord_prefix() {
2142        // Verify that message IDs follow the format: discord_{message_id}
2143        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        // Same message_id = same ID (prevents duplicates after restart)
2151        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        // Different message IDs produce different IDs
2160        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        // Discord snowflake IDs are numeric strings
2168        let message_id = "123456789012345678"; // Typical snowflake format
2169        let id = format!("discord_{message_id}");
2170        assert!(id.starts_with("discord_"));
2171        // Snowflake IDs are numeric
2172        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        // Edge case: empty message_id falls back to UUID
2178        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        // Should have UUID dashes
2186        assert!(id.contains('-'));
2187    }
2188
2189    // ─────────────────────────────────────────────────────────────────────
2190    // TG6: Channel platform limit edge cases for Discord (2000 char limit)
2191    // Prevents: Pattern 6 — issues #574, #499
2192    // ─────────────────────────────────────────────────────────────────────
2193
2194    #[test]
2195    fn split_message_code_block_at_boundary() {
2196        // Code block that spans the split boundary
2197        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        // A single word longer than 2000 chars must be hard-split
2218        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        // Reassembled content should match original
2229        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        // Many short lines should be batched into chunks under the limit
2251        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        // All content should be preserved
2263        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        // Should handle gracefully without panic
2272        assert!(parts.len() <= 1);
2273    }
2274
2275    #[test]
2276    fn split_message_emoji_at_boundary() {
2277        // Emoji are multi-byte; ensure we don't split mid-emoji
2278        let mut msg = "a".repeat(1998);
2279        msg.push_str("🎉🎊"); // 2 emoji at the boundary (2000 chars total)
2280        let parts = split_message_for_discord(&msg);
2281        for part in &parts {
2282            // The function splits on character count, not byte count
2283            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    // process_attachments tests
2302
2303    #[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    // ── Streaming mode tests ──────────────────────────────────────────
2389
2390    #[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        // MultiMessage returns a synthetic ID so the draft_updater task runs.
2429        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        // Seed a recent edit time.
2446        ch.last_draft_edit
2447            .lock()
2448            .insert("chan".to_string(), std::time::Instant::now());
2449
2450        // Should return Ok immediately (rate-limited) without making a network call.
2451        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        // cancel_draft will try to delete a message (will fail with network error)
2470        // but should still clean up the tracking entry.
2471        let _ = ch.cancel_draft("chan", "fake_msg_id").await;
2472        assert!(!ch.last_draft_edit.lock().contains_key("chan"));
2473    }
2474
2475    // ── MultiMessage splitter tests ───────────────────────────────────
2476
2477    #[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        // Create a single paragraph that exceeds max_len.
2498        let long_para = "a ".repeat(1100); // ~2200 chars
2499        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        // The code fence contains \n\n but should not be split there.
2516        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}