use crate::channels::transport::{parse_conversation_key, Transport};
use crate::channels::{ChannelRegistry, OutgoingMessage};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::time::{Duration, Instant};
const TELEGRAM_MAX_BYTES: usize = 4096;
const DISCORD_MAX_BYTES: usize = 2000;
const SLACK_MAX_BYTES: usize = 4000;
fn platform_max_bytes(channel_name: &str) -> usize {
match channel_name {
"telegram" => TELEGRAM_MAX_BYTES,
"discord" => DISCORD_MAX_BYTES,
"slack" => SLACK_MAX_BYTES,
_ => TELEGRAM_MAX_BYTES,
}
}
fn platform_rate(channel_name: &str) -> Duration {
match channel_name {
"telegram" => Duration::from_millis(100),
"discord" => Duration::from_millis(500),
"slack" => Duration::from_millis(200),
_ => Duration::from_millis(500),
}
}
fn split_for_platform(content: &str, channel_name: &str) -> Vec<String> {
let max = platform_max_bytes(channel_name);
if content.len() <= max {
return vec![content.to_string()];
}
let mut parts = Vec::new();
let mut start = 0;
while start < content.len() {
let mut end = (start + max).min(content.len());
while end > start && !content.is_char_boundary(end) {
end -= 1;
}
if end == start {
if let Some(ch) = content[start..].chars().next() {
end = start + ch.len_utf8();
} else {
break;
}
}
parts.push(content[start..end].to_string());
start = end;
}
parts
}
fn telegram_html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
async fn send_to_thread(
channels: &Arc<ChannelRegistry>,
channel_name: &str,
thread_id: &str,
topic_id: Option<&str>,
body: String,
) {
let metadata = if channel_name == "telegram" {
serde_json::json!({ "preformatted_html": true })
} else {
serde_json::json!({})
};
for ch in channels.iter() {
if ch.name() == channel_name {
let msg = OutgoingMessage {
thread_id: thread_id.to_string(),
body,
reply_to: None,
metadata,
topic_id: topic_id.map(String::from),
};
if let Err(e) = ch.send(&msg).await {
tracing::warn!(channel = channel_name, thread_id, ?e, "stream: send failed");
}
return;
}
}
tracing::debug!(
channel = channel_name,
"stream: channel not found in registry"
);
}
pub async fn fanout_output(
repo: String,
task_id: String,
transport: Arc<Transport>,
channels: Arc<ChannelRegistry>,
) {
let mut rx = match transport.subscribe(&repo, &task_id).await {
Some(r) => r,
None => {
tracing::debug!(task_id, "fanout: no binding for task, cannot subscribe");
return;
}
};
tracing::debug!(task_id, "fanout: started output streaming");
let mut last_send: HashMap<String, Instant> = HashMap::new();
let mut buffers: HashMap<String, String> = HashMap::new();
loop {
match rx.recv().await {
Ok(chunk) => {
let is_final = chunk.is_final;
if !chunk.content.is_empty() {
if let Some(binding) = transport.get_binding(&repo, &task_id).await {
let now = Instant::now();
for thread_key in &binding.connected_threads {
let (ch_name, thread_id, topic_id) =
match parse_conversation_key(thread_key) {
Some(p) => p,
None => continue,
};
buffers
.entry(thread_key.clone())
.or_default()
.push_str(&chunk.content);
let rate = platform_rate(ch_name);
let can_send = last_send
.get(ch_name)
.map(|t| now.duration_since(*t) >= rate)
.unwrap_or(true);
if can_send {
if let Some(buffered) = buffers.remove(thread_key) {
let to_send = if ch_name == "telegram" {
telegram_html_escape(&buffered)
} else {
buffered
};
let parts = split_for_platform(&to_send, ch_name);
for part in parts {
send_to_thread(
&channels, ch_name, thread_id, topic_id, part,
)
.await;
}
last_send.insert(ch_name.to_string(), now);
}
}
}
}
}
if is_final {
if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
for (thread_key, buffered) in buffers.drain() {
if buffered.is_empty() {
continue;
}
let (ch_name, thread_id, topic_id) =
match parse_conversation_key(&thread_key) {
Some(p) => p,
None => continue,
};
let to_send = if ch_name == "telegram" {
telegram_html_escape(&buffered)
} else {
buffered
};
let parts = split_for_platform(&to_send, ch_name);
for part in parts {
send_to_thread(&channels, ch_name, thread_id, topic_id, part).await;
}
}
}
tracing::debug!(task_id, "fanout: final chunk received, stream complete");
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
task_id,
missed = n,
"fanout: output receiver lagged, some output dropped"
);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
tracing::debug!(task_id, "fanout: output broadcast closed");
break;
}
}
if !buffers.is_empty() {
let now = Instant::now();
let mut earliest = None::<Instant>;
for thread_key in buffers.keys() {
if let Some((ch_name, _, _)) = parse_conversation_key(thread_key) {
let rate = platform_rate(ch_name);
if let Some(last) = last_send.get(ch_name) {
let when = *last + rate;
if when > now {
earliest = Some(match earliest {
Some(e) if e <= when => e,
_ => when,
});
} else {
earliest = Some(now);
}
} else {
earliest = Some(now);
}
}
}
if let Some(wake_at) = earliest {
let sleep_dur = if wake_at > now {
wake_at - now
} else {
Duration::from_millis(50)
};
tokio::time::sleep(sleep_dur).await;
if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
let now = Instant::now();
let keys: Vec<String> = buffers.keys().cloned().collect();
for thread_key in keys {
if let Some(buffered) = buffers.remove(&thread_key) {
let (ch_name, thread_id, topic_id) =
match parse_conversation_key(&thread_key) {
Some(p) => p,
None => continue,
};
let rate = platform_rate(ch_name);
let can_send = last_send
.get(ch_name)
.map(|t| now.duration_since(*t) >= rate)
.unwrap_or(true);
if can_send {
let to_send = if ch_name == "telegram" {
telegram_html_escape(&buffered)
} else {
buffered
};
let parts = split_for_platform(&to_send, ch_name);
for part in parts {
send_to_thread(&channels, ch_name, thread_id, topic_id, part)
.await;
}
last_send.insert(ch_name.to_string(), now);
} else {
buffers.insert(thread_key, buffered);
}
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_short_content_unchanged() {
let parts = split_for_platform("hello", "telegram");
assert_eq!(parts, vec!["hello"]);
}
#[test]
fn split_long_content_telegram() {
let content = "a".repeat(5000);
let parts = split_for_platform(&content, "telegram");
assert!(parts.len() >= 2);
for p in &parts {
assert!(p.len() <= TELEGRAM_MAX_BYTES);
}
assert_eq!(parts.join(""), content);
}
#[test]
fn split_long_content_discord() {
let content = "x".repeat(3000);
let parts = split_for_platform(&content, "discord");
assert!(parts.len() >= 2);
for p in &parts {
assert!(p.len() <= DISCORD_MAX_BYTES);
}
assert_eq!(parts.join(""), content);
}
#[test]
fn platform_max_bytes_known_channels() {
assert_eq!(platform_max_bytes("telegram"), 4096);
assert_eq!(platform_max_bytes("discord"), 2000);
assert_eq!(platform_max_bytes("slack"), 4000);
assert_eq!(platform_max_bytes("unknown"), 4096);
}
#[test]
fn split_multibyte_char_at_boundary_does_not_panic() {
let content = "x".repeat(1997) + "😀"; let parts = split_for_platform(&content, "discord");
assert_eq!(parts.join(""), content);
for p in &parts {
assert!(std::str::from_utf8(p.as_bytes()).is_ok());
}
}
#[test]
fn split_single_multibyte_char_exceeding_limit() {
let content = "😀".repeat(600); let parts = split_for_platform(&content, "discord");
assert_eq!(parts.join(""), content);
for p in &parts {
assert!(std::str::from_utf8(p.as_bytes()).is_ok());
}
}
#[test]
fn split_telegram_html_content_escaped_before_split() {
let raw = "a".repeat(4096) + "<code>123</code>"; assert_eq!(raw.len(), 4112);
let escaped = telegram_html_escape(&raw);
assert_eq!(escaped.len(), 4112 + 12); assert!(escaped.len() > raw.len());
let parts = split_for_platform(&escaped, "telegram");
assert!(
parts.len() >= 2,
"escaped content ({}) must split into ≥2 parts",
escaped.len()
);
for (i, p) in parts.iter().enumerate() {
assert!(
p.len() <= TELEGRAM_MAX_BYTES,
"part {} ({} bytes) exceeds Telegram limit {}",
i,
p.len(),
TELEGRAM_MAX_BYTES
);
}
assert_eq!(parts.join(""), escaped);
}
#[test]
fn split_then_escape_exceeds_limit_when_boundary_inside_html_tag() {
let html_tag = "<code>123</code>";
assert_eq!(html_tag.len(), 16);
let raw = "a".repeat(4093) + html_tag; assert_eq!(raw.len(), 4109);
let buggy_parts: Vec<String> = split_for_platform(&raw, "telegram")
.into_iter()
.map(|p| telegram_html_escape(&p))
.collect();
let has_overflow = buggy_parts.iter().any(|p| p.len() > TELEGRAM_MAX_BYTES);
assert!(
has_overflow,
"expected old buggy approach to produce an oversized chunk"
);
let escaped = telegram_html_escape(&raw);
let fixed_parts = split_for_platform(&escaped, "telegram");
for (i, p) in fixed_parts.iter().enumerate() {
assert!(
p.len() <= TELEGRAM_MAX_BYTES,
"fixed part {} ({} bytes) must not exceed limit",
i,
p.len()
);
}
assert_eq!(fixed_parts.join(""), escaped);
}
#[test]
fn telegram_html_escape_produces_valid_html_entities() {
assert_eq!(telegram_html_escape("<test>"), "<test>");
assert_eq!(telegram_html_escape("a & b"), "a & b");
assert_eq!(telegram_html_escape("<>&"), "<>&");
}
struct TestChannel {
name: String,
tx: tokio::sync::mpsc::Sender<crate::channels::OutgoingMessage>,
}
#[async_trait::async_trait]
impl crate::channels::Channel for TestChannel {
fn name(&self) -> &str {
&self.name
}
async fn start(
&self,
) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::channels::IncomingMessage>> {
let (_tx, rx) = tokio::sync::mpsc::channel(1);
Ok(rx)
}
async fn send(&self, msg: &crate::channels::OutgoingMessage) -> anyhow::Result<()> {
let _ = self.tx.send(msg.clone()).await;
Ok(())
}
async fn health_check(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn fanout_sends_to_registered_channel() {
use crate::channels::transport::Transport;
let transport = std::sync::Arc::new(Transport::new());
let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::channels::OutgoingMessage>(16);
let test_ch = TestChannel {
name: "telegram".to_string(),
tx,
};
let mut registry = crate::channels::ChannelRegistry::new();
registry.register(Box::new(test_ch));
let registry = std::sync::Arc::new(registry);
let task_id = "fanout-test-task";
let repo = "owner/test-repo";
transport
.bind(
repo,
task_id,
"orch-fanout-test",
"telegram",
"thread-1",
None,
)
.await;
let transport_clone = transport.clone();
let registry_clone = registry.clone();
let task_id_str = task_id.to_string();
let repo_str = repo.to_string();
tokio::spawn(async move {
fanout_output(repo_str, task_id_str, transport_clone, registry_clone).await
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
transport
.push_output(
repo,
task_id,
crate::channels::OutputChunk {
content: "hello from agent".to_string(),
is_final: false,
},
)
.await;
transport
.push_output(
repo,
task_id,
crate::channels::OutputChunk {
content: "".to_string(),
is_final: true,
},
)
.await;
let mut got = false;
for _ in 0..10 {
if let Ok(Some(msg)) =
tokio::time::timeout(std::time::Duration::from_millis(200), async {
rx.recv().await
})
.await
{
if msg.body.contains("hello from agent") {
got = true;
break;
}
}
}
assert!(
got,
"fanout did not send expected outgoing message to test channel"
);
}
#[tokio::test]
async fn fanout_forwards_topic_id() {
let transport = std::sync::Arc::new(Transport::new());
let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::channels::OutgoingMessage>(16);
let test_ch = TestChannel {
name: "telegram".to_string(),
tx,
};
let mut registry = crate::channels::ChannelRegistry::new();
registry.register(Box::new(test_ch));
let registry = std::sync::Arc::new(registry);
let task_id = "topic-forward-task";
let repo = "owner/test-repo";
transport
.bind(
repo,
task_id,
"orch-topic-test",
"telegram",
"chat-111",
Some("topic-42"),
)
.await;
let transport_clone = transport.clone();
let registry_clone = registry.clone();
let task_id_str = task_id.to_string();
let repo_str = repo.to_string();
tokio::spawn(async move {
fanout_output(repo_str, task_id_str, transport_clone, registry_clone).await
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
transport
.push_output(
repo,
task_id,
crate::channels::OutputChunk {
content: "topic reply".to_string(),
is_final: true,
},
)
.await;
let mut got_topic = false;
for _ in 0..10 {
if let Ok(Some(msg)) =
tokio::time::timeout(std::time::Duration::from_millis(200), async {
rx.recv().await
})
.await
{
if msg.body.contains("topic reply") {
assert_eq!(
msg.topic_id.as_deref(),
Some("topic-42"),
"outgoing message must carry the bound topic_id"
);
got_topic = true;
break;
}
}
}
assert!(
got_topic,
"fanout did not forward topic_id in outgoing message"
);
}
}