1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
//! # Delivery router — format and deliver responses to platforms
//!
//! WHY message splitting: Platforms have strict message length limits
//! (Telegram: 4096 chars, Discord: 2000 chars). The delivery router
//! splits long responses at natural boundaries (paragraph, sentence)
//! to avoid mid-word breaks.
//!
//! ```text
//! DeliveryRouter
//! ├── deliver() → format + split + send via adapter
//! ├── split_message() → break at paragraph/sentence boundaries
//! └── adapters → HashMap<Platform, Arc<dyn PlatformAdapter>>
//! ```
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use lingshu_types::Platform;
use crate::platform::{
MediaRef, MessageMetadata, OutgoingMessage, PlatformAdapter, extract_media_from_response,
response_text_after_media_extraction,
};
/// Routes formatted responses to the correct platform adapter.
pub struct DeliveryRouter {
adapters: HashMap<Platform, Arc<dyn PlatformAdapter>>,
}
impl DeliveryRouter {
pub fn new() -> Self {
Self {
adapters: HashMap::new(),
}
}
/// Register a platform adapter for delivery.
pub fn register(&mut self, adapter: Arc<dyn PlatformAdapter>) {
self.adapters.insert(adapter.platform(), adapter);
}
/// Deliver a response to the specified platform.
///
/// Extracts any `MEDIA:/path` references first: image paths are sent via
/// `send_photo`, audio via `send_voice`, other files via `send_document`.
/// The remaining text (if any) is formatted, split to fit the platform's
/// message length limit, and sent as plain text chunks.
///
/// WHY media-aware here: the DeliveryRouter is the single dispatch point for
/// all outbound messages — both gateway agent replies (run.rs) and the
/// `send_message` tool (via GatewaySenderBridge). Centralising MEDIA:
/// extraction here ensures neither path silently sends a raw file path as
/// text.
pub async fn deliver(
&self,
response: &str,
platform: Platform,
metadata: &MessageMetadata,
) -> anyhow::Result<()> {
let adapter = self
.adapters
.get(&platform)
.ok_or_else(|| anyhow::anyhow!("No adapter registered for {:?}", platform))?;
let (cleaned, media_refs) = extract_media_from_response(response);
// Send text portion (skip if the response was entirely media)
if let Some(text) = response_text_after_media_extraction(response, &cleaned, &media_refs) {
let formatted = adapter.format_response(&text, metadata);
let max_len = adapter.max_message_length();
if formatted.len() > max_len {
let chunks = split_message(&formatted, max_len);
for (i, chunk) in chunks.iter().enumerate() {
adapter
.send(OutgoingMessage {
text: chunk.clone(),
metadata: metadata.clone(),
})
.await?;
if i < chunks.len() - 1 {
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
} else {
adapter
.send(OutgoingMessage {
text: formatted,
metadata: metadata.clone(),
})
.await?;
}
}
// Send media attachments
for mref in &media_refs {
let result = if mref.is_image {
adapter.send_photo(&mref.path, None, metadata).await
} else if MediaRef::detect_audio(&mref.path) {
adapter.send_voice(&mref.path, None, metadata).await
} else {
adapter.send_document(&mref.path, None, metadata).await
};
if let Err(e) = result {
tracing::warn!(
path = %mref.path,
error = %e,
"media delivery failed — falling back to path in text"
);
}
}
Ok(())
}
/// Check if a platform adapter is registered.
pub fn has_adapter(&self, platform: &Platform) -> bool {
self.adapters.contains_key(platform)
}
/// Return the list of platforms that have registered adapters.
pub fn list_platforms(&self) -> Vec<Platform> {
self.adapters.keys().cloned().collect()
}
}
impl Default for DeliveryRouter {
fn default() -> Self {
Self::new()
}
}
/// Split a message into chunks that fit within `max_len`.
///
/// WHY paragraph-first: Splitting at paragraph breaks (double newline)
/// preserves formatting better than arbitrary cuts. Falls back to
/// single newline, then space, then hard cut as last resort.
pub fn split_message(text: &str, max_len: usize) -> Vec<String> {
if text.len() <= max_len {
return vec![text.to_string()];
}
let mut chunks = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if remaining.len() <= max_len {
chunks.push(remaining.to_string());
break;
}
// Try to split at a natural boundary within max_len
let cut_at = find_split_point(remaining, max_len);
let (chunk, rest) = remaining.split_at(cut_at);
chunks.push(chunk.trim_end().to_string());
remaining = rest.trim_start();
}
chunks
}
/// Find the best split point within `max_len` bytes.
///
/// Priority: paragraph break > newline > space > hard cut.
fn find_split_point(text: &str, max_len: usize) -> usize {
let safe_max = (0..=max_len)
.rev()
.find(|&i| text.is_char_boundary(i))
.unwrap_or(0);
let search_window = &text[..safe_max];
// Try paragraph break (double newline)
if let Some(pos) = search_window.rfind("\n\n")
&& pos > 0
{
return pos + 1; // Include one newline
}
// Try single newline
if let Some(pos) = search_window.rfind('\n')
&& pos > 0
{
return pos + 1;
}
// Try space
if let Some(pos) = search_window.rfind(' ')
&& pos > 0
{
return pos + 1;
}
// Hard cut at max_len (ensure valid UTF-8 boundary)
let mut cut = max_len;
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
cut
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_short_message_no_split() {
let chunks = split_message("hello world", 100);
assert_eq!(chunks, vec!["hello world"]);
}
#[test]
fn split_at_paragraph_boundary() {
let text = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
let chunks = split_message(text, 30);
assert!(chunks.len() >= 2);
assert!(chunks[0].contains("First"));
}
#[test]
fn split_at_newline() {
let text = "Line one\nLine two\nLine three\nLine four";
let chunks = split_message(text, 20);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.len() <= 20, "chunk too long: {}", chunk.len());
}
}
#[test]
fn split_at_space() {
let text = "word1 word2 word3 word4 word5 word6 word7 word8";
let chunks = split_message(text, 15);
assert!(chunks.len() >= 2);
}
#[test]
fn split_hard_cut_on_long_word() {
let text = "a".repeat(100);
let chunks = split_message(&text, 30);
assert!(chunks.len() >= 4);
// First chunk should be exactly 30
assert_eq!(chunks[0].len(), 30);
}
#[test]
fn delivery_router_default_empty() {
let router = DeliveryRouter::new();
assert!(!router.has_adapter(&Platform::Telegram));
}
}