fix(channels): resolve telegram reply target and media delivery (#525)
Co-authored-by: Will Sarg <12886992+willsarg@users.noreply.github.com>
This commit is contained in:
parent
efa6e5aa4a
commit
ae37e59423
15 changed files with 561 additions and 164 deletions
15
README.md
15
README.md
|
|
@ -291,6 +291,21 @@ rerun channel setup only:
|
||||||
zeroclaw onboard --channels-only
|
zeroclaw onboard --channels-only
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Telegram media replies
|
||||||
|
|
||||||
|
Telegram routing now replies to the source **chat ID** from incoming updates (instead of usernames),
|
||||||
|
which avoids `Bad Request: chat not found` failures.
|
||||||
|
|
||||||
|
For non-text replies, ZeroClaw can send Telegram attachments when the assistant includes markers:
|
||||||
|
|
||||||
|
- `[IMAGE:<path-or-url>]`
|
||||||
|
- `[DOCUMENT:<path-or-url>]`
|
||||||
|
- `[VIDEO:<path-or-url>]`
|
||||||
|
- `[AUDIO:<path-or-url>]`
|
||||||
|
- `[VOICE:<path-or-url>]`
|
||||||
|
|
||||||
|
Paths can be local files (for example `/tmp/screenshot.png`) or HTTPS URLs.
|
||||||
|
|
||||||
### WhatsApp Business Cloud API Setup
|
### WhatsApp Business Cloud API Setup
|
||||||
|
|
||||||
WhatsApp uses Meta's Cloud API with webhooks (push-based, not polling):
|
WhatsApp uses Meta's Cloud API with webhooks (push-based, not polling):
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,14 @@ mod tests {
|
||||||
let msg = ChannelMessage {
|
let msg = ChannelMessage {
|
||||||
id: "test-id".into(),
|
id: "test-id".into(),
|
||||||
sender: "user".into(),
|
sender: "user".into(),
|
||||||
reply_to: "user".into(),
|
reply_target: "user".into(),
|
||||||
content: "hello".into(),
|
content: "hello".into(),
|
||||||
channel: "cli".into(),
|
channel: "cli".into(),
|
||||||
timestamp: 1_234_567_890,
|
timestamp: 1_234_567_890,
|
||||||
};
|
};
|
||||||
assert_eq!(msg.id, "test-id");
|
assert_eq!(msg.id, "test-id");
|
||||||
assert_eq!(msg.sender, "user");
|
assert_eq!(msg.sender, "user");
|
||||||
|
assert_eq!(msg.reply_target, "user");
|
||||||
assert_eq!(msg.content, "hello");
|
assert_eq!(msg.content, "hello");
|
||||||
assert_eq!(msg.channel, "cli");
|
assert_eq!(msg.channel, "cli");
|
||||||
assert_eq!(msg.timestamp, 1_234_567_890);
|
assert_eq!(msg.timestamp, 1_234_567_890);
|
||||||
|
|
@ -108,7 +109,7 @@ mod tests {
|
||||||
let msg = ChannelMessage {
|
let msg = ChannelMessage {
|
||||||
id: "id".into(),
|
id: "id".into(),
|
||||||
sender: "s".into(),
|
sender: "s".into(),
|
||||||
reply_to: "s".into(),
|
reply_target: "s".into(),
|
||||||
content: "c".into(),
|
content: "c".into(),
|
||||||
channel: "ch".into(),
|
channel: "ch".into(),
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use tokio::sync::RwLock;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// DingTalk (钉钉) channel — connects via Stream Mode WebSocket for real-time messages.
|
/// DingTalk channel — connects via Stream Mode WebSocket for real-time messages.
|
||||||
/// Replies are sent through per-message session webhook URLs.
|
/// Replies are sent through per-message session webhook URLs.
|
||||||
pub struct DingTalkChannel {
|
pub struct DingTalkChannel {
|
||||||
client_id: String,
|
client_id: String,
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,11 @@ impl Channel for DiscordChannel {
|
||||||
};
|
};
|
||||||
|
|
||||||
let message_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
let message_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||||
let channel_id = d.get("channel_id").and_then(|c| c.as_str()).unwrap_or("").to_string();
|
let channel_id = d
|
||||||
|
.get("channel_id")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let channel_msg = ChannelMessage {
|
let channel_msg = ChannelMessage {
|
||||||
id: if message_id.is_empty() {
|
id: if message_id.is_empty() {
|
||||||
|
|
@ -372,8 +376,12 @@ impl Channel for DiscordChannel {
|
||||||
format!("discord_{message_id}")
|
format!("discord_{message_id}")
|
||||||
},
|
},
|
||||||
sender: author_id.to_string(),
|
sender: author_id.to_string(),
|
||||||
reply_to: channel_id.clone(),
|
reply_target: if channel_id.is_empty() {
|
||||||
content: clean_content,
|
author_id.to_string()
|
||||||
|
} else {
|
||||||
|
channel_id
|
||||||
|
},
|
||||||
|
content: content.to_string(),
|
||||||
channel: "discord".to_string(),
|
channel: "discord".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
|
||||||
|
|
@ -428,8 +428,8 @@ impl Channel for EmailChannel {
|
||||||
} // MutexGuard dropped before await
|
} // MutexGuard dropped before await
|
||||||
let msg = ChannelMessage {
|
let msg = ChannelMessage {
|
||||||
id,
|
id,
|
||||||
sender: sender.clone(),
|
reply_target: sender.clone(),
|
||||||
reply_to: sender,
|
sender,
|
||||||
content,
|
content,
|
||||||
channel: "email".to_string(),
|
channel: "email".to_string(),
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ end tell"#
|
||||||
let msg = ChannelMessage {
|
let msg = ChannelMessage {
|
||||||
id: rowid.to_string(),
|
id: rowid.to_string(),
|
||||||
sender: sender.clone(),
|
sender: sender.clone(),
|
||||||
reply_to: sender.clone(),
|
reply_target: sender.clone(),
|
||||||
content: text,
|
content: text,
|
||||||
channel: "imessage".to_string(),
|
channel: "imessage".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
|
|
|
||||||
|
|
@ -565,8 +565,8 @@ impl Channel for IrcChannel {
|
||||||
let seq = MSG_SEQ.fetch_add(1, Ordering::Relaxed);
|
let seq = MSG_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
let channel_msg = ChannelMessage {
|
let channel_msg = ChannelMessage {
|
||||||
id: format!("irc_{}_{seq}", chrono::Utc::now().timestamp_millis()),
|
id: format!("irc_{}_{seq}", chrono::Utc::now().timestamp_millis()),
|
||||||
sender: reply_to.clone(),
|
sender: sender_nick.to_string(),
|
||||||
reply_to,
|
reply_target: reply_to,
|
||||||
content,
|
content,
|
||||||
channel: "irc".to_string(),
|
channel: "irc".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
|
|
|
||||||
|
|
@ -614,7 +614,7 @@ impl LarkChannel {
|
||||||
messages.push(ChannelMessage {
|
messages.push(ChannelMessage {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
sender: chat_id.to_string(),
|
sender: chat_id.to_string(),
|
||||||
reply_to: chat_id.to_string(),
|
reply_target: chat_id.to_string(),
|
||||||
content: text,
|
content: text,
|
||||||
channel: "lark".to_string(),
|
channel: "lark".to_string(),
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,7 @@ impl Channel for MatrixChannel {
|
||||||
let msg = ChannelMessage {
|
let msg = ChannelMessage {
|
||||||
id: format!("mx_{}", chrono::Utc::now().timestamp_millis()),
|
id: format!("mx_{}", chrono::Utc::now().timestamp_millis()),
|
||||||
sender: event.sender.clone(),
|
sender: event.sender.clone(),
|
||||||
reply_to: self.room_id.clone(),
|
reply_target: event.sender.clone(),
|
||||||
content: body.clone(),
|
content: body.clone(),
|
||||||
channel: "matrix".to_string(),
|
channel: "matrix".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,15 @@ fn conversation_memory_key(msg: &traits::ChannelMessage) -> String {
|
||||||
format!("{}_{}_{}", msg.channel, msg.sender, msg.id)
|
format!("{}_{}_{}", msg.channel, msg.sender, msg.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
|
||||||
|
match channel_name {
|
||||||
|
"telegram" => Some(
|
||||||
|
"When responding on Telegram, include media markers for files or URLs that should be sent as attachments. Use one marker per attachment with this exact syntax: [IMAGE:<path-or-url>], [DOCUMENT:<path-or-url>], [VIDEO:<path-or-url>], [AUDIO:<path-or-url>], or [VOICE:<path-or-url>]. Keep normal user-facing text outside markers and never wrap markers in code fences.",
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn build_memory_context(mem: &dyn Memory, user_msg: &str) -> String {
|
async fn build_memory_context(mem: &dyn Memory, user_msg: &str) -> String {
|
||||||
let mut context = String::new();
|
let mut context = String::new();
|
||||||
|
|
||||||
|
|
@ -172,7 +181,7 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
let target_channel = ctx.channels_by_name.get(&msg.channel).cloned();
|
let target_channel = ctx.channels_by_name.get(&msg.channel).cloned();
|
||||||
|
|
||||||
if let Some(channel) = target_channel.as_ref() {
|
if let Some(channel) = target_channel.as_ref() {
|
||||||
if let Err(e) = channel.start_typing(&msg.reply_to).await {
|
if let Err(e) = channel.start_typing(&msg.reply_target).await {
|
||||||
tracing::debug!("Failed to start typing on {}: {e}", channel.name());
|
tracing::debug!("Failed to start typing on {}: {e}", channel.name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +194,10 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
ChatMessage::user(&enriched_message),
|
ChatMessage::user(&enriched_message),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if let Some(instructions) = channel_delivery_instructions(&msg.channel) {
|
||||||
|
history.push(ChatMessage::system(instructions));
|
||||||
|
}
|
||||||
|
|
||||||
let llm_result = tokio::time::timeout(
|
let llm_result = tokio::time::timeout(
|
||||||
Duration::from_secs(CHANNEL_MESSAGE_TIMEOUT_SECS),
|
Duration::from_secs(CHANNEL_MESSAGE_TIMEOUT_SECS),
|
||||||
run_tool_call_loop(
|
run_tool_call_loop(
|
||||||
|
|
@ -201,7 +214,7 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Some(channel) = target_channel.as_ref() {
|
if let Some(channel) = target_channel.as_ref() {
|
||||||
if let Err(e) = channel.stop_typing(&msg.reply_to).await {
|
if let Err(e) = channel.stop_typing(&msg.reply_target).await {
|
||||||
tracing::debug!("Failed to stop typing on {}: {e}", channel.name());
|
tracing::debug!("Failed to stop typing on {}: {e}", channel.name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +227,7 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
truncate_with_ellipsis(&response, 80)
|
truncate_with_ellipsis(&response, 80)
|
||||||
);
|
);
|
||||||
if let Some(channel) = target_channel.as_ref() {
|
if let Some(channel) = target_channel.as_ref() {
|
||||||
if let Err(e) = channel.send(&response, &msg.reply_to).await {
|
if let Err(e) = channel.send(&response, &msg.reply_target).await {
|
||||||
eprintln!(" ❌ Failed to reply on {}: {e}", channel.name());
|
eprintln!(" ❌ Failed to reply on {}: {e}", channel.name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +238,9 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
started_at.elapsed().as_millis()
|
started_at.elapsed().as_millis()
|
||||||
);
|
);
|
||||||
if let Some(channel) = target_channel.as_ref() {
|
if let Some(channel) = target_channel.as_ref() {
|
||||||
let _ = channel.send(&format!("⚠️ Error: {e}"), &msg.reply_to).await;
|
let _ = channel
|
||||||
|
.send(&format!("⚠️ Error: {e}"), &msg.reply_target)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
|
@ -242,7 +257,7 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
let _ = channel
|
let _ = channel
|
||||||
.send(
|
.send(
|
||||||
"⚠️ Request timed out while waiting for the model. Please try again.",
|
"⚠️ Request timed out while waiting for the model. Please try again.",
|
||||||
&msg.reply_to,
|
&msg.reply_target,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -1245,7 +1260,7 @@ mod tests {
|
||||||
traits::ChannelMessage {
|
traits::ChannelMessage {
|
||||||
id: "msg-1".to_string(),
|
id: "msg-1".to_string(),
|
||||||
sender: "alice".to_string(),
|
sender: "alice".to_string(),
|
||||||
reply_to: "alice".to_string(),
|
reply_target: "chat-42".to_string(),
|
||||||
content: "What is the BTC price now?".to_string(),
|
content: "What is the BTC price now?".to_string(),
|
||||||
channel: "test-channel".to_string(),
|
channel: "test-channel".to_string(),
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
@ -1255,6 +1270,7 @@ mod tests {
|
||||||
|
|
||||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||||
assert_eq!(sent_messages.len(), 1);
|
assert_eq!(sent_messages.len(), 1);
|
||||||
|
assert!(sent_messages[0].starts_with("chat-42:"));
|
||||||
assert!(sent_messages[0].contains("BTC is currently around"));
|
assert!(sent_messages[0].contains("BTC is currently around"));
|
||||||
assert!(!sent_messages[0].contains("\"tool_calls\""));
|
assert!(!sent_messages[0].contains("\"tool_calls\""));
|
||||||
assert!(!sent_messages[0].contains("mock_price"));
|
assert!(!sent_messages[0].contains("mock_price"));
|
||||||
|
|
@ -1338,7 +1354,7 @@ mod tests {
|
||||||
tx.send(traits::ChannelMessage {
|
tx.send(traits::ChannelMessage {
|
||||||
id: "1".to_string(),
|
id: "1".to_string(),
|
||||||
sender: "alice".to_string(),
|
sender: "alice".to_string(),
|
||||||
reply_to: "alice".to_string(),
|
reply_target: "alice".to_string(),
|
||||||
content: "hello".to_string(),
|
content: "hello".to_string(),
|
||||||
channel: "test-channel".to_string(),
|
channel: "test-channel".to_string(),
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
@ -1348,7 +1364,7 @@ mod tests {
|
||||||
tx.send(traits::ChannelMessage {
|
tx.send(traits::ChannelMessage {
|
||||||
id: "2".to_string(),
|
id: "2".to_string(),
|
||||||
sender: "bob".to_string(),
|
sender: "bob".to_string(),
|
||||||
reply_to: "bob".to_string(),
|
reply_target: "bob".to_string(),
|
||||||
content: "world".to_string(),
|
content: "world".to_string(),
|
||||||
channel: "test-channel".to_string(),
|
channel: "test-channel".to_string(),
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
|
|
@ -1611,7 +1627,7 @@ mod tests {
|
||||||
let msg = traits::ChannelMessage {
|
let msg = traits::ChannelMessage {
|
||||||
id: "msg_abc123".into(),
|
id: "msg_abc123".into(),
|
||||||
sender: "U123".into(),
|
sender: "U123".into(),
|
||||||
reply_to: "U123".into(),
|
reply_target: "C456".into(),
|
||||||
content: "hello".into(),
|
content: "hello".into(),
|
||||||
channel: "slack".into(),
|
channel: "slack".into(),
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
@ -1625,7 +1641,7 @@ mod tests {
|
||||||
let msg1 = traits::ChannelMessage {
|
let msg1 = traits::ChannelMessage {
|
||||||
id: "msg_1".into(),
|
id: "msg_1".into(),
|
||||||
sender: "U123".into(),
|
sender: "U123".into(),
|
||||||
reply_to: "U123".into(),
|
reply_target: "C456".into(),
|
||||||
content: "first".into(),
|
content: "first".into(),
|
||||||
channel: "slack".into(),
|
channel: "slack".into(),
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
@ -1633,7 +1649,7 @@ mod tests {
|
||||||
let msg2 = traits::ChannelMessage {
|
let msg2 = traits::ChannelMessage {
|
||||||
id: "msg_2".into(),
|
id: "msg_2".into(),
|
||||||
sender: "U123".into(),
|
sender: "U123".into(),
|
||||||
reply_to: "U123".into(),
|
reply_target: "C456".into(),
|
||||||
content: "second".into(),
|
content: "second".into(),
|
||||||
channel: "slack".into(),
|
channel: "slack".into(),
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
|
|
@ -1653,7 +1669,7 @@ mod tests {
|
||||||
let msg1 = traits::ChannelMessage {
|
let msg1 = traits::ChannelMessage {
|
||||||
id: "msg_1".into(),
|
id: "msg_1".into(),
|
||||||
sender: "U123".into(),
|
sender: "U123".into(),
|
||||||
reply_to: "U123".into(),
|
reply_target: "C456".into(),
|
||||||
content: "I'm Paul".into(),
|
content: "I'm Paul".into(),
|
||||||
channel: "slack".into(),
|
channel: "slack".into(),
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
@ -1661,7 +1677,7 @@ mod tests {
|
||||||
let msg2 = traits::ChannelMessage {
|
let msg2 = traits::ChannelMessage {
|
||||||
id: "msg_2".into(),
|
id: "msg_2".into(),
|
||||||
sender: "U123".into(),
|
sender: "U123".into(),
|
||||||
reply_to: "U123".into(),
|
reply_target: "C456".into(),
|
||||||
content: "I'm 45".into(),
|
content: "I'm 45".into(),
|
||||||
channel: "slack".into(),
|
channel: "slack".into(),
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ impl Channel for SlackChannel {
|
||||||
let channel_msg = ChannelMessage {
|
let channel_msg = ChannelMessage {
|
||||||
id: format!("slack_{channel_id}_{ts}"),
|
id: format!("slack_{channel_id}_{ts}"),
|
||||||
sender: user.to_string(),
|
sender: user.to_string(),
|
||||||
reply_to: channel_id.to_string(),
|
reply_target: channel_id.clone(),
|
||||||
content: text.to_string(),
|
content: text.to_string(),
|
||||||
channel: "slack".to_string(),
|
channel: "slack".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,133 @@ fn split_message_for_telegram(message: &str) -> Vec<String> {
|
||||||
chunks
|
chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum TelegramAttachmentKind {
|
||||||
|
Image,
|
||||||
|
Document,
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
Voice,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct TelegramAttachment {
|
||||||
|
kind: TelegramAttachmentKind,
|
||||||
|
target: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelegramAttachmentKind {
|
||||||
|
fn from_marker(marker: &str) -> Option<Self> {
|
||||||
|
match marker.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"IMAGE" | "PHOTO" => Some(Self::Image),
|
||||||
|
"DOCUMENT" | "FILE" => Some(Self::Document),
|
||||||
|
"VIDEO" => Some(Self::Video),
|
||||||
|
"AUDIO" => Some(Self::Audio),
|
||||||
|
"VOICE" => Some(Self::Voice),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_http_url(target: &str) -> bool {
|
||||||
|
target.starts_with("http://") || target.starts_with("https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_attachment_kind_from_target(target: &str) -> Option<TelegramAttachmentKind> {
|
||||||
|
let normalized = target
|
||||||
|
.split('?')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(target)
|
||||||
|
.split('#')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(target);
|
||||||
|
|
||||||
|
let extension = Path::new(normalized)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())?
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
match extension.as_str() {
|
||||||
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => Some(TelegramAttachmentKind::Image),
|
||||||
|
"mp4" | "mov" | "mkv" | "avi" | "webm" => Some(TelegramAttachmentKind::Video),
|
||||||
|
"mp3" | "m4a" | "wav" | "flac" => Some(TelegramAttachmentKind::Audio),
|
||||||
|
"ogg" | "oga" | "opus" => Some(TelegramAttachmentKind::Voice),
|
||||||
|
"pdf" | "txt" | "md" | "csv" | "json" | "zip" | "tar" | "gz" | "doc" | "docx" | "xls"
|
||||||
|
| "xlsx" | "ppt" | "pptx" => Some(TelegramAttachmentKind::Document),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_path_only_attachment(message: &str) -> Option<TelegramAttachment> {
|
||||||
|
let trimmed = message.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.contains('\n') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = trimmed.trim_matches(|c| matches!(c, '`' | '"' | '\''));
|
||||||
|
if candidate.chars().any(char::is_whitespace) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = candidate.strip_prefix("file://").unwrap_or(candidate);
|
||||||
|
let kind = infer_attachment_kind_from_target(candidate)?;
|
||||||
|
|
||||||
|
if !is_http_url(candidate) && !Path::new(candidate).exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(TelegramAttachment {
|
||||||
|
kind,
|
||||||
|
target: candidate.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>) {
|
||||||
|
let mut cleaned = String::with_capacity(message.len());
|
||||||
|
let mut attachments = Vec::new();
|
||||||
|
let mut cursor = 0;
|
||||||
|
|
||||||
|
while cursor < message.len() {
|
||||||
|
let Some(open_rel) = message[cursor..].find('[') else {
|
||||||
|
cleaned.push_str(&message[cursor..]);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let open = cursor + open_rel;
|
||||||
|
cleaned.push_str(&message[cursor..open]);
|
||||||
|
|
||||||
|
let Some(close_rel) = message[open..].find(']') else {
|
||||||
|
cleaned.push_str(&message[open..]);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let close = open + close_rel;
|
||||||
|
let marker = &message[open + 1..close];
|
||||||
|
|
||||||
|
let parsed = marker.split_once(':').and_then(|(kind, target)| {
|
||||||
|
let kind = TelegramAttachmentKind::from_marker(kind)?;
|
||||||
|
let target = target.trim();
|
||||||
|
if target.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(TelegramAttachment {
|
||||||
|
kind,
|
||||||
|
target: target.to_string(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(attachment) = parsed {
|
||||||
|
attachments.push(attachment);
|
||||||
|
} else {
|
||||||
|
cleaned.push_str(&message[open..=close]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
(cleaned.trim().to_string(), attachments)
|
||||||
|
}
|
||||||
|
|
||||||
/// Telegram channel — long-polls the Bot API for updates
|
/// Telegram channel — long-polls the Bot API for updates
|
||||||
pub struct TelegramChannel {
|
pub struct TelegramChannel {
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
|
|
@ -82,6 +209,216 @@ impl TelegramChannel {
|
||||||
identities.into_iter().any(|id| self.is_user_allowed(id))
|
identities.into_iter().any(|id| self.is_user_allowed(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
|
||||||
|
let message = update.get("message")?;
|
||||||
|
|
||||||
|
let text = message.get("text").and_then(serde_json::Value::as_str)?;
|
||||||
|
|
||||||
|
let username = message
|
||||||
|
.get("from")
|
||||||
|
.and_then(|from| from.get("username"))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let user_id = message
|
||||||
|
.get("from")
|
||||||
|
.and_then(|from| from.get("id"))
|
||||||
|
.and_then(serde_json::Value::as_i64)
|
||||||
|
.map(|id| id.to_string());
|
||||||
|
|
||||||
|
let sender_identity = if username == "unknown" {
|
||||||
|
user_id.clone().unwrap_or_else(|| "unknown".to_string())
|
||||||
|
} else {
|
||||||
|
username.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut identities = vec![username.as_str()];
|
||||||
|
if let Some(id) = user_id.as_deref() {
|
||||||
|
identities.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.is_any_user_allowed(identities.iter().copied()) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
|
||||||
|
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
|
||||||
|
user_id.as_deref().unwrap_or("unknown")
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_id = message
|
||||||
|
.get("chat")
|
||||||
|
.and_then(|chat| chat.get("id"))
|
||||||
|
.and_then(serde_json::Value::as_i64)
|
||||||
|
.map(|id| id.to_string())?;
|
||||||
|
|
||||||
|
let message_id = message
|
||||||
|
.get("message_id")
|
||||||
|
.and_then(serde_json::Value::as_i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Some(ChannelMessage {
|
||||||
|
id: format!("telegram_{chat_id}_{message_id}"),
|
||||||
|
sender: sender_identity,
|
||||||
|
reply_target: chat_id,
|
||||||
|
content: text.to_string(),
|
||||||
|
channel: "telegram".to_string(),
|
||||||
|
timestamp: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
||||||
|
let chunks = split_message_for_telegram(message);
|
||||||
|
|
||||||
|
for (index, chunk) in chunks.iter().enumerate() {
|
||||||
|
let text = if chunks.len() > 1 {
|
||||||
|
if index == 0 {
|
||||||
|
format!("{chunk}\n\n(continues...)")
|
||||||
|
} else if index == chunks.len() - 1 {
|
||||||
|
format!("(continued)\n\n{chunk}")
|
||||||
|
} else {
|
||||||
|
format!("(continued)\n\n{chunk}\n\n(continues...)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chunk.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let markdown_body = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown"
|
||||||
|
});
|
||||||
|
|
||||||
|
let markdown_resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendMessage"))
|
||||||
|
.json(&markdown_body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if markdown_resp.status().is_success() {
|
||||||
|
if index < chunks.len() - 1 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let markdown_status = markdown_resp.status();
|
||||||
|
let markdown_err = markdown_resp.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
status = ?markdown_status,
|
||||||
|
"Telegram sendMessage with Markdown failed; retrying without parse_mode"
|
||||||
|
);
|
||||||
|
|
||||||
|
let plain_body = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
});
|
||||||
|
let plain_resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendMessage"))
|
||||||
|
.json(&plain_body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !plain_resp.status().is_success() {
|
||||||
|
let plain_status = plain_resp.status();
|
||||||
|
let plain_err = plain_resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!(
|
||||||
|
"Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
|
||||||
|
markdown_status,
|
||||||
|
markdown_err,
|
||||||
|
plain_status,
|
||||||
|
plain_err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if index < chunks.len() - 1 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_media_by_url(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
media_field: &str,
|
||||||
|
chat_id: &str,
|
||||||
|
url: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
});
|
||||||
|
body[media_field] = serde_json::Value::String(url.to_string());
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
body["caption"] = serde_json::Value::String(cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url(method))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram {method} by URL failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram {method} sent to {chat_id}: {url}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_attachment(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
attachment: &TelegramAttachment,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let target = attachment.target.trim();
|
||||||
|
|
||||||
|
if is_http_url(target) {
|
||||||
|
return match attachment.kind {
|
||||||
|
TelegramAttachmentKind::Image => {
|
||||||
|
self.send_photo_by_url(chat_id, target, None).await
|
||||||
|
}
|
||||||
|
TelegramAttachmentKind::Document => {
|
||||||
|
self.send_document_by_url(chat_id, target, None).await
|
||||||
|
}
|
||||||
|
TelegramAttachmentKind::Video => {
|
||||||
|
self.send_video_by_url(chat_id, target, None).await
|
||||||
|
}
|
||||||
|
TelegramAttachmentKind::Audio => {
|
||||||
|
self.send_audio_by_url(chat_id, target, None).await
|
||||||
|
}
|
||||||
|
TelegramAttachmentKind::Voice => {
|
||||||
|
self.send_voice_by_url(chat_id, target, None).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Path::new(target);
|
||||||
|
if !path.exists() {
|
||||||
|
anyhow::bail!("Telegram attachment path not found: {target}");
|
||||||
|
}
|
||||||
|
|
||||||
|
match attachment.kind {
|
||||||
|
TelegramAttachmentKind::Image => self.send_photo(chat_id, path, None).await,
|
||||||
|
TelegramAttachmentKind::Document => self.send_document(chat_id, path, None).await,
|
||||||
|
TelegramAttachmentKind::Video => self.send_video(chat_id, path, None).await,
|
||||||
|
TelegramAttachmentKind::Audio => self.send_audio(chat_id, path, None).await,
|
||||||
|
TelegramAttachmentKind::Voice => self.send_voice(chat_id, path, None).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a document/file to a Telegram chat
|
/// Send a document/file to a Telegram chat
|
||||||
pub async fn send_document(
|
pub async fn send_document(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -408,6 +745,39 @@ impl TelegramChannel {
|
||||||
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
|
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a video by URL (Telegram will download it)
|
||||||
|
pub async fn send_video_by_url(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
url: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.send_media_by_url("sendVideo", "video", chat_id, url, caption)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an audio file by URL (Telegram will download it)
|
||||||
|
pub async fn send_audio_by_url(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
url: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.send_media_by_url("sendAudio", "audio", chat_id, url, caption)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a voice message by URL (Telegram will download it)
|
||||||
|
pub async fn send_voice_by_url(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
url: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.send_media_by_url("sendVoice", "voice", chat_id, url, caption)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -417,82 +787,27 @@ impl Channel for TelegramChannel {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
||||||
// Split message if it exceeds Telegram's 4096 character limit
|
let (text_without_markers, attachments) = parse_attachment_markers(message);
|
||||||
let chunks = split_message_for_telegram(message);
|
|
||||||
|
|
||||||
for (i, chunk) in chunks.iter().enumerate() {
|
if !attachments.is_empty() {
|
||||||
// Add continuation marker for multi-part messages
|
if !text_without_markers.is_empty() {
|
||||||
let text = if chunks.len() > 1 {
|
self.send_text_chunks(&text_without_markers, chat_id)
|
||||||
if i == 0 {
|
|
||||||
format!("{chunk}\n\n(continues...)")
|
|
||||||
} else if i == chunks.len() - 1 {
|
|
||||||
format!("(continued)\n\n{chunk}")
|
|
||||||
} else {
|
|
||||||
format!("(continued)\n\n{chunk}\n\n(continues...)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
chunk.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let markdown_body = serde_json::json!({
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"text": text,
|
|
||||||
"parse_mode": "Markdown"
|
|
||||||
});
|
|
||||||
|
|
||||||
let markdown_resp = self
|
|
||||||
.client
|
|
||||||
.post(self.api_url("sendMessage"))
|
|
||||||
.json(&markdown_body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if markdown_resp.status().is_success() {
|
|
||||||
// Small delay between chunks to avoid rate limiting
|
|
||||||
if i < chunks.len() - 1 {
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let markdown_status = markdown_resp.status();
|
for attachment in &attachments {
|
||||||
let markdown_err = markdown_resp.text().await.unwrap_or_default();
|
self.send_attachment(chat_id, attachment).await?;
|
||||||
tracing::warn!(
|
|
||||||
status = ?markdown_status,
|
|
||||||
"Telegram sendMessage with Markdown failed; retrying without parse_mode"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retry without parse_mode as a compatibility fallback.
|
|
||||||
let plain_body = serde_json::json!({
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"text": text,
|
|
||||||
});
|
|
||||||
let plain_resp = self
|
|
||||||
.client
|
|
||||||
.post(self.api_url("sendMessage"))
|
|
||||||
.json(&plain_body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !plain_resp.status().is_success() {
|
|
||||||
let plain_status = plain_resp.status();
|
|
||||||
let plain_err = plain_resp.text().await.unwrap_or_default();
|
|
||||||
anyhow::bail!(
|
|
||||||
"Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
|
|
||||||
markdown_status,
|
|
||||||
markdown_err,
|
|
||||||
plain_status,
|
|
||||||
plain_err
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay between chunks to avoid rate limiting
|
return Ok(());
|
||||||
if i < chunks.len() - 1 {
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
if let Some(attachment) = parse_path_only_attachment(message) {
|
||||||
|
self.send_attachment(chat_id, &attachment).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.send_text_chunks(message, chat_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||||
|
|
@ -533,59 +848,13 @@ impl Channel for TelegramChannel {
|
||||||
offset = uid + 1;
|
offset = uid + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(message) = update.get("message") else {
|
let Some(msg) = self.parse_update_message(update) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let username_opt = message
|
|
||||||
.get("from")
|
|
||||||
.and_then(|f| f.get("username"))
|
|
||||||
.and_then(|u| u.as_str());
|
|
||||||
let username = username_opt.unwrap_or("unknown");
|
|
||||||
|
|
||||||
let user_id = message
|
|
||||||
.get("from")
|
|
||||||
.and_then(|f| f.get("id"))
|
|
||||||
.and_then(serde_json::Value::as_i64);
|
|
||||||
let user_id_str = user_id.map(|id| id.to_string());
|
|
||||||
|
|
||||||
let mut identities = vec![username];
|
|
||||||
if let Some(ref id) = user_id_str {
|
|
||||||
identities.push(id.as_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.is_any_user_allowed(identities.iter().copied()) {
|
|
||||||
tracing::warn!(
|
|
||||||
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
|
|
||||||
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
|
|
||||||
user_id_str.as_deref().unwrap_or("unknown")
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let chat_id = message
|
|
||||||
.get("chat")
|
|
||||||
.and_then(|c| c.get("id"))
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.map(|id| id.to_string());
|
|
||||||
|
|
||||||
let Some(chat_id) = chat_id else {
|
|
||||||
tracing::warn!("Telegram: missing chat_id in message, skipping");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let message_id = message
|
|
||||||
.get("message_id")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
// Send "typing" indicator immediately when we receive a message
|
// Send "typing" indicator immediately when we receive a message
|
||||||
let typing_body = serde_json::json!({
|
let typing_body = serde_json::json!({
|
||||||
"chat_id": &chat_id,
|
"chat_id": &msg.reply_target,
|
||||||
"action": "typing"
|
"action": "typing"
|
||||||
});
|
});
|
||||||
let _ = self
|
let _ = self
|
||||||
|
|
@ -595,18 +864,6 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||||
.send()
|
.send()
|
||||||
.await; // Ignore errors for typing indicator
|
.await; // Ignore errors for typing indicator
|
||||||
|
|
||||||
let msg = ChannelMessage {
|
|
||||||
id: format!("telegram_{chat_id}_{message_id}"),
|
|
||||||
sender: username.to_string(),
|
|
||||||
reply_to: chat_id.clone(),
|
|
||||||
content: text.to_string(),
|
|
||||||
channel: "telegram".to_string(),
|
|
||||||
timestamp: std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if tx.send(msg).await.is_err() {
|
if tx.send(msg).await.is_err() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -717,6 +974,107 @@ mod tests {
|
||||||
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_attachment_markers_extracts_multiple_types() {
|
||||||
|
let message = "Here are files [IMAGE:/tmp/a.png] and [DOCUMENT:https://example.com/a.pdf]";
|
||||||
|
let (cleaned, attachments) = parse_attachment_markers(message);
|
||||||
|
|
||||||
|
assert_eq!(cleaned, "Here are files and");
|
||||||
|
assert_eq!(attachments.len(), 2);
|
||||||
|
assert_eq!(attachments[0].kind, TelegramAttachmentKind::Image);
|
||||||
|
assert_eq!(attachments[0].target, "/tmp/a.png");
|
||||||
|
assert_eq!(attachments[1].kind, TelegramAttachmentKind::Document);
|
||||||
|
assert_eq!(attachments[1].target, "https://example.com/a.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_attachment_markers_keeps_invalid_markers_in_text() {
|
||||||
|
let message = "Report [UNKNOWN:/tmp/a.bin]";
|
||||||
|
let (cleaned, attachments) = parse_attachment_markers(message);
|
||||||
|
|
||||||
|
assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]");
|
||||||
|
assert!(attachments.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_path_only_attachment_detects_existing_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let image_path = dir.path().join("snap.png");
|
||||||
|
std::fs::write(&image_path, b"fake-png").unwrap();
|
||||||
|
|
||||||
|
let parsed = parse_path_only_attachment(image_path.to_string_lossy().as_ref())
|
||||||
|
.expect("expected attachment");
|
||||||
|
|
||||||
|
assert_eq!(parsed.kind, TelegramAttachmentKind::Image);
|
||||||
|
assert_eq!(parsed.target, image_path.to_string_lossy());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_path_only_attachment_rejects_sentence_text() {
|
||||||
|
assert!(parse_path_only_attachment("Screenshot saved to /tmp/snap.png").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infer_attachment_kind_from_target_detects_document_extension() {
|
||||||
|
assert_eq!(
|
||||||
|
infer_attachment_kind_from_target("https://example.com/files/specs.pdf?download=1"),
|
||||||
|
Some(TelegramAttachmentKind::Document)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_update_message_uses_chat_id_as_reply_target() {
|
||||||
|
let ch = TelegramChannel::new("token".into(), vec!["*".into()]);
|
||||||
|
let update = serde_json::json!({
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"message_id": 33,
|
||||||
|
"text": "hello",
|
||||||
|
"from": {
|
||||||
|
"id": 555,
|
||||||
|
"username": "alice"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": -100200300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = ch
|
||||||
|
.parse_update_message(&update)
|
||||||
|
.expect("message should parse");
|
||||||
|
|
||||||
|
assert_eq!(msg.sender, "alice");
|
||||||
|
assert_eq!(msg.reply_target, "-100200300");
|
||||||
|
assert_eq!(msg.content, "hello");
|
||||||
|
assert_eq!(msg.id, "telegram_-100200300_33");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_update_message_allows_numeric_id_without_username() {
|
||||||
|
let ch = TelegramChannel::new("token".into(), vec!["555".into()]);
|
||||||
|
let update = serde_json::json!({
|
||||||
|
"update_id": 2,
|
||||||
|
"message": {
|
||||||
|
"message_id": 9,
|
||||||
|
"text": "ping",
|
||||||
|
"from": {
|
||||||
|
"id": 555
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = ch
|
||||||
|
.parse_update_message(&update)
|
||||||
|
.expect("numeric allowlist should pass");
|
||||||
|
|
||||||
|
assert_eq!(msg.sender, "555");
|
||||||
|
assert_eq!(msg.reply_target, "12345");
|
||||||
|
}
|
||||||
|
|
||||||
// ── File sending API URL tests ──────────────────────────────────
|
// ── File sending API URL tests ──────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ use async_trait::async_trait;
|
||||||
pub struct ChannelMessage {
|
pub struct ChannelMessage {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub sender: String,
|
pub sender: String,
|
||||||
/// Channel-specific reply address (e.g. Telegram chat_id, Discord channel_id, Slack channel).
|
pub reply_target: String,
|
||||||
/// Used by `Channel::send()` to route the reply to the correct destination.
|
|
||||||
pub reply_to: String,
|
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub channel: String,
|
pub channel: String,
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
|
|
@ -65,7 +63,7 @@ mod tests {
|
||||||
tx.send(ChannelMessage {
|
tx.send(ChannelMessage {
|
||||||
id: "1".into(),
|
id: "1".into(),
|
||||||
sender: "tester".into(),
|
sender: "tester".into(),
|
||||||
reply_to: "tester".into(),
|
reply_target: "tester".into(),
|
||||||
content: "hello".into(),
|
content: "hello".into(),
|
||||||
channel: "dummy".into(),
|
channel: "dummy".into(),
|
||||||
timestamp: 123,
|
timestamp: 123,
|
||||||
|
|
@ -80,7 +78,7 @@ mod tests {
|
||||||
let message = ChannelMessage {
|
let message = ChannelMessage {
|
||||||
id: "42".into(),
|
id: "42".into(),
|
||||||
sender: "alice".into(),
|
sender: "alice".into(),
|
||||||
reply_to: "alice".into(),
|
reply_target: "alice".into(),
|
||||||
content: "ping".into(),
|
content: "ping".into(),
|
||||||
channel: "dummy".into(),
|
channel: "dummy".into(),
|
||||||
timestamp: 999,
|
timestamp: 999,
|
||||||
|
|
@ -89,6 +87,7 @@ mod tests {
|
||||||
let cloned = message.clone();
|
let cloned = message.clone();
|
||||||
assert_eq!(cloned.id, "42");
|
assert_eq!(cloned.id, "42");
|
||||||
assert_eq!(cloned.sender, "alice");
|
assert_eq!(cloned.sender, "alice");
|
||||||
|
assert_eq!(cloned.reply_target, "alice");
|
||||||
assert_eq!(cloned.content, "ping");
|
assert_eq!(cloned.content, "ping");
|
||||||
assert_eq!(cloned.channel, "dummy");
|
assert_eq!(cloned.channel, "dummy");
|
||||||
assert_eq!(cloned.timestamp, 999);
|
assert_eq!(cloned.timestamp, 999);
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,8 @@ impl WhatsAppChannel {
|
||||||
|
|
||||||
messages.push(ChannelMessage {
|
messages.push(ChannelMessage {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
sender: normalized_from.clone(),
|
reply_target: normalized_from.clone(),
|
||||||
reply_to: normalized_from,
|
sender: normalized_from,
|
||||||
content,
|
content,
|
||||||
channel: "whatsapp".to_string(),
|
channel: "whatsapp".to_string(),
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|
|
||||||
|
|
@ -862,7 +862,7 @@ mod tests {
|
||||||
let msg = ChannelMessage {
|
let msg = ChannelMessage {
|
||||||
id: "wamid-123".into(),
|
id: "wamid-123".into(),
|
||||||
sender: "+1234567890".into(),
|
sender: "+1234567890".into(),
|
||||||
reply_to: "+1234567890".into(),
|
reply_target: "+1234567890".into(),
|
||||||
content: "hello".into(),
|
content: "hello".into(),
|
||||||
channel: "whatsapp".into(),
|
channel: "whatsapp".into(),
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue