1use crate::channels::transport::{parse_conversation_key, Transport};
13use crate::channels::{ChannelRegistry, OutgoingMessage};
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::time::{Duration, Instant};
17
18const TELEGRAM_MAX_BYTES: usize = 4096;
19const DISCORD_MAX_BYTES: usize = 2000;
20const SLACK_MAX_BYTES: usize = 4000;
21
22fn platform_max_bytes(channel_name: &str) -> usize {
23 match channel_name {
24 "telegram" => TELEGRAM_MAX_BYTES,
25 "discord" => DISCORD_MAX_BYTES,
26 "slack" => SLACK_MAX_BYTES,
27 _ => TELEGRAM_MAX_BYTES,
28 }
29}
30
31fn platform_rate(channel_name: &str) -> Duration {
32 match channel_name {
33 "telegram" => Duration::from_millis(100),
34 "discord" => Duration::from_millis(500),
35 "slack" => Duration::from_millis(200),
36 _ => Duration::from_millis(500),
37 }
38}
39
40fn split_for_platform(content: &str, channel_name: &str) -> Vec<String> {
42 let max = platform_max_bytes(channel_name);
43 if content.len() <= max {
44 return vec![content.to_string()];
45 }
46
47 let mut parts = Vec::new();
48 let mut start = 0;
49 while start < content.len() {
50 let mut end = (start + max).min(content.len());
51 while end > start && !content.is_char_boundary(end) {
53 end -= 1;
54 }
55 if end == start {
56 if let Some(ch) = content[start..].chars().next() {
58 end = start + ch.len_utf8();
59 } else {
60 break;
61 }
62 }
63 parts.push(content[start..end].to_string());
64 start = end;
65 }
66 parts
67}
68
69fn telegram_html_escape(s: &str) -> String {
74 s.replace('&', "&")
75 .replace('<', "<")
76 .replace('>', ">")
77}
78
79async fn send_to_thread(
89 channels: &Arc<ChannelRegistry>,
90 channel_name: &str,
91 thread_id: &str,
92 topic_id: Option<&str>,
93 body: String,
94) {
95 let metadata = if channel_name == "telegram" {
96 serde_json::json!({ "preformatted_html": true })
97 } else {
98 serde_json::json!({})
99 };
100
101 for ch in channels.iter() {
102 if ch.name() == channel_name {
103 let msg = OutgoingMessage {
104 thread_id: thread_id.to_string(),
105 body,
106 reply_to: None,
107 metadata,
108 topic_id: topic_id.map(String::from),
109 };
110 if let Err(e) = ch.send(&msg).await {
111 tracing::warn!(channel = channel_name, thread_id, ?e, "stream: send failed");
112 }
113 return;
114 }
115 }
116 tracing::debug!(
117 channel = channel_name,
118 "stream: channel not found in registry"
119 );
120}
121
122pub async fn fanout_output(
131 repo: String,
132 task_id: String,
133 transport: Arc<Transport>,
134 channels: Arc<ChannelRegistry>,
135) {
136 let mut rx = match transport.subscribe(&repo, &task_id).await {
137 Some(r) => r,
138 None => {
139 tracing::debug!(task_id, "fanout: no binding for task, cannot subscribe");
140 return;
141 }
142 };
143
144 tracing::debug!(task_id, "fanout: started output streaming");
145
146 let mut last_send: HashMap<String, Instant> = HashMap::new();
148 let mut buffers: HashMap<String, String> = HashMap::new();
150
151 loop {
152 match rx.recv().await {
154 Ok(chunk) => {
155 let is_final = chunk.is_final;
156
157 if !chunk.content.is_empty() {
158 if let Some(binding) = transport.get_binding(&repo, &task_id).await {
160 let now = Instant::now();
161
162 for thread_key in &binding.connected_threads {
163 let (ch_name, thread_id, topic_id) =
164 match parse_conversation_key(thread_key) {
165 Some(p) => p,
166 None => continue,
167 };
168
169 buffers
171 .entry(thread_key.clone())
172 .or_default()
173 .push_str(&chunk.content);
174
175 let rate = platform_rate(ch_name);
177 let can_send = last_send
178 .get(ch_name)
179 .map(|t| now.duration_since(*t) >= rate)
180 .unwrap_or(true);
181
182 if can_send {
183 if let Some(buffered) = buffers.remove(thread_key) {
184 let to_send = if ch_name == "telegram" {
187 telegram_html_escape(&buffered)
188 } else {
189 buffered
190 };
191 let parts = split_for_platform(&to_send, ch_name);
192 for part in parts {
193 send_to_thread(
194 &channels, ch_name, thread_id, topic_id, part,
195 )
196 .await;
197 }
198 last_send.insert(ch_name.to_string(), now);
199 }
200 }
201 }
202 }
203 }
204
205 if is_final {
206 if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
208 for (thread_key, buffered) in buffers.drain() {
210 if buffered.is_empty() {
211 continue;
212 }
213 let (ch_name, thread_id, topic_id) =
214 match parse_conversation_key(&thread_key) {
215 Some(p) => p,
216 None => continue,
217 };
218 let to_send = if ch_name == "telegram" {
220 telegram_html_escape(&buffered)
221 } else {
222 buffered
223 };
224 let parts = split_for_platform(&to_send, ch_name);
225 for part in parts {
226 send_to_thread(&channels, ch_name, thread_id, topic_id, part).await;
227 }
228 }
229 }
230 tracing::debug!(task_id, "fanout: final chunk received, stream complete");
231 break;
232 }
233 }
234 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
235 tracing::warn!(
236 task_id,
237 missed = n,
238 "fanout: output receiver lagged, some output dropped"
239 );
240 }
241 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
242 tracing::debug!(task_id, "fanout: output broadcast closed");
243 break;
244 }
245 }
246
247 if !buffers.is_empty() {
251 let now = Instant::now();
252 let mut earliest = None::<Instant>;
253 for thread_key in buffers.keys() {
254 if let Some((ch_name, _, _)) = parse_conversation_key(thread_key) {
255 let rate = platform_rate(ch_name);
256 if let Some(last) = last_send.get(ch_name) {
257 let when = *last + rate;
258 if when > now {
259 earliest = Some(match earliest {
260 Some(e) if e <= when => e,
261 _ => when,
262 });
263 } else {
264 earliest = Some(now);
265 }
266 } else {
267 earliest = Some(now);
268 }
269 }
270 }
271
272 if let Some(wake_at) = earliest {
273 let sleep_dur = if wake_at > now {
275 wake_at - now
276 } else {
277 Duration::from_millis(50)
278 };
279 tokio::time::sleep(sleep_dur).await;
280
281 if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
283 let now = Instant::now();
284 let keys: Vec<String> = buffers.keys().cloned().collect();
286 for thread_key in keys {
287 if let Some(buffered) = buffers.remove(&thread_key) {
288 let (ch_name, thread_id, topic_id) =
289 match parse_conversation_key(&thread_key) {
290 Some(p) => p,
291 None => continue,
292 };
293 let rate = platform_rate(ch_name);
294 let can_send = last_send
295 .get(ch_name)
296 .map(|t| now.duration_since(*t) >= rate)
297 .unwrap_or(true);
298 if can_send {
299 let to_send = if ch_name == "telegram" {
301 telegram_html_escape(&buffered)
302 } else {
303 buffered
304 };
305 let parts = split_for_platform(&to_send, ch_name);
306 for part in parts {
307 send_to_thread(&channels, ch_name, thread_id, topic_id, part)
308 .await;
309 }
310 last_send.insert(ch_name.to_string(), now);
311 } else {
312 buffers.insert(thread_key, buffered);
314 }
315 }
316 }
317 }
318 }
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn split_short_content_unchanged() {
329 let parts = split_for_platform("hello", "telegram");
330 assert_eq!(parts, vec!["hello"]);
331 }
332
333 #[test]
334 fn split_long_content_telegram() {
335 let content = "a".repeat(5000);
336 let parts = split_for_platform(&content, "telegram");
337 assert!(parts.len() >= 2);
338 for p in &parts {
339 assert!(p.len() <= TELEGRAM_MAX_BYTES);
340 }
341 assert_eq!(parts.join(""), content);
343 }
344
345 #[test]
346 fn split_long_content_discord() {
347 let content = "x".repeat(3000);
348 let parts = split_for_platform(&content, "discord");
349 assert!(parts.len() >= 2);
350 for p in &parts {
351 assert!(p.len() <= DISCORD_MAX_BYTES);
352 }
353 assert_eq!(parts.join(""), content);
354 }
355
356 #[test]
357 fn platform_max_bytes_known_channels() {
358 assert_eq!(platform_max_bytes("telegram"), 4096);
359 assert_eq!(platform_max_bytes("discord"), 2000);
360 assert_eq!(platform_max_bytes("slack"), 4000);
361 assert_eq!(platform_max_bytes("unknown"), 4096);
362 }
363
364 #[test]
365 fn split_multibyte_char_at_boundary_does_not_panic() {
366 let content = "x".repeat(1997) + "😀"; let parts = split_for_platform(&content, "discord");
371 assert_eq!(parts.join(""), content);
373 for p in &parts {
374 assert!(std::str::from_utf8(p.as_bytes()).is_ok());
376 }
377 }
378
379 #[test]
380 fn split_single_multibyte_char_exceeding_limit() {
381 let content = "😀".repeat(600); let parts = split_for_platform(&content, "discord");
387 assert_eq!(parts.join(""), content);
388 for p in &parts {
389 assert!(std::str::from_utf8(p.as_bytes()).is_ok());
390 }
391 }
392
393 #[test]
399 fn split_telegram_html_content_escaped_before_split() {
400 let raw = "a".repeat(4096) + "<code>123</code>"; assert_eq!(raw.len(), 4112);
404 let escaped = telegram_html_escape(&raw);
405 assert_eq!(escaped.len(), 4112 + 12); assert!(escaped.len() > raw.len());
408
409 let parts = split_for_platform(&escaped, "telegram");
411 assert!(
412 parts.len() >= 2,
413 "escaped content ({}) must split into ≥2 parts",
414 escaped.len()
415 );
416 for (i, p) in parts.iter().enumerate() {
417 assert!(
418 p.len() <= TELEGRAM_MAX_BYTES,
419 "part {} ({} bytes) exceeds Telegram limit {}",
420 i,
421 p.len(),
422 TELEGRAM_MAX_BYTES
423 );
424 }
425 assert_eq!(parts.join(""), escaped);
427 }
428
429 #[test]
433 fn split_then_escape_exceeds_limit_when_boundary_inside_html_tag() {
434 let html_tag = "<code>123</code>";
436 assert_eq!(html_tag.len(), 16);
437
438 let raw = "a".repeat(4093) + html_tag; assert_eq!(raw.len(), 4109);
445
446 let buggy_parts: Vec<String> = split_for_platform(&raw, "telegram")
448 .into_iter()
449 .map(|p| telegram_html_escape(&p))
450 .collect();
451
452 let has_overflow = buggy_parts.iter().any(|p| p.len() > TELEGRAM_MAX_BYTES);
454 assert!(
455 has_overflow,
456 "expected old buggy approach to produce an oversized chunk"
457 );
458
459 let escaped = telegram_html_escape(&raw);
461 let fixed_parts = split_for_platform(&escaped, "telegram");
462 for (i, p) in fixed_parts.iter().enumerate() {
463 assert!(
464 p.len() <= TELEGRAM_MAX_BYTES,
465 "fixed part {} ({} bytes) must not exceed limit",
466 i,
467 p.len()
468 );
469 }
470 assert_eq!(fixed_parts.join(""), escaped);
472 }
473
474 #[test]
475 fn telegram_html_escape_produces_valid_html_entities() {
476 assert_eq!(telegram_html_escape("<test>"), "<test>");
477 assert_eq!(telegram_html_escape("a & b"), "a & b");
478 assert_eq!(telegram_html_escape("<>&"), "<>&");
479 }
480
481 struct TestChannel {
484 name: String,
485 tx: tokio::sync::mpsc::Sender<crate::channels::OutgoingMessage>,
486 }
487
488 #[async_trait::async_trait]
489 impl crate::channels::Channel for TestChannel {
490 fn name(&self) -> &str {
491 &self.name
492 }
493
494 async fn start(
495 &self,
496 ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::channels::IncomingMessage>> {
497 let (_tx, rx) = tokio::sync::mpsc::channel(1);
499 Ok(rx)
500 }
501
502 async fn send(&self, msg: &crate::channels::OutgoingMessage) -> anyhow::Result<()> {
503 let _ = self.tx.send(msg.clone()).await;
505 Ok(())
506 }
507
508 async fn health_check(&self) -> anyhow::Result<()> {
509 Ok(())
510 }
511 }
512
513 #[tokio::test]
514 async fn fanout_sends_to_registered_channel() {
515 use crate::channels::transport::Transport;
516
517 let transport = std::sync::Arc::new(Transport::new());
518
519 let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::channels::OutgoingMessage>(16);
521 let test_ch = TestChannel {
522 name: "telegram".to_string(),
523 tx,
524 };
525
526 let mut registry = crate::channels::ChannelRegistry::new();
528 registry.register(Box::new(test_ch));
529 let registry = std::sync::Arc::new(registry);
530
531 let task_id = "fanout-test-task";
533 let repo = "owner/test-repo";
534 transport
535 .bind(
536 repo,
537 task_id,
538 "orch-fanout-test",
539 "telegram",
540 "thread-1",
541 None,
542 )
543 .await;
544
545 let transport_clone = transport.clone();
547 let registry_clone = registry.clone();
548 let task_id_str = task_id.to_string();
549 let repo_str = repo.to_string();
550 tokio::spawn(async move {
551 fanout_output(repo_str, task_id_str, transport_clone, registry_clone).await
552 });
553
554 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
556
557 transport
559 .push_output(
560 repo,
561 task_id,
562 crate::channels::OutputChunk {
563 content: "hello from agent".to_string(),
564 is_final: false,
565 },
566 )
567 .await;
568
569 transport
571 .push_output(
572 repo,
573 task_id,
574 crate::channels::OutputChunk {
575 content: "".to_string(),
576 is_final: true,
577 },
578 )
579 .await;
580
581 let mut got = false;
583 for _ in 0..10 {
584 if let Ok(Some(msg)) =
585 tokio::time::timeout(std::time::Duration::from_millis(200), async {
586 rx.recv().await
587 })
588 .await
589 {
590 if msg.body.contains("hello from agent") {
591 got = true;
592 break;
593 }
594 }
595 }
596
597 assert!(
598 got,
599 "fanout did not send expected outgoing message to test channel"
600 );
601 }
602
603 #[tokio::test]
607 async fn fanout_forwards_topic_id() {
608 let transport = std::sync::Arc::new(Transport::new());
609
610 let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::channels::OutgoingMessage>(16);
611 let test_ch = TestChannel {
612 name: "telegram".to_string(),
613 tx,
614 };
615
616 let mut registry = crate::channels::ChannelRegistry::new();
617 registry.register(Box::new(test_ch));
618 let registry = std::sync::Arc::new(registry);
619
620 let task_id = "topic-forward-task";
621 let repo = "owner/test-repo";
622 transport
624 .bind(
625 repo,
626 task_id,
627 "orch-topic-test",
628 "telegram",
629 "chat-111",
630 Some("topic-42"),
631 )
632 .await;
633
634 let transport_clone = transport.clone();
635 let registry_clone = registry.clone();
636 let task_id_str = task_id.to_string();
637 let repo_str = repo.to_string();
638 tokio::spawn(async move {
639 fanout_output(repo_str, task_id_str, transport_clone, registry_clone).await
640 });
641
642 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
643
644 transport
645 .push_output(
646 repo,
647 task_id,
648 crate::channels::OutputChunk {
649 content: "topic reply".to_string(),
650 is_final: true,
651 },
652 )
653 .await;
654
655 let mut got_topic = false;
656 for _ in 0..10 {
657 if let Ok(Some(msg)) =
658 tokio::time::timeout(std::time::Duration::from_millis(200), async {
659 rx.recv().await
660 })
661 .await
662 {
663 if msg.body.contains("topic reply") {
664 assert_eq!(
665 msg.topic_id.as_deref(),
666 Some("topic-42"),
667 "outgoing message must carry the bound topic_id"
668 );
669 got_topic = true;
670 break;
671 }
672 }
673 }
674
675 assert!(
676 got_topic,
677 "fanout did not forward topic_id in outgoing message"
678 );
679 }
680}