Skip to main content

zeroclaw_channels/
discord.rs

1use anyhow::Context as _;
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 std::time::Duration;
12use tokio::sync::{Mutex as AsyncMutex, oneshot};
13use tokio_tungstenite::tungstenite::Message;
14use uuid::Uuid;
15use zeroclaw_api::channel::{
16    Channel, ChannelApprovalRequest, ChannelApprovalResponse, ChannelMessage, SendMessage,
17};
18use zeroclaw_api::media::MediaAttachment;
19
20/// Discord channel — connects via Gateway WebSocket for real-time messages
21pub struct DiscordChannel {
22    bot_token: String,
23    /// Empty = listen across all guilds the bot is invited to.
24    guild_ids: Vec<String>,
25    /// Empty = watch every channel; non-empty = restrict the bot to listed
26    /// channel IDs (for both interaction and archive).
27    channel_ids: Vec<String>,
28    /// When set, every non-bot message that passes the channel filter is
29    /// archived to a sidecar SQLite memory backend (`discord.db`). The
30    /// `discord_search` tool reads from this when registered.
31    archive_memory: Option<std::sync::Arc<dyn zeroclaw_memory::Memory>>,
32    /// The alias key under `[channels.discord.<alias>]` this handle is
33    /// bound to. Used to scope peer-group writes and resolver lookups.
34    alias: String,
35    /// Resolves inbound external peers from canonical state at message-time.
36    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
37    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
38    listen_to_bots: bool,
39    mention_only: bool,
40    typing_handles: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
41    /// Per-channel proxy URL override.
42    proxy_url: Option<String>,
43    /// Voice transcription config — when set, audio attachments are
44    /// downloaded, transcribed, and their text inlined into the message.
45    transcription: Option<zeroclaw_config::schema::TranscriptionConfig>,
46    transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
47    /// Workspace directory for saving downloaded inbound media attachments.
48    workspace_dir: Option<PathBuf>,
49    /// Streaming mode: Off, Partial (draft edits), or MultiMessage (paragraph splits).
50    stream_mode: zeroclaw_config::schema::StreamMode,
51    /// Minimum interval (ms) between draft message edits (Partial mode only).
52    draft_update_interval_ms: u64,
53    /// Delay (ms) between sending each message chunk (MultiMessage mode only).
54    multi_message_delay_ms: u64,
55    /// Per-channel rate-limit tracking for draft edits.
56    last_draft_edit: Mutex<HashMap<String, std::time::Instant>>,
57    /// Tracks how much text has been sent in MultiMessage mode.
58    multi_message_sent_len: Mutex<HashMap<String, usize>>,
59    /// Thread context captured from `send_draft()` for MultiMessage paragraph delivery.
60    multi_message_thread_ts: Mutex<HashMap<String, Option<String>>>,
61    /// Stall-watchdog timeout in seconds (0 = disabled).
62    stall_timeout_secs: u64,
63    pending_approvals: Arc<AsyncMutex<HashMap<String, oneshot::Sender<ChannelApprovalResponse>>>>,
64    /// Seconds to wait for an operator reply to a `request_approval` prompt
65    /// before treating the silence as a deny. Default 300.
66    approval_timeout_secs: u64,
67    /// Cached `channel_id -> is_thread` lookups. Populated lazily on first
68    /// inbound message from a channel via `GET /channels/{id}`. Thread type
69    /// is stable for the channel's lifetime so the cache lives as long as
70    /// the channel instance.
71    ///
72    /// Value is `Some(parent_id)` when the channel is a thread, `None`
73    /// when it is a regular (non-thread) channel.
74    thread_channels: Arc<AsyncMutex<HashMap<String, Option<String>>>>,
75    /// Ephemeral Discord gateway session state for Resume across reconnects.
76    gateway_session: Mutex<DiscordGatewaySession>,
77}
78
79#[derive(Clone, Debug, Default)]
80struct DiscordGatewaySession {
81    session_id: Option<String>,
82    resume_gateway_url: Option<String>,
83    sequence: Option<i64>,
84}
85
86#[derive(Debug)]
87pub(crate) struct DiscordListenerFatalError {
88    message: String,
89}
90
91impl DiscordListenerFatalError {
92    pub(crate) fn new(message: impl Into<String>) -> Self {
93        Self {
94            message: message.into(),
95        }
96    }
97}
98
99impl std::fmt::Display for DiscordListenerFatalError {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.write_str(&self.message)
102    }
103}
104
105impl std::error::Error for DiscordListenerFatalError {}
106
107impl DiscordChannel {
108    pub fn new(
109        bot_token: String,
110        guild_ids: Vec<String>,
111        alias: impl Into<String>,
112        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
113        listen_to_bots: bool,
114        mention_only: bool,
115    ) -> Self {
116        Self {
117            bot_token,
118            guild_ids,
119            channel_ids: vec![],
120            archive_memory: None,
121            alias: alias.into(),
122            peer_resolver,
123            listen_to_bots,
124            mention_only,
125            typing_handles: Mutex::new(HashMap::new()),
126            proxy_url: None,
127            transcription: None,
128            transcription_manager: None,
129            workspace_dir: None,
130            stream_mode: zeroclaw_config::schema::StreamMode::Off,
131            draft_update_interval_ms: 1000,
132            multi_message_delay_ms: 800,
133            last_draft_edit: Mutex::new(HashMap::new()),
134            multi_message_sent_len: Mutex::new(HashMap::new()),
135            multi_message_thread_ts: Mutex::new(HashMap::new()),
136            stall_timeout_secs: 0,
137            pending_approvals: Arc::new(AsyncMutex::new(HashMap::new())),
138            approval_timeout_secs: 300,
139            thread_channels: Arc::new(AsyncMutex::new(HashMap::new())),
140            gateway_session: Mutex::new(DiscordGatewaySession::default()),
141        }
142    }
143
144    /// Set a per-channel proxy URL that overrides the global proxy config.
145    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
146        self.proxy_url = proxy_url;
147        self
148    }
149
150    pub fn with_approval_timeout_secs(mut self, secs: u64) -> Self {
151        self.approval_timeout_secs = secs;
152        self
153    }
154
155    /// Configure workspace directory for saving downloaded attachments.
156    pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
157        self.workspace_dir = Some(dir);
158        self
159    }
160
161    /// Configure voice transcription for audio attachments.
162    pub fn with_transcription(
163        mut self,
164        config: zeroclaw_config::schema::TranscriptionConfig,
165    ) -> Self {
166        if !config.enabled {
167            return self;
168        }
169        match super::transcription::TranscriptionManager::new(&config) {
170            Ok(m) => {
171                self.transcription_manager = Some(std::sync::Arc::new(m));
172                self.transcription = Some(config);
173            }
174            Err(e) => {
175                ::zeroclaw_log::record!(
176                    WARN,
177                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
178                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
179                        .with_attrs(::serde_json::json!({"e": e.to_string()})),
180                    "transcription manager init failed, voice transcription disabled"
181                );
182            }
183        }
184        self
185    }
186
187    /// Configure streaming mode for progressive draft updates or multi-message delivery.
188    pub fn with_streaming(
189        mut self,
190        stream_mode: zeroclaw_config::schema::StreamMode,
191        draft_update_interval_ms: u64,
192        multi_message_delay_ms: u64,
193    ) -> Self {
194        self.stream_mode = stream_mode;
195        self.draft_update_interval_ms = draft_update_interval_ms;
196        self.multi_message_delay_ms = multi_message_delay_ms;
197        self
198    }
199
200    /// Set the stall-watchdog timeout (0 = disabled).
201    pub fn with_stall_timeout(mut self, secs: u64) -> Self {
202        self.stall_timeout_secs = secs;
203        self
204    }
205
206    pub fn with_channel_ids(mut self, ids: Vec<String>) -> Self {
207        self.channel_ids = ids;
208        self
209    }
210
211    fn fatal_listener_error(message: impl Into<String>) -> anyhow::Error {
212        anyhow::Error::new(DiscordListenerFatalError::new(message))
213    }
214
215    fn validate_gateway_preflight_response(
216        response: reqwest::Response,
217    ) -> anyhow::Result<reqwest::Response> {
218        Ok(response.error_for_status()?)
219    }
220
221    pub fn with_archive_memory(mut self, mem: std::sync::Arc<dyn zeroclaw_memory::Memory>) -> Self {
222        self.archive_memory = Some(mem);
223        self
224    }
225
226    fn http_client(&self) -> reqwest::Client {
227        zeroclaw_config::schema::build_channel_proxy_client(
228            "channel.discord",
229            self.proxy_url.as_deref(),
230        )
231    }
232
233    /// Check if a Discord user ID is in the allowlist.
234    /// Empty list means deny everyone until explicitly configured.
235    /// `"*"` means allow everyone.
236    fn is_user_allowed(&self, user_id: &str) -> bool {
237        let peers = (self.peer_resolver)();
238        crate::allowlist::is_user_allowed(&peers, user_id, crate::allowlist::Match::Sensitive)
239    }
240
241    fn bot_user_id_from_token(token: &str) -> Option<String> {
242        // Discord bot tokens are base64(bot_user_id).timestamp.hmac
243        let part = token.split('.').next()?;
244        base64_decode(part)
245    }
246
247    /// Resolve whether `channel_id` is a Discord thread (ANNOUNCEMENT,
248    /// PUBLIC, or PRIVATE thread) via `GET /channels/{id}`. Returns
249    /// `Some(parent_id)` when the channel is a thread, `None` otherwise.
250    /// Results are cached for the channel instance's lifetime: thread-ness
251    /// is stable for a given channel ID, so one lookup per ID per process.
252    /// Failures (network, 429, missing fields) return `None` without
253    /// caching so the next message retries.
254    async fn thread_parent(&self, client: &reqwest::Client, channel_id: &str) -> Option<String> {
255        {
256            let cache = self.thread_channels.lock().await;
257            if let Some(value) = cache.get(channel_id) {
258                return value.clone();
259            }
260        }
261
262        // Only a successful API response is cached. A transient network blip
263        // or 429 must not poison the cache for the channel's lifetime; the
264        // next message should retry the lookup. Failure paths return `None`
265        // (the safe default) without writing to the cache. The whole request
266        // is wrapped in an explicit timeout so a hung Discord API call can
267        // never stall the listener; the shared channel HTTP client may not
268        // carry a request-level timeout.
269        let url = format!("https://discord.com/api/v10/channels/{channel_id}");
270        let lookup = async {
271            let resp = client
272                .get(&url)
273                .header("Authorization", format!("Bot {}", self.bot_token))
274                .send()
275                .await
276                .map_err(|e| {
277                    ::zeroclaw_log::record!(
278                        ERROR,
279                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
280                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
281                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
282                        "request failed"
283                    );
284                    anyhow::Error::msg(format!("request failed: {e}"))
285                })?;
286            if !resp.status().is_success() {
287                anyhow::bail!("non-success status {}", resp.status());
288            }
289            let body: serde_json::Value = resp.json().await.map_err(|e| {
290                ::zeroclaw_log::record!(
291                    ERROR,
292                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
293                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
294                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
295                    "body parse failed"
296                );
297                anyhow::Error::msg(format!("body parse failed: {e}"))
298            })?;
299            let is_thread = body
300                .get("type")
301                .and_then(serde_json::Value::as_u64)
302                .map(is_thread_channel_type)
303                .unwrap_or(false);
304            Ok::<Option<String>, anyhow::Error>(if is_thread {
305                body.get("parent_id")
306                    .and_then(serde_json::Value::as_str)
307                    .map(str::to_string)
308            } else {
309                None
310            })
311        };
312        let result = match tokio::time::timeout(THREAD_LOOKUP_TIMEOUT, lookup).await {
313            Ok(Ok(value)) => value,
314            Ok(Err(e)) => {
315                ::zeroclaw_log::record!(
316                    DEBUG,
317                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
318                        .with_attrs(
319                            ::serde_json::json!({"channel_id": channel_id, "error": format!("{}", e)})
320                        ),
321                    "channel lookup failed"
322                );
323                return None;
324            }
325            Err(_) => {
326                ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"channel_id": channel_id, "timeout_secs": THREAD_LOOKUP_TIMEOUT.as_secs()})), "channel lookup timed out");
327                return None;
328            }
329        };
330
331        self.thread_channels
332            .lock()
333            .await
334            .insert(channel_id.to_string(), result.clone());
335        result
336    }
337
338    /// Apply the trust-boundary / delivery-failure emoji reactions to the
339    /// bot's just-sent message. Best-effort: reaction failures are debug
340    /// logged but never propagated. `message_id` being `None` (e.g. when
341    /// every chunk failed to post) skips the reaction step entirely.
342    async fn apply_failure_reactions(
343        &self,
344        channel_id: &str,
345        message_id: Option<&str>,
346        reactions: &[&'static str],
347    ) {
348        let Some(message_id) = message_id else {
349            return;
350        };
351        for emoji in reactions {
352            if let Err(e) = self.add_reaction(channel_id, message_id, emoji).await {
353                ::zeroclaw_log::record!(
354                    DEBUG,
355                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
356                        .with_attrs(
357                            ::serde_json::json!({"emoji": emoji, "error": format!("{}", e)})
358                        ),
359                    "failed to add failure reaction to outgoing message"
360                );
361            }
362        }
363    }
364}
365
366/// Whether a Discord channel type integer identifies a thread.
367/// Discord channel types `10` (ANNOUNCEMENT_THREAD), `11` (PUBLIC_THREAD),
368/// and `12` (PRIVATE_THREAD) per the Channel Types documentation.
369const fn is_thread_channel_type(channel_type: u64) -> bool {
370    matches!(channel_type, 10..=12)
371}
372
373/// Hard cap on `GET /channels/{id}` while resolving whether an inbound
374/// channel is a thread. Discord normally responds in under 200 ms; this
375/// is a safety bound so a hung request cannot stall the listener.
376const THREAD_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
377
378/// Pure channel-filter decision: does `msg_channel` pass the allowlist?
379///
380/// A channel passes when:
381/// 1. `channel_filter` is empty (accept all), OR
382/// 2. `msg_channel` is directly in `channel_filter`, OR
383/// 3. `thread_parent_id` is `Some(parent)` and `parent` is in `channel_filter`
384///    (thread whose parent forum/channel is allowed).
385fn channel_passes_filter(
386    channel_filter: &[String],
387    msg_channel: &str,
388    thread_parent_id: Option<&str>,
389) -> bool {
390    if channel_filter.is_empty() {
391        return true;
392    }
393    if channel_filter.iter().any(|c| c == msg_channel) {
394        return true;
395    }
396    if let Some(parent) = thread_parent_id {
397        return channel_filter.iter().any(|c| c == parent);
398    }
399    false
400}
401
402/// Process Discord message attachments in a single pass.
403///
404/// Returns the text block appended to the agent's prompt and the structured
405/// `MediaAttachment` list consumed by the media pipeline. Each attachment is
406/// downloaded at most once: text/* is inlined as text, audio is transcribed
407/// inline when a transcription manager is configured (otherwise it goes
408/// through the media pipeline), and image/video/document attachments are
409/// saved to the workspace and emitted as `[KIND:<path>]` markers plus a
410/// `MediaAttachment` for vision-capable providers.
411async fn process_attachments(
412    attachments: &[serde_json::Value],
413    client: &reqwest::Client,
414    workspace_dir: Option<&Path>,
415    transcription_manager: Option<&super::transcription::TranscriptionManager>,
416) -> (String, Vec<MediaAttachment>) {
417    let mut text_parts: Vec<String> = Vec::new();
418    let mut media: Vec<MediaAttachment> = Vec::new();
419
420    for att in attachments {
421        let ct = att
422            .get("content_type")
423            .and_then(|v| v.as_str())
424            .unwrap_or("");
425        let name = att
426            .get("filename")
427            .and_then(|v| v.as_str())
428            .unwrap_or("file");
429        let Some(url) = att.get("url").and_then(|v| v.as_str()) else {
430            ::zeroclaw_log::record!(
431                WARN,
432                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
433                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
434                    .with_attrs(::serde_json::json!({"name": name})),
435                "attachment has no url, skipping"
436            );
437            continue;
438        };
439
440        if ct.starts_with("text/") {
441            match client.get(url).send().await {
442                Ok(resp) if resp.status().is_success() => {
443                    if let Ok(text) = resp.text().await {
444                        text_parts.push(format!("[{name}]\n{text}"));
445                    }
446                }
447                Ok(resp) => {
448                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"name": name, "status": resp.status().to_string()})), "attachment fetch failed");
449                }
450                Err(e) => {
451                    ::zeroclaw_log::record!(
452                        WARN,
453                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
454                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
455                            .with_attrs(
456                                ::serde_json::json!({"name": name, "error": format!("{}", e)})
457                            ),
458                        "attachment fetch error"
459                    );
460                }
461            }
462            continue;
463        }
464
465        let is_audio = is_discord_audio_attachment(ct, name);
466
467        // Audio with channel-level transcription configured: transcribe
468        // inline so the agent receives `[Voice] <transcript>` text rather
469        // than opaque bytes through the media pipeline.
470        if is_audio && let Some(manager) = transcription_manager {
471            let bytes = match download_attachment_bytes(client, url, name).await {
472                Some(b) => b,
473                None => continue,
474            };
475            match manager.transcribe(&bytes, name).await {
476                Ok(text) => {
477                    let trimmed = text.trim();
478                    if !trimmed.is_empty() {
479                        ::zeroclaw_log::record!(
480                            INFO,
481                            ::zeroclaw_log::Event::new(
482                                module_path!(),
483                                ::zeroclaw_log::Action::Note
484                            ),
485                            &format!(
486                                "transcribed audio attachment {} ({} chars)",
487                                name,
488                                trimmed.len()
489                            )
490                        );
491                        text_parts.push(format!("[Voice] {trimmed}"));
492                    }
493                }
494                Err(e) => {
495                    ::zeroclaw_log::record!(
496                        WARN,
497                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
498                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
499                            .with_attrs(
500                                ::serde_json::json!({"name": name, "error": format!("{}", e)})
501                            ),
502                        "voice transcription failed"
503                    );
504                }
505            }
506            continue;
507        }
508
509        let marker_kind = marker_kind_for(ct, is_audio);
510
511        let bytes = match download_attachment_bytes(client, url, name).await {
512            Some(b) => b,
513            None => continue,
514        };
515
516        let marker_target = match workspace_dir {
517            Some(dir) => match save_attachment_bytes_to_workspace(dir, name, &bytes).await {
518                Ok(local_path) => local_path.display().to_string(),
519                Err(e) => {
520                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"name": name, "kind": marker_kind, "error": format!("{}", e)})), "attachment save failed, falling back to url");
521                    url.to_string()
522                }
523            },
524            None => url.to_string(),
525        };
526        text_parts.push(format!("[{marker_kind}:{marker_target}]"));
527
528        media.push(MediaAttachment {
529            file_name: name.to_string(),
530            data: bytes,
531            mime_type: if ct.is_empty() {
532                None
533            } else {
534                Some(ct.to_string())
535            },
536        });
537    }
538
539    (text_parts.join("\n---\n"), media)
540}
541
542/// Download an attachment URL into memory, with structured warn-logging on
543/// each failure mode. Returns `None` when the attachment should be skipped.
544async fn download_attachment_bytes(
545    client: &reqwest::Client,
546    url: &str,
547    name: &str,
548) -> Option<Vec<u8>> {
549    match client.get(url).send().await {
550        Ok(resp) if resp.status().is_success() => match resp.bytes().await {
551            Ok(b) => Some(b.to_vec()),
552            Err(e) => {
553                ::zeroclaw_log::record!(
554                    WARN,
555                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
556                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
557                        .with_attrs(::serde_json::json!({"name": name, "error": format!("{}", e)})),
558                    "failed to read attachment bytes"
559                );
560                None
561            }
562        },
563        Ok(resp) => {
564            ::zeroclaw_log::record!(
565                WARN,
566                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
567                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
568                    .with_attrs(
569                        ::serde_json::json!({"name": name, "status": resp.status().to_string()})
570                    ),
571                "attachment download failed"
572            );
573            None
574        }
575        Err(e) => {
576            ::zeroclaw_log::record!(
577                WARN,
578                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
579                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
580                    .with_attrs(::serde_json::json!({"name": name, "error": format!("{}", e)})),
581                "attachment fetch error"
582            );
583            None
584        }
585    }
586}
587
588async fn save_attachment_bytes_to_workspace(
589    workspace_dir: &Path,
590    filename: &str,
591    bytes: &[u8],
592) -> anyhow::Result<PathBuf> {
593    let save_dir = workspace_dir.join("discord_files");
594    tokio::fs::create_dir_all(&save_dir).await?;
595
596    let safe_name = Path::new(filename)
597        .file_name()
598        .and_then(|name| name.to_str())
599        .filter(|name| !name.is_empty())
600        .unwrap_or("attachment");
601    let local_name = format!("{}_{}", Uuid::new_v4(), safe_name);
602    let local_path = save_dir.join(local_name);
603
604    tokio::fs::write(&local_path, bytes).await?;
605    Ok(local_path)
606}
607
608/// Audio file extensions accepted for voice transcription.
609const DISCORD_AUDIO_EXTENSIONS: &[&str] = &[
610    "flac", "mp3", "mpeg", "mpga", "mp4", "m4a", "ogg", "oga", "opus", "wav", "webm",
611];
612
613/// Check if a content type or filename indicates an audio file.
614fn is_discord_audio_attachment(content_type: &str, filename: &str) -> bool {
615    if content_type.starts_with("audio/") {
616        return true;
617    }
618    if let Some(ext) = filename.rsplit('.').next() {
619        return DISCORD_AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str());
620    }
621    false
622}
623
624/// Map a Discord attachment's content type plus audio-detection result to
625/// the canonical outbound marker kind. Pulled out of `process_attachments`
626/// so the MIME-to-marker dispatch can be unit-tested without a live HTTP
627/// download.
628fn marker_kind_for(content_type: &str, is_audio: bool) -> &'static str {
629    if content_type.starts_with("image/") {
630        "IMAGE"
631    } else if is_audio {
632        "AUDIO"
633    } else if content_type.starts_with("video/") {
634        "VIDEO"
635    } else {
636        "DOCUMENT"
637    }
638}
639
640#[derive(Debug, Clone, PartialEq, Eq)]
641enum DiscordAttachmentKind {
642    Image,
643    Document,
644    Video,
645    Audio,
646    Voice,
647}
648
649impl DiscordAttachmentKind {
650    fn from_marker(kind: &str) -> Option<Self> {
651        match kind.trim().to_ascii_uppercase().as_str() {
652            "IMAGE" | "PHOTO" => Some(Self::Image),
653            "DOCUMENT" | "FILE" => Some(Self::Document),
654            "VIDEO" => Some(Self::Video),
655            "AUDIO" => Some(Self::Audio),
656            "VOICE" => Some(Self::Voice),
657            _ => None,
658        }
659    }
660
661    fn marker_name(&self) -> &'static str {
662        match self {
663            Self::Image => "IMAGE",
664            Self::Document => "DOCUMENT",
665            Self::Video => "VIDEO",
666            Self::Audio => "AUDIO",
667            Self::Voice => "VOICE",
668        }
669    }
670}
671
672#[derive(Debug, Clone, PartialEq, Eq)]
673struct DiscordAttachment {
674    kind: DiscordAttachmentKind,
675    target: String,
676}
677
678fn parse_attachment_markers(message: &str) -> (String, Vec<DiscordAttachment>) {
679    let mut cleaned = String::with_capacity(message.len());
680    let mut attachments = Vec::new();
681    let mut cursor = 0usize;
682
683    while let Some(rel_start) = message[cursor..].find('[') {
684        let start = cursor + rel_start;
685        cleaned.push_str(&message[cursor..start]);
686
687        let Some(rel_end) = message[start..].find(']') else {
688            cleaned.push_str(&message[start..]);
689            cursor = message.len();
690            break;
691        };
692        let end = start + rel_end;
693        let marker_text = &message[start + 1..end];
694
695        let parsed = marker_text.split_once(':').and_then(|(kind, target)| {
696            let kind = DiscordAttachmentKind::from_marker(kind)?;
697            let target = target.trim();
698            if target.is_empty() {
699                return None;
700            }
701            Some(DiscordAttachment {
702                kind,
703                target: target.to_string(),
704            })
705        });
706
707        if let Some(attachment) = parsed {
708            attachments.push(attachment);
709        } else {
710            cleaned.push_str(&message[start..=end]);
711        }
712
713        cursor = end + 1;
714    }
715
716    if cursor < message.len() {
717        cleaned.push_str(&message[cursor..]);
718    }
719
720    (cleaned.trim().to_string(), attachments)
721}
722
723/// Resolved outbound attachment target after sandbox validation.
724#[derive(Debug)]
725enum DiscordMarkerTarget {
726    Local(PathBuf),
727    Http(String),
728}
729
730/// Why a marker target was rejected. Drives the user-facing emoji reaction
731/// on the bot's outgoing message: `Refused` (trust-boundary rejection) maps
732/// to 🚫, `NotFound` (path didn't resolve on disk) maps to ⚠️. The
733/// distinction matters because a chatter should see at a glance that the
734/// bot deliberately declined a target rather than tried and failed.
735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
736enum DiscordMarkerFailure {
737    /// Trust-boundary refusal: disallowed scheme, relative path, missing
738    /// workspace_dir, or canonicalised path outside the workspace.
739    Refused,
740    /// Path passed scheme/absolute/workspace checks but did not resolve
741    /// to anything on disk.
742    NotFound,
743}
744
745#[derive(Debug)]
746enum DiscordMarkerError {
747    Refused(anyhow::Error),
748    NotFound(anyhow::Error),
749}
750
751impl DiscordMarkerError {
752    fn kind(&self) -> DiscordMarkerFailure {
753        match self {
754            Self::Refused(_) => DiscordMarkerFailure::Refused,
755            Self::NotFound(_) => DiscordMarkerFailure::NotFound,
756        }
757    }
758}
759
760impl std::fmt::Display for DiscordMarkerError {
761    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
762        match self {
763            Self::Refused(e) | Self::NotFound(e) => write!(f, "{e}"),
764        }
765    }
766}
767
768/// Validate an outbound marker target against Discord's trust-boundary policy.
769///
770/// The orchestrator system prompt mandates absolute paths for media markers,
771/// and the workspace is the only directory the agent is authorised to
772/// expose to chatters:
773///
774/// * `http`/`https` URLs are accepted and inlined as links.
775/// * Any other URL scheme (`file:`, `data:`, custom `://`) is refused.
776/// * Local paths must be absolute. Relative paths are agent
777///   misconfiguration and dropped, not silently resolved against cwd.
778/// * Absolute paths are canonicalised and must resolve inside
779///   `workspace_dir`. Anything outside or any traversal escape is
780///   refused; a path that simply doesn't exist on disk returns
781///   `NotFound`, which the caller renders differently from a refusal.
782/// * When `workspace_dir` is not configured, no local path can be safely
783///   bounded, so all local targets are refused.
784fn validate_marker_target(
785    target: &str,
786    workspace_dir: Option<&Path>,
787) -> Result<DiscordMarkerTarget, DiscordMarkerError> {
788    if target.starts_with("http://") || target.starts_with("https://") {
789        return Ok(DiscordMarkerTarget::Http(target.to_string()));
790    }
791    if target.contains("://") {
792        let scheme = target.split("://").next().unwrap_or("?");
793        ::zeroclaw_log::record!(
794            WARN,
795            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
796                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
797                .with_attrs(::serde_json::json!({
798                    "scheme": scheme,
799                    "target": target,
800                })),
801            "discord: marker target uses disallowed scheme"
802        );
803        return Err(DiscordMarkerError::Refused(anyhow::Error::msg(format!(
804            "marker target uses disallowed scheme {scheme:?}; only http/https and absolute workspace paths are accepted"
805        ))));
806    }
807    if target.starts_with("data:") || target.starts_with("file:") {
808        ::zeroclaw_log::record!(
809            WARN,
810            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
811                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
812                .with_attrs(::serde_json::json!({"target": target})),
813            "discord: marker target uses disallowed data: or file: scheme"
814        );
815        return Err(DiscordMarkerError::Refused(anyhow::Error::msg(
816            "marker target uses disallowed scheme; only http/https and absolute workspace paths are accepted",
817        )));
818    }
819
820    let target_path = Path::new(target);
821    if !target_path.is_absolute() {
822        ::zeroclaw_log::record!(
823            WARN,
824            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
825                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
826                .with_attrs(::serde_json::json!({
827                    "target": target,
828                    "reason": "not_absolute",
829                })),
830            "discord: marker target is not absolute"
831        );
832        return Err(DiscordMarkerError::Refused(anyhow::Error::msg(format!(
833            "marker target {target} is not an absolute path; the agent must emit absolute paths inside workspace_dir"
834        ))));
835    }
836
837    let workspace = workspace_dir.ok_or_else(|| {
838        ::zeroclaw_log::record!(
839            WARN,
840            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
841                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
842                .with_attrs(::serde_json::json!({
843                    "target": target,
844                    "reason": "no_workspace_dir",
845                })),
846            "discord: marker target is local path but channel has no workspace_dir"
847        );
848        DiscordMarkerError::Refused(anyhow::Error::msg(format!(
849            "marker target {target} is a local path but the channel was started without a workspace_dir, refusing for safety"
850        )))
851    })?;
852    let workspace_canon = std::fs::canonicalize(workspace)
853        .with_context(|| format!("canonicalize workspace {}", workspace.display()))
854        .map_err(DiscordMarkerError::Refused)?;
855    let target_canon = match std::fs::canonicalize(target_path) {
856        Ok(p) => p,
857        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
858            ::zeroclaw_log::record!(
859                WARN,
860                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
861                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
862                    .with_attrs(::serde_json::json!({
863                        "target": target,
864                        "reason": "not_found",
865                    })),
866                "discord: marker target not found on disk"
867            );
868            return Err(DiscordMarkerError::NotFound(anyhow::Error::msg(format!(
869                "marker target {target} not found on disk"
870            ))));
871        }
872        Err(e) => {
873            return Err(DiscordMarkerError::Refused(
874                anyhow::Error::from(e).context(format!("canonicalize marker target {target}")),
875            ));
876        }
877    };
878
879    if !target_canon.starts_with(&workspace_canon) {
880        ::zeroclaw_log::record!(
881            WARN,
882            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
883                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
884                .with_attrs(::serde_json::json!({
885                    "target": target,
886                    "target_canon": target_canon.display().to_string(),
887                    "workspace_canon": workspace_canon.display().to_string(),
888                    "reason": "outside_workspace",
889                })),
890            "discord: marker target escapes workspace_dir"
891        );
892        return Err(DiscordMarkerError::Refused(anyhow::Error::msg(format!(
893            "marker target {target} resolves to {} which is outside workspace_dir {}; refusing",
894            target_canon.display(),
895            workspace_canon.display(),
896        ))));
897    }
898    Ok(DiscordMarkerTarget::Local(target_canon))
899}
900
901fn classify_outgoing_attachments(
902    attachments: &[DiscordAttachment],
903    workspace_dir: Option<&Path>,
904) -> (
905    Vec<PathBuf>,
906    Vec<String>,
907    Vec<(String, DiscordMarkerFailure)>,
908) {
909    let mut local_files = Vec::new();
910    let mut remote_urls = Vec::new();
911    let mut failures = Vec::new();
912
913    for attachment in attachments {
914        match validate_marker_target(&attachment.target, workspace_dir) {
915            Ok(DiscordMarkerTarget::Local(path)) => local_files.push(path),
916            Ok(DiscordMarkerTarget::Http(url)) => remote_urls.push(url),
917            Err(e) => {
918                let kind_label = match e.kind() {
919                    DiscordMarkerFailure::Refused => "trust boundary",
920                    DiscordMarkerFailure::NotFound => "not found",
921                };
922                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"kind": attachment.kind.marker_name(), "target": attachment.target, "reason": kind_label, "error": format!("{}", e)})), "dropping unresolved outbound attachment marker");
923                failures.push((attachment.target.clone(), e.kind()));
924            }
925        }
926    }
927
928    (local_files, remote_urls, failures)
929}
930
931/// Build the Matrix-style "(note: I couldn't deliver ...)" tail appended
932/// to the bot's reply when at least one marker was dropped. Returns
933/// `None` when the failure list is empty so callers can keep the body
934/// untouched.
935fn delivery_failure_note(failures: &[(String, DiscordMarkerFailure)]) -> Option<String> {
936    if failures.is_empty() {
937        return None;
938    }
939    let targets: Vec<&str> = failures.iter().map(|(t, _)| t.as_str()).collect();
940    Some(if targets.len() == 1 {
941        format!("(note: I couldn't deliver the file at {}.)", targets[0])
942    } else {
943        format!(
944            "(note: I couldn't deliver these files: {}.)",
945            targets.join(", ")
946        )
947    })
948}
949
950/// Compose the final reply body with the delivery-failure note appended.
951/// When the marker-stripped content is empty the note replaces the body;
952/// otherwise the note follows the content separated by a blank line.
953fn compose_body_with_failure_note(content: &str, note: Option<&str>) -> String {
954    match note {
955        Some(note) if content.trim().is_empty() => note.to_string(),
956        Some(note) => format!("{content}\n\n{note}"),
957        None => content.to_string(),
958    }
959}
960
961/// Emoji reactions applied to the bot's own outgoing message based on which
962/// kinds of marker failures occurred. 🚫 signals a trust-boundary refusal,
963/// ⚠️ signals a post-validation delivery failure. Both can fire on the
964/// same message when a batch mixes refusals and not-found targets.
965fn decide_failure_reactions(failures: &[(String, DiscordMarkerFailure)]) -> Vec<&'static str> {
966    let mut out = Vec::new();
967    if failures
968        .iter()
969        .any(|(_, k)| matches!(k, DiscordMarkerFailure::Refused))
970    {
971        out.push("🚫");
972    }
973    if failures
974        .iter()
975        .any(|(_, k)| matches!(k, DiscordMarkerFailure::NotFound))
976    {
977        out.push("⚠️");
978    }
979    out
980}
981
982fn with_inline_attachment_urls(content: &str, remote_urls: &[String]) -> String {
983    let mut lines = Vec::new();
984    if !content.trim().is_empty() {
985        lines.push(content.trim().to_string());
986    }
987    if !remote_urls.is_empty() {
988        lines.extend(remote_urls.iter().cloned());
989    }
990    lines.join("\n")
991}
992
993/// POST a plain-text message and return the new message's ID. Callers
994/// that don't need the ID (e.g. non-first chunks) can discard it.
995async fn send_discord_message_json(
996    client: &reqwest::Client,
997    bot_token: &str,
998    recipient: &str,
999    content: &str,
1000) -> anyhow::Result<String> {
1001    let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
1002    let body = json!({ "content": content });
1003
1004    let resp = client
1005        .post(&url)
1006        .header("Authorization", format!("Bot {bot_token}"))
1007        .json(&body)
1008        .send()
1009        .await?;
1010
1011    if !resp.status().is_success() {
1012        let status = resp.status();
1013        let err = resp
1014            .text()
1015            .await
1016            .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1017        anyhow::bail!("Discord send message failed ({status}): {err}");
1018    }
1019
1020    extract_message_id(resp).await
1021}
1022
1023/// POST a message with file attachments via multipart, returning the new
1024/// message's ID. Callers that don't need the ID can discard it.
1025async fn send_discord_message_with_files(
1026    client: &reqwest::Client,
1027    bot_token: &str,
1028    recipient: &str,
1029    content: &str,
1030    files: &[PathBuf],
1031) -> anyhow::Result<String> {
1032    let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
1033
1034    let mut form = Form::new().text("payload_json", json!({ "content": content }).to_string());
1035
1036    for (idx, path) in files.iter().enumerate() {
1037        let bytes = tokio::fs::read(path).await.map_err(|error| {
1038            ::zeroclaw_log::record!(
1039                ERROR,
1040                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1041                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1042                    .with_attrs(::serde_json::json!({
1043                        "path": path.display().to_string(),
1044                        "phase": "attachment_read",
1045                        "error": format!("{}", error),
1046                    })),
1047                "discord: failed to read attachment"
1048            );
1049            anyhow::Error::msg(format!(
1050                "Discord attachment read failed for '{}': {error}",
1051                path.display()
1052            ))
1053        })?;
1054        let filename = path
1055            .file_name()
1056            .and_then(|name| name.to_str())
1057            .unwrap_or("attachment.bin")
1058            .to_string();
1059        form = form.part(
1060            format!("files[{idx}]"),
1061            Part::bytes(bytes).file_name(filename),
1062        );
1063    }
1064
1065    let resp = client
1066        .post(&url)
1067        .header("Authorization", format!("Bot {bot_token}"))
1068        .multipart(form)
1069        .send()
1070        .await?;
1071
1072    if !resp.status().is_success() {
1073        let status = resp.status();
1074        let err = resp
1075            .text()
1076            .await
1077            .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1078        anyhow::bail!("Discord send message with files failed ({status}): {err}");
1079    }
1080
1081    extract_message_id(resp).await
1082}
1083
1084async fn extract_message_id(resp: reqwest::Response) -> anyhow::Result<String> {
1085    let body: serde_json::Value = resp.json().await?;
1086    body.get("id")
1087        .and_then(|v| v.as_str())
1088        .map(|s| s.to_string())
1089        .ok_or_else(|| {
1090            ::zeroclaw_log::record!(
1091                WARN,
1092                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1093                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1094                    .with_attrs(::serde_json::json!({"field": "id"})),
1095                "discord: send response missing id field"
1096            );
1097            anyhow::Error::msg("Discord send response missing 'id' field")
1098        })
1099}
1100
1101/// Edit an existing Discord message via PATCH.
1102///
1103/// Returns `Ok(())` on success. On HTTP 429 (rate limited), logs at debug
1104/// level and returns `Ok(())` since skipping a mid-stream edit is harmless.
1105async fn edit_discord_message(
1106    client: &reqwest::Client,
1107    bot_token: &str,
1108    channel_id: &str,
1109    message_id: &str,
1110    content: &str,
1111) -> anyhow::Result<()> {
1112    let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}");
1113    let body = json!({ "content": content });
1114
1115    let resp = client
1116        .patch(&url)
1117        .header("Authorization", format!("Bot {bot_token}"))
1118        .json(&body)
1119        .send()
1120        .await?;
1121
1122    if resp.status().as_u16() == 429 {
1123        ::zeroclaw_log::record!(
1124            DEBUG,
1125            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1126            "edit message rate-limited (429), skipping update"
1127        );
1128        return Ok(());
1129    }
1130
1131    if !resp.status().is_success() {
1132        let status = resp.status();
1133        let err = resp
1134            .text()
1135            .await
1136            .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1137        anyhow::bail!("edit message failed ({status}): {err}");
1138    }
1139
1140    Ok(())
1141}
1142
1143/// Delete a Discord message.
1144///
1145/// Returns `Ok(())` on success. On HTTP 429 (rate limited), logs at debug
1146/// level and returns `Ok(())` since a stale message is cosmetic only.
1147async fn delete_discord_message(
1148    client: &reqwest::Client,
1149    bot_token: &str,
1150    channel_id: &str,
1151    message_id: &str,
1152) -> anyhow::Result<()> {
1153    let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}");
1154
1155    let resp = client
1156        .delete(&url)
1157        .header("Authorization", format!("Bot {bot_token}"))
1158        .send()
1159        .await?;
1160
1161    if resp.status().as_u16() == 429 {
1162        ::zeroclaw_log::record!(
1163            DEBUG,
1164            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1165            "delete message rate-limited (429), skipping"
1166        );
1167        return Ok(());
1168    }
1169
1170    if !resp.status().is_success() {
1171        let status = resp.status();
1172        let err = resp
1173            .text()
1174            .await
1175            .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
1176        anyhow::bail!("delete message failed ({status}): {err}");
1177    }
1178
1179    Ok(())
1180}
1181
1182const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1183
1184/// Discord's maximum message length for regular messages.
1185///
1186/// Discord rejects longer payloads with `50035 Invalid Form Body`.
1187const DISCORD_MAX_MESSAGE_LENGTH: usize = 2000;
1188const DISCORD_ACK_REACTIONS: &[&str] = &["⚡️", "🦀", "🙌", "💪", "👌", "👀", "👣"];
1189
1190/// Split a message into chunks that respect Discord's 2000-character limit.
1191/// Tries to split at word boundaries when possible.
1192fn split_message_for_discord(message: &str) -> Vec<String> {
1193    if message.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH {
1194        return vec![message.to_string()];
1195    }
1196
1197    let mut chunks = Vec::new();
1198    let mut remaining = message;
1199
1200    while !remaining.is_empty() {
1201        // Find the byte offset for the 2000th character boundary.
1202        // If there are fewer than 2000 chars left, we can emit the tail directly.
1203        let hard_split = remaining
1204            .char_indices()
1205            .nth(DISCORD_MAX_MESSAGE_LENGTH)
1206            .map_or(remaining.len(), |(idx, _)| idx);
1207
1208        let chunk_end = if hard_split == remaining.len() {
1209            hard_split
1210        } else {
1211            // Try to find a good break point (newline, then space)
1212            let search_area = &remaining[..hard_split];
1213
1214            // Prefer splitting at newline
1215            if let Some(pos) = search_area.rfind('\n') {
1216                // Don't split if the newline is too close to the end
1217                if search_area[..pos].chars().count() >= DISCORD_MAX_MESSAGE_LENGTH / 2 {
1218                    pos + 1
1219                } else {
1220                    // Try space as fallback
1221                    search_area.rfind(' ').map_or(hard_split, |space| space + 1)
1222                }
1223            } else if let Some(pos) = search_area.rfind(' ') {
1224                pos + 1
1225            } else {
1226                // Hard split at the limit
1227                hard_split
1228            }
1229        };
1230
1231        chunks.push(remaining[..chunk_end].to_string());
1232        remaining = &remaining[chunk_end..];
1233    }
1234
1235    chunks
1236}
1237
1238/// Split a message into multiple logical chunks at paragraph boundaries for
1239/// multi-message delivery. Respects code fences — never splits inside a
1240/// fenced code block. Falls back to [`split_message_for_discord`] for any
1241/// segment that exceeds `max_len`.
1242fn split_message_for_discord_multi(content: &str, max_len: usize) -> Vec<String> {
1243    if content.is_empty() {
1244        return vec![];
1245    }
1246
1247    // Gather paragraph-level segments, respecting code fences.
1248    let mut segments: Vec<String> = Vec::new();
1249    let mut current = String::new();
1250    let mut in_fence = false;
1251
1252    for line in content.lines() {
1253        let trimmed = line.trim_start();
1254        if trimmed.starts_with("```") {
1255            in_fence = !in_fence;
1256        }
1257
1258        // If we hit a blank line outside a fence, that's a paragraph break.
1259        if line.is_empty() && !in_fence && !current.is_empty() {
1260            segments.push(current.trim_end().to_string());
1261            current.clear();
1262            continue;
1263        }
1264
1265        if !current.is_empty() {
1266            current.push('\n');
1267        }
1268        current.push_str(line);
1269    }
1270    if !current.is_empty() {
1271        segments.push(current.trim_end().to_string());
1272    }
1273
1274    // Now coalesce small segments and split oversized ones.
1275    let mut chunks: Vec<String> = Vec::new();
1276
1277    for segment in segments {
1278        if segment.chars().count() > max_len {
1279            // This segment (possibly a large code fence) exceeds the limit.
1280            // Fall back to the word-boundary splitter.
1281            let sub_chunks = split_message_for_discord(&segment);
1282            chunks.extend(sub_chunks);
1283        } else {
1284            chunks.push(segment);
1285        }
1286    }
1287
1288    if chunks.is_empty() {
1289        vec![content.to_string()]
1290    } else {
1291        chunks
1292    }
1293}
1294
1295/// Choose the chunks to deliver for an outbound Discord message.
1296///
1297/// `split_message_for_discord_multi` returns an empty vec for empty input
1298/// (its paragraph splitter has no segments to emit); the non-multi
1299/// splitter returns `vec![""]`. When MultiMessage stream mode hands
1300/// `send()` a paragraph that collapses to empty text after marker strip,
1301/// the chunk loop would iterate zero times and silently skip an attached
1302/// file upload. Force a single empty chunk in exactly that case so the
1303/// multipart POST fires.
1304fn chunks_for_send(
1305    content: &str,
1306    stream_mode: zeroclaw_config::schema::StreamMode,
1307    max_len: usize,
1308    has_local_files: bool,
1309) -> Vec<String> {
1310    let mut chunks = match stream_mode {
1311        zeroclaw_config::schema::StreamMode::MultiMessage => {
1312            split_message_for_discord_multi(content, max_len)
1313        }
1314        _ => split_message_for_discord(content),
1315    };
1316    if chunks.is_empty() && has_local_files {
1317        chunks.push(String::new());
1318    }
1319    chunks
1320}
1321
1322fn pick_uniform_index(len: usize) -> usize {
1323    debug_assert!(len > 0);
1324    let upper = len as u64;
1325    let reject_threshold = (u64::MAX / upper) * upper;
1326
1327    loop {
1328        let value = rand::random::<u64>();
1329        if value < reject_threshold {
1330            #[allow(clippy::cast_possible_truncation)]
1331            return (value % upper) as usize;
1332        }
1333    }
1334}
1335
1336fn random_discord_ack_reaction() -> &'static str {
1337    DISCORD_ACK_REACTIONS[pick_uniform_index(DISCORD_ACK_REACTIONS.len())]
1338}
1339
1340/// URL-encode a Unicode emoji for use in Discord reaction API paths.
1341///
1342/// Discord's reaction endpoints accept raw Unicode emoji in the URL path,
1343/// but they must be percent-encoded per RFC 3986. Custom guild emojis use
1344/// the `name:id` format and are passed through unencoded.
1345fn encode_emoji_for_discord(emoji: &str) -> String {
1346    if emoji.contains(':') {
1347        return emoji.to_string();
1348    }
1349
1350    let mut encoded = String::new();
1351    for byte in emoji.as_bytes() {
1352        let _ = write!(encoded, "%{byte:02X}");
1353    }
1354    encoded
1355}
1356
1357fn discord_reaction_url(channel_id: &str, message_id: &str, emoji: &str) -> String {
1358    let raw_id = message_id.strip_prefix("discord_").unwrap_or(message_id);
1359    let encoded_emoji = encode_emoji_for_discord(emoji);
1360    format!(
1361        "https://discord.com/api/v10/channels/{channel_id}/messages/{raw_id}/reactions/{encoded_emoji}/@me"
1362    )
1363}
1364
1365fn mention_tags(bot_user_id: &str) -> [String; 2] {
1366    [format!("<@{bot_user_id}>"), format!("<@!{bot_user_id}>")]
1367}
1368
1369fn contains_bot_mention(content: &str, bot_user_id: &str) -> bool {
1370    let tags = mention_tags(bot_user_id);
1371    content.contains(&tags[0]) || content.contains(&tags[1])
1372}
1373
1374/// Decide whether an inbound Discord message passes the listener gate.
1375/// Returns the cleaned text body when admitted, or `None` to drop the
1376/// message. Attachment-only messages (empty `content` plus at least one
1377/// attachment) are admitted as long as the mention requirement is
1378/// satisfied; otherwise a Discord message that contained only an image,
1379/// PDF, ZIP, video, or audio with no caption would never reach the
1380/// media pipeline.
1381fn admit_discord_message(
1382    content: &str,
1383    has_attachments: bool,
1384    mention_only: bool,
1385    bot_user_id: &str,
1386) -> Option<String> {
1387    if mention_only && !contains_bot_mention(content, bot_user_id) {
1388        return None;
1389    }
1390
1391    let normalized = content.trim().to_string();
1392    if normalized.is_empty() && !has_attachments {
1393        return None;
1394    }
1395
1396    Some(normalized)
1397}
1398
1399/// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion
1400#[allow(clippy::cast_possible_truncation)]
1401fn base64_decode(input: &str) -> Option<String> {
1402    let padded = match input.len() % 4 {
1403        2 => format!("{input}=="),
1404        3 => format!("{input}="),
1405        _ => input.to_string(),
1406    };
1407
1408    let mut bytes = Vec::new();
1409    let chars: Vec<u8> = padded.bytes().collect();
1410
1411    for chunk in chars.chunks(4) {
1412        if chunk.len() < 4 {
1413            break;
1414        }
1415
1416        let mut v = [0usize; 4];
1417        for (i, &b) in chunk.iter().enumerate() {
1418            if b == b'=' {
1419                v[i] = 0;
1420            } else {
1421                v[i] = BASE64_ALPHABET.iter().position(|&a| a == b)?;
1422            }
1423        }
1424
1425        bytes.push(((v[0] << 2) | (v[1] >> 4)) as u8);
1426        if chunk[2] != b'=' {
1427            bytes.push((((v[1] & 0xF) << 4) | (v[2] >> 2)) as u8);
1428        }
1429        if chunk[3] != b'=' {
1430            bytes.push((((v[2] & 0x3) << 6) | v[3]) as u8);
1431        }
1432    }
1433
1434    String::from_utf8(bytes).ok()
1435}
1436
1437fn is_fatal_gateway_close_code(code: u16) -> bool {
1438    matches!(code, 4004 | 4010 | 4011 | 4012 | 4013 | 4014)
1439}
1440
1441fn requires_new_session_close_code(code: u16) -> bool {
1442    matches!(code, 4007 | 4009)
1443}
1444
1445impl ::zeroclaw_api::attribution::Attributable for DiscordChannel {
1446    fn role(&self) -> ::zeroclaw_api::attribution::Role {
1447        ::zeroclaw_api::attribution::Role::Channel(
1448            ::zeroclaw_api::attribution::ChannelKind::Discord,
1449        )
1450    }
1451    fn alias(&self) -> &str {
1452        &self.alias
1453    }
1454}
1455
1456#[async_trait]
1457impl Channel for DiscordChannel {
1458    fn name(&self) -> &str {
1459        "discord"
1460    }
1461
1462    /// Discord bot tokens encode the bot's user ID in the first
1463    /// segment (`base64(user_id).timestamp.hmac`); decode on demand
1464    /// rather than caching since the result is deterministic and the
1465    /// orchestrator only calls `self_handle` on the inbound path.
1466    /// Returning the user ID engages the SDK self-loop guard against
1467    /// gateway events the bot itself produced (typing indicators,
1468    /// echoed message events from intent overlap, etc.).
1469    fn self_handle(&self) -> Option<String> {
1470        Self::bot_user_id_from_token(&self.bot_token)
1471    }
1472
1473    /// Discord renders user mentions as `<@SNOWFLAKE>` (or
1474    /// `<@!SNOWFLAKE>` with the legacy nickname prefix, which the API
1475    /// normalizes to the bare form on inbound). Returns the bot's
1476    /// snowflake wrapped in that exact form so the agent matches its
1477    /// own mention without parsing the angle brackets itself.
1478    fn self_addressed_mention(&self) -> Option<String> {
1479        self.self_handle().map(|id| format!("<@{id}>"))
1480    }
1481
1482    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
1483        let raw_content = crate::util::strip_tool_call_tags(&message.content);
1484        let (cleaned_content, parsed_attachments) = parse_attachment_markers(&raw_content);
1485        let (mut local_files, remote_urls, failures) =
1486            classify_outgoing_attachments(&parsed_attachments, self.workspace_dir.as_deref());
1487
1488        // Discord accepts max 10 files per message.
1489        if local_files.len() > 10 {
1490            ::zeroclaw_log::record!(
1491                WARN,
1492                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1493                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1494                    .with_attrs(::serde_json::json!({"count": local_files.len()})),
1495                "truncating local attachment upload list to 10 files"
1496            );
1497            local_files.truncate(10);
1498        }
1499
1500        let body = with_inline_attachment_urls(&cleaned_content, &remote_urls);
1501        let note = delivery_failure_note(&failures);
1502        let content = compose_body_with_failure_note(&body, note.as_deref());
1503        let reactions = decide_failure_reactions(&failures);
1504
1505        let client = self.http_client();
1506        let chunks = chunks_for_send(
1507            &content,
1508            self.stream_mode,
1509            DISCORD_MAX_MESSAGE_LENGTH,
1510            !local_files.is_empty(),
1511        );
1512        let inter_chunk_delay_ms =
1513            if self.stream_mode == zeroclaw_config::schema::StreamMode::MultiMessage {
1514                self.multi_message_delay_ms
1515            } else {
1516                500
1517            };
1518
1519        let mut first_message_id: Option<String> = None;
1520        for (i, chunk) in chunks.iter().enumerate() {
1521            let message_id = if i == 0 && !local_files.is_empty() {
1522                send_discord_message_with_files(
1523                    &client,
1524                    &self.bot_token,
1525                    &message.recipient,
1526                    chunk,
1527                    &local_files,
1528                )
1529                .await?
1530            } else {
1531                send_discord_message_json(&client, &self.bot_token, &message.recipient, chunk)
1532                    .await?
1533            };
1534            if first_message_id.is_none() {
1535                first_message_id = Some(message_id);
1536            }
1537
1538            if i < chunks.len() - 1 {
1539                if message
1540                    .cancellation_token
1541                    .as_ref()
1542                    .is_some_and(|t| t.is_cancelled())
1543                {
1544                    ::zeroclaw_log::record!(
1545                        DEBUG,
1546                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1547                        &format!(
1548                            "Discord delivery interrupted after chunk {}/{}",
1549                            i + 1,
1550                            chunks.len()
1551                        )
1552                    );
1553                    break;
1554                }
1555                tokio::time::sleep(std::time::Duration::from_millis(inter_chunk_delay_ms)).await;
1556            }
1557        }
1558
1559        self.apply_failure_reactions(&message.recipient, first_message_id.as_deref(), &reactions)
1560            .await;
1561
1562        Ok(())
1563    }
1564
1565    #[allow(clippy::too_many_lines)]
1566    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
1567        let bot_user_id = Self::bot_user_id_from_token(&self.bot_token).unwrap_or_default();
1568        let mut had_ready = false;
1569
1570        // Get Gateway URL
1571        let gw_resp = self
1572            .http_client()
1573            .get("https://discord.com/api/v10/gateway/bot")
1574            .header("Authorization", format!("Bot {}", self.bot_token))
1575            .send()
1576            .await?;
1577        let gw_resp = Self::validate_gateway_preflight_response(gw_resp)?;
1578        let gw_resp: serde_json::Value = gw_resp.json().await?;
1579
1580        if let Some(remaining) = gw_resp
1581            .get("session_start_limit")
1582            .and_then(|v| v.get("remaining"))
1583            .and_then(serde_json::Value::as_u64)
1584            && remaining == 0
1585        {
1586            return Err(Self::fatal_listener_error(
1587                "discord gateway identify blocked: session_start_limit.remaining is 0",
1588            ));
1589        }
1590
1591        let fresh_gateway_url = gw_resp
1592            .get("url")
1593            .and_then(|u| u.as_str())
1594            .ok_or_else(|| Self::fatal_listener_error("discord gateway preflight missing url"))?
1595            .to_string();
1596        let session_snapshot = self.gateway_session.lock().clone();
1597        let can_resume =
1598            session_snapshot.session_id.is_some() && session_snapshot.sequence.is_some();
1599        let gw_url = if can_resume {
1600            session_snapshot
1601                .resume_gateway_url
1602                .clone()
1603                .unwrap_or_else(|| fresh_gateway_url.clone())
1604        } else {
1605            fresh_gateway_url.clone()
1606        };
1607
1608        let ws_url = format!("{gw_url}/?v=10&encoding=json");
1609        ::zeroclaw_log::record!(
1610            INFO,
1611            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1612                .with_attrs(::serde_json::json!({"resume": can_resume, "gateway_url": gw_url})),
1613            "connecting to gateway..."
1614        );
1615
1616        let (ws_stream, _) = zeroclaw_config::schema::ws_connect_with_proxy(
1617            &ws_url,
1618            "channel.discord",
1619            self.proxy_url.as_deref(),
1620        )
1621        .await?;
1622        let (mut write, mut read) = ws_stream.split();
1623
1624        // Read Hello (opcode 10)
1625        let hello = read.next().await.ok_or_else(|| {
1626            ::zeroclaw_log::record!(
1627                ERROR,
1628                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1629                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1630                    .with_attrs(::serde_json::json!({"phase": "gateway_hello"})),
1631                "discord: gateway closed before Hello"
1632            );
1633            anyhow::Error::msg("No hello")
1634        })??;
1635        let hello_data: serde_json::Value = serde_json::from_str(&hello.to_string())?;
1636        let heartbeat_interval = hello_data
1637            .get("d")
1638            .and_then(|d| d.get("heartbeat_interval"))
1639            .and_then(serde_json::Value::as_u64)
1640            .unwrap_or(41250);
1641
1642        let mut sequence = session_snapshot.sequence.unwrap_or(-1);
1643
1644        if can_resume {
1645            let resume = json!({
1646                "op": 6,
1647                "d": {
1648                    "token": self.bot_token,
1649                    "session_id": session_snapshot.session_id,
1650                    "seq": session_snapshot.sequence,
1651                }
1652            });
1653            write.send(Message::Text(resume.to_string().into())).await?;
1654            ::zeroclaw_log::record!(
1655                INFO,
1656                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1657                    .with_attrs(::serde_json::json!({"sequence": sequence})),
1658                "sent Discord Resume"
1659            );
1660        } else {
1661            let identify = json!({
1662                "op": 2,
1663                "d": {
1664                    "token": self.bot_token,
1665                    "intents": 37377,
1666                    "properties": {
1667                        "os": "linux",
1668                        "browser": "zeroclaw",
1669                        "device": "zeroclaw"
1670                    }
1671                }
1672            });
1673            write
1674                .send(Message::Text(identify.to_string().into()))
1675                .await?;
1676            ::zeroclaw_log::record!(
1677                INFO,
1678                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1679                "sent Discord Identify"
1680            );
1681        }
1682
1683        // Spawn heartbeat timer — sends a tick signal, actual heartbeat
1684        // is assembled in the select! loop where `sequence` lives.
1685        let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
1686        let hb_interval = heartbeat_interval;
1687        tokio::spawn(async move {
1688            let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval));
1689            loop {
1690                interval.tick().await;
1691                if hb_tx.send(()).await.is_err() {
1692                    break;
1693                }
1694            }
1695        });
1696
1697        let guild_filter = self.guild_ids.clone();
1698        let channel_filter = self.channel_ids.clone();
1699        let archive_memory = self.archive_memory.clone();
1700
1701        // --- Stall watchdog --------------------------------------------------
1702        let watchdog = if self.stall_timeout_secs > 0 {
1703            Some(zeroclaw_infra::stall_watchdog::StallWatchdog::new(
1704                self.stall_timeout_secs,
1705            ))
1706        } else {
1707            None
1708        };
1709
1710        let (stall_tx, mut stall_rx) = tokio::sync::mpsc::channel::<()>(1);
1711        if let Some(ref wd) = watchdog {
1712            let stall_signal = stall_tx.clone();
1713            wd.start(move || {
1714                ::zeroclaw_log::record!(
1715                    WARN,
1716                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1717                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1718                    "stall watchdog fired — no events for configured timeout, triggering reconnect"
1719                );
1720                let _ = stall_signal.try_send(());
1721            })
1722            .await;
1723        }
1724        // Keep stall_tx alive so the receiver doesn't close prematurely when
1725        // the watchdog is disabled (recv will just pend forever).
1726        let _stall_tx_guard = stall_tx;
1727
1728        loop {
1729            tokio::select! {
1730                _ = stall_rx.recv() => {
1731                    ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note), "breaking listen loop due to stall watchdog");
1732                    break;
1733                }
1734                _ = hb_rx.recv() => {
1735                    let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
1736                    let hb = json!({"op": 1, "d": d});
1737                    if write.send(Message::Text(hb.to_string().into())).await.is_err() {
1738                        break;
1739                    }
1740                }
1741                msg = read.next() => {
1742                    let msg = match msg {
1743                        Some(Ok(Message::Text(t))) => t,
1744                        Some(Ok(Message::Ping(payload))) => {
1745                            if write.send(Message::Pong(payload)).await.is_err() {
1746                                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown), "pong send failed, reconnecting");
1747                                break;
1748                            }
1749                            continue;
1750                        }
1751                        Some(Ok(Message::Close(frame))) => {
1752                            if let Some(frame) = frame {
1753                                let code = u16::from(frame.code);
1754                                let reason = frame.reason.to_string();
1755                                if requires_new_session_close_code(code) {
1756                                    let mut session = self.gateway_session.lock();
1757                                    session.session_id = None;
1758                                    session.resume_gateway_url = None;
1759                                    session.sequence = None;
1760                                }
1761                                if is_fatal_gateway_close_code(code) {
1762                                    return Err(Self::fatal_listener_error(format!(
1763                                        "discord gateway closed with fatal code {code}: {reason}"
1764                                    )));
1765                                }
1766                                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"code": code, "reason": reason, "had_ready": had_ready, "sequence": sequence})), "discord gateway closed; reconnecting");
1767                            }
1768                            break;
1769                        }
1770                        None => {
1771                            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"had_ready": had_ready, "sequence": sequence})), "discord gateway stream ended; reconnecting");
1772                            break;
1773                        }
1774                        Some(Err(e)) => {
1775                            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error": format!("{}", e), "had_ready": had_ready, "sequence": sequence})), "websocket read error, reconnecting");
1776                            break;
1777                        }
1778                        _ => continue,
1779                    };
1780
1781                    let event: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
1782                        Ok(e) => e,
1783                        Err(_) => continue,
1784                    };
1785
1786                    // Mark activity for the stall watchdog on every
1787                    // successfully parsed gateway event.
1788                    if let Some(ref wd) = watchdog {
1789                        wd.touch();
1790                    }
1791
1792                    // Track sequence number from all dispatch events
1793                    if let Some(s) = event.get("s").and_then(serde_json::Value::as_i64) {
1794                        sequence = s;
1795                        self.gateway_session.lock().sequence = Some(s);
1796                    }
1797
1798                    let op = event.get("op").and_then(serde_json::Value::as_u64).unwrap_or(0);
1799                    let event_type = event.get("t").and_then(|t| t.as_str()).unwrap_or("");
1800
1801                    match event_type {
1802                        "READY" => {
1803                            had_ready = true;
1804                            let session_id = event
1805                                .get("d")
1806                                .and_then(|d| d.get("session_id"))
1807                                .and_then(serde_json::Value::as_str)
1808                                .map(ToString::to_string);
1809                            let resume_gateway_url = event
1810                                .get("d")
1811                                .and_then(|d| d.get("resume_gateway_url"))
1812                                .and_then(serde_json::Value::as_str)
1813                                .map(ToString::to_string);
1814                            {
1815                                let mut session = self.gateway_session.lock();
1816                                session.session_id = session_id.clone();
1817                                session.resume_gateway_url = resume_gateway_url;
1818                                session.sequence = if sequence >= 0 { Some(sequence) } else { None };
1819                            }
1820                            ::zeroclaw_log::record!(
1821                                INFO,
1822                                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1823                                    ::serde_json::json!({"sequence": sequence, "session_id_present": session_id.is_some()})
1824                                ),
1825                                "discord READY received"
1826                            );
1827                            continue;
1828                        }
1829                        "RESUMED" => {
1830                            had_ready = true;
1831                            ::zeroclaw_log::record!(
1832                                INFO,
1833                                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1834                                    ::serde_json::json!({"sequence": sequence})
1835                                ),
1836                                "discord RESUMED received"
1837                            );
1838                            continue;
1839                        }
1840                        _ => {}
1841                    }
1842
1843                    match op {
1844                        // Op 1: Server requests an immediate heartbeat
1845                        1 => {
1846                            let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
1847                            let hb = json!({"op": 1, "d": d});
1848                            if write.send(Message::Text(hb.to_string().into())).await.is_err() {
1849                                break;
1850                            }
1851                            continue;
1852                        }
1853                        // Op 7: Reconnect
1854                        7 => {
1855                            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"had_ready": had_ready, "sequence": sequence})), "received Reconnect (op 7), closing for restart");
1856                            break;
1857                        }
1858                        // Op 9: Invalid Session
1859                        9 => {
1860                            let resumable = event.get("d").and_then(serde_json::Value::as_bool).unwrap_or(false);
1861                            if !resumable {
1862                                let mut session = self.gateway_session.lock();
1863                                session.session_id = None;
1864                                session.resume_gateway_url = None;
1865                                session.sequence = None;
1866                            }
1867                            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"resumable": resumable, "had_ready": had_ready, "sequence": sequence})), "received Invalid Session (op 9), closing for restart");
1868                            break;
1869                        }
1870                        _ => {}
1871                    }
1872
1873                    // Only handle MESSAGE_CREATE (opcode 0, type "MESSAGE_CREATE")
1874                    if event_type != "MESSAGE_CREATE" {
1875                        continue;
1876                    }
1877
1878                    let Some(d) = event.get("d") else {
1879                        continue;
1880                    };
1881
1882                    // Skip messages from the bot itself
1883                    let author_id = d.get("author").and_then(|a| a.get("id")).and_then(|i| i.as_str()).unwrap_or("");
1884                    if author_id == bot_user_id {
1885                        continue;
1886                    }
1887
1888                    // Skip bot messages (unless listen_to_bots is enabled)
1889                    if !self.listen_to_bots && d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) {
1890                        continue;
1891                    }
1892
1893                    // Sender validation
1894                    if !self.is_user_allowed(author_id) {
1895                        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"author_id": author_id})), "ignoring message from unauthorized user");
1896                        continue;
1897                    }
1898
1899                    // Guild allowlist. Empty list = accept all guilds.
1900                    // DMs have no guild_id, so they always pass through.
1901                    if !guild_filter.is_empty() {
1902                        let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str);
1903                        if let Some(g) = msg_guild
1904                            && !guild_filter.iter().any(|allowed| allowed == g)
1905                        {
1906                            continue;
1907                        }
1908                    }
1909
1910                    // Channel allowlist. Empty = watch every channel.
1911                    // Thread messages carry the thread's own channel_id, not the
1912                    // parent's. When the direct match fails, look up the thread's
1913                    // parent_id and accept if *that* is in the allowlist.
1914                    if !channel_filter.is_empty() {
1915                        let msg_channel = d
1916                            .get("channel_id")
1917                            .and_then(serde_json::Value::as_str)
1918                            .unwrap_or("");
1919                        let parent_id = if !msg_channel.is_empty()
1920                            && !channel_filter.iter().any(|c| c == msg_channel)
1921                        {
1922                            self.thread_parent(&self.http_client(), msg_channel).await
1923                        } else {
1924                            None
1925                        };
1926                        if !channel_passes_filter(
1927                            &channel_filter,
1928                            msg_channel,
1929                            parent_id.as_deref(),
1930                        ) {
1931                            continue;
1932                        }
1933                    }
1934
1935                    // Archive every non-bot message to discord.db when enabled.
1936                    if let Some(ref archive_mem) = archive_memory {
1937                        let archive_channel_id =
1938                            d.get("channel_id").and_then(|c| c.as_str()).unwrap_or("");
1939                        let is_dm_event = d.get("guild_id").is_none();
1940                        let username = d
1941                            .get("author")
1942                            .and_then(|a| a.get("username"))
1943                            .and_then(|u| u.as_str())
1944                            .unwrap_or(author_id);
1945                        let content_raw =
1946                            d.get("content").and_then(|c| c.as_str()).unwrap_or("");
1947                        let archive_msg_id =
1948                            d.get("id").and_then(|i| i.as_str()).unwrap_or("");
1949                        if !content_raw.is_empty() {
1950                            let ts = chrono::Utc::now().to_rfc3339();
1951                            let channel_display =
1952                                if is_dm_event { "dm" } else { archive_channel_id };
1953                            let atts = d
1954                                .get("attachments")
1955                                .and_then(|a| a.as_array())
1956                                .map(|arr| {
1957                                    arr.iter()
1958                                        .filter_map(|a| a.get("url").and_then(|u| u.as_str()))
1959                                        .collect::<Vec<_>>()
1960                                        .join(", ")
1961                                })
1962                                .unwrap_or_default();
1963                            let mut mem_content = format!(
1964                                "@{username} in #{channel_display} at {ts}: {content_raw}"
1965                            );
1966                            if !atts.is_empty() {
1967                                mem_content.push_str(&format!(" [attachments: {atts}]"));
1968                            }
1969                            let mem_key = if archive_msg_id.is_empty() {
1970                                format!("discord_{}", Uuid::new_v4())
1971                            } else {
1972                                format!("discord_{archive_msg_id}")
1973                            };
1974                            let session = if archive_channel_id.is_empty() {
1975                                None
1976                            } else {
1977                                Some(archive_channel_id)
1978                            };
1979                            if let Err(e) = archive_mem
1980                                .store(
1981                                    &mem_key,
1982                                    &mem_content,
1983                                    zeroclaw_memory::MemoryCategory::Custom(
1984                                        "discord".to_string(),
1985                                    ),
1986                                    session,
1987                                )
1988                                .await
1989                            {
1990                                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error": format!("{}", e)})), "archive store failed");
1991                            }
1992                        }
1993                    }
1994
1995                    let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
1996                    // DMs carry no guild_id in the Discord gateway payload. They are
1997                    // inherently private and implicitly addressed to the bot, so bypass
1998                    // the mention gate — requiring a @mention in a DM is never correct.
1999                    let is_dm = d.get("guild_id").is_none();
2000                    let effective_mention_only = self.mention_only && !is_dm;
2001                    let atts = d
2002                        .get("attachments")
2003                        .and_then(|a| a.as_array())
2004                        .cloned()
2005                        .unwrap_or_default();
2006                    let has_attachments = !atts.is_empty();
2007                    let Some(clean_content) = admit_discord_message(
2008                        content,
2009                        has_attachments,
2010                        effective_mention_only,
2011                        &bot_user_id,
2012                    ) else {
2013                        continue;
2014                    };
2015
2016                    let client = self.http_client();
2017                    let (attachment_text, media_attachments) = process_attachments(
2018                        &atts,
2019                        &client,
2020                        self.workspace_dir.as_deref(),
2021                        self.transcription_manager.as_deref(),
2022                    )
2023                    .await;
2024                    let final_content = if attachment_text.is_empty() {
2025                        clean_content
2026                    } else {
2027                        format!("{clean_content}\n\n[Attachments]\n{attachment_text}")
2028                    };
2029
2030                    // Intercept approval replies before forwarding to the agent.
2031                    if let Some((token, response)) =
2032                        crate::util::parse_approval_reply(&final_content)
2033                    {
2034                        let mut map = self.pending_approvals.lock().await;
2035                        if let Some(sender) = map.remove(&token) {
2036                            let _ = sender.send(response);
2037                            continue;
2038                        }
2039                    }
2040
2041                    let message_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
2042                    let channel_id = d
2043                        .get("channel_id")
2044                        .and_then(|c| c.as_str())
2045                        .unwrap_or("")
2046                        .to_string();
2047
2048                    if !message_id.is_empty() && !channel_id.is_empty() {
2049                        let reaction_channel = DiscordChannel::new(
2050                            self.bot_token.clone(),
2051                            self.guild_ids.clone(),
2052                            self.alias.clone(),
2053                            Arc::clone(&self.peer_resolver),
2054                            self.listen_to_bots,
2055                            self.mention_only,
2056                        );
2057                        let reaction_channel_id = channel_id.clone();
2058                        let reaction_message_id = message_id.to_string();
2059                        let reaction_emoji = random_discord_ack_reaction().to_string();
2060                        tokio::spawn(async move {
2061                            if let Err(err) = reaction_channel
2062                                .add_reaction(
2063                                    &reaction_channel_id,
2064                                    &reaction_message_id,
2065                                    &reaction_emoji,
2066                                )
2067                                .await
2068                            {
2069                                ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"reaction_message_id": reaction_message_id, "err": err.to_string()})), "failed to add ACK reaction for message");
2070                            }
2071                        });
2072                    }
2073
2074                    // Thread context decides `thread_ts` plus `interruption_scope_id`,
2075                    // which the orchestrator uses as part of the conversation-history
2076                    // key and the cancellation scope. When the lookup fails it falls
2077                    // back to `None` and the failure is not cached, so the next
2078                    // message in the same Discord thread will retry. The trade-off:
2079                    // the first message after a transient lookup miss is keyed
2080                    // without the thread suffix; once the cache warms, subsequent
2081                    // messages are keyed with it. History for that thread can split
2082                    // across two scopes until the warm-up completes. Acceptable
2083                    // because the lookup is bounded by `THREAD_LOOKUP_TIMEOUT` and
2084                    // the alternative (stalling the listener on a hung Discord call)
2085                    // is worse.
2086                    let thread_ts = if channel_id.is_empty() {
2087                        None
2088                    } else if self.thread_parent(&client, &channel_id).await.is_some()
2089                    {
2090                        Some(channel_id.clone())
2091                    } else {
2092                        None
2093                    };
2094
2095                    let channel_msg = ChannelMessage {
2096                        id: if message_id.is_empty() {
2097                            Uuid::new_v4().to_string()
2098                        } else {
2099                            format!("discord_{message_id}")
2100                        },
2101                        sender: author_id.to_string(),
2102                        reply_target: if channel_id.is_empty() {
2103                            author_id.to_string()
2104                        } else {
2105                            channel_id.clone()
2106                        },
2107                        content: final_content,
2108                        channel: "discord".to_string(),
2109                        channel_alias: Some(self.alias.clone()),
2110                        timestamp: std::time::SystemTime::now()
2111                            .duration_since(std::time::UNIX_EPOCH)
2112                            .unwrap_or_default()
2113                            .as_secs(),
2114                        interruption_scope_id: thread_ts.clone(),
2115                        thread_ts,
2116                        attachments: media_attachments,
2117                        subject: None,
2118                    };
2119
2120                    if tx.send(channel_msg).await.is_err() {
2121                        break;
2122                    }
2123                }
2124            }
2125        }
2126
2127        // Clean up the watchdog task before returning so the outer
2128        // reconnection loop can start fresh.
2129        if let Some(ref wd) = watchdog {
2130            wd.stop().await;
2131        }
2132
2133        Ok(())
2134    }
2135
2136    async fn health_check(&self) -> bool {
2137        self.http_client()
2138            .get("https://discord.com/api/v10/users/@me")
2139            .header("Authorization", format!("Bot {}", self.bot_token))
2140            .send()
2141            .await
2142            .map(|r| r.status().is_success())
2143            .unwrap_or(false)
2144    }
2145
2146    async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
2147        self.stop_typing(recipient).await?;
2148
2149        let client = self.http_client();
2150        let token = self.bot_token.clone();
2151        let channel_id = recipient.to_string();
2152
2153        let handle = tokio::spawn(async move {
2154            let url = format!("https://discord.com/api/v10/channels/{channel_id}/typing");
2155            loop {
2156                let _ = client
2157                    .post(&url)
2158                    .header("Authorization", format!("Bot {token}"))
2159                    .send()
2160                    .await;
2161                tokio::time::sleep(std::time::Duration::from_secs(8)).await;
2162            }
2163        });
2164
2165        let mut guard = self.typing_handles.lock();
2166        guard.insert(recipient.to_string(), handle);
2167
2168        Ok(())
2169    }
2170
2171    async fn stop_typing(&self, recipient: &str) -> anyhow::Result<()> {
2172        let mut guard = self.typing_handles.lock();
2173        if let Some(handle) = guard.remove(recipient) {
2174            handle.abort();
2175        }
2176        Ok(())
2177    }
2178
2179    fn supports_draft_updates(&self) -> bool {
2180        self.stream_mode != zeroclaw_config::schema::StreamMode::Off
2181    }
2182
2183    fn supports_multi_message_streaming(&self) -> bool {
2184        self.stream_mode == zeroclaw_config::schema::StreamMode::MultiMessage
2185    }
2186
2187    fn multi_message_delay_ms(&self) -> u64 {
2188        self.multi_message_delay_ms
2189    }
2190
2191    async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
2192        use zeroclaw_config::schema::StreamMode;
2193        match self.stream_mode {
2194            StreamMode::Off => Ok(None),
2195            StreamMode::Partial => {
2196                let initial_text = if message.content.is_empty() {
2197                    "...".to_string()
2198                } else {
2199                    message.content.clone()
2200                };
2201
2202                let client = self.http_client();
2203                let msg_id = send_discord_message_json(
2204                    &client,
2205                    &self.bot_token,
2206                    &message.recipient,
2207                    &initial_text,
2208                )
2209                .await?;
2210
2211                self.last_draft_edit
2212                    .lock()
2213                    .insert(message.recipient.clone(), std::time::Instant::now());
2214
2215                Ok(Some(msg_id))
2216            }
2217            StreamMode::MultiMessage => {
2218                // No initial draft — paragraphs are sent as new messages.
2219                // Store thread context for paragraph delivery.
2220                self.multi_message_sent_len.lock().clear();
2221                self.multi_message_thread_ts
2222                    .lock()
2223                    .insert(message.recipient.clone(), message.thread_ts.clone());
2224                Ok(Some("multi_message_synthetic".to_string()))
2225            }
2226        }
2227    }
2228
2229    async fn update_draft(
2230        &self,
2231        recipient: &str,
2232        message_id: &str,
2233        text: &str,
2234    ) -> anyhow::Result<()> {
2235        use zeroclaw_config::schema::StreamMode;
2236        match self.stream_mode {
2237            StreamMode::Off => Ok(()),
2238            StreamMode::Partial => {
2239                // Rate-limit edits per channel.
2240                {
2241                    let last_edits = self.last_draft_edit.lock();
2242                    if let Some(last_time) = last_edits.get(recipient) {
2243                        let elapsed_ms =
2244                            u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX);
2245                        if elapsed_ms < self.draft_update_interval_ms {
2246                            return Ok(());
2247                        }
2248                    }
2249                }
2250
2251                // UTF-8 safe truncation to Discord limit.
2252                let display_text = if text.len() > DISCORD_MAX_MESSAGE_LENGTH {
2253                    let mut end = 0;
2254                    for (idx, ch) in text.char_indices() {
2255                        let next = idx + ch.len_utf8();
2256                        if next > DISCORD_MAX_MESSAGE_LENGTH {
2257                            break;
2258                        }
2259                        end = next;
2260                    }
2261                    &text[..end]
2262                } else {
2263                    text
2264                };
2265
2266                let client = self.http_client();
2267                match edit_discord_message(
2268                    &client,
2269                    &self.bot_token,
2270                    recipient,
2271                    message_id,
2272                    display_text,
2273                )
2274                .await
2275                {
2276                    Ok(()) => {
2277                        self.last_draft_edit
2278                            .lock()
2279                            .insert(recipient.to_string(), std::time::Instant::now());
2280                    }
2281                    Err(e) => {
2282                        ::zeroclaw_log::record!(
2283                            DEBUG,
2284                            ::zeroclaw_log::Event::new(
2285                                module_path!(),
2286                                ::zeroclaw_log::Action::Note
2287                            )
2288                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
2289                            "draft update failed"
2290                        );
2291                    }
2292                }
2293
2294                Ok(())
2295            }
2296            StreamMode::MultiMessage => {
2297                // Track accumulated text and send new paragraphs at \n\n boundaries.
2298                // Extract paragraph (if any) under the lock, then drop it before async work.
2299                let (paragraph, thread_ts) = {
2300                    let thread_ts = self
2301                        .multi_message_thread_ts
2302                        .lock()
2303                        .get(recipient)
2304                        .cloned()
2305                        .flatten();
2306                    let mut sent_map = self.multi_message_sent_len.lock();
2307                    let sent_so_far = sent_map.get(recipient).copied().unwrap_or(0);
2308
2309                    // DraftEvent::Clear resets accumulated text — reset our counter.
2310                    if text.len() < sent_so_far {
2311                        sent_map.insert(recipient.to_string(), 0);
2312                        return Ok(());
2313                    }
2314                    if text.len() == sent_so_far {
2315                        return Ok(());
2316                    }
2317
2318                    let new_text = &text[sent_so_far..];
2319                    let mut scan_pos = 0;
2320                    let mut in_fence = false;
2321                    let bytes = new_text.as_bytes();
2322                    let mut found_paragraph = None;
2323
2324                    while scan_pos < bytes.len() {
2325                        let ch = bytes[scan_pos];
2326
2327                        if ch == b'`'
2328                            && scan_pos + 2 < bytes.len()
2329                            && bytes[scan_pos + 1] == b'`'
2330                            && bytes[scan_pos + 2] == b'`'
2331                            && (scan_pos == 0 || bytes[scan_pos - 1] == b'\n')
2332                        {
2333                            in_fence = !in_fence;
2334                        }
2335
2336                        if !in_fence
2337                            && ch == b'\n'
2338                            && scan_pos + 1 < bytes.len()
2339                            && bytes[scan_pos + 1] == b'\n'
2340                        {
2341                            let paragraph = new_text[..scan_pos].trim().to_string();
2342                            let consumed = scan_pos + 2;
2343                            *sent_map.entry(recipient.to_string()).or_insert(0) += consumed;
2344                            if !paragraph.is_empty() {
2345                                found_paragraph = Some(paragraph);
2346                            }
2347                            break;
2348                        }
2349
2350                        scan_pos += 1;
2351                    }
2352                    // Lock is dropped here at end of block.
2353                    (found_paragraph, thread_ts)
2354                };
2355
2356                if let Some(paragraph) = paragraph {
2357                    let msg = SendMessage::new(&paragraph, recipient).in_thread(thread_ts.clone());
2358                    if let Err(e) = self.send(&msg).await {
2359                        ::zeroclaw_log::record!(
2360                            DEBUG,
2361                            ::zeroclaw_log::Event::new(
2362                                module_path!(),
2363                                ::zeroclaw_log::Action::Note
2364                            )
2365                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
2366                            "multi-message paragraph send failed"
2367                        );
2368                    }
2369                    if self.multi_message_delay_ms > 0 {
2370                        tokio::time::sleep(std::time::Duration::from_millis(
2371                            self.multi_message_delay_ms,
2372                        ))
2373                        .await;
2374                    }
2375                    // Recurse to handle remaining text.
2376                    return self.update_draft(recipient, message_id, text).await;
2377                }
2378
2379                Ok(())
2380            }
2381        }
2382    }
2383
2384    async fn finalize_draft(
2385        &self,
2386        recipient: &str,
2387        message_id: &str,
2388        text: &str,
2389    ) -> anyhow::Result<()> {
2390        if self.stream_mode == zeroclaw_config::schema::StreamMode::MultiMessage {
2391            // Flush remaining buffered text.
2392            let thread_ts = self
2393                .multi_message_thread_ts
2394                .lock()
2395                .remove(recipient)
2396                .flatten();
2397            let sent_so_far = self
2398                .multi_message_sent_len
2399                .lock()
2400                .remove(recipient)
2401                .unwrap_or(0);
2402            if text.len() > sent_so_far {
2403                let remaining = text[sent_so_far..].trim().to_string();
2404                if !remaining.is_empty() {
2405                    let msg = SendMessage::new(&remaining, recipient).in_thread(thread_ts);
2406                    if let Err(e) = self.send(&msg).await {
2407                        ::zeroclaw_log::record!(
2408                            DEBUG,
2409                            ::zeroclaw_log::Event::new(
2410                                module_path!(),
2411                                ::zeroclaw_log::Action::Note
2412                            )
2413                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
2414                            "multi-message final flush failed"
2415                        );
2416                    }
2417                }
2418            }
2419            return Ok(());
2420        }
2421
2422        // Belt-and-suspenders: kill any typing handles for this channel.
2423        let _ = self.stop_typing(recipient).await;
2424        self.last_draft_edit.lock().remove(recipient);
2425
2426        let text = &crate::util::strip_tool_call_tags(text);
2427        let (cleaned_content, parsed_attachments) = parse_attachment_markers(text);
2428        let (mut local_files, remote_urls, failures) =
2429            classify_outgoing_attachments(&parsed_attachments, self.workspace_dir.as_deref());
2430        let body = with_inline_attachment_urls(&cleaned_content, &remote_urls);
2431        let note = delivery_failure_note(&failures);
2432        let content = compose_body_with_failure_note(&body, note.as_deref());
2433        let reactions = decide_failure_reactions(&failures);
2434
2435        let client = self.http_client();
2436
2437        // Path 1: file attachments — delete draft and POST fresh message with files.
2438        if !local_files.is_empty() {
2439            let _ = delete_discord_message(&client, &self.bot_token, recipient, message_id).await;
2440
2441            if local_files.len() > 10 {
2442                local_files.truncate(10);
2443            }
2444            let chunks = split_message_for_discord(&content);
2445            let mut first_message_id: Option<String> = None;
2446            for (i, chunk) in chunks.iter().enumerate() {
2447                let new_id = if i == 0 {
2448                    send_discord_message_with_files(
2449                        &client,
2450                        &self.bot_token,
2451                        recipient,
2452                        chunk,
2453                        &local_files,
2454                    )
2455                    .await?
2456                } else {
2457                    send_discord_message_json(&client, &self.bot_token, recipient, chunk).await?
2458                };
2459                if first_message_id.is_none() {
2460                    first_message_id = Some(new_id);
2461                }
2462                if i < chunks.len() - 1 {
2463                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2464                }
2465            }
2466            self.apply_failure_reactions(recipient, first_message_id.as_deref(), &reactions)
2467                .await;
2468            return Ok(());
2469        }
2470
2471        // Path 2: text exceeds limit — delete draft and POST as chunked messages.
2472        if content.chars().count() > DISCORD_MAX_MESSAGE_LENGTH {
2473            let _ = delete_discord_message(&client, &self.bot_token, recipient, message_id).await;
2474
2475            let chunks = split_message_for_discord(&content);
2476            let mut first_message_id: Option<String> = None;
2477            for (i, chunk) in chunks.iter().enumerate() {
2478                let new_id =
2479                    send_discord_message_json(&client, &self.bot_token, recipient, chunk).await?;
2480                if first_message_id.is_none() {
2481                    first_message_id = Some(new_id);
2482                }
2483                if i < chunks.len() - 1 {
2484                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2485                }
2486            }
2487            self.apply_failure_reactions(recipient, first_message_id.as_deref(), &reactions)
2488                .await;
2489            return Ok(());
2490        }
2491
2492        // Path 3: simple case — edit in-place; fall back to delete + POST on failure.
2493        // The reaction target is the draft message_id when the edit lands;
2494        // when the fallback fires it's the freshly posted message instead.
2495        let reaction_target =
2496            match edit_discord_message(&client, &self.bot_token, recipient, message_id, &content)
2497                .await
2498            {
2499                Ok(()) => message_id.to_string(),
2500                Err(e) => {
2501                    ::zeroclaw_log::record!(
2502                        WARN,
2503                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2504                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2505                            .with_attrs(::serde_json::json!({"e": e.to_string()})),
2506                        "Discord finalize_draft edit failed: ; falling back to delete+send"
2507                    );
2508                    let _ = delete_discord_message(&client, &self.bot_token, recipient, message_id)
2509                        .await;
2510                    send_discord_message_json(&client, &self.bot_token, recipient, &content).await?
2511                }
2512            };
2513        self.apply_failure_reactions(recipient, Some(&reaction_target), &reactions)
2514            .await;
2515
2516        Ok(())
2517    }
2518
2519    async fn cancel_draft(&self, recipient: &str, message_id: &str) -> anyhow::Result<()> {
2520        if self.stream_mode == zeroclaw_config::schema::StreamMode::MultiMessage {
2521            self.multi_message_sent_len.lock().remove(recipient);
2522            self.multi_message_thread_ts.lock().remove(recipient);
2523            return Ok(());
2524        }
2525
2526        let _ = self.stop_typing(recipient).await;
2527        self.last_draft_edit.lock().remove(recipient);
2528
2529        let client = self.http_client();
2530        if let Err(e) =
2531            delete_discord_message(&client, &self.bot_token, recipient, message_id).await
2532        {
2533            ::zeroclaw_log::record!(
2534                DEBUG,
2535                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2536                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
2537                "cancel_draft delete failed"
2538            );
2539        }
2540
2541        Ok(())
2542    }
2543
2544    async fn add_reaction(
2545        &self,
2546        channel_id: &str,
2547        message_id: &str,
2548        emoji: &str,
2549    ) -> anyhow::Result<()> {
2550        let url = discord_reaction_url(channel_id, message_id, emoji);
2551
2552        let resp = self
2553            .http_client()
2554            .put(&url)
2555            .header("Authorization", format!("Bot {}", self.bot_token))
2556            .header("Content-Length", "0")
2557            .send()
2558            .await?;
2559
2560        if !resp.status().is_success() {
2561            let status = resp.status();
2562            let err = resp
2563                .text()
2564                .await
2565                .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
2566            anyhow::bail!("Discord add reaction failed ({status}): {err}");
2567        }
2568
2569        Ok(())
2570    }
2571
2572    async fn remove_reaction(
2573        &self,
2574        channel_id: &str,
2575        message_id: &str,
2576        emoji: &str,
2577    ) -> anyhow::Result<()> {
2578        let url = discord_reaction_url(channel_id, message_id, emoji);
2579
2580        let resp = self
2581            .http_client()
2582            .delete(&url)
2583            .header("Authorization", format!("Bot {}", self.bot_token))
2584            .send()
2585            .await?;
2586
2587        if !resp.status().is_success() {
2588            let status = resp.status();
2589            let err = resp
2590                .text()
2591                .await
2592                .unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
2593            anyhow::bail!("Discord remove reaction failed ({status}): {err}");
2594        }
2595
2596        Ok(())
2597    }
2598
2599    async fn request_approval(
2600        &self,
2601        recipient: &str,
2602        request: &ChannelApprovalRequest,
2603    ) -> anyhow::Result<Option<ChannelApprovalResponse>> {
2604        let token = crate::util::new_approval_token();
2605        let text = format!(
2606            "APPROVAL REQUIRED [{}]\nTool: {}\nArgs: {}\n\nReply: \"{} yes\", \"{} no\", or \"{} always\"",
2607            token, request.tool_name, request.arguments_summary, token, token, token,
2608        );
2609
2610        let (tx, rx) = oneshot::channel();
2611        self.pending_approvals
2612            .lock()
2613            .await
2614            .insert(token.clone(), tx);
2615
2616        // Strip thread suffix — approval message goes to the channel root.
2617        let channel_id = recipient.split(':').next().unwrap_or(recipient);
2618        if let Err(err) = self.send(&SendMessage::new(text, channel_id)).await {
2619            self.pending_approvals.lock().await.remove(&token);
2620            return Err(err);
2621        }
2622
2623        let response =
2624            match tokio::time::timeout(Duration::from_secs(self.approval_timeout_secs), rx).await {
2625                Ok(Ok(resp)) => resp,
2626                _ => {
2627                    self.pending_approvals.lock().await.remove(&token);
2628                    ChannelApprovalResponse::Deny
2629                }
2630            };
2631        Ok(Some(response))
2632    }
2633}
2634
2635#[cfg(test)]
2636mod tests {
2637    use super::*;
2638
2639    #[test]
2640    fn discord_channel_name() {
2641        let listen_to_bots = false;
2642        let mention_only = false;
2643        let ch = DiscordChannel::new(
2644            "fake".into(),
2645            vec![],
2646            "discord_test_alias",
2647            Arc::new(Vec::new),
2648            listen_to_bots,
2649            mention_only,
2650        );
2651        assert_eq!(ch.name(), "discord");
2652    }
2653
2654    #[test]
2655    fn base64_decode_bot_id() {
2656        // "MTIzNDU2" decodes to "123456"
2657        let decoded = base64_decode("MTIzNDU2");
2658        assert_eq!(decoded, Some("123456".to_string()));
2659    }
2660
2661    #[test]
2662    fn bot_user_id_extraction() {
2663        // Token format: base64(user_id).timestamp.hmac
2664        let token = "MTIzNDU2.fake.hmac";
2665        let id = DiscordChannel::bot_user_id_from_token(token);
2666        assert_eq!(id, Some("123456".to_string()));
2667    }
2668
2669    #[test]
2670    fn gateway_preflight_429_remains_retryable_http_error() {
2671        let response = reqwest::Response::from(
2672            axum::http::Response::builder()
2673                .status(reqwest::StatusCode::TOO_MANY_REQUESTS)
2674                .header(reqwest::header::RETRY_AFTER, "1")
2675                .body(reqwest::Body::from(""))
2676                .expect("test response should build"),
2677        );
2678
2679        let error = DiscordChannel::validate_gateway_preflight_response(response)
2680            .expect_err("429 should remain an HTTP error");
2681        assert!(error.downcast_ref::<reqwest::Error>().is_some());
2682        assert!(
2683            error.downcast_ref::<DiscordListenerFatalError>().is_none(),
2684            "gateway preflight 429 must not be wrapped as fatal"
2685        );
2686        assert!(
2687            !zeroclaw_providers::reliable::is_non_retryable(&error),
2688            "gateway preflight 429 should stay on the supervisor retry path"
2689        );
2690    }
2691
2692    #[test]
2693    fn empty_allowlist_denies_everyone() {
2694        let listen_to_bots = false;
2695        let mention_only = false;
2696        let ch = DiscordChannel::new(
2697            "fake".into(),
2698            vec![],
2699            "discord_test_alias",
2700            Arc::new(Vec::new),
2701            listen_to_bots,
2702            mention_only,
2703        );
2704        assert!(!ch.is_user_allowed("12345"));
2705        assert!(!ch.is_user_allowed("anyone"));
2706    }
2707
2708    #[test]
2709    fn wildcard_allows_everyone() {
2710        let listen_to_bots = false;
2711        let mention_only = false;
2712        let ch = DiscordChannel::new(
2713            "fake".into(),
2714            vec![],
2715            "discord_test_alias",
2716            Arc::new(|| vec!["*".into()]),
2717            listen_to_bots,
2718            mention_only,
2719        );
2720        assert!(ch.is_user_allowed("12345"));
2721        assert!(ch.is_user_allowed("anyone"));
2722    }
2723
2724    #[test]
2725    fn specific_allowlist_filters() {
2726        let listen_to_bots = false;
2727        let mention_only = false;
2728        let ch = DiscordChannel::new(
2729            "fake".into(),
2730            vec![],
2731            "discord_test_alias",
2732            Arc::new(|| vec!["111".into(), "222".into()]),
2733            listen_to_bots,
2734            mention_only,
2735        );
2736        assert!(ch.is_user_allowed("111"));
2737        assert!(ch.is_user_allowed("222"));
2738        assert!(!ch.is_user_allowed("333"));
2739        assert!(!ch.is_user_allowed("unknown"));
2740    }
2741
2742    #[test]
2743    fn allowlist_is_exact_match_not_substring() {
2744        let listen_to_bots = false;
2745        let mention_only = false;
2746        let ch = DiscordChannel::new(
2747            "fake".into(),
2748            vec![],
2749            "discord_test_alias",
2750            Arc::new(|| vec!["111".into()]),
2751            listen_to_bots,
2752            mention_only,
2753        );
2754        assert!(!ch.is_user_allowed("1111"));
2755        assert!(!ch.is_user_allowed("11"));
2756        assert!(!ch.is_user_allowed("0111"));
2757    }
2758
2759    #[test]
2760    fn allowlist_empty_string_user_id() {
2761        let listen_to_bots = false;
2762        let mention_only = false;
2763        let ch = DiscordChannel::new(
2764            "fake".into(),
2765            vec![],
2766            "discord_test_alias",
2767            Arc::new(|| vec!["111".into()]),
2768            listen_to_bots,
2769            mention_only,
2770        );
2771        assert!(!ch.is_user_allowed(""));
2772    }
2773
2774    #[test]
2775    fn allowlist_with_wildcard_and_specific() {
2776        let listen_to_bots = false;
2777        let mention_only = false;
2778        let ch = DiscordChannel::new(
2779            "fake".into(),
2780            vec![],
2781            "discord_test_alias",
2782            Arc::new(|| vec!["111".into(), "*".into()]),
2783            listen_to_bots,
2784            mention_only,
2785        );
2786        assert!(ch.is_user_allowed("111"));
2787        assert!(ch.is_user_allowed("anyone_else"));
2788    }
2789
2790    #[test]
2791    fn allowlist_case_sensitive() {
2792        let listen_to_bots = false;
2793        let mention_only = false;
2794        let ch = DiscordChannel::new(
2795            "fake".into(),
2796            vec![],
2797            "discord_test_alias",
2798            Arc::new(|| vec!["ABC".into()]),
2799            listen_to_bots,
2800            mention_only,
2801        );
2802        assert!(ch.is_user_allowed("ABC"));
2803        assert!(!ch.is_user_allowed("abc"));
2804        assert!(!ch.is_user_allowed("Abc"));
2805    }
2806
2807    #[test]
2808    fn base64_decode_empty_string() {
2809        let decoded = base64_decode("");
2810        assert_eq!(decoded, Some(String::new()));
2811    }
2812
2813    #[test]
2814    fn fatal_gateway_close_codes_match_expected_discord_auth_and_intent_errors() {
2815        for code in [4004_u16, 4010, 4011, 4012, 4013, 4014] {
2816            assert!(
2817                is_fatal_gateway_close_code(code),
2818                "code {code} should be fatal"
2819            );
2820        }
2821        assert!(!is_fatal_gateway_close_code(4007));
2822        assert!(!is_fatal_gateway_close_code(4009));
2823    }
2824
2825    #[test]
2826    fn new_session_close_codes_match_invalidated_gateway_sessions() {
2827        assert!(requires_new_session_close_code(4007));
2828        assert!(requires_new_session_close_code(4009));
2829        assert!(!requires_new_session_close_code(4004));
2830    }
2831
2832    #[test]
2833    fn base64_decode_invalid_chars() {
2834        let decoded = base64_decode("!!!!");
2835        assert!(decoded.is_none());
2836    }
2837
2838    #[test]
2839    fn bot_user_id_from_empty_token() {
2840        let id = DiscordChannel::bot_user_id_from_token("");
2841        assert_eq!(id, Some(String::new()));
2842    }
2843
2844    #[test]
2845    fn contains_bot_mention_supports_plain_and_nick_forms() {
2846        assert!(contains_bot_mention("hi <@12345>", "12345"));
2847        assert!(contains_bot_mention("hi <@!12345>", "12345"));
2848        assert!(!contains_bot_mention("hi <@99999>", "12345"));
2849    }
2850
2851    #[test]
2852    fn admit_discord_message_requires_mention_when_enabled() {
2853        let cleaned = admit_discord_message("hello there", false, true, "12345");
2854        assert!(cleaned.is_none());
2855    }
2856
2857    #[test]
2858    fn admit_discord_message_preserves_mention_in_body() {
2859        let cleaned = admit_discord_message("  <@!12345> run status  ", false, true, "12345");
2860        assert_eq!(cleaned.as_deref(), Some("<@!12345> run status"));
2861    }
2862
2863    #[test]
2864    fn admit_discord_message_admits_caption_that_is_only_the_mention() {
2865        let cleaned = admit_discord_message("<@12345>", false, true, "12345");
2866        assert_eq!(cleaned.as_deref(), Some("<@12345>"));
2867    }
2868
2869    #[test]
2870    fn admit_discord_message_attachment_only_in_dm_is_admitted() {
2871        // DM (effective_mention_only=false), empty text body, at least one
2872        // attachment. Previously dropped at the empty-text gate; now passes
2873        // through so process_attachments can run on the media.
2874        let cleaned = admit_discord_message("", true, false, "12345");
2875        assert_eq!(cleaned.as_deref(), Some(""));
2876    }
2877
2878    #[test]
2879    fn admit_discord_message_attachment_only_with_mention_in_guild_is_admitted() {
2880        // Guild channel with mention_only=true. Caption is the @mention tag
2881        // and the message has a media attachment. Mention gate passes; the
2882        // body keeps the mention text so downstream code (and the agent it
2883        // routes to) can see who was addressed.
2884        let cleaned = admit_discord_message("<@12345>", true, true, "12345");
2885        assert_eq!(cleaned.as_deref(), Some("<@12345>"));
2886    }
2887
2888    #[test]
2889    fn admit_discord_message_attachment_only_without_mention_in_guild_is_rejected() {
2890        // Guild channel with mention_only=true, attachment but no mention
2891        // anywhere in the caption. The mention gate is orthogonal to
2892        // attachment presence: no mention signal means drop.
2893        let cleaned = admit_discord_message("", true, true, "12345");
2894        assert!(cleaned.is_none());
2895    }
2896
2897    #[test]
2898    fn admit_discord_message_drops_when_no_text_and_no_attachments() {
2899        // Completely empty payload with attachments absent is always dropped,
2900        // regardless of mention_only setting.
2901        assert!(admit_discord_message("", false, false, "12345").is_none());
2902        assert!(admit_discord_message("", false, true, "12345").is_none());
2903    }
2904
2905    // mention_only DM-bypass tests
2906
2907    #[test]
2908    fn mention_only_dm_bypasses_mention_gate() {
2909        // DMs (no guild_id) must pass through even when mention_only is true
2910        // and the message contains no @mention. Mirrors the listen call-site logic.
2911        let mention_only = true;
2912        let is_dm = true;
2913        let effective = mention_only && !is_dm;
2914        let cleaned = admit_discord_message("hello without mention", false, effective, "12345");
2915        assert_eq!(cleaned.as_deref(), Some("hello without mention"));
2916    }
2917
2918    #[test]
2919    fn mention_only_guild_message_without_mention_is_rejected() {
2920        // Guild messages (has guild_id, so is_dm = false) must still be rejected
2921        // when mention_only is true and the message contains no @mention.
2922        let mention_only = true;
2923        let is_dm = false;
2924        let effective = mention_only && !is_dm;
2925        let cleaned = admit_discord_message("hello without mention", false, effective, "12345");
2926        assert!(cleaned.is_none());
2927    }
2928
2929    #[test]
2930    fn mention_only_guild_message_with_mention_passes_through() {
2931        // Guild messages that carry a @mention pass through the gate with
2932        // the mention text preserved so downstream consumers (and the agent
2933        // it routes to) can see who was addressed.
2934        let mention_only = true;
2935        let is_dm = false;
2936        let effective = mention_only && !is_dm;
2937        let cleaned = admit_discord_message("<@12345> run status", false, effective, "12345");
2938        assert_eq!(cleaned.as_deref(), Some("<@12345> run status"));
2939    }
2940
2941    // Message splitting tests
2942
2943    #[test]
2944    fn split_empty_message() {
2945        let chunks = split_message_for_discord("");
2946        assert_eq!(chunks, vec![""]);
2947    }
2948
2949    #[test]
2950    fn split_short_message_under_limit() {
2951        let msg = "Hello, world!";
2952        let chunks = split_message_for_discord(msg);
2953        assert_eq!(chunks, vec![msg]);
2954    }
2955
2956    #[test]
2957    fn split_message_exactly_2000_chars() {
2958        let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
2959        let chunks = split_message_for_discord(&msg);
2960        assert_eq!(chunks.len(), 1);
2961        assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
2962    }
2963
2964    #[test]
2965    fn split_message_just_over_limit() {
2966        let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
2967        let chunks = split_message_for_discord(&msg);
2968        assert_eq!(chunks.len(), 2);
2969        assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
2970        assert_eq!(chunks[1].chars().count(), 1);
2971    }
2972
2973    #[test]
2974    fn split_very_long_message() {
2975        let msg = "word ".repeat(2000); // 10000 characters (5 chars per "word ")
2976        let chunks = split_message_for_discord(&msg);
2977        // Should split into 5 chunks of <= 2000 chars
2978        assert_eq!(chunks.len(), 5);
2979        assert!(
2980            chunks
2981                .iter()
2982                .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH)
2983        );
2984        // Verify total content is preserved
2985        let reconstructed = chunks.concat();
2986        assert_eq!(reconstructed, msg);
2987    }
2988
2989    #[test]
2990    fn split_prefer_newline_break() {
2991        let msg = format!("{}\n{}", "a".repeat(1500), "b".repeat(500));
2992        let chunks = split_message_for_discord(&msg);
2993        // Should split at the newline
2994        assert_eq!(chunks.len(), 2);
2995        assert!(chunks[0].ends_with('\n'));
2996        assert!(chunks[1].starts_with('b'));
2997    }
2998
2999    #[test]
3000    fn split_prefer_space_break() {
3001        let msg = format!("{} {}", "a".repeat(1500), "b".repeat(600));
3002        let chunks = split_message_for_discord(&msg);
3003        assert_eq!(chunks.len(), 2);
3004    }
3005
3006    #[test]
3007    fn split_without_good_break_points_hard_split() {
3008        // No spaces or newlines - should hard split at 2000
3009        let msg = "a".repeat(5000);
3010        let chunks = split_message_for_discord(&msg);
3011        assert_eq!(chunks.len(), 3);
3012        assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
3013        assert_eq!(chunks[1].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
3014        assert_eq!(chunks[2].chars().count(), 1000);
3015    }
3016
3017    #[test]
3018    fn split_multiple_breaks() {
3019        // Create a message with multiple newlines
3020        let part1 = "a".repeat(900);
3021        let part2 = "b".repeat(900);
3022        let part3 = "c".repeat(900);
3023        let msg = format!("{part1}\n{part2}\n{part3}");
3024        let chunks = split_message_for_discord(&msg);
3025        // Should split into 2 chunks (first two parts + third part)
3026        assert_eq!(chunks.len(), 2);
3027        assert!(chunks[0].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
3028        assert!(chunks[1].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
3029    }
3030
3031    #[test]
3032    fn split_preserves_content() {
3033        let original = "Hello world! This is a test message with some content. ".repeat(200);
3034        let chunks = split_message_for_discord(&original);
3035        let reconstructed = chunks.concat();
3036        assert_eq!(reconstructed, original);
3037    }
3038
3039    #[test]
3040    fn split_unicode_content() {
3041        // Test with emoji and multi-byte characters
3042        let msg = "🦀 Rust is awesome! ".repeat(500);
3043        let chunks = split_message_for_discord(&msg);
3044        // All chunks should be valid UTF-8
3045        for chunk in &chunks {
3046            assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
3047            assert!(chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
3048        }
3049        // Reconstruct and verify
3050        let reconstructed = chunks.concat();
3051        assert_eq!(reconstructed, msg);
3052    }
3053
3054    #[test]
3055    fn split_newline_too_close_to_end() {
3056        // If newline is in the first half, don't use it - use space instead or hard split
3057        let msg = format!("{}\n{}", "a".repeat(1900), "b".repeat(500));
3058        let chunks = split_message_for_discord(&msg);
3059        // Should split at newline since it's in the second half of the window
3060        assert_eq!(chunks.len(), 2);
3061    }
3062
3063    #[test]
3064    fn split_multibyte_only_content_without_panics() {
3065        let msg = "🦀".repeat(2500);
3066        let chunks = split_message_for_discord(&msg);
3067        assert_eq!(chunks.len(), 2);
3068        assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
3069        assert_eq!(chunks[1].chars().count(), 500);
3070        let reconstructed = chunks.concat();
3071        assert_eq!(reconstructed, msg);
3072    }
3073
3074    #[test]
3075    fn split_chunks_always_within_discord_limit() {
3076        let msg = "x".repeat(12_345);
3077        let chunks = split_message_for_discord(&msg);
3078        assert!(
3079            chunks
3080                .iter()
3081                .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH)
3082        );
3083    }
3084
3085    #[test]
3086    fn split_message_with_multiple_newlines() {
3087        let msg = "Line 1\nLine 2\nLine 3\n".repeat(1000);
3088        let chunks = split_message_for_discord(&msg);
3089        assert!(chunks.len() > 1);
3090        let reconstructed = chunks.concat();
3091        assert_eq!(reconstructed, msg);
3092    }
3093
3094    #[test]
3095    fn typing_handles_start_empty() {
3096        let listen_to_bots = false;
3097        let mention_only = false;
3098        let ch = DiscordChannel::new(
3099            "fake".into(),
3100            vec![],
3101            "discord_test_alias",
3102            Arc::new(Vec::new),
3103            listen_to_bots,
3104            mention_only,
3105        );
3106        let guard = ch.typing_handles.lock();
3107        assert!(guard.is_empty());
3108    }
3109
3110    #[tokio::test]
3111    async fn start_typing_sets_handle() {
3112        let listen_to_bots = false;
3113        let mention_only = false;
3114        let ch = DiscordChannel::new(
3115            "fake".into(),
3116            vec![],
3117            "discord_test_alias",
3118            Arc::new(Vec::new),
3119            listen_to_bots,
3120            mention_only,
3121        );
3122        let _ = ch.start_typing("123456").await;
3123        let guard = ch.typing_handles.lock();
3124        assert!(guard.contains_key("123456"));
3125    }
3126
3127    #[tokio::test]
3128    async fn stop_typing_clears_handle() {
3129        let listen_to_bots = false;
3130        let mention_only = false;
3131        let ch = DiscordChannel::new(
3132            "fake".into(),
3133            vec![],
3134            "discord_test_alias",
3135            Arc::new(Vec::new),
3136            listen_to_bots,
3137            mention_only,
3138        );
3139        let _ = ch.start_typing("123456").await;
3140        let _ = ch.stop_typing("123456").await;
3141        let guard = ch.typing_handles.lock();
3142        assert!(!guard.contains_key("123456"));
3143    }
3144
3145    #[tokio::test]
3146    async fn stop_typing_is_idempotent() {
3147        let listen_to_bots = false;
3148        let mention_only = false;
3149        let ch = DiscordChannel::new(
3150            "fake".into(),
3151            vec![],
3152            "discord_test_alias",
3153            Arc::new(Vec::new),
3154            listen_to_bots,
3155            mention_only,
3156        );
3157        assert!(ch.stop_typing("123456").await.is_ok());
3158        assert!(ch.stop_typing("123456").await.is_ok());
3159    }
3160
3161    #[tokio::test]
3162    async fn concurrent_typing_handles_are_independent() {
3163        let listen_to_bots = false;
3164        let mention_only = false;
3165        let ch = DiscordChannel::new(
3166            "fake".into(),
3167            vec![],
3168            "discord_test_alias",
3169            Arc::new(Vec::new),
3170            listen_to_bots,
3171            mention_only,
3172        );
3173        let _ = ch.start_typing("111").await;
3174        let _ = ch.start_typing("222").await;
3175        {
3176            let guard = ch.typing_handles.lock();
3177            assert_eq!(guard.len(), 2);
3178            assert!(guard.contains_key("111"));
3179            assert!(guard.contains_key("222"));
3180        }
3181        // Stopping one does not affect the other
3182        let _ = ch.stop_typing("111").await;
3183        let guard = ch.typing_handles.lock();
3184        assert_eq!(guard.len(), 1);
3185        assert!(guard.contains_key("222"));
3186    }
3187
3188    // ── Emoji encoding for reactions ──────────────────────────────
3189
3190    #[test]
3191    fn encode_emoji_unicode_percent_encodes() {
3192        let encoded = encode_emoji_for_discord("\u{1F440}");
3193        assert_eq!(encoded, "%F0%9F%91%80");
3194    }
3195
3196    #[test]
3197    fn encode_emoji_checkmark() {
3198        let encoded = encode_emoji_for_discord("\u{2705}");
3199        assert_eq!(encoded, "%E2%9C%85");
3200    }
3201
3202    #[test]
3203    fn encode_emoji_custom_guild_emoji_passthrough() {
3204        let encoded = encode_emoji_for_discord("custom_emoji:123456789");
3205        assert_eq!(encoded, "custom_emoji:123456789");
3206    }
3207
3208    #[test]
3209    fn encode_emoji_simple_ascii_char() {
3210        let encoded = encode_emoji_for_discord("A");
3211        assert_eq!(encoded, "%41");
3212    }
3213
3214    #[test]
3215    fn random_discord_ack_reaction_is_from_pool() {
3216        for _ in 0..128 {
3217            let emoji = random_discord_ack_reaction();
3218            assert!(DISCORD_ACK_REACTIONS.contains(&emoji));
3219        }
3220    }
3221
3222    #[test]
3223    fn discord_reaction_url_encodes_emoji_and_strips_prefix() {
3224        let url = discord_reaction_url("123", "discord_456", "👀");
3225        assert_eq!(
3226            url,
3227            "https://discord.com/api/v10/channels/123/messages/456/reactions/%F0%9F%91%80/@me"
3228        );
3229    }
3230
3231    // ── Message ID edge cases ─────────────────────────────────────
3232
3233    #[test]
3234    fn discord_message_id_format_includes_discord_prefix() {
3235        // Verify that message IDs follow the format: discord_{message_id}
3236        let message_id = "123456789012345678";
3237        let expected_id = format!("discord_{message_id}");
3238        assert_eq!(expected_id, "discord_123456789012345678");
3239    }
3240
3241    #[test]
3242    fn discord_message_id_is_deterministic() {
3243        // Same message_id = same ID (prevents duplicates after restart)
3244        let message_id = "123456789012345678";
3245        let id1 = format!("discord_{message_id}");
3246        let id2 = format!("discord_{message_id}");
3247        assert_eq!(id1, id2);
3248    }
3249
3250    #[test]
3251    fn discord_message_id_different_message_different_id() {
3252        // Different message IDs produce different IDs
3253        let id1 = "discord_123456789012345678".to_string();
3254        let id2 = "discord_987654321098765432".to_string();
3255        assert_ne!(id1, id2);
3256    }
3257
3258    #[test]
3259    fn discord_message_id_uses_snowflake_id() {
3260        // Discord snowflake IDs are numeric strings
3261        let message_id = "123456789012345678"; // Typical snowflake format
3262        let id = format!("discord_{message_id}");
3263        assert!(id.starts_with("discord_"));
3264        // Snowflake IDs are numeric
3265        assert!(message_id.chars().all(|c| c.is_ascii_digit()));
3266    }
3267
3268    #[test]
3269    fn discord_message_id_fallback_to_uuid_on_empty() {
3270        // Edge case: empty message_id falls back to UUID
3271        let message_id = "";
3272        let id = if message_id.is_empty() {
3273            format!("discord_{}", uuid::Uuid::new_v4())
3274        } else {
3275            format!("discord_{message_id}")
3276        };
3277        assert!(id.starts_with("discord_"));
3278        // Should have UUID dashes
3279        assert!(id.contains('-'));
3280    }
3281
3282    // ─────────────────────────────────────────────────────────────────────
3283    // TG6: Channel platform limit edge cases for Discord (2000 char limit)
3284    // Prevents: Pattern 6 — issues #574, #499
3285    // ─────────────────────────────────────────────────────────────────────
3286
3287    #[test]
3288    fn split_message_code_block_at_boundary() {
3289        // Code block that spans the split boundary
3290        let mut msg = String::new();
3291        msg.push_str("```rust\n");
3292        msg.push_str(&"x".repeat(1990));
3293        msg.push_str("\n```\nMore text after code block");
3294        let parts = split_message_for_discord(&msg);
3295        assert!(
3296            parts.len() >= 2,
3297            "code block spanning boundary should split"
3298        );
3299        for part in &parts {
3300            assert!(
3301                part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
3302                "each part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
3303                part.len()
3304            );
3305        }
3306    }
3307
3308    #[test]
3309    fn split_message_single_long_word_exceeds_limit() {
3310        // A single word longer than 2000 chars must be hard-split
3311        let long_word = "a".repeat(2500);
3312        let parts = split_message_for_discord(&long_word);
3313        assert!(parts.len() >= 2, "word exceeding limit must be split");
3314        for part in &parts {
3315            assert!(
3316                part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
3317                "hard-split part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
3318                part.len()
3319            );
3320        }
3321        // Reassembled content should match original
3322        let reassembled: String = parts.join("");
3323        assert_eq!(reassembled, long_word);
3324    }
3325
3326    #[test]
3327    fn split_message_exactly_at_limit_no_split() {
3328        let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
3329        let parts = split_message_for_discord(&msg);
3330        assert_eq!(parts.len(), 1, "message exactly at limit should not split");
3331        assert_eq!(parts[0].len(), DISCORD_MAX_MESSAGE_LENGTH);
3332    }
3333
3334    #[test]
3335    fn split_message_one_over_limit_splits() {
3336        let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
3337        let parts = split_message_for_discord(&msg);
3338        assert!(parts.len() >= 2, "message 1 char over limit must split");
3339    }
3340
3341    #[test]
3342    fn split_message_many_short_lines() {
3343        // Many short lines should be batched into chunks under the limit
3344        let msg: String = (0..500).fold(String::new(), |mut acc, i| {
3345            let _ = writeln!(acc, "line {i}");
3346            acc
3347        });
3348        let parts = split_message_for_discord(&msg);
3349        for part in &parts {
3350            assert!(
3351                part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
3352                "short-line batch must be <= limit"
3353            );
3354        }
3355        // All content should be preserved
3356        let reassembled: String = parts.join("");
3357        assert_eq!(reassembled.trim(), msg.trim());
3358    }
3359
3360    #[test]
3361    fn split_message_only_whitespace() {
3362        let msg = "   \n\n\t  ";
3363        let parts = split_message_for_discord(msg);
3364        // Should handle gracefully without panic
3365        assert!(parts.len() <= 1);
3366    }
3367
3368    #[test]
3369    fn split_message_emoji_at_boundary() {
3370        // Emoji are multi-byte; ensure we don't split mid-emoji
3371        let mut msg = "a".repeat(1998);
3372        msg.push_str("🎉🎊"); // 2 emoji at the boundary (2000 chars total)
3373        let parts = split_message_for_discord(&msg);
3374        for part in &parts {
3375            // The function splits on character count, not byte count
3376            assert!(
3377                part.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH,
3378                "emoji boundary split must respect limit"
3379            );
3380        }
3381    }
3382
3383    #[test]
3384    fn split_message_consecutive_newlines_at_boundary() {
3385        let mut msg = "a".repeat(1995);
3386        msg.push_str("\n\n\n\n\n");
3387        msg.push_str(&"b".repeat(100));
3388        let parts = split_message_for_discord(&msg);
3389        for part in &parts {
3390            assert!(part.len() <= DISCORD_MAX_MESSAGE_LENGTH);
3391        }
3392    }
3393
3394    // process_attachments tests
3395
3396    #[tokio::test]
3397    async fn process_attachments_empty_list_returns_empty() {
3398        let client = reqwest::Client::new();
3399        let (text, media) = process_attachments(&[], &client, None, None).await;
3400        assert!(text.is_empty());
3401        assert!(media.is_empty());
3402    }
3403
3404    #[test]
3405    fn marker_kind_for_classifies_each_mime_family() {
3406        assert_eq!(marker_kind_for("image/png", false), "IMAGE");
3407        assert_eq!(marker_kind_for("image/jpeg", false), "IMAGE");
3408        assert_eq!(marker_kind_for("video/mp4", false), "VIDEO");
3409        assert_eq!(marker_kind_for("application/pdf", false), "DOCUMENT");
3410        assert_eq!(marker_kind_for("application/zip", false), "DOCUMENT");
3411        assert_eq!(marker_kind_for("", false), "DOCUMENT");
3412    }
3413
3414    #[test]
3415    fn marker_kind_for_treats_audio_flag_as_audio_regardless_of_content_type() {
3416        // Filename-detected audio with no content_type should still classify
3417        // as AUDIO, matching the unified inbound pipeline.
3418        assert_eq!(marker_kind_for("", true), "AUDIO");
3419        assert_eq!(marker_kind_for("application/octet-stream", true), "AUDIO");
3420    }
3421
3422    #[test]
3423    fn marker_kind_for_prefers_image_over_audio_when_content_type_is_image() {
3424        // Defensive: if a Discord attachment somehow tripped both heuristics,
3425        // image MIME wins so vision-capable providers still receive image
3426        // bytes through the MediaAttachment path.
3427        assert_eq!(marker_kind_for("image/png", true), "IMAGE");
3428    }
3429
3430    #[test]
3431    fn is_thread_channel_type_matches_only_thread_types() {
3432        // Thread types per Discord docs: 10/11/12.
3433        assert!(is_thread_channel_type(10));
3434        assert!(is_thread_channel_type(11));
3435        assert!(is_thread_channel_type(12));
3436        // Non-thread channel types must not be classified as threads.
3437        for non_thread in [0u64, 1, 2, 3, 4, 5, 13, 14, 15, 16] {
3438            assert!(
3439                !is_thread_channel_type(non_thread),
3440                "type {non_thread} must not classify as thread"
3441            );
3442        }
3443    }
3444
3445    #[test]
3446    fn channel_filter_empty_accepts_everything() {
3447        let filter: Vec<String> = vec![];
3448        assert!(channel_passes_filter(&filter, "12345", None));
3449        assert!(channel_passes_filter(&filter, "99999", Some("12345")));
3450        assert!(channel_passes_filter(&filter, "", None));
3451    }
3452
3453    #[test]
3454    fn channel_filter_direct_match() {
3455        let filter = vec!["111".to_string(), "222".to_string()];
3456        assert!(channel_passes_filter(&filter, "111", None));
3457        assert!(channel_passes_filter(&filter, "222", None));
3458        assert!(!channel_passes_filter(&filter, "333", None));
3459    }
3460
3461    #[test]
3462    fn channel_filter_thread_parent_fallback() {
3463        let filter = vec!["111".to_string()];
3464        // Thread whose parent is in the allowlist — accepted.
3465        assert!(channel_passes_filter(&filter, "999", Some("111")));
3466        // Thread whose parent is NOT in the allowlist — rejected.
3467        assert!(!channel_passes_filter(&filter, "999", Some("888")));
3468        // Non-thread channel not in the allowlist — rejected.
3469        assert!(!channel_passes_filter(&filter, "999", None));
3470    }
3471
3472    #[test]
3473    fn channel_filter_direct_match_skips_parent_check() {
3474        let filter = vec!["111".to_string()];
3475        // Direct match with a parent_id present — parent is irrelevant.
3476        assert!(channel_passes_filter(&filter, "111", Some("999")));
3477    }
3478
3479    #[test]
3480    fn parse_attachment_markers_extracts_supported_markers() {
3481        let input = "Report\n[IMAGE:https://example.com/a.png]\n[DOCUMENT:/tmp/a.pdf]";
3482        let (cleaned, attachments) = parse_attachment_markers(input);
3483
3484        assert_eq!(cleaned, "Report");
3485        assert_eq!(attachments.len(), 2);
3486        assert_eq!(attachments[0].kind, DiscordAttachmentKind::Image);
3487        assert_eq!(attachments[0].target, "https://example.com/a.png");
3488        assert_eq!(attachments[1].kind, DiscordAttachmentKind::Document);
3489        assert_eq!(attachments[1].target, "/tmp/a.pdf");
3490    }
3491
3492    #[test]
3493    fn parse_attachment_markers_keeps_invalid_marker_text() {
3494        let input = "Hello [NOT_A_MARKER:foo] world";
3495        let (cleaned, attachments) = parse_attachment_markers(input);
3496
3497        assert_eq!(cleaned, input);
3498        assert!(attachments.is_empty());
3499    }
3500
3501    #[test]
3502    fn classify_outgoing_attachments_keeps_workspace_locals_and_http() {
3503        let temp = tempfile::tempdir().expect("tempdir");
3504        let file_path = temp.path().join("image.png");
3505        std::fs::write(&file_path, b"fake").expect("write fixture");
3506
3507        let attachments = vec![
3508            DiscordAttachment {
3509                kind: DiscordAttachmentKind::Image,
3510                target: file_path.to_string_lossy().to_string(),
3511            },
3512            DiscordAttachment {
3513                kind: DiscordAttachmentKind::Image,
3514                target: "https://example.com/remote.png".to_string(),
3515            },
3516        ];
3517
3518        let (locals, remotes, failures) =
3519            classify_outgoing_attachments(&attachments, Some(temp.path()));
3520        assert_eq!(locals.len(), 1);
3521        let canonical_file = std::fs::canonicalize(&file_path).expect("canonicalize fixture");
3522        assert_eq!(locals[0], canonical_file);
3523        assert_eq!(remotes, vec!["https://example.com/remote.png".to_string()]);
3524        assert!(failures.is_empty());
3525    }
3526
3527    #[test]
3528    fn classify_outgoing_attachments_drops_missing_absolute_paths() {
3529        let temp = tempfile::tempdir().expect("tempdir");
3530        let attachments = vec![DiscordAttachment {
3531            kind: DiscordAttachmentKind::Video,
3532            target: temp
3533                .path()
3534                .join("does-not-exist.mp4")
3535                .to_string_lossy()
3536                .to_string(),
3537        }];
3538
3539        let (locals, remotes, failures) =
3540            classify_outgoing_attachments(&attachments, Some(temp.path()));
3541        assert!(locals.is_empty());
3542        assert!(remotes.is_empty());
3543        assert_eq!(failures.len(), 1);
3544        assert_eq!(failures[0].1, DiscordMarkerFailure::NotFound);
3545    }
3546
3547    #[test]
3548    fn classify_outgoing_attachments_drops_paths_outside_workspace() {
3549        let workspace = tempfile::tempdir().expect("workspace tempdir");
3550        let outside = tempfile::tempdir().expect("outside tempdir");
3551        let outside_file = outside.path().join("escape.png");
3552        std::fs::write(&outside_file, b"fake").expect("write fixture");
3553
3554        let attachments = vec![DiscordAttachment {
3555            kind: DiscordAttachmentKind::Image,
3556            target: outside_file.to_string_lossy().to_string(),
3557        }];
3558
3559        let (locals, remotes, failures) =
3560            classify_outgoing_attachments(&attachments, Some(workspace.path()));
3561        assert!(
3562            locals.is_empty(),
3563            "absolute paths outside workspace must be refused"
3564        );
3565        assert!(remotes.is_empty());
3566        assert_eq!(failures.len(), 1);
3567        assert_eq!(failures[0].1, DiscordMarkerFailure::Refused);
3568    }
3569
3570    #[test]
3571    fn classify_outgoing_attachments_drops_relative_paths() {
3572        let temp = tempfile::tempdir().expect("tempdir");
3573        let attachments = vec![DiscordAttachment {
3574            kind: DiscordAttachmentKind::Document,
3575            target: "relative/report.pdf".to_string(),
3576        }];
3577
3578        let (locals, remotes, failures) =
3579            classify_outgoing_attachments(&attachments, Some(temp.path()));
3580        assert!(locals.is_empty(), "relative paths must be refused");
3581        assert!(remotes.is_empty());
3582        assert_eq!(failures.len(), 1);
3583        assert_eq!(failures[0].1, DiscordMarkerFailure::Refused);
3584    }
3585
3586    #[test]
3587    fn classify_outgoing_attachments_drops_disallowed_schemes() {
3588        let temp = tempfile::tempdir().expect("tempdir");
3589        let attachments = vec![
3590            DiscordAttachment {
3591                kind: DiscordAttachmentKind::Image,
3592                target: "file:///etc/hostname".to_string(),
3593            },
3594            DiscordAttachment {
3595                kind: DiscordAttachmentKind::Document,
3596                target: "data:text/plain;base64,aGk=".to_string(),
3597            },
3598            DiscordAttachment {
3599                kind: DiscordAttachmentKind::Video,
3600                target: "ftp://example.com/clip.mp4".to_string(),
3601            },
3602        ];
3603
3604        let (locals, remotes, failures) =
3605            classify_outgoing_attachments(&attachments, Some(temp.path()));
3606        assert!(locals.is_empty());
3607        assert!(remotes.is_empty());
3608        assert_eq!(failures.len(), 3);
3609        for (_, kind) in &failures {
3610            assert_eq!(*kind, DiscordMarkerFailure::Refused);
3611        }
3612    }
3613
3614    #[test]
3615    fn classify_outgoing_attachments_refuses_local_without_workspace() {
3616        let attachments = vec![DiscordAttachment {
3617            kind: DiscordAttachmentKind::Image,
3618            target: "/some/absolute/path.png".to_string(),
3619        }];
3620
3621        let (locals, remotes, failures) = classify_outgoing_attachments(&attachments, None);
3622        assert!(
3623            locals.is_empty(),
3624            "local paths must be refused without workspace_dir"
3625        );
3626        assert!(remotes.is_empty());
3627        assert_eq!(failures.len(), 1);
3628        assert_eq!(failures[0].1, DiscordMarkerFailure::Refused);
3629    }
3630
3631    #[test]
3632    fn classify_outgoing_attachments_passes_http_without_workspace() {
3633        let attachments = vec![DiscordAttachment {
3634            kind: DiscordAttachmentKind::Image,
3635            target: "https://example.com/x.png".to_string(),
3636        }];
3637
3638        let (locals, remotes, failures) = classify_outgoing_attachments(&attachments, None);
3639        assert!(locals.is_empty());
3640        assert_eq!(remotes, vec!["https://example.com/x.png".to_string()]);
3641        assert!(failures.is_empty());
3642    }
3643
3644    #[test]
3645    fn with_inline_attachment_urls_appends_remote_urls_only() {
3646        let content = "Done";
3647        let remote_urls = vec!["https://example.com/a.png".to_string()];
3648
3649        let rendered = with_inline_attachment_urls(content, &remote_urls);
3650        assert_eq!(rendered, "Done\nhttps://example.com/a.png");
3651    }
3652
3653    #[test]
3654    fn with_inline_attachment_urls_keeps_content_when_no_urls() {
3655        let rendered = with_inline_attachment_urls("Done", &[]);
3656        assert_eq!(rendered, "Done");
3657    }
3658
3659    #[test]
3660    fn delivery_failure_note_is_none_when_no_failures() {
3661        assert!(delivery_failure_note(&[]).is_none());
3662    }
3663
3664    #[test]
3665    fn delivery_failure_note_singular_for_one_failure() {
3666        let note = delivery_failure_note(&[(
3667            "/workspace/missing.png".to_string(),
3668            DiscordMarkerFailure::NotFound,
3669        )])
3670        .expect("one failure should produce a note");
3671        assert_eq!(
3672            note,
3673            "(note: I couldn't deliver the file at /workspace/missing.png.)"
3674        );
3675    }
3676
3677    #[test]
3678    fn delivery_failure_note_plural_lists_targets_in_order() {
3679        let note = delivery_failure_note(&[
3680            ("a.png".to_string(), DiscordMarkerFailure::Refused),
3681            ("b.pdf".to_string(), DiscordMarkerFailure::NotFound),
3682            ("c.mp4".to_string(), DiscordMarkerFailure::Refused),
3683        ])
3684        .expect("multiple failures should produce a note");
3685        assert_eq!(
3686            note,
3687            "(note: I couldn't deliver these files: a.png, b.pdf, c.mp4.)"
3688        );
3689    }
3690
3691    #[test]
3692    fn compose_body_with_failure_note_uses_note_alone_when_content_empty() {
3693        let composed = compose_body_with_failure_note("", Some("(note: ...)"));
3694        assert_eq!(composed, "(note: ...)");
3695    }
3696
3697    #[test]
3698    fn compose_body_with_failure_note_appends_note_to_existing_content() {
3699        let composed = compose_body_with_failure_note("Hello.", Some("(note: ...)"));
3700        assert_eq!(composed, "Hello.\n\n(note: ...)");
3701    }
3702
3703    #[test]
3704    fn compose_body_with_failure_note_returns_content_when_no_note() {
3705        let composed = compose_body_with_failure_note("Hello.", None);
3706        assert_eq!(composed, "Hello.");
3707    }
3708
3709    #[test]
3710    fn compose_body_with_failure_note_returns_empty_when_no_content_and_no_note() {
3711        let composed = compose_body_with_failure_note("", None);
3712        assert_eq!(composed, "");
3713    }
3714
3715    #[test]
3716    fn decide_failure_reactions_empty_for_no_failures() {
3717        assert!(decide_failure_reactions(&[]).is_empty());
3718    }
3719
3720    #[test]
3721    fn decide_failure_reactions_emits_refused_only() {
3722        let r = decide_failure_reactions(&[
3723            ("a".to_string(), DiscordMarkerFailure::Refused),
3724            ("b".to_string(), DiscordMarkerFailure::Refused),
3725        ]);
3726        assert_eq!(r, vec!["🚫"]);
3727    }
3728
3729    #[test]
3730    fn decide_failure_reactions_emits_not_found_only() {
3731        let r = decide_failure_reactions(&[("a".to_string(), DiscordMarkerFailure::NotFound)]);
3732        assert_eq!(r, vec!["\u{26A0}\u{FE0F}"]);
3733    }
3734
3735    #[test]
3736    fn decide_failure_reactions_emits_both_when_mixed() {
3737        let r = decide_failure_reactions(&[
3738            ("a".to_string(), DiscordMarkerFailure::Refused),
3739            ("b".to_string(), DiscordMarkerFailure::NotFound),
3740        ]);
3741        assert_eq!(r, vec!["🚫", "\u{26A0}\u{FE0F}"]);
3742    }
3743
3744    // ── Streaming mode tests ──────────────────────────────────────────
3745
3746    #[test]
3747    fn supports_draft_updates_respects_stream_mode() {
3748        use zeroclaw_config::schema::StreamMode;
3749
3750        let listen_to_bots = false;
3751        let mention_only = false;
3752        let off = DiscordChannel::new(
3753            "t".into(),
3754            vec![],
3755            "discord_test_alias",
3756            Arc::new(Vec::new),
3757            listen_to_bots,
3758            mention_only,
3759        );
3760        assert!(!off.supports_draft_updates());
3761
3762        let partial = DiscordChannel::new(
3763            "t".into(),
3764            vec![],
3765            "discord_test_alias",
3766            Arc::new(Vec::new),
3767            listen_to_bots,
3768            mention_only,
3769        )
3770        .with_streaming(StreamMode::Partial, 750, 800);
3771        assert!(partial.supports_draft_updates());
3772        assert_eq!(partial.draft_update_interval_ms, 750);
3773
3774        let multi = DiscordChannel::new(
3775            "t".into(),
3776            vec![],
3777            "discord_test_alias",
3778            Arc::new(Vec::new),
3779            listen_to_bots,
3780            mention_only,
3781        )
3782        .with_streaming(StreamMode::MultiMessage, 1000, 600);
3783        assert!(multi.supports_draft_updates());
3784        assert_eq!(multi.multi_message_delay_ms, 600);
3785    }
3786
3787    #[tokio::test]
3788    async fn send_draft_returns_none_when_not_partial() {
3789        use zeroclaw_api::channel::SendMessage;
3790        use zeroclaw_config::schema::StreamMode;
3791
3792        let listen_to_bots = false;
3793        let mention_only = false;
3794        let off = DiscordChannel::new(
3795            "t".into(),
3796            vec![],
3797            "discord_test_alias",
3798            Arc::new(Vec::new),
3799            listen_to_bots,
3800            mention_only,
3801        );
3802        let msg = SendMessage::new("hello", "123");
3803        assert!(off.send_draft(&msg).await.unwrap().is_none());
3804
3805        let multi = DiscordChannel::new(
3806            "t".into(),
3807            vec![],
3808            "discord_test_alias",
3809            Arc::new(Vec::new),
3810            listen_to_bots,
3811            mention_only,
3812        )
3813        .with_streaming(StreamMode::MultiMessage, 1000, 800);
3814        // MultiMessage returns a synthetic ID so the draft_updater task runs.
3815        assert_eq!(
3816            multi.send_draft(&msg).await.unwrap().as_deref(),
3817            Some("multi_message_synthetic")
3818        );
3819    }
3820
3821    #[tokio::test]
3822    async fn update_draft_rate_limit_short_circuits() {
3823        use zeroclaw_config::schema::StreamMode;
3824
3825        let listen_to_bots = false;
3826        let mention_only = false;
3827        let ch = DiscordChannel::new(
3828            "t".into(),
3829            vec![],
3830            "discord_test_alias",
3831            Arc::new(Vec::new),
3832            listen_to_bots,
3833            mention_only,
3834        )
3835        .with_streaming(StreamMode::Partial, 60_000, 800);
3836
3837        // Seed a recent edit time.
3838        ch.last_draft_edit
3839            .lock()
3840            .insert("chan".to_string(), std::time::Instant::now());
3841
3842        // Should return Ok immediately (rate-limited) without making a network call.
3843        let result = ch.update_draft("chan", "fake_msg_id", "new text").await;
3844        assert!(result.is_ok());
3845    }
3846
3847    #[tokio::test]
3848    async fn cancel_draft_cleans_up_tracking() {
3849        use zeroclaw_config::schema::StreamMode;
3850
3851        let listen_to_bots = false;
3852        let mention_only = false;
3853        let ch = DiscordChannel::new(
3854            "t".into(),
3855            vec![],
3856            "discord_test_alias",
3857            Arc::new(Vec::new),
3858            listen_to_bots,
3859            mention_only,
3860        )
3861        .with_streaming(StreamMode::Partial, 1000, 800);
3862
3863        ch.last_draft_edit
3864            .lock()
3865            .insert("chan".to_string(), std::time::Instant::now());
3866
3867        // cancel_draft will try to delete a message (will fail with network error)
3868        // but should still clean up the tracking entry.
3869        let _ = ch.cancel_draft("chan", "fake_msg_id").await;
3870        assert!(!ch.last_draft_edit.lock().contains_key("chan"));
3871    }
3872
3873    // ── MultiMessage splitter tests ───────────────────────────────────
3874
3875    #[test]
3876    fn split_message_for_discord_multi_splits_at_paragraphs() {
3877        let content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
3878        let chunks = split_message_for_discord_multi(content, 2000);
3879        assert_eq!(chunks.len(), 3);
3880        assert_eq!(chunks[0], "First paragraph.");
3881        assert_eq!(chunks[1], "Second paragraph.");
3882        assert_eq!(chunks[2], "Third paragraph.");
3883    }
3884
3885    #[test]
3886    fn split_message_for_discord_multi_single_paragraph() {
3887        let content = "Just one paragraph with no breaks.";
3888        let chunks = split_message_for_discord_multi(content, 2000);
3889        assert_eq!(chunks.len(), 1);
3890        assert_eq!(chunks[0], content);
3891    }
3892
3893    #[test]
3894    fn split_message_for_discord_multi_respects_max_len() {
3895        // Create a single paragraph that exceeds max_len.
3896        let long_para = "a ".repeat(1100); // ~2200 chars
3897        let chunks = split_message_for_discord_multi(&long_para, 2000);
3898        assert!(chunks.len() > 1, "should split oversized paragraph");
3899        for chunk in &chunks {
3900            assert!(
3901                chunk.chars().count() <= 2000,
3902                "chunk exceeds max: {}",
3903                chunk.chars().count()
3904            );
3905        }
3906    }
3907
3908    #[test]
3909    fn split_message_for_discord_multi_preserves_code_fences() {
3910        let content =
3911            "Before.\n\n```rust\nfn main() {\n\n    println!(\"hello\");\n}\n```\n\nAfter.";
3912        let chunks = split_message_for_discord_multi(content, 2000);
3913        // The code fence contains \n\n but should not be split there.
3914        assert_eq!(chunks.len(), 3);
3915        assert_eq!(chunks[0], "Before.");
3916        assert!(chunks[1].contains("```rust"));
3917        assert!(chunks[1].contains("println!"));
3918        assert!(chunks[1].contains("```"));
3919        assert_eq!(chunks[2], "After.");
3920    }
3921
3922    #[test]
3923    fn split_message_for_discord_multi_empty_input() {
3924        let chunks = split_message_for_discord_multi("", 2000);
3925        assert!(chunks.is_empty());
3926    }
3927
3928    // Regression lock for the marker-only paragraph in MultiMessage stream
3929    // mode. Before the fix this produced an empty chunk vec and the chunk
3930    // loop in send() iterated zero times, silently skipping the file upload.
3931    #[test]
3932    fn chunks_for_send_emits_empty_chunk_when_multi_message_paragraph_collapses_to_only_a_file() {
3933        use zeroclaw_config::schema::StreamMode;
3934        let chunks = chunks_for_send("", StreamMode::MultiMessage, 2000, true);
3935        assert_eq!(chunks, vec![String::new()]);
3936    }
3937
3938    #[test]
3939    fn chunks_for_send_does_not_emit_empty_chunk_when_no_files_to_upload() {
3940        use zeroclaw_config::schema::StreamMode;
3941        let chunks = chunks_for_send("", StreamMode::MultiMessage, 2000, false);
3942        assert!(chunks.is_empty());
3943    }
3944
3945    #[test]
3946    fn chunks_for_send_passes_through_non_empty_content() {
3947        use zeroclaw_config::schema::StreamMode;
3948        for mode in [
3949            StreamMode::MultiMessage,
3950            StreamMode::Partial,
3951            StreamMode::Off,
3952        ] {
3953            for has_files in [true, false] {
3954                let chunks = chunks_for_send("hello", mode, 2000, has_files);
3955                assert_eq!(
3956                    chunks,
3957                    vec!["hello".to_string()],
3958                    "mode={mode:?} has_files={has_files}"
3959                );
3960            }
3961        }
3962    }
3963
3964    #[test]
3965    fn pending_approvals_map_is_initially_empty() {
3966        let listen_to_bots = false;
3967        let mention_only = false;
3968        let ch = DiscordChannel::new(
3969            "token".into(),
3970            vec![],
3971            "discord_test_alias",
3972            Arc::new(Vec::new),
3973            listen_to_bots,
3974            mention_only,
3975        );
3976        let map = ch.pending_approvals.try_lock().unwrap();
3977        assert!(map.is_empty());
3978    }
3979
3980    #[test]
3981    fn approval_timeout_defaults_to_300_and_is_overridable() {
3982        let listen_to_bots = false;
3983        let mention_only = false;
3984        let ch = DiscordChannel::new(
3985            "token".into(),
3986            vec![],
3987            "discord_test_alias",
3988            Arc::new(Vec::new),
3989            listen_to_bots,
3990            mention_only,
3991        );
3992        assert_eq!(ch.approval_timeout_secs, 300);
3993        let ch = ch.with_approval_timeout_secs(60);
3994        assert_eq!(ch.approval_timeout_secs, 60);
3995    }
3996
3997    #[tokio::test]
3998    async fn pending_approval_oneshot_delivers_response() {
3999        let listen_to_bots = false;
4000        let mention_only = false;
4001        let ch = DiscordChannel::new(
4002            "token".into(),
4003            vec![],
4004            "discord_test_alias",
4005            Arc::new(Vec::new),
4006            listen_to_bots,
4007            mention_only,
4008        );
4009        let (tx, rx) = oneshot::channel();
4010        ch.pending_approvals
4011            .lock()
4012            .await
4013            .insert("abc123".to_string(), tx);
4014        let sender = ch.pending_approvals.lock().await.remove("abc123").unwrap();
4015        sender.send(ChannelApprovalResponse::Deny).unwrap();
4016        assert_eq!(rx.await.unwrap(), ChannelApprovalResponse::Deny);
4017    }
4018}