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 tokio::io::{self, AsyncBufReadExt, BufReader};
use uuid::Uuid;
@ -18,8 +18,8 @@ impl Channel for CliChannel {
"cli"
}
async fn send(&self, message: &str, _recipient: &str) -> anyhow::Result<()> {
println!("{message}");
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
println!("{}", message.content);
Ok(())
}
@ -69,14 +69,26 @@ mod tests {
#[tokio::test]
async fn cli_channel_send_does_not_panic() {
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());
}
#[tokio::test]
async fn cli_channel_send_empty_message() {
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());
}

View file

@ -1,4 +1,4 @@
use super::traits::{Channel, ChannelMessage};
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
@ -84,20 +84,22 @@ impl Channel for DingTalkChannel {
"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 webhook_url = webhooks.get(recipient).ok_or_else(|| {
let webhook_url = webhooks.get(&message.recipient).ok_or_else(|| {
anyhow::anyhow!(
"No session webhook found for chat {recipient}. \
The user must send a message first to establish a session."
"No session webhook found for chat {}. \
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!({
"msgtype": "markdown",
"markdown": {
"title": "ZeroClaw",
"text": message,
"title": title,
"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 futures_util::{SinkExt, StreamExt};
use serde_json::json;
@ -185,11 +185,15 @@ impl Channel for DiscordChannel {
"discord"
}
async fn send(&self, message: &str, channel_id: &str) -> anyhow::Result<()> {
let chunks = split_message_for_discord(message);
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let chunks = split_message_for_discord(&message.content);
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 resp = self

View file

@ -25,7 +25,7 @@ use tokio::time::{interval, sleep};
use tracing::{error, info, warn};
use uuid::Uuid;
use super::traits::{Channel, ChannelMessage};
use super::traits::{Channel, ChannelMessage, SendMessage};
/// Email channel configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -375,26 +375,29 @@ impl Channel for EmailChannel {
"email"
}
async fn send(&self, message: &str, recipient: &str) -> Result<()> {
let (subject, body) = if message.starts_with("Subject: ") {
if let Some(pos) = message.find('\n') {
(&message[9..pos], message[pos + 1..].trim())
async fn send(&self, message: &SendMessage) -> Result<()> {
// Use explicit subject if provided, otherwise fall back to legacy parsing or default
let (subject, body) = if let Some(ref subj) = message.subject {
(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 {
("ZeroClaw Message", message)
("ZeroClaw Message", message.content.as_str())
}
} else {
("ZeroClaw Message", message)
("ZeroClaw Message", message.content.as_str())
};
let email = Message::builder()
.from(self.config.from_address.parse()?)
.to(recipient.parse()?)
.to(message.recipient.parse()?)
.subject(subject)
.singlepart(SinglePart::plain(body.to_string()))?;
let transport = self.create_smtp_transport()?;
transport.send(&email)?;
info!("Email sent to {}", recipient);
info!("Email sent to {}", message.recipient);
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 directories::UserDirs;
use rusqlite::{Connection, OpenFlags};
@ -95,9 +95,9 @@ impl Channel for IMessageChannel {
"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
if !is_valid_imessage_target(target) {
if !is_valid_imessage_target(&message.recipient) {
anyhow::bail!(
"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
// See: CWE-78 (OS Command Injection)
let escaped_msg = escape_applescript(message);
let escaped_target = escape_applescript(target);
let escaped_msg = escape_applescript(&message.content);
let escaped_target = escape_applescript(&message.recipient);
let script = format!(
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 std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
@ -345,7 +345,7 @@ impl Channel for IrcChannel {
"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 writer = guard
.as_mut()
@ -353,12 +353,12 @@ impl Channel for IrcChannel {
// Calculate safe payload size:
// 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 chunks = split_message(message, max_payload);
let chunks = split_message(&message.content, max_payload);
for chunk in chunks {
Self::send_raw(writer, &format!("PRIVMSG {recipient} :{chunk}")).await?;
Self::send_raw(writer, &format!("PRIVMSG {} :{chunk}", message.recipient)).await?;
}
Ok(())

View file

@ -1,4 +1,4 @@
use super::traits::{Channel, ChannelMessage};
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use prost::Message as ProstMessage;
@ -630,13 +630,13 @@ impl Channel for LarkChannel {
"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 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!({
"receive_id": recipient,
"receive_id": message.recipient,
"msg_type": "text",
"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 reqwest::Client;
use serde::Deserialize;
@ -117,7 +117,7 @@ impl Channel for MatrixChannel {
"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 url = format!(
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
@ -126,7 +126,7 @@ impl Channel for MatrixChannel {
let body = serde_json::json!({
"msgtype": "m.text",
"body": message
"body": message.content
});
let resp = self

View file

@ -25,7 +25,7 @@ pub use qq::QQChannel;
pub use signal::SignalChannel;
pub use slack::SlackChannel;
pub use telegram::TelegramChannel;
pub use traits::Channel;
pub use traits::{Channel, SendMessage};
pub use whatsapp::WhatsAppChannel;
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)
);
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());
}
}
@ -247,7 +250,7 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
);
if let Some(channel) = target_channel.as_ref() {
let _ = channel
.send(&format!("⚠️ Error: {e}"), &msg.reply_target)
.send(&SendMessage::new(format!("⚠️ Error: {e}"), &msg.reply_target))
.await;
}
}
@ -263,10 +266,10 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
);
if let Some(channel) = target_channel.as_ref() {
let _ = channel
.send(
.send(&SendMessage::new(
"⚠️ Request timed out while waiting for the model. Please try again.",
&msg.reply_target,
)
))
.await;
}
}
@ -1310,11 +1313,11 @@ mod tests {
"test-channel"
}
async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> {
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
self.sent_messages
.lock()
.await
.push(format!("{recipient}:{message}"));
.push(format!("{}:{}", message.recipient, message.content));
Ok(())
}
@ -2089,7 +2092,7 @@ mod tests {
self.name
}
async fn send(&self, _message: &str, _recipient: &str) -> anyhow::Result<()> {
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
Ok(())
}

View file

@ -1,4 +1,4 @@
use super::traits::{Channel, ChannelMessage};
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
/// Slack channel — polls conversations.history via Web API
@ -51,10 +51,10 @@ impl Channel for SlackChannel {
"slack"
}
async fn send(&self, message: &str, channel: &str) -> anyhow::Result<()> {
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let body = serde_json::json!({
"channel": channel,
"text": message
"channel": message.recipient,
"text": message.content
});
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::security::pairing::PairingGuard;
use anyhow::Context;
@ -1049,28 +1049,29 @@ impl Channel for TelegramChannel {
"telegram"
}
async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
let (text_without_markers, attachments) = parse_attachment_markers(message);
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let (text_without_markers, attachments) = parse_attachment_markers(&message.content);
if !attachments.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?;
}
for attachment in &attachments {
self.send_attachment(chat_id, attachment).await?;
self.send_attachment(&message.recipient, attachment).await?;
}
return Ok(());
}
if let Some(attachment) = parse_path_only_attachment(message) {
self.send_attachment(chat_id, &attachment).await?;
if let Some(attachment) = parse_path_only_attachment(&message.content) {
self.send_attachment(&message.recipient, &attachment).await?;
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<()> {

View file

@ -11,6 +11,58 @@ pub struct ChannelMessage {
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
#[async_trait]
pub trait Channel: Send + Sync {
@ -18,7 +70,7 @@ pub trait Channel: Send + Sync {
fn name(&self) -> &str;
/// 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)
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()>;
@ -52,7 +104,7 @@ mod tests {
"dummy"
}
async fn send(&self, _message: &str, _recipient: &str) -> anyhow::Result<()> {
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
Ok(())
}
@ -100,7 +152,7 @@ mod tests {
assert!(channel.health_check().await);
assert!(channel.start_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]

View file

@ -1,4 +1,4 @@
use super::traits::{Channel, ChannelMessage};
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use uuid::Uuid;
@ -139,7 +139,7 @@ impl Channel for WhatsAppChannel {
"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
let url = format!(
"https://graph.facebook.com/v18.0/{}/messages",
@ -147,7 +147,10 @@ impl Channel for WhatsAppChannel {
);
// 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!({
"messaging_product": "whatsapp",
@ -156,7 +159,7 @@ impl Channel for WhatsAppChannel {
"type": "text",
"text": {
"preview_url": false,
"body": message
"body": message.content
}
});

View file

@ -7,7 +7,7 @@
//! - Request timeouts (30s) to prevent slow-loris attacks
//! - Header sanitization (handled by axum/hyper)
use crate::channels::{Channel, WhatsAppChannel};
use crate::channels::{Channel, SendMessage, WhatsAppChannel};
use crate::config::Config;
use crate::memory::{self, Memory, MemoryCategory};
use crate::providers::{self, Provider};
@ -704,17 +704,17 @@ async fn handle_whatsapp_message(
{
Ok(response) => {
// 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}");
}
}
Err(e) => {
tracing::error!("LLM error for WhatsApp message: {e:#}");
let _ = wa
.send(
.send(&SendMessage::new(
"Sorry, I couldn't process your message right now.",
&msg.reply_target,
)
))
.await;
}
}