use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use crate::channels::whatsapp::WhatsAppState;
use crate::config::Config;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use wacore_binary::jid::Jid;
pub struct WhatsAppSendTool {
whatsapp_state: Arc<WhatsAppState>,
config_rx: tokio::sync::watch::Receiver<Config>,
}
impl WhatsAppSendTool {
pub fn new(
whatsapp_state: Arc<WhatsAppState>,
config_rx: tokio::sync::watch::Receiver<Config>,
) -> Self {
Self {
whatsapp_state,
config_rx,
}
}
}
#[allow(clippy::result_large_err)]
pub(crate) fn get_str<'a>(input: &'a Value, key: &str) -> std::result::Result<&'a str, ToolResult> {
match input.get(key).and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => Ok(s),
_ => Err(ToolResult::error(format!(
"Missing required parameter '{key}'."
))),
}
}
pub(crate) fn get_f64(input: &Value, key: &str) -> Option<f64> {
input.get(key).and_then(|v| v.as_f64())
}
macro_rules! pget {
($expr:expr) => {
match $expr {
Ok(v) => v,
Err(e) => return Ok(e),
}
};
}
#[allow(clippy::result_large_err)]
async fn get_client(
whatsapp_state: &WhatsAppState,
) -> std::result::Result<Arc<whatsapp_rust::client::Client>, ToolResult> {
whatsapp_state.client().await.ok_or_else(|| {
ToolResult::error(
"WhatsApp is not connected. Ask the user to connect WhatsApp first \
(use the whatsapp_connect tool)."
.to_string(),
)
})
}
#[allow(clippy::result_large_err)]
async fn resolve_jid(
input: &Value,
whatsapp_state: &WhatsAppState,
config_rx: &tokio::sync::watch::Receiver<Config>,
) -> std::result::Result<(Jid, String), ToolResult> {
if let Some(phone) = input.get("phone").and_then(|v| v.as_str()) {
let allowed = &config_rx.borrow().channels.whatsapp.allowed_phones;
let normalized = phone.trim_start_matches('+');
let phone_allowed = allowed.is_empty()
|| allowed
.iter()
.any(|p| p.trim_start_matches('+') == normalized);
if !phone_allowed {
return Err(ToolResult::error(format!(
"Sending to {} is not permitted. This number is not in the \
allowed_users config.",
phone
)));
}
let digits = phone.trim_start_matches('+');
let jid_str = format!("{}@s.whatsapp.net", digits);
let jid: Jid = jid_str
.parse()
.map_err(|e| ToolResult::error(format!("Invalid phone number format: {}", e)))?;
Ok((jid, jid_str))
} else {
let jid_str = whatsapp_state.owner_jid().await.ok_or_else(|| {
ToolResult::error(
"No owner phone number configured and no 'phone' parameter provided. \
Specify a phone number to send to."
.to_string(),
)
})?;
let jid: Jid = jid_str
.parse()
.map_err(|e| ToolResult::error(format!("Invalid owner JID: {}", e)))?;
Ok((jid, jid_str))
}
}
async fn persist_outgoing(jid: &Jid, content: &str) {
if content.trim().is_empty() {
return;
}
let Some(pool) = crate::db::global_pool() else {
return;
};
let repo = crate::db::ChannelMessageRepository::new(pool.clone());
let chat_id = jid.to_string();
let cm = crate::db::models::ChannelMessage::new(
"whatsapp".to_string(),
chat_id,
None,
"bot:opencrabs".to_string(),
"OpenCrabs".to_string(),
content.to_string(),
"text".to_string(),
None,
);
if let Err(e) = repo.insert(&cm).await {
tracing::warn!("whatsapp_send: failed to persist outgoing message: {}", e);
}
}
async fn read_local_media(
path: &str,
default_mime: &str,
) -> std::result::Result<(Vec<u8>, String, String), ToolResult> {
let expanded = crate::brain::tools::error::expand_tilde(path);
let bytes = tokio::fs::read(&expanded).await.map_err(|e| {
ToolResult::error(format!(
"Failed to read file '{}': {}",
expanded.display(),
e
))
})?;
let filename = expanded
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
let mime = mime_from_extension(&expanded.to_string_lossy())
.unwrap_or_else(|| default_mime.to_string());
Ok((bytes, mime, filename))
}
pub(crate) fn mime_from_extension(path: &str) -> Option<String> {
let ext = path.rsplit('.').next()?.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => Some("image/jpeg".to_string()),
"png" => Some("image/png".to_string()),
"gif" => Some("image/gif".to_string()),
"webp" => Some("image/webp".to_string()),
"mp4" => Some("video/mp4".to_string()),
"3gp" => Some("video/3gpp".to_string()),
"avi" => Some("video/x-msvideo".to_string()),
"mov" => Some("video/quicktime".to_string()),
"mp3" => Some("audio/mpeg".to_string()),
"ogg" | "opus" => Some("audio/ogg".to_string()),
"aac" => Some("audio/aac".to_string()),
"m4a" => Some("audio/mp4".to_string()),
"pdf" => Some("application/pdf".to_string()),
"doc" | "docx" => Some("application/msword".to_string()),
"xls" | "xlsx" => Some("application/vnd.ms-excel".to_string()),
"ppt" | "pptx" => Some("application/vnd.ms-powerpoint".to_string()),
"txt" => Some("text/plain".to_string()),
"csv" => Some("text/csv".to_string()),
"zip" => Some("application/zip".to_string()),
_ => None,
}
}
async fn upload_media(
client: &whatsapp_rust::client::Client,
data: Vec<u8>,
media_type: wacore::download::MediaType,
) -> std::result::Result<whatsapp_rust::upload::UploadResponse, ToolResult> {
client
.upload(
data,
media_type,
whatsapp_rust::upload::UploadOptions::new(),
)
.await
.map_err(|e| ToolResult::error(format!("Media upload failed: {}", e)))
}
pub(crate) fn build_vcard(name: &str, phone: &str) -> String {
format!(
"BEGIN:VCARD\nVERSION:3.0\nFN:{}\nTEL;TYPE=CELL:{}\nEND:VCARD",
name, phone
)
}
#[async_trait]
impl Tool for WhatsAppSendTool {
fn name(&self) -> &str {
"whatsapp_send"
}
fn description(&self) -> &str {
"Full WhatsApp control: send messages, reply, delete, send photos/documents/audio/video/stickers, \
locations, contacts, emoji reactions, polls, typing indicators, and mark messages as read. \
If no phone number is specified, messages go to the owner (primary user). \
Requires WhatsApp to be connected first."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"send", "reply", "delete",
"send_photo", "send_document", "send_audio", "send_video", "send_sticker",
"send_location", "send_contact",
"react", "send_poll",
"typing", "mark_read"
],
"description": "The WhatsApp action to perform."
},
"message": {
"type": "string",
"description": "Message text (send, reply, caption for media)"
},
"phone": {
"type": "string",
"description": "Phone number in E.164 format (e.g. '+15551234567'). Omit to message the owner."
},
"message_id": {
"type": "string",
"description": "WhatsApp message ID for reply, delete, react, mark_read"
},
"from_me": {
"type": "boolean",
"description": "Whether the target message was sent by us (for react/mark_read). Default false."
},
"media_path": {
"type": "string",
"description": "Local file path for media (send_photo, send_document, send_audio, send_video, send_sticker)"
},
"caption": {
"type": "string",
"description": "Caption for media messages (send_photo, send_video, send_document)"
},
"latitude": {
"type": "number",
"description": "Latitude for send_location"
},
"longitude": {
"type": "number",
"description": "Longitude for send_location"
},
"location_name": {
"type": "string",
"description": "Optional location name for send_location"
},
"location_address": {
"type": "string",
"description": "Optional location address for send_location"
},
"contact_name": {
"type": "string",
"description": "Contact display name for send_contact"
},
"contact_phone": {
"type": "string",
"description": "Contact phone number for send_contact"
},
"emoji": {
"type": "string",
"description": "Emoji for react action (e.g. '👍', '❤️'). Empty string to remove reaction."
},
"poll_question": {
"type": "string",
"description": "Poll question text for send_poll"
},
"poll_options": {
"type": "array",
"items": {"type": "string"},
"description": "Array of poll option strings (2-12) for send_poll"
},
"typing_active": {
"type": "boolean",
"description": "For typing action: true = composing, false = paused. Default true."
}
},
"required": ["action"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::Network]
}
async fn execute(&self, input: Value, _context: &ToolExecutionContext) -> Result<ToolResult> {
let action = match input.get("action").and_then(|v| v.as_str()) {
Some(a) if !a.is_empty() => a.to_string(),
_ => {
return Ok(ToolResult::error(
"Missing required 'action' parameter.".to_string(),
));
}
};
let client = pget!(get_client(&self.whatsapp_state).await);
match action.as_str() {
"send" => {
let message = match input.get("message").and_then(|v| v.as_str()) {
Some(m) if !m.is_empty() => m.to_string(),
_ => {
return Ok(ToolResult::error(
"Missing or empty 'message' parameter.".to_string(),
));
}
};
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let message = crate::utils::slack_fmt::markdown_to_mrkdwn(&message);
let tagged = format!(
"{}\n\n{}",
crate::channels::whatsapp::handler::MSG_HEADER,
message
);
let chunks = crate::channels::whatsapp::handler::split_message(&tagged, 4000);
for chunk in chunks {
let wa_msg = waproto::whatsapp::Message {
conversation: Some(chunk.to_string()),
..Default::default()
};
if let Err(e) = client.send_message(jid.clone(), wa_msg).await {
return Ok(ToolResult::error(format!(
"Failed to send WhatsApp message: {}",
e
)));
}
}
persist_outgoing(&jid, &tagged).await;
Ok(ToolResult::success(format!(
"Message sent to {} via WhatsApp.",
jid_str
)))
}
"reply" => {
let message = pget!(get_str(&input, "message")).to_string();
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let msg_id = pget!(get_str(&input, "message_id")).to_string();
let formatted = crate::utils::slack_fmt::markdown_to_mrkdwn(&message);
let wa_msg = waproto::whatsapp::Message {
extended_text_message: Some(Box::new(
waproto::whatsapp::message::ExtendedTextMessage {
text: Some(formatted.clone()),
context_info: Some(Box::new(waproto::whatsapp::ContextInfo {
stanza_id: Some(msg_id),
remote_jid: Some(jid_str.clone()),
..Default::default()
})),
..Default::default()
},
)),
..Default::default()
};
match client.send_message(jid.clone(), wa_msg).await {
Ok(_) => {
persist_outgoing(&jid, &formatted).await;
Ok(ToolResult::success(format!(
"Reply sent to {} via WhatsApp.",
jid_str
)))
}
Err(e) => Ok(ToolResult::error(format!("Failed to reply: {}", e))),
}
}
"delete" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let msg_id = pget!(get_str(&input, "message_id")).to_string();
match client
.revoke_message(jid, msg_id.clone(), Default::default())
.await
{
Ok(_) => Ok(ToolResult::success(format!(
"Message {} deleted for {}.",
msg_id, jid_str
))),
Err(e) => Ok(ToolResult::error(format!(
"Failed to delete message: {}",
e
))),
}
}
"send_photo" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let path = pget!(get_str(&input, "media_path")).to_string();
let caption = input
.get("caption")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let (bytes, mime, _filename) = pget!(read_local_media(&path, "image/jpeg").await);
let upload =
pget!(upload_media(&client, bytes, wacore::download::MediaType::Image).await);
let wa_msg = waproto::whatsapp::Message {
image_message: Some(Box::new(waproto::whatsapp::message::ImageMessage {
url: Some(upload.url),
direct_path: Some(upload.direct_path),
media_key: Some(upload.media_key.to_vec()),
file_enc_sha256: Some(upload.file_enc_sha256.to_vec()),
file_sha256: Some(upload.file_sha256.to_vec()),
file_length: Some(upload.file_length),
mimetype: Some(mime),
caption,
..Default::default()
})),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Photo sent to {} via WhatsApp.",
jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send photo: {}", e))),
}
}
"send_document" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let path = pget!(get_str(&input, "media_path")).to_string();
let caption = input
.get("caption")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let (bytes, mime, filename) =
pget!(read_local_media(&path, "application/octet-stream").await);
let upload = pget!(
upload_media(&client, bytes, wacore::download::MediaType::Document).await
);
let wa_msg = waproto::whatsapp::Message {
document_message: Some(Box::new(waproto::whatsapp::message::DocumentMessage {
url: Some(upload.url),
direct_path: Some(upload.direct_path),
media_key: Some(upload.media_key.to_vec()),
file_enc_sha256: Some(upload.file_enc_sha256.to_vec()),
file_sha256: Some(upload.file_sha256.to_vec()),
file_length: Some(upload.file_length),
mimetype: Some(mime),
file_name: Some(filename),
caption,
..Default::default()
})),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Document sent to {} via WhatsApp.",
jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send document: {}", e))),
}
}
"send_audio" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let path = pget!(get_str(&input, "media_path")).to_string();
let (bytes, mime, _filename) = pget!(read_local_media(&path, "audio/ogg").await);
let upload =
pget!(upload_media(&client, bytes, wacore::download::MediaType::Audio).await);
let wa_msg = waproto::whatsapp::Message {
audio_message: Some(Box::new(waproto::whatsapp::message::AudioMessage {
url: Some(upload.url),
direct_path: Some(upload.direct_path),
media_key: Some(upload.media_key.to_vec()),
file_enc_sha256: Some(upload.file_enc_sha256.to_vec()),
file_sha256: Some(upload.file_sha256.to_vec()),
file_length: Some(upload.file_length),
mimetype: Some(mime),
..Default::default()
})),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Audio sent to {} via WhatsApp.",
jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send audio: {}", e))),
}
}
"send_video" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let path = pget!(get_str(&input, "media_path")).to_string();
let caption = input
.get("caption")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let (bytes, mime, _filename) = pget!(read_local_media(&path, "video/mp4").await);
let upload =
pget!(upload_media(&client, bytes, wacore::download::MediaType::Video).await);
let wa_msg = waproto::whatsapp::Message {
video_message: Some(Box::new(waproto::whatsapp::message::VideoMessage {
url: Some(upload.url),
direct_path: Some(upload.direct_path),
media_key: Some(upload.media_key.to_vec()),
file_enc_sha256: Some(upload.file_enc_sha256.to_vec()),
file_sha256: Some(upload.file_sha256.to_vec()),
file_length: Some(upload.file_length),
mimetype: Some(mime),
caption,
..Default::default()
})),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Video sent to {} via WhatsApp.",
jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send video: {}", e))),
}
}
"send_sticker" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let path = pget!(get_str(&input, "media_path")).to_string();
let (bytes, mime, _filename) = pget!(read_local_media(&path, "image/webp").await);
let upload =
pget!(upload_media(&client, bytes, wacore::download::MediaType::Sticker).await);
let wa_msg = waproto::whatsapp::Message {
sticker_message: Some(Box::new(waproto::whatsapp::message::StickerMessage {
url: Some(upload.url),
direct_path: Some(upload.direct_path),
media_key: Some(upload.media_key.to_vec()),
file_enc_sha256: Some(upload.file_enc_sha256.to_vec()),
file_sha256: Some(upload.file_sha256.to_vec()),
file_length: Some(upload.file_length),
mimetype: Some(mime),
..Default::default()
})),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Sticker sent to {} via WhatsApp.",
jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send sticker: {}", e))),
}
}
"send_location" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let lat = match get_f64(&input, "latitude") {
Some(v) => v,
None => {
return Ok(ToolResult::error(
"Missing required 'latitude' parameter.".to_string(),
));
}
};
let lng = match get_f64(&input, "longitude") {
Some(v) => v,
None => {
return Ok(ToolResult::error(
"Missing required 'longitude' parameter.".to_string(),
));
}
};
let name = input
.get("location_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let address = input
.get("location_address")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let wa_msg = waproto::whatsapp::Message {
location_message: Some(Box::new(waproto::whatsapp::message::LocationMessage {
degrees_latitude: Some(lat),
degrees_longitude: Some(lng),
name,
address,
..Default::default()
})),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Location ({}, {}) sent to {} via WhatsApp.",
lat, lng, jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send location: {}", e))),
}
}
"send_contact" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let contact_name = pget!(get_str(&input, "contact_name")).to_string();
let contact_phone = pget!(get_str(&input, "contact_phone")).to_string();
let vcard = build_vcard(&contact_name, &contact_phone);
let wa_msg = waproto::whatsapp::Message {
contact_message: Some(Box::new(waproto::whatsapp::message::ContactMessage {
display_name: Some(contact_name.clone()),
vcard: Some(vcard),
..Default::default()
})),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Contact '{}' sent to {} via WhatsApp.",
contact_name, jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send contact: {}", e))),
}
}
"react" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let msg_id = pget!(get_str(&input, "message_id")).to_string();
let emoji = input
.get("emoji")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let from_me = input
.get("from_me")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let message_key = waproto::whatsapp::MessageKey {
remote_jid: Some(jid.to_string()),
from_me: Some(from_me),
id: Some(msg_id.clone()),
..Default::default()
};
#[cfg(crates_publish)]
let boxed_reaction = waproto::whatsapp::message::ReactionMessage {
key: Some(message_key),
text: if emoji.is_empty() {
None
} else {
Some(emoji.clone())
},
sender_timestamp_ms: Some(chrono::Utc::now().timestamp_millis()),
..Default::default()
};
#[cfg(not(crates_publish))]
let boxed_reaction = Box::new(waproto::whatsapp::message::ReactionMessage {
key: Some(message_key),
text: if emoji.is_empty() {
None
} else {
Some(emoji.clone())
},
sender_timestamp_ms: Some(chrono::Utc::now().timestamp_millis()),
..Default::default()
});
let reaction_msg = waproto::whatsapp::Message {
reaction_message: Some(boxed_reaction),
..Default::default()
};
match client.send_message(jid, reaction_msg).await {
Ok(_) => {
if emoji.is_empty() {
Ok(ToolResult::success(format!(
"Reaction removed from message {} for {}.",
msg_id, jid_str
)))
} else {
Ok(ToolResult::success(format!(
"Reaction '{}' set on message {} for {}.",
emoji, msg_id, jid_str
)))
}
}
Err(e) => Ok(ToolResult::error(format!("Failed to send reaction: {}", e))),
}
}
"send_poll" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let question = pget!(get_str(&input, "poll_question")).to_string();
let opts: Vec<String> = match input.get("poll_options").and_then(|v| v.as_array()) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
None => {
return Ok(ToolResult::error(
"Missing required 'poll_options' parameter.".to_string(),
));
}
};
if opts.len() < 2 {
return Ok(ToolResult::error(
"'poll_options' must have at least 2 options.".to_string(),
));
}
if opts.len() > 12 {
return Ok(ToolResult::error(
"'poll_options' supports a maximum of 12 options.".to_string(),
));
}
use sha2::{Digest, Sha256};
let poll_options: Vec<waproto::whatsapp::message::poll_creation_message::Option> =
opts.iter()
.map(|opt| {
let mut hasher = Sha256::new();
hasher.update(opt.as_bytes());
let hash = format!("{:x}", hasher.finalize());
waproto::whatsapp::message::poll_creation_message::Option {
option_name: Some(opt.clone()),
option_hash: Some(hash),
}
})
.collect();
let wa_msg = waproto::whatsapp::Message {
poll_creation_message: Some(Box::new(
waproto::whatsapp::message::PollCreationMessage {
name: Some(question.clone()),
options: poll_options,
selectable_options_count: Some(0), ..Default::default()
},
)),
..Default::default()
};
match client.send_message(jid, wa_msg).await {
Ok(_) => Ok(ToolResult::success(format!(
"Poll '{}' sent to {} via WhatsApp.",
question, jid_str
))),
Err(e) => Ok(ToolResult::error(format!("Failed to send poll: {}", e))),
}
}
"typing" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let active = input
.get("typing_active")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let result = if active {
client.chatstate().send_composing(&jid).await
} else {
client.chatstate().send_paused(&jid).await
};
match result {
Ok(_) => Ok(ToolResult::success(format!(
"Typing {} sent to {}.",
if active { "composing" } else { "paused" },
jid_str
))),
Err(e) => Ok(ToolResult::error(format!(
"Failed to send typing indicator: {}",
e
))),
}
}
"mark_read" => {
let (jid, jid_str) =
pget!(resolve_jid(&input, &self.whatsapp_state, &self.config_rx).await);
let msg_id = pget!(get_str(&input, "message_id")).to_string();
let node = wacore_binary::builder::NodeBuilder::new("receipt")
.attrs([
("id", msg_id.clone()),
("type", "read".to_string()),
("to", jid.to_string()),
])
.build();
match client.send_node(node).await {
Ok(_) => Ok(ToolResult::success(format!(
"Message {} marked as read for {}.",
msg_id, jid_str
))),
Err(e) => Ok(ToolResult::error(format!(
"Failed to mark message as read: {}",
e
))),
}
}
unknown => Ok(ToolResult::error(format!(
"Unknown action '{}'. Valid actions: send, reply, delete, send_photo, \
send_document, send_audio, send_video, send_sticker, send_location, \
send_contact, react, send_poll, typing, mark_read",
unknown
))),
}
}
}