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:
Kieran 2026-02-17 14:37:03 +00:00 committed by Chummy
parent b8ed42edbb
commit dbebd48dfe
14 changed files with 153 additions and 73 deletions

View file

@ -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());
} }

View file

@ -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,
} }
}); });

View file

@ -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

View file

@ -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(())
} }

View file

@ -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"

View file

@ -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(())

View file

@ -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,
}); });

View file

@ -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

View file

@ -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(())
} }

View file

@ -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

View file

@ -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<()> {

View file

@ -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]

View file

@ -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
} }
}); });

View file

@ -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;
} }
} }