refactor(channel): accept SendMessage struct in Channel::send()
Refactor the Channel trait to accept a SendMessage struct instead of separate message and recipient string parameters. This enables passing additional metadata like email subjects. Changes: - Add SendMessage struct with content, recipient, and optional subject - Update Channel::send() signature to accept &SendMessage - Update all 12 channel implementations - Update call sites in channels/mod.rs and gateway/mod.rs Subject field usage: - Email: uses subject for email subject line - DingTalk: uses subject as markdown message title - All others: ignore subject (no native platform support)
This commit is contained in:
parent
b8ed42edbb
commit
dbebd48dfe
14 changed files with 153 additions and 73 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::io::{self, AsyncBufReadExt, BufReader};
|
use tokio::io::{self, AsyncBufReadExt, BufReader};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -18,8 +18,8 @@ impl Channel for CliChannel {
|
||||||
"cli"
|
"cli"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, _recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
println!("{message}");
|
println!("{}", message.content);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,14 +69,26 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cli_channel_send_does_not_panic() {
|
async fn cli_channel_send_does_not_panic() {
|
||||||
let ch = CliChannel::new();
|
let ch = CliChannel::new();
|
||||||
let result = ch.send("hello", "user").await;
|
let result = ch
|
||||||
|
.send(&SendMessage {
|
||||||
|
content: "hello".into(),
|
||||||
|
recipient: "user".into(),
|
||||||
|
subject: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cli_channel_send_empty_message() {
|
async fn cli_channel_send_empty_message() {
|
||||||
let ch = CliChannel::new();
|
let ch = CliChannel::new();
|
||||||
let result = ch.send("", "").await;
|
let result = ch
|
||||||
|
.send(&SendMessage {
|
||||||
|
content: String::new(),
|
||||||
|
recipient: String::new(),
|
||||||
|
subject: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -84,20 +84,22 @@ impl Channel for DingTalkChannel {
|
||||||
"dingtalk"
|
"dingtalk"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let webhooks = self.session_webhooks.read().await;
|
let webhooks = self.session_webhooks.read().await;
|
||||||
let webhook_url = webhooks.get(recipient).ok_or_else(|| {
|
let webhook_url = webhooks.get(&message.recipient).ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"No session webhook found for chat {recipient}. \
|
"No session webhook found for chat {}. \
|
||||||
The user must send a message first to establish a session."
|
The user must send a message first to establish a session.",
|
||||||
|
message.recipient
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let title = message.subject.as_deref().unwrap_or("ZeroClaw");
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"msgtype": "markdown",
|
"msgtype": "markdown",
|
||||||
"markdown": {
|
"markdown": {
|
||||||
"title": "ZeroClaw",
|
"title": title,
|
||||||
"text": message,
|
"text": message.content,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
@ -185,11 +185,15 @@ impl Channel for DiscordChannel {
|
||||||
"discord"
|
"discord"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, channel_id: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let chunks = split_message_for_discord(message);
|
let chunks = split_message_for_discord(&message.content);
|
||||||
|
|
||||||
for (i, chunk) in chunks.iter().enumerate() {
|
for (i, chunk) in chunks.iter().enumerate() {
|
||||||
let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages");
|
let url = format!(
|
||||||
|
"https://discord.com/api/v10/channels/{}/messages",
|
||||||
|
message.recipient
|
||||||
|
);
|
||||||
|
|
||||||
let body = json!({ "content": chunk });
|
let body = json!({ "content": chunk });
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ use tokio::time::{interval, sleep};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
|
|
||||||
/// Email channel configuration
|
/// Email channel configuration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -375,26 +375,29 @@ impl Channel for EmailChannel {
|
||||||
"email"
|
"email"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, recipient: &str) -> Result<()> {
|
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||||
let (subject, body) = if message.starts_with("Subject: ") {
|
// Use explicit subject if provided, otherwise fall back to legacy parsing or default
|
||||||
if let Some(pos) = message.find('\n') {
|
let (subject, body) = if let Some(ref subj) = message.subject {
|
||||||
(&message[9..pos], message[pos + 1..].trim())
|
(subj.as_str(), message.content.as_str())
|
||||||
|
} else if message.content.starts_with("Subject: ") {
|
||||||
|
if let Some(pos) = message.content.find('\n') {
|
||||||
|
(&message.content[9..pos], message.content[pos + 1..].trim())
|
||||||
} else {
|
} else {
|
||||||
("ZeroClaw Message", message)
|
("ZeroClaw Message", message.content.as_str())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
("ZeroClaw Message", message)
|
("ZeroClaw Message", message.content.as_str())
|
||||||
};
|
};
|
||||||
|
|
||||||
let email = Message::builder()
|
let email = Message::builder()
|
||||||
.from(self.config.from_address.parse()?)
|
.from(self.config.from_address.parse()?)
|
||||||
.to(recipient.parse()?)
|
.to(message.recipient.parse()?)
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.singlepart(SinglePart::plain(body.to_string()))?;
|
.singlepart(SinglePart::plain(body.to_string()))?;
|
||||||
|
|
||||||
let transport = self.create_smtp_transport()?;
|
let transport = self.create_smtp_transport()?;
|
||||||
transport.send(&email)?;
|
transport.send(&email)?;
|
||||||
info!("Email sent to {}", recipient);
|
info!("Email sent to {}", message.recipient);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::channels::traits::{Channel, ChannelMessage};
|
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use directories::UserDirs;
|
use directories::UserDirs;
|
||||||
use rusqlite::{Connection, OpenFlags};
|
use rusqlite::{Connection, OpenFlags};
|
||||||
|
|
@ -95,9 +95,9 @@ impl Channel for IMessageChannel {
|
||||||
"imessage"
|
"imessage"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, target: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
// Defense-in-depth: validate target format before any interpolation
|
// Defense-in-depth: validate target format before any interpolation
|
||||||
if !is_valid_imessage_target(target) {
|
if !is_valid_imessage_target(&message.recipient) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Invalid iMessage target: must be a phone number (+1234567890) or email (user@example.com)"
|
"Invalid iMessage target: must be a phone number (+1234567890) or email (user@example.com)"
|
||||||
);
|
);
|
||||||
|
|
@ -105,8 +105,8 @@ impl Channel for IMessageChannel {
|
||||||
|
|
||||||
// SECURITY: Escape both message AND target to prevent AppleScript injection
|
// SECURITY: Escape both message AND target to prevent AppleScript injection
|
||||||
// See: CWE-78 (OS Command Injection)
|
// See: CWE-78 (OS Command Injection)
|
||||||
let escaped_msg = escape_applescript(message);
|
let escaped_msg = escape_applescript(&message.content);
|
||||||
let escaped_target = escape_applescript(target);
|
let escaped_target = escape_applescript(&message.recipient);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"tell application "Messages"
|
r#"tell application "Messages"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::channels::traits::{Channel, ChannelMessage};
|
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -345,7 +345,7 @@ impl Channel for IrcChannel {
|
||||||
"irc"
|
"irc"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let mut guard = self.writer.lock().await;
|
let mut guard = self.writer.lock().await;
|
||||||
let writer = guard
|
let writer = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
|
|
@ -353,12 +353,12 @@ impl Channel for IrcChannel {
|
||||||
|
|
||||||
// Calculate safe payload size:
|
// Calculate safe payload size:
|
||||||
// 512 - sender prefix (~64 bytes for :nick!user@host) - "PRIVMSG " - target - " :" - "\r\n"
|
// 512 - sender prefix (~64 bytes for :nick!user@host) - "PRIVMSG " - target - " :" - "\r\n"
|
||||||
let overhead = SENDER_PREFIX_RESERVE + 10 + recipient.len() + 2;
|
let overhead = SENDER_PREFIX_RESERVE + 10 + message.recipient.len() + 2;
|
||||||
let max_payload = 512_usize.saturating_sub(overhead);
|
let max_payload = 512_usize.saturating_sub(overhead);
|
||||||
let chunks = split_message(message, max_payload);
|
let chunks = split_message(&message.content, max_payload);
|
||||||
|
|
||||||
for chunk in chunks {
|
for chunk in chunks {
|
||||||
Self::send_raw(writer, &format!("PRIVMSG {recipient} :{chunk}")).await?;
|
Self::send_raw(writer, &format!("PRIVMSG {} :{chunk}", message.recipient)).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
|
|
@ -630,13 +630,13 @@ impl Channel for LarkChannel {
|
||||||
"lark"
|
"lark"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let token = self.get_tenant_access_token().await?;
|
let token = self.get_tenant_access_token().await?;
|
||||||
let url = self.send_message_url();
|
let url = self.send_message_url();
|
||||||
|
|
||||||
let content = serde_json::json!({ "text": message }).to_string();
|
let content = serde_json::json!({ "text": message.content }).to_string();
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"receive_id": recipient,
|
"receive_id": message.recipient,
|
||||||
"msg_type": "text",
|
"msg_type": "text",
|
||||||
"content": content,
|
"content": content,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::channels::traits::{Channel, ChannelMessage};
|
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
@ -117,7 +117,7 @@ impl Channel for MatrixChannel {
|
||||||
"matrix"
|
"matrix"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, _target: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let txn_id = format!("zc_{}", chrono::Utc::now().timestamp_millis());
|
let txn_id = format!("zc_{}", chrono::Utc::now().timestamp_millis());
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
||||||
|
|
@ -126,7 +126,7 @@ impl Channel for MatrixChannel {
|
||||||
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"msgtype": "m.text",
|
"msgtype": "m.text",
|
||||||
"body": message
|
"body": message.content
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ pub use qq::QQChannel;
|
||||||
pub use signal::SignalChannel;
|
pub use signal::SignalChannel;
|
||||||
pub use slack::SlackChannel;
|
pub use slack::SlackChannel;
|
||||||
pub use telegram::TelegramChannel;
|
pub use telegram::TelegramChannel;
|
||||||
pub use traits::Channel;
|
pub use traits::{Channel, SendMessage};
|
||||||
pub use whatsapp::WhatsAppChannel;
|
pub use whatsapp::WhatsAppChannel;
|
||||||
|
|
||||||
use crate::agent::loop_::{build_tool_instructions, run_tool_call_loop};
|
use crate::agent::loop_::{build_tool_instructions, run_tool_call_loop};
|
||||||
|
|
@ -235,7 +235,10 @@ 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_target).await {
|
if let Err(e) = channel
|
||||||
|
.send(&SendMessage::new(response, &msg.reply_target))
|
||||||
|
.await
|
||||||
|
{
|
||||||
eprintln!(" ❌ Failed to reply on {}: {e}", channel.name());
|
eprintln!(" ❌ Failed to reply on {}: {e}", channel.name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +250,7 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
);
|
);
|
||||||
if let Some(channel) = target_channel.as_ref() {
|
if let Some(channel) = target_channel.as_ref() {
|
||||||
let _ = channel
|
let _ = channel
|
||||||
.send(&format!("⚠️ Error: {e}"), &msg.reply_target)
|
.send(&SendMessage::new(format!("⚠️ Error: {e}"), &msg.reply_target))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -263,10 +266,10 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
||||||
);
|
);
|
||||||
if let Some(channel) = target_channel.as_ref() {
|
if let Some(channel) = target_channel.as_ref() {
|
||||||
let _ = channel
|
let _ = channel
|
||||||
.send(
|
.send(&SendMessage::new(
|
||||||
"⚠️ Request timed out while waiting for the model. Please try again.",
|
"⚠️ Request timed out while waiting for the model. Please try again.",
|
||||||
&msg.reply_target,
|
&msg.reply_target,
|
||||||
)
|
))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1310,11 +1313,11 @@ mod tests {
|
||||||
"test-channel"
|
"test-channel"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
self.sent_messages
|
self.sent_messages
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.push(format!("{recipient}:{message}"));
|
.push(format!("{}:{}", message.recipient, message.content));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2089,7 +2092,7 @@ mod tests {
|
||||||
self.name
|
self.name
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, _message: &str, _recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
/// Slack channel — polls conversations.history via Web API
|
/// Slack channel — polls conversations.history via Web API
|
||||||
|
|
@ -51,10 +51,10 @@ impl Channel for SlackChannel {
|
||||||
"slack"
|
"slack"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, channel: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"channel": channel,
|
"channel": message.recipient,
|
||||||
"text": message
|
"text": message.content
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::security::pairing::PairingGuard;
|
use crate::security::pairing::PairingGuard;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
@ -1049,28 +1049,29 @@ impl Channel for TelegramChannel {
|
||||||
"telegram"
|
"telegram"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
let (text_without_markers, attachments) = parse_attachment_markers(message);
|
let (text_without_markers, attachments) = parse_attachment_markers(&message.content);
|
||||||
|
|
||||||
if !attachments.is_empty() {
|
if !attachments.is_empty() {
|
||||||
if !text_without_markers.is_empty() {
|
if !text_without_markers.is_empty() {
|
||||||
self.send_text_chunks(&text_without_markers, chat_id)
|
self.send_text_chunks(&text_without_markers, &message.recipient)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for attachment in &attachments {
|
for attachment in &attachments {
|
||||||
self.send_attachment(chat_id, attachment).await?;
|
self.send_attachment(&message.recipient, attachment).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(attachment) = parse_path_only_attachment(message) {
|
if let Some(attachment) = parse_path_only_attachment(&message.content) {
|
||||||
self.send_attachment(chat_id, &attachment).await?;
|
self.send_attachment(&message.recipient, &attachment).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.send_text_chunks(message, chat_id).await
|
self.send_text_chunks(&message.content, &message.recipient)
|
||||||
|
.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<()> {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,58 @@ pub struct ChannelMessage {
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Message to send through a channel
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SendMessage {
|
||||||
|
pub content: String,
|
||||||
|
pub recipient: String,
|
||||||
|
pub subject: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendMessage {
|
||||||
|
/// Create a new message with content and recipient
|
||||||
|
pub fn new(content: impl Into<String>, recipient: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
content: content.into(),
|
||||||
|
recipient: recipient.into(),
|
||||||
|
subject: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new message with content, recipient, and subject
|
||||||
|
pub fn with_subject(
|
||||||
|
content: impl Into<String>,
|
||||||
|
recipient: impl Into<String>,
|
||||||
|
subject: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
content: content.into(),
|
||||||
|
recipient: recipient.into(),
|
||||||
|
subject: Some(subject.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for SendMessage {
|
||||||
|
fn from(content: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
content: content.to_string(),
|
||||||
|
recipient: String::new(),
|
||||||
|
subject: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(String, String)> for SendMessage {
|
||||||
|
fn from(value: (String, String)) -> Self {
|
||||||
|
Self {
|
||||||
|
content: value.0,
|
||||||
|
recipient: value.1,
|
||||||
|
subject: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Core channel trait — implement for any messaging platform
|
/// Core channel trait — implement for any messaging platform
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Channel: Send + Sync {
|
pub trait Channel: Send + Sync {
|
||||||
|
|
@ -18,7 +70,7 @@ pub trait Channel: Send + Sync {
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
/// Send a message through this channel
|
/// Send a message through this channel
|
||||||
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()>;
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()>;
|
||||||
|
|
||||||
/// Start listening for incoming messages (long-running)
|
/// Start listening for incoming messages (long-running)
|
||||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()>;
|
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()>;
|
||||||
|
|
@ -52,7 +104,7 @@ mod tests {
|
||||||
"dummy"
|
"dummy"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, _message: &str, _recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +152,7 @@ mod tests {
|
||||||
assert!(channel.health_check().await);
|
assert!(channel.health_check().await);
|
||||||
assert!(channel.start_typing("bob").await.is_ok());
|
assert!(channel.start_typing("bob").await.is_ok());
|
||||||
assert!(channel.stop_typing("bob").await.is_ok());
|
assert!(channel.stop_typing("bob").await.is_ok());
|
||||||
assert!(channel.send("hello", "bob").await.is_ok());
|
assert!(channel.send(&SendMessage::new("hello", "bob")).await.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -139,7 +139,7 @@ impl Channel for WhatsAppChannel {
|
||||||
"whatsapp"
|
"whatsapp"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
// WhatsApp Cloud API: POST to /v18.0/{phone_number_id}/messages
|
// WhatsApp Cloud API: POST to /v18.0/{phone_number_id}/messages
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://graph.facebook.com/v18.0/{}/messages",
|
"https://graph.facebook.com/v18.0/{}/messages",
|
||||||
|
|
@ -147,7 +147,10 @@ impl Channel for WhatsAppChannel {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Normalize recipient (remove leading + if present for API)
|
// Normalize recipient (remove leading + if present for API)
|
||||||
let to = recipient.strip_prefix('+').unwrap_or(recipient);
|
let to = message
|
||||||
|
.recipient
|
||||||
|
.strip_prefix('+')
|
||||||
|
.unwrap_or(&message.recipient);
|
||||||
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"messaging_product": "whatsapp",
|
"messaging_product": "whatsapp",
|
||||||
|
|
@ -156,7 +159,7 @@ impl Channel for WhatsAppChannel {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": {
|
"text": {
|
||||||
"preview_url": false,
|
"preview_url": false,
|
||||||
"body": message
|
"body": message.content
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
//! - Request timeouts (30s) to prevent slow-loris attacks
|
//! - Request timeouts (30s) to prevent slow-loris attacks
|
||||||
//! - Header sanitization (handled by axum/hyper)
|
//! - Header sanitization (handled by axum/hyper)
|
||||||
|
|
||||||
use crate::channels::{Channel, WhatsAppChannel};
|
use crate::channels::{Channel, SendMessage, WhatsAppChannel};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::memory::{self, Memory, MemoryCategory};
|
use crate::memory::{self, Memory, MemoryCategory};
|
||||||
use crate::providers::{self, Provider};
|
use crate::providers::{self, Provider};
|
||||||
|
|
@ -704,17 +704,17 @@ async fn handle_whatsapp_message(
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
// Send reply via WhatsApp
|
// Send reply via WhatsApp
|
||||||
if let Err(e) = wa.send(&response, &msg.reply_target).await {
|
if let Err(e) = wa.send(&SendMessage::new(response, &msg.reply_target)).await {
|
||||||
tracing::error!("Failed to send WhatsApp reply: {e}");
|
tracing::error!("Failed to send WhatsApp reply: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("LLM error for WhatsApp message: {e:#}");
|
tracing::error!("LLM error for WhatsApp message: {e:#}");
|
||||||
let _ = wa
|
let _ = wa
|
||||||
.send(
|
.send(&SendMessage::new(
|
||||||
"Sorry, I couldn't process your message right now.",
|
"Sorry, I couldn't process your message right now.",
|
||||||
&msg.reply_target,
|
&msg.reply_target,
|
||||||
)
|
))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue