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
20pub struct DiscordChannel {
22 bot_token: String,
23 guild_ids: Vec<String>,
25 channel_ids: Vec<String>,
28 archive_memory: Option<std::sync::Arc<dyn zeroclaw_memory::Memory>>,
32 alias: String,
35 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 proxy_url: Option<String>,
43 transcription: Option<zeroclaw_config::schema::TranscriptionConfig>,
46 transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
47 workspace_dir: Option<PathBuf>,
49 stream_mode: zeroclaw_config::schema::StreamMode,
51 draft_update_interval_ms: u64,
53 multi_message_delay_ms: u64,
55 last_draft_edit: Mutex<HashMap<String, std::time::Instant>>,
57 multi_message_sent_len: Mutex<HashMap<String, usize>>,
59 multi_message_thread_ts: Mutex<HashMap<String, Option<String>>>,
61 stall_timeout_secs: u64,
63 pending_approvals: Arc<AsyncMutex<HashMap<String, oneshot::Sender<ChannelApprovalResponse>>>>,
64 approval_timeout_secs: u64,
67 thread_channels: Arc<AsyncMutex<HashMap<String, Option<String>>>>,
75 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 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 pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
157 self.workspace_dir = Some(dir);
158 self
159 }
160
161 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 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 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 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 let part = token.split('.').next()?;
244 base64_decode(part)
245 }
246
247 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 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 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
366const fn is_thread_channel_type(channel_type: u64) -> bool {
370 matches!(channel_type, 10..=12)
371}
372
373const THREAD_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
377
378fn 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
402async 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 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
542async 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
608const DISCORD_AUDIO_EXTENSIONS: &[&str] = &[
610 "flac", "mp3", "mpeg", "mpga", "mp4", "m4a", "ogg", "oga", "opus", "wav", "webm",
611];
612
613fn 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
624fn 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#[derive(Debug)]
725enum DiscordMarkerTarget {
726 Local(PathBuf),
727 Http(String),
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
736enum DiscordMarkerFailure {
737 Refused,
740 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
768fn 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
931fn 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
950fn 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
961fn 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
993async 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
1023async 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
1101async 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
1143async 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
1184const DISCORD_MAX_MESSAGE_LENGTH: usize = 2000;
1188const DISCORD_ACK_REACTIONS: &[&str] = &["⚡️", "🦀", "🙌", "💪", "👌", "👀", "👣"];
1189
1190fn 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 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 let search_area = &remaining[..hard_split];
1213
1214 if let Some(pos) = search_area.rfind('\n') {
1216 if search_area[..pos].chars().count() >= DISCORD_MAX_MESSAGE_LENGTH / 2 {
1218 pos + 1
1219 } else {
1220 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
1228 }
1229 };
1230
1231 chunks.push(remaining[..chunk_end].to_string());
1232 remaining = &remaining[chunk_end..];
1233 }
1234
1235 chunks
1236}
1237
1238fn split_message_for_discord_multi(content: &str, max_len: usize) -> Vec<String> {
1243 if content.is_empty() {
1244 return vec![];
1245 }
1246
1247 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 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 let mut chunks: Vec<String> = Vec::new();
1276
1277 for segment in segments {
1278 if segment.chars().count() > max_len {
1279 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
1295fn 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
1340fn 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
1374fn 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#[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 fn self_handle(&self) -> Option<String> {
1470 Self::bot_user_id_from_token(&self.bot_token)
1471 }
1472
1473 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 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 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 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 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 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 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 if let Some(ref wd) = watchdog {
1789 wd.touch();
1790 }
1791
1792 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 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 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 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 if event_type != "MESSAGE_CREATE" {
1875 continue;
1876 }
1877
1878 let Some(d) = event.get("d") else {
1879 continue;
1880 };
1881
1882 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 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 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 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 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 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 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 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 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 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 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 {
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 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 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 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 (found_paragraph, thread_ts)
2354 };
2355
2356 if let Some(paragraph) = paragraph {
2357 let msg = SendMessage::new(¶graph, 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 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 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 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 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 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 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 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 let decoded = base64_decode("MTIzNDU2");
2658 assert_eq!(decoded, Some("123456".to_string()));
2659 }
2660
2661 #[test]
2662 fn bot_user_id_extraction() {
2663 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 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 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 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 assert!(admit_discord_message("", false, false, "12345").is_none());
2902 assert!(admit_discord_message("", false, true, "12345").is_none());
2903 }
2904
2905 #[test]
2908 fn mention_only_dm_bypasses_mention_gate() {
2909 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 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 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 #[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); let chunks = split_message_for_discord(&msg);
2977 assert_eq!(chunks.len(), 5);
2979 assert!(
2980 chunks
2981 .iter()
2982 .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH)
2983 );
2984 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 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 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 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 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 let msg = "🦀 Rust is awesome! ".repeat(500);
3043 let chunks = split_message_for_discord(&msg);
3044 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 let reconstructed = chunks.concat();
3051 assert_eq!(reconstructed, msg);
3052 }
3053
3054 #[test]
3055 fn split_newline_too_close_to_end() {
3056 let msg = format!("{}\n{}", "a".repeat(1900), "b".repeat(500));
3058 let chunks = split_message_for_discord(&msg);
3059 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 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 #[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 #[test]
3234 fn discord_message_id_format_includes_discord_prefix() {
3235 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 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 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 let message_id = "123456789012345678"; let id = format!("discord_{message_id}");
3263 assert!(id.starts_with("discord_"));
3264 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 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 assert!(id.contains('-'));
3280 }
3281
3282 #[test]
3288 fn split_message_code_block_at_boundary() {
3289 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 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 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 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 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 assert!(parts.len() <= 1);
3366 }
3367
3368 #[test]
3369 fn split_message_emoji_at_boundary() {
3370 let mut msg = "a".repeat(1998);
3372 msg.push_str("🎉🎊"); let parts = split_message_for_discord(&msg);
3374 for part in &parts {
3375 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 #[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 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 assert_eq!(marker_kind_for("image/png", true), "IMAGE");
3428 }
3429
3430 #[test]
3431 fn is_thread_channel_type_matches_only_thread_types() {
3432 assert!(is_thread_channel_type(10));
3434 assert!(is_thread_channel_type(11));
3435 assert!(is_thread_channel_type(12));
3436 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 assert!(channel_passes_filter(&filter, "999", Some("111")));
3466 assert!(!channel_passes_filter(&filter, "999", Some("888")));
3468 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 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 #[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 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 ch.last_draft_edit
3839 .lock()
3840 .insert("chan".to_string(), std::time::Instant::now());
3841
3842 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 let _ = ch.cancel_draft("chan", "fake_msg_id").await;
3870 assert!(!ch.last_draft_edit.lock().contains_key("chan"));
3871 }
3872
3873 #[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 let long_para = "a ".repeat(1100); 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 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 #[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}