use crate::brain::agent::AgentService;
use crate::brain::agent::{ApprovalCallback, ProgressCallback, ProgressEvent};
use crate::channels::group_history;
use crate::channels::whatsapp::WhatsAppState;
use crate::config::Config;
use crate::db::ChannelMessageRepository;
use crate::db::models::ChannelMessage as DbChannelMessage;
use crate::services::SessionService;
use crate::utils::sanitize::redact_secrets;
use crate::utils::truncate_str;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::Mutex as TokioMutex;
use uuid::Uuid;
use tokio_util::sync::CancellationToken;
use wacore::types::message::MessageInfo;
use wacore_binary::jid::JidExt;
use waproto::whatsapp::Message;
use whatsapp_rust::SendOptions;
use whatsapp_rust::client::Client;
pub(crate) fn is_inbound_status(chat: &wacore_binary::jid::Jid, is_from_me: bool) -> bool {
!is_from_me && chat.is_status_broadcast()
}
pub(crate) const COMPOSING_BASE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
pub(crate) const COMPOSING_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
pub(crate) const COMPOSING_MAX_FAILURES: u32 = 5;
pub(crate) fn composing_retry_delay(failures: u32) -> Option<std::time::Duration> {
if failures == 0 {
return Some(COMPOSING_BASE_DELAY);
}
if failures >= COMPOSING_MAX_FAILURES {
return None;
}
let factor = 1u64 << (failures - 1).min(3);
let base = COMPOSING_BASE_DELAY.as_millis() as u64;
let cap = COMPOSING_MAX_DELAY.as_millis() as u64;
Some(std::time::Duration::from_millis(
base.saturating_mul(factor).min(cap),
))
}
pub const MSG_HEADER: &str = "\u{1f980} *OpenCrabs*";
async fn send_resilient(
client: &Arc<Client>,
jid: wacore_binary::jid::Jid,
msg: Message,
) -> Option<String> {
let gen_id = client.generate_message_id();
let opts = SendOptions::default().with_message_id(gen_id.clone());
let mut sent = Some(gen_id);
if let Err(e) = client
.send_message_with_options(jid.clone(), msg.clone(), opts.clone())
.await
{
tracing::error!("WhatsApp: send failed: {e}");
sent = None;
}
let client = client.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
if let Err(e) = client.send_message_with_options(jid, msg, opts).await {
tracing::debug!("WhatsApp: idempotent resend failed: {e}");
}
});
sent
}
fn unwrap_message(msg: &Message) -> &Message {
if let Some(dsm) = msg.device_sent_message.as_option()
&& let Some(inner) = dsm.message.as_option()
{
return unwrap_message(inner);
}
if let Some(eph) = msg.ephemeral_message.as_option()
&& let Some(inner) = eph.message.as_option()
{
return unwrap_message(inner);
}
if let Some(vo) = msg.view_once_message.as_option()
&& let Some(inner) = vo.message.as_option()
{
return unwrap_message(inner);
}
if let Some(dwc) = msg.document_with_caption_message.as_option()
&& let Some(inner) = dwc.message.as_option()
{
return unwrap_message(inner);
}
msg
}
fn extract_reply_context(msg: &Message) -> Option<String> {
let msg = unwrap_message(msg);
let ctx = msg
.extended_text_message
.as_option()?
.context_info
.as_option()?;
let quoted = ctx.quoted_message.as_option()?;
let quoted_text = crate::utils::strip_ctx_footer(&extract_text(quoted)?);
if quoted_text.is_empty() {
return None;
}
let sender = ctx
.participant
.as_ref()
.map(|p| p.split('@').next().unwrap_or(p).to_string())
.unwrap_or_else(|| "unknown".to_string());
Some(format!("[Replying to {sender}: \"{quoted_text}\"]"))
}
pub(crate) fn extract_text(msg: &Message) -> Option<String> {
let msg = unwrap_message(msg);
if let Some(ref conv) = msg.conversation
&& !conv.is_empty()
{
return Some(conv.clone());
}
if let Some(ext) = msg.extended_text_message.as_option()
&& let Some(ref text) = ext.text
{
return Some(text.clone());
}
if let Some(img) = msg.image_message.as_option()
&& let Some(ref caption) = img.caption
&& !caption.is_empty()
{
return Some(caption.clone());
}
None
}
pub(crate) fn has_image(msg: &Message) -> bool {
let msg = unwrap_message(msg);
msg.image_message.is_set()
}
fn has_audio(msg: &Message) -> bool {
let msg = unwrap_message(msg);
msg.audio_message.is_set()
}
fn has_document(msg: &Message) -> bool {
let msg = unwrap_message(msg);
msg.document_message.is_set()
}
fn has_video(msg: &Message) -> bool {
let msg = unwrap_message(msg);
msg.video_message.is_set()
}
fn has_sticker(msg: &Message) -> bool {
let msg = unwrap_message(msg);
msg.sticker_message.is_set()
}
async fn download_video(
msg: &Message,
client: &Client,
ctx: &super::media_retry::MediaContext,
) -> Option<(Vec<u8>, String, String)> {
let msg = unwrap_message(msg);
let video = msg.video_message.as_option()?;
let mime = video
.mimetype
.clone()
.unwrap_or_else(|| "video/mp4".to_string());
let ext = match mime.split(';').next().unwrap_or("").trim() {
"video/3gpp" => "3gp",
"video/quicktime" => "mov",
"video/webm" => "webm",
_ => "mp4",
};
let bytes = super::media_retry::download_with_retry(client, video, ctx, "video", |m, path| {
m.direct_path = Some(path)
})
.await?;
tracing::debug!("WhatsApp: downloaded video ({} bytes)", bytes.len());
Some((bytes, mime, format!("video.{ext}")))
}
async fn download_sticker(
msg: &Message,
client: &Client,
ctx: &super::media_retry::MediaContext,
) -> Option<(Vec<u8>, String, String)> {
let msg = unwrap_message(msg);
let sticker = msg.sticker_message.as_option()?;
let mime = sticker
.mimetype
.clone()
.unwrap_or_else(|| "image/webp".to_string());
let bytes =
super::media_retry::download_with_retry(client, sticker, ctx, "sticker", |m, path| {
m.direct_path = Some(path)
})
.await?;
tracing::debug!("WhatsApp: downloaded sticker ({} bytes)", bytes.len());
Some((bytes, mime, "sticker.webp".to_string()))
}
fn describe_non_media(msg: &Message) -> Option<String> {
let msg = unwrap_message(msg);
if let Some(loc) = msg.location_message.as_option() {
return Some(super::inbound::describe_location(loc));
}
if let Some(loc) = msg.live_location_message.as_option() {
return Some(super::inbound::describe_live_location(loc));
}
if let Some(contact) = msg.contact_message.as_option() {
return Some(super::inbound::describe_contact(contact));
}
if let Some(reaction) = msg.reaction_message.as_option() {
return super::inbound::describe_reaction(reaction);
}
None
}
async fn download_document(
msg: &Message,
client: &Client,
ctx: &super::media_retry::MediaContext,
) -> Option<(Vec<u8>, String, String)> {
let msg = unwrap_message(msg);
let doc = msg.document_message.as_option()?;
let mime = doc.mimetype.clone().unwrap_or_default();
let fname = doc.file_name.clone().unwrap_or_else(|| "file".to_string());
let bytes = super::media_retry::download_with_retry(client, doc, ctx, "document", |m, path| {
m.direct_path = Some(path)
})
.await?;
tracing::debug!(
"WhatsApp: downloaded document {} ({} bytes)",
fname,
bytes.len()
);
Some((bytes, mime, fname))
}
async fn download_audio(
msg: &Message,
client: &Client,
ctx: &super::media_retry::MediaContext,
) -> Option<Vec<u8>> {
let msg = unwrap_message(msg);
let audio = msg.audio_message.as_option()?;
let bytes = super::media_retry::download_with_retry(client, audio, ctx, "audio", |m, path| {
m.direct_path = Some(path)
})
.await?;
tracing::debug!("WhatsApp: downloaded audio ({} bytes)", bytes.len());
Some(bytes)
}
async fn download_image(
msg: &Message,
client: &Client,
ctx: &super::media_retry::MediaContext,
) -> Option<(Vec<u8>, String, String)> {
let msg = unwrap_message(msg);
let img = msg.image_message.as_option()?;
let mime = img.mimetype.as_deref().unwrap_or("image/jpeg").to_string();
let ext = match mime.as_str() {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
};
let fname = format!("image.{ext}");
match super::media_retry::download_with_retry(client, img, ctx, "image", |m, path| {
m.direct_path = Some(path)
})
.await
{
Some(bytes) => {
tracing::debug!(
"WhatsApp: downloaded image ({} bytes, mime={})",
bytes.len(),
mime
);
Some((bytes, mime, fname))
}
None => {
tracing::error!("WhatsApp: image unavailable even after a re-upload request");
None
}
}
}
fn sender_phone(info: &MessageInfo) -> String {
let full = info.source.sender.to_string();
let without_server = full.split('@').next().unwrap_or(&full);
without_server
.split(':')
.next()
.unwrap_or(without_server)
.to_string()
}
fn chat_user(info: &MessageInfo) -> String {
let full = info.source.chat.to_string();
let without_server = full.split('@').next().unwrap_or(&full);
without_server
.split(':')
.next()
.unwrap_or(without_server)
.to_string()
}
pub(crate) fn wa_should_respond(
policy: crate::config::types::WaResponsePolicy,
is_from_me: bool,
sender_user: &str,
sender_alt_user: Option<&str>,
chat_user: &str,
allowed: &[String],
operators: &[String],
) -> bool {
use crate::config::types::WaResponsePolicy;
let sender = sender_user.trim_start_matches('+');
let alt = sender_alt_user.map(|a| a.trim_start_matches('+'));
let chat = chat_user.trim_start_matches('+');
let in_list = |list: &[String]| {
list.iter().any(|entry| {
let entry = entry.trim_start_matches('+');
entry == sender || alt.is_some_and(|a| a == entry)
})
};
if (is_from_me && sender == chat) || (!is_from_me && in_list(operators)) {
return true;
}
match policy {
WaResponsePolicy::Auto => {
if allowed.is_empty() {
true
} else {
!is_from_me && in_list(allowed)
}
}
WaResponsePolicy::OwnerOnly => false,
WaResponsePolicy::Allowlist => !is_from_me && in_list(allowed),
WaResponsePolicy::Open => true,
}
}
pub fn split_message(text: &str, max_len: usize) -> Vec<&str> {
if text.len() <= max_len {
return vec![text];
}
let mut chunks = Vec::new();
let mut start = 0;
while start < text.len() {
let mut end = (start + max_len).min(text.len());
while end < text.len() && !text.is_char_boundary(end) {
end -= 1;
}
let break_at = if end < text.len() {
text[start..end]
.rfind('\n')
.filter(|&pos| pos > end - start - 200)
.map(|pos| start + pos + 1)
.unwrap_or(end)
} else {
end
};
chunks.push(&text[start..break_at]);
start = break_at;
}
chunks
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn handle_message(
msg: Message,
info: MessageInfo,
client: Arc<Client>,
agent: Arc<AgentService>,
session_svc: SessionService,
shared_session: Arc<TokioMutex<Option<Uuid>>>,
wa_state: Arc<WhatsAppState>,
config_rx: tokio::sync::watch::Receiver<Config>,
channel_msg_repo: ChannelMessageRepository,
) {
let phone = sender_phone(&info);
let blocked = !info.source.is_from_me && {
let sender = info.source.sender.to_string();
let alt = info.source.sender_alt.as_ref().map(|j| j.to_string());
wa_state
.blocklist
.blocks_either(&sender, alt.as_deref())
.await
};
if blocked {
tracing::info!(
target: "whatsapp",
"dropping inbound message from a blocked contact (#1487)"
);
return;
}
if is_inbound_status(&info.source.chat, info.source.is_from_me) {
tracing::debug!(
target: "whatsapp",
"dropping inbound status broadcast from {}",
phone
);
return;
}
tracing::debug!(
"WhatsApp handler: from={}, is_from_me={}, has_text={}, has_image={}, has_audio={}",
phone,
info.source.is_from_me,
extract_text(&msg).is_some(),
has_image(&msg),
has_audio(&msg),
);
wa_state.recent.remember(info.id.clone(), msg.clone()).await;
if info.source.is_from_me {
if let Some(text) = extract_text(&msg) {
if text.starts_with(MSG_HEADER) {
return;
}
} else if !has_audio(&msg) && !has_image(&msg) {
return;
}
}
let has_img = has_image(&msg);
let has_aud = has_audio(&msg);
let has_doc = has_document(&msg);
let has_vid = has_video(&msg);
let has_stk = has_sticker(&msg);
let text = extract_text(&msg);
let media_ctx = super::media_retry::MediaContext {
msg_id: info.id.clone(),
chat: info.source.chat.clone(),
is_from_me: info.source.is_from_me,
participant: if info.source.is_group {
Some(info.source.sender.clone())
} else {
None
},
};
let non_media = describe_non_media(&msg);
let poll_vote = match unwrap_message(&msg).poll_update_message.as_option() {
Some(update) => {
let voter = if info.push_name.trim().is_empty() {
phone.clone()
} else {
info.push_name.trim().to_string()
};
super::poll::decode_vote(
&client,
&wa_state,
update,
&info.source.chat,
&info.source.sender,
&voter,
)
.await
}
None => None,
};
if text.is_none()
&& !has_img
&& !has_aud
&& !has_doc
&& !has_vid
&& !has_stk
&& non_media.is_none()
&& poll_vote.is_none()
{
return;
}
if let Some(ref t) = text
&& !t.is_empty()
{
let chat_id = format!("{}", info.source.chat);
let is_group = info.source.is_group;
let push_name = info.push_name.clone();
let cm = DbChannelMessage::new(
"whatsapp".into(),
chat_id,
if is_group {
Some(format!("{}", info.source.chat))
} else {
None
},
phone.clone(),
push_name,
t.clone(),
"text".into(),
None,
);
if let Err(e) = channel_msg_repo.insert(&cm).await {
tracing::warn!("Failed to store WhatsApp channel message: {e}");
}
}
let cfg = config_rx.borrow().clone();
let wa_cfg = &cfg.channels.whatsapp;
let idle_timeout_hours = wa_cfg.session_idle_hours;
let voice_config = cfg.voice_config();
{
let recovered = info.is_offline || info.unavailable_request_id.is_some();
if recovered && !wa_cfg.history_import_chats.is_empty() {
let hist_key = format!("{}", info.source.chat);
if super::history::opted_in(&wa_cfg.history_import_chats, &hist_key) {
if let Some(t) = text.as_deref()
&& !t.is_empty()
&& super::history::in_window(info.timestamp, chrono::Utc::now())
{
let pmid = info.id.to_string();
match channel_msg_repo
.content_by_platform_message_id("whatsapp", &hist_key, &pmid)
.await
{
Ok(None) => {
let cm = DbChannelMessage {
created_at: info.timestamp,
..DbChannelMessage::new(
"whatsapp".into(),
hist_key.clone(),
if info.source.is_group {
Some(hist_key.clone())
} else {
None
},
phone.clone(),
info.push_name.clone(),
t.to_string(),
super::history::IMPORTED_TYPE.into(),
Some(pmid),
)
};
if let Err(e) = channel_msg_repo.insert(&cm).await {
tracing::warn!("whatsapp history: import capture failed: {e}");
}
}
Ok(Some(_)) => {
}
Err(e) => {
tracing::warn!("whatsapp history: dedupe lookup failed: {e}")
}
}
}
return;
}
}
}
let sender_user = phone.trim_start_matches('+').to_string();
let sender_alt_user = info.source.sender_alt.as_ref().map(|j| j.user.to_string());
let canonical_phone =
super::identity::canonical_user(&info.source.sender, info.source.sender_alt.as_ref());
let chat_user_part = chat_user(&info);
if !wa_should_respond(
wa_cfg.response_policy,
info.source.is_from_me,
&sender_user,
sender_alt_user.as_deref(),
&chat_user_part,
&wa_cfg.allowed_phones,
&wa_cfg.bot_owner,
) {
tracing::debug!(
"WhatsApp: ignoring message from={} (alt={:?}) chat={} is_from_me={}",
sender_user,
sender_alt_user,
chat_user_part,
info.source.is_from_me,
);
return;
}
let mut suggestion_pick: Option<usize> = None;
{
use crate::channels::whatsapp::WaApproval;
let btn_id = super::interactive::parse_tap(unwrap_message(&msg));
let choice: Option<WaApproval> = if let Some(id) = btn_id.as_deref() {
match id {
"wa_approve_yes" => Some(WaApproval::Yes),
"wa_approve_always" => Some(WaApproval::Always),
"wa_approve_yolo" => Some(WaApproval::Yolo),
"wa_approve_no" => Some(WaApproval::No),
_ => None,
}
} else if let Some(raw_text) = extract_text(&msg) {
let answer = raw_text.trim().to_lowercase();
if matches!(answer.as_str(), "yes" | "y" | "sim" | "s") {
Some(WaApproval::Yes)
} else if matches!(answer.as_str(), "always" | "sempre") {
Some(WaApproval::Always)
} else if matches!(answer.as_str(), "yolo") {
Some(WaApproval::Yolo)
} else if matches!(answer.as_str(), "no" | "n" | "nao" | "não") {
Some(WaApproval::No)
} else {
None
}
} else {
None
};
if choice.is_none()
&& let Some(n) = btn_id
.as_deref()
.and_then(super::interactive::parse_suggestion_tap)
{
suggestion_pick = Some(n);
}
if let Some(c) = choice {
let is_owner =
crate::config::owner::is_owner(&wa_cfg.allowed_phones, &wa_cfg.bot_owner, &phone)
|| sender_alt_user.as_deref().is_some_and(|alt| {
crate::config::owner::is_owner(
&wa_cfg.allowed_phones,
&wa_cfg.bot_owner,
alt,
)
});
if !is_owner {
tracing::warn!(
"WhatsApp: non-owner {} replied approval {:?} — refused (OC-01)",
phone,
c
);
return;
}
if wa_state
.resolve_pending_approval(&canonical_phone, c)
.await
.is_some()
{
tracing::info!("WhatsApp: approval from {}: {:?}", phone, c);
if c == WaApproval::Always {
crate::utils::persist_auto_session_policy();
} else if c == WaApproval::Yolo {
crate::utils::persist_auto_always_policy();
}
return;
}
}
}
let text_preview = text
.as_deref()
.map(|t| truncate_str(t, 50))
.unwrap_or("[image]");
tracing::info!("WhatsApp: message from {}: {}", phone, text_preview);
if has_aud
&& voice_config.stt_enabled
&& let Err(e) = client.chatstate().send_composing(&info.source.chat).await
{
tracing::warn!(error = %e, "failed to send WhatsApp composing indicator");
}
let mut content;
if has_aud
&& voice_config.stt_enabled
&& let Some(audio_bytes) = download_audio(&msg, &client, &media_ctx).await
{
let stored_audio =
crate::channels::persist_channel_attachment("whatsapp", "voice-note.ogg", &audio_bytes);
match crate::channels::voice::transcribe(audio_bytes, &voice_config).await {
Ok(transcript) => {
tracing::info!(
"WhatsApp: transcribed voice: {}",
truncate_str(&transcript, 80)
);
content = transcript;
}
Err(e) => {
tracing::error!("WhatsApp: STT error: {e}");
content = text.unwrap_or_default();
}
}
if let Some(p) = stored_audio {
content.push_str(&format!("\n\n[file: {}]", p.display()));
}
} else {
content = text.unwrap_or_default();
}
if has_img
&& !has_aud
&& let Some((img_bytes, img_mime, img_fname)) =
download_image(&msg, &client, &media_ctx).await
{
let stored_img =
crate::channels::persist_channel_attachment("whatsapp", &img_fname, &img_bytes);
use crate::utils::{inject_file_content, process_file_with_vision};
let cfg = crate::config::Config::load();
if let Ok(cfg) = cfg {
let fc = process_file_with_vision(&img_bytes, &img_mime, &img_fname, &cfg);
let (injected, _) = inject_file_content(&fc);
if !injected.is_empty() {
let caption = extract_text(&msg);
wa_state
.buffer_photo(&canonical_phone, injected, caption)
.await;
let token = wa_state.reset_photo_debounce(&canonical_phone).await;
if !wa_state.wait_photo_debounce(&token).await {
return;
}
let (markers, first_caption) = wa_state.drain_photo_buffer(&canonical_phone).await;
wa_state.cleanup_photo_debounce(&canonical_phone).await;
if markers.is_empty() {
return;
}
content = markers.join("\n\n");
if let Some(caption) = first_caption
&& !caption.trim().is_empty()
{
content = format!("{}\n\n{}", caption.trim(), content);
}
if let Some(p) = stored_img {
content.push_str(&format!("\n\n[file: {}]", p.display()));
}
}
}
}
if has_stk
&& !has_img
&& !has_aud
&& let Some((bytes, mime, fname)) = download_sticker(&msg, &client, &media_ctx).await
{
let stored = crate::channels::persist_channel_attachment("whatsapp", &fname, &bytes);
use crate::utils::{inject_file_content, process_file_with_vision};
if let Ok(cfg) = crate::config::Config::load() {
let fc = process_file_with_vision(&bytes, &mime, &fname, &cfg);
let injected = inject_file_content(&fc).0;
if !injected.is_empty() {
content.push_str(&format!("\n\n{injected}"));
}
}
if let Some(p) = stored {
content.push_str(&format!("\n\n[file: {}]", p.display()));
}
}
if has_vid
&& !has_aud
&& let Some((bytes, mime, fname)) = download_video(&msg, &client, &media_ctx).await
{
let stored = crate::channels::persist_channel_attachment("whatsapp", &fname, &bytes);
use crate::utils::{inject_file_content, process_file_with_vision};
if let Ok(cfg) = crate::config::Config::load() {
let fc = process_file_with_vision(&bytes, &mime, &fname, &cfg);
let injected = inject_file_content(&fc).0;
if !injected.is_empty() {
content.push_str(&format!("\n\n{injected}"));
}
}
if let Some(p) = stored {
content.push_str(&format!("\n\n[file: {}]", p.display()));
}
}
let voted_labels = poll_vote.as_ref().map(|v| v.chosen.clone());
if let Some(vote) = poll_vote {
if content.trim().is_empty() {
content = vote.line;
} else {
content.push_str(&format!("\n\n{}", vote.line));
}
}
if let Some(extract) = non_media {
if content.trim().is_empty() {
content = extract;
} else {
content.push_str(&format!("\n\n{extract}"));
}
}
if has_doc
&& !has_aud
&& !has_img
&& let Some((bytes, mime, fname)) = download_document(&msg, &client, &media_ctx).await
{
let stored = crate::channels::persist_channel_attachment("whatsapp", &fname, &bytes);
use crate::utils::{inject_file_content, process_file_with_vision};
let cfg = crate::config::Config::load();
if let Ok(cfg) = cfg {
let fc = process_file_with_vision(&bytes, &mime, &fname, &cfg);
let injected = inject_file_content(&fc).0;
if !injected.is_empty() {
content.push_str(&format!("\n\n{injected}"));
}
}
if let Some(p) = stored {
content.push_str(&format!("\n\n[file: {}]", p.display()));
}
}
if let Some(n) = suggestion_pick
&& content.trim().is_empty()
{
content = n.to_string();
}
if content.is_empty() {
return;
}
let is_owner_self_chat = info.source.is_from_me && sender_user == chat_user_part;
let owner_number = match wa_state.owner_jid().await {
Some(jid) => jid
.split('@')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.trim_start_matches('+')
.to_string(),
None => wa_cfg
.allowed_phones
.first()
.map(|a| a.trim_start_matches('+').to_string())
.unwrap_or_default(),
};
let owner_number = if owner_number.is_empty() {
None
} else {
Some(owner_number)
};
let session_phone = match (is_owner_self_chat, &owner_number) {
(true, Some(num)) => num.clone(),
_ => canonical_phone.clone(),
};
let reply_target: wacore_binary::jid::Jid = match (is_owner_self_chat, &owner_number) {
(true, Some(num)) => format!("{num}@s.whatsapp.net")
.parse()
.unwrap_or_else(|_| info.source.chat.clone()),
_ => {
if !info.source.is_group && info.source.chat.is_lid() {
if let Some(ref alt) = sender_alt_user {
let alt_jid = format!("{alt}@s.whatsapp.net");
if let Ok(jid) = alt_jid.parse() {
jid
} else {
info.source.chat.clone()
}
} else {
info.source.chat.clone()
}
} else {
info.source.chat.clone()
}
}
};
if reply_target.is_status_broadcast() {
tracing::warn!(
target: "whatsapp",
"refusing to reply into status@broadcast; dropping this stanza"
);
return;
}
let is_owner = is_owner_self_chat
|| owner_number.as_deref() == Some(phone.as_str())
|| sender_alt_user
.as_deref()
.is_some_and(|alt| owner_number.as_deref() == Some(alt))
|| crate::config::owner::is_owner(&wa_cfg.allowed_phones, &wa_cfg.bot_owner, &phone)
|| sender_alt_user.as_deref().is_some_and(|alt| {
crate::config::owner::is_owner(&wa_cfg.allowed_phones, &wa_cfg.bot_owner, alt)
});
let session_id = {
use crate::channels::session_resolve;
let legacy_title = format!("WhatsApp: {}", session_phone);
let suffix = session_resolve::chat_id_suffix(&format!("wa-{session_phone}"));
let session_title = format!("{legacy_title} {suffix}");
match session_resolve::resolve_or_create_channel_session(
&session_svc,
&suffix,
&legacy_title,
&session_title,
idle_timeout_hours,
"WhatsApp",
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!("WhatsApp: failed to resolve session: {e:#} (#442)");
let reply = waproto::whatsapp::Message {
conversation: Some(format!(
"⚠️ Could not load this chat's session ({e}). Your history is \
intact and this message was NOT processed. Try again, or send \
/new if you deliberately want a fresh session."
)),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
}
};
if info.source.is_group {
crate::memory::mark_session_shared(session_id);
}
wa_state
.register_session_jid(session_id, reply_target.to_string())
.await;
crate::brain::agent::service::session_routes::claim_for_channel(
session_id,
agent.message_enqueue_callback(),
);
let voted_pick = match voted_labels.as_deref() {
Some([label]) => wa_state.take_followup_by_label(session_id, label).await,
_ => None,
};
if let Some(picked) = voted_pick {
content = picked;
} else if let Some(picked) = wa_state
.take_followup_by_reply(session_id, content.trim())
.await
{
content = picked;
} else {
wa_state.clear_pending_followups(session_id).await;
}
if crate::utils::stop_intent::is_stop_command_or_intent(&content) {
wa_state.cancel_session(session_id).await;
let reply = waproto::whatsapp::Message {
conversation: Some("Operation cancelled.".to_string()),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
let session_meta = session_svc.get_session(session_id).await.ok().flatten();
crate::channels::commands::sync_provider_for_session(
&agent,
session_id,
session_meta
.as_ref()
.and_then(|s| s.provider_name.as_deref()),
session_meta.as_ref().and_then(|s| s.model.as_deref()),
)
.await;
if let Some(reply) = crate::channels::respond_to_scope::respond_to_outside_telegram(
&content, is_owner, "WhatsApp", None,
) {
let reply_msg = waproto::whatsapp::Message {
conversation: Some(reply),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply_msg).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
{
use crate::channels::commands::{self, ChannelCommand};
let cmd =
commands::handle_command(&content, session_id, &agent, &session_svc, is_owner, None)
.await;
if let Some(reply_text) = commands::try_execute_text_command(&cmd).await {
let reply = waproto::whatsapp::Message {
conversation: Some(reply_text),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
match cmd {
ChannelCommand::Models(resp) => {
let reply = waproto::whatsapp::Message {
conversation: Some(resp.text),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
ChannelCommand::NewSession => {
let session_title = format!("WhatsApp: {}", phone);
let prior_session = session_svc
.find_session_by_title(&session_title)
.await
.unwrap_or_else(|e| {
tracing::error!("WhatsApp: /new prior-session lookup failed: {e:#}");
None
});
if !is_owner
&& let Some(old) = prior_session.as_ref()
&& let Err(e) = session_svc.archive_session(old.id).await
{
tracing::error!("WhatsApp: failed to archive old session {}: {}", old.id, e);
}
match crate::channels::session_init::create_channel_session(
&session_svc,
Some(session_title),
prior_session.as_ref(),
)
.await
{
Ok(new_session) => {
if is_owner {
*shared_session.lock().await = Some(new_session.id);
}
let new_meta = session_svc.get_session(new_session.id).await.ok().flatten();
crate::channels::commands::sync_provider_for_session(
&agent,
new_session.id,
new_meta.as_ref().and_then(|s| s.provider_name.as_deref()),
new_meta.as_ref().and_then(|s| s.model.as_deref()),
)
.await;
let baseline = agent.base_context_tokens();
let ctx_max = agent.context_limit_for_session(new_session.id);
let footer = crate::utils::format_ctx_footer(baseline, ctx_max, None);
let msg_text = format!("✅ New session started.\n\n{footer}");
let reply = waproto::whatsapp::Message {
conversation: Some(msg_text),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
tracing::info!(
"WhatsApp /new: sent ctx footer='{}' (baseline={}, ctx_max={})",
footer,
baseline,
ctx_max,
);
}
Err(e) => {
tracing::error!("WhatsApp: failed to create session: {}", e);
let reply = waproto::whatsapp::Message {
conversation: Some("Failed to create session.".to_string()),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
}
}
return;
}
ChannelCommand::Sessions(resp) => {
let reply = waproto::whatsapp::Message {
conversation: Some(resp.text),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
ChannelCommand::Stop => {
let cancelled = wa_state.cancel_session(session_id).await;
let text = if cancelled {
"Operation cancelled."
} else {
"No operation in progress."
};
let reply = waproto::whatsapp::Message {
conversation: Some(text.to_string()),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
ChannelCommand::StopTarget(_) => {
let reply = waproto::whatsapp::Message {
conversation: Some(
"Targeted stop is Telegram-only. Use /stop here.".to_string(),
),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
ChannelCommand::Compact => {
let status = waproto::whatsapp::Message {
conversation: Some("⏳ Compacting context...".to_string()),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), status).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
content =
"[SYSTEM: Compact context now. Summarize this conversation for continuity.]"
.to_string();
}
ChannelCommand::ClearContext => {
let reply = match agent.clear_context(session_id).await {
Ok(receipt) => receipt.user_line(),
Err(e) => {
tracing::error!("/clear failed: {e}");
format!("/clear did nothing, the context is unchanged: {e}")
}
};
let status = waproto::whatsapp::Message {
conversation: Some(reply),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), status).await {
tracing::warn!(error = %e, "failed to send WhatsApp clear receipt");
}
return;
}
ChannelCommand::UserPrompt(prompt) => {
content = prompt;
}
ChannelCommand::NotACommand => {}
ChannelCommand::Profiles(resp) => {
let reply = waproto::whatsapp::Message {
conversation: Some(resp.text.clone()),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), reply).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
return;
}
_ => {}
}
}
let reply_context = extract_reply_context(&msg);
let display_text = if is_owner && !info.source.is_group {
content.clone()
} else {
let name = info.push_name.trim();
let sender = if name.is_empty() {
format!("+{}", phone)
} else {
name.to_string()
};
format!("{sender}: {content}")
};
let agent_input = if info.source.is_group {
let name = info.push_name.trim().to_string();
let display = if name.is_empty() {
format!("+{}", phone)
} else {
name
};
let group = info.source.chat.to_string();
let group_id = group.split('@').next().unwrap_or(&group).to_string();
let role = if is_owner { "owner" } else { "user" };
format!(
"{}\n{}",
group_history::current_sender_label(
"WhatsApp group",
&group_id,
&display,
&format!(" (+{phone})"),
role,
),
content
)
} else if !is_owner {
let name = info.push_name.trim().to_string();
let from = if name.is_empty() {
format!("+{}", phone)
} else {
format!("{} (+{})", name, phone)
};
format!("[WhatsApp message from {}]\n{}", from, content)
} else {
content
};
let agent_input = if let Some(ref ctx) = reply_context {
format!("{ctx}\n{agent_input}")
} else {
agent_input
};
let agent_input = if info.source.is_group {
let chat_id_str = info.source.chat.to_string();
let fetched = channel_msg_repo
.recent(Some("whatsapp"), &chat_id_str, 30, None, None)
.await
.unwrap_or_default();
match group_history::build_preamble(
session_svc.pool(),
session_id,
fetched,
"group",
"WhatsApp",
)
.await
{
Some(preamble) => format!("{preamble}\n{agent_input}"),
None => agent_input,
}
} else {
agent_input
};
let chat_id = reply_target.to_string();
let agent_input = format!(
"[Channel: WhatsApp (chat_id: {chat_id}) — your text response is automatically sent to this chat. \
Do NOT call whatsapp_send to deliver your answer. Only use whatsapp_send for: \
sending to a different chat_id, media (photo/video/audio/document/sticker), polls, \
reactions, quote-replies, locations, contacts, or deleting a message. \
ORDERING: send any files/documents/photos FIRST, then write your final text — \
the turn must never end on a bare attachment with no closing text after it.]\n{agent_input}"
);
let turn_guard = match wa_state.try_begin_turn(session_id) {
Some(guard) => guard,
None => {
tracing::info!(
"WhatsApp: message arrived mid-turn on session {session_id} — queued for \
injection between tool rounds (#2179)"
);
wa_state.enqueue_turn_message(
session_id,
crate::brain::agent::QueuedUserMessage {
context_text: agent_input,
display_text,
origin: crate::brain::agent::PushOrigin::Ingress,
bg_meta: None,
},
);
return;
}
};
wa_state.begin_turn(session_id).await;
let typing_cancel = CancellationToken::new();
let cancel_token = CancellationToken::new();
tokio::spawn({
let client = client.clone();
let chat_jid = reply_target.clone();
let cancel = typing_cancel.clone();
let stop = cancel_token.clone();
let background = agent.background_manager();
let agents = agent.subagent_manager();
let state = wa_state.clone();
async move {
let mut failures: u32 = 0;
loop {
let delay = if !state.is_connected().await {
failures = failures.saturating_add(1);
tracing::debug!(
failures,
"WhatsApp not connected; skipping composing indicator"
);
match composing_retry_delay(failures) {
Some(d) => d,
None => break,
}
} else {
match client.chatstate().send_composing(&chat_jid).await {
Ok(()) => {
failures = 0;
composing_retry_delay(0).unwrap_or(COMPOSING_BASE_DELAY)
}
Err(e) => {
failures = failures.saturating_add(1);
if failures == 1 {
tracing::warn!(
error = %e,
"failed to send WhatsApp composing indicator"
);
} else {
tracing::debug!(
error = %e,
failures,
"composing indicator still failing"
);
}
match composing_retry_delay(failures) {
Some(d) => d,
None => break,
}
}
}
};
tokio::select! {
_ = cancel.cancelled() => break,
_ = tokio::time::sleep(delay) => {}
}
}
crate::channels::typing_tick::tick_while_detached(
background,
agents,
session_id,
Some(stop.clone()),
std::time::Duration::from_secs(5),
crate::channels::typing_tick::DEFAULT_TICK_CEILING,
|| async {
if !state.is_connected().await {
return;
}
if let Err(e) = client.chatstate().send_composing(&chat_jid).await {
tracing::debug!(
error = %e,
"failed to send WhatsApp composing indicator"
);
}
},
)
.await;
if !stop.is_cancelled()
&& let Err(e) = client.chatstate().send_paused(&chat_jid).await
{
tracing::warn!(error = %e, "failed to send WhatsApp paused indicator");
}
}
});
let was_streamed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let sent_intermediates: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let (stream_sink, stream_task) = {
let rl_jid = reply_target.to_string();
super::stream::spawn(super::stream::StreamConfig {
client: client.clone(),
jid: reply_target.clone(),
session_id,
state: wa_state.clone(),
rate_limit: wa_cfg.rate_limit.clone(),
is_owner: wa_cfg.is_owner(rl_jid.split('@').next().unwrap_or(&rl_jid)),
header: MSG_HEADER.to_string(),
})
};
let stream_sink_cb = stream_sink.clone();
let progress_cb: ProgressCallback = {
let client_cb = client.clone();
let jid_cb = reply_target.clone();
let was_streamed_cb = was_streamed.clone();
let wa_state_cb = wa_state.clone();
let interactive_cb = wa_cfg.interactive_buttons;
Arc::new(move |session_id, event| match event {
ProgressEvent::IntermediateText { text, .. } => {
let (clean, _) = crate::utils::extract_img_markers(&text);
let clean = redact_secrets(&clean);
let clean = crate::utils::sanitize::strip_llm_artifacts(&clean);
let clean = crate::utils::slack_fmt::markdown_to_mrkdwn(&clean);
if !clean.trim().is_empty() {
let mut prev = sent_intermediates.lock().unwrap();
if prev.iter().any(|s| s == &clean) {
drop(prev);
return;
}
prev.push(clean.clone());
drop(prev);
was_streamed_cb.store(true, std::sync::atomic::Ordering::Relaxed);
stream_sink_cb.push(clean);
}
}
ProgressEvent::SelfHealingAlert { message } => {
let client = client_cb.clone();
let jid = jid_cb.clone();
let alert = format!(
"{}\n\n🔧 {}",
MSG_HEADER,
crate::utils::sanitize::normalize_dashes(&message)
);
tokio::spawn(async move {
let msg = waproto::whatsapp::Message {
conversation: Some(alert),
..Default::default()
};
if let Err(e) = client.send_message(jid, msg).await {
tracing::error!("WhatsApp: self-healing alert send failed: {}", e);
}
});
}
ProgressEvent::RetryAttempt {
attempt,
max,
reason,
} => {
let client = client_cb.clone();
let jid = jid_cb.clone();
let text = format!(
"{}\n\n⏳ Retry {}/{} — {}",
MSG_HEADER, attempt, max, reason
);
tokio::spawn(async move {
let msg = waproto::whatsapp::Message {
conversation: Some(text),
..Default::default()
};
if let Err(e) = client.send_message(jid, msg).await {
tracing::error!("WhatsApp: retry attempt send failed: {}", e);
}
});
}
ProgressEvent::ProviderSwitched {
to_name, to_model, ..
} => {
let client = client_cb.clone();
let jid = jid_cb.clone();
let text = format!("{}\n\n🔄 Now using {}/{}", MSG_HEADER, to_name, to_model);
tokio::spawn(async move {
let msg = waproto::whatsapp::Message {
conversation: Some(text),
..Default::default()
};
if let Err(e) = client.send_message(jid, msg).await {
tracing::error!("WhatsApp: provider switched send failed: {}", e);
}
});
}
ProgressEvent::SuggestedOptions(options) if !options.is_empty() => {
let client = client_cb.clone();
let jid = jid_cb.clone();
let wa = wa_state_cb.clone();
let interactive = interactive_cb;
let raw_options: Vec<String> = options.into_iter().map(|item| item.label).collect();
tokio::spawn(async move {
super::followups::send_suggestions(
client,
jid,
wa,
session_id,
raw_options,
interactive,
)
.await;
});
}
_ => {}
})
};
let interactive_buttons = wa_cfg.interactive_buttons;
let approval_cb: ApprovalCallback = {
use crate::channels::whatsapp::WaApproval;
use crate::utils::check_approval_policy;
let client = client.clone();
let chat_jid = reply_target.clone();
let phone_key = canonical_phone.clone();
let wa_state = wa_state.clone();
Arc::new(move |tool_info| {
let client = client.clone();
let chat_jid = chat_jid.clone();
let phone_key = phone_key.clone();
let wa_state = wa_state.clone();
Box::pin(async move {
if let Some(result) = check_approval_policy() {
return Ok(result);
}
let safe_input = crate::utils::redact_tool_input(&tool_info.tool_input);
let input_preview = serde_json::to_string_pretty(&safe_input).unwrap_or_default();
let body = format!(
"🔐 *Tool Approval Required*\n\nTool: `{}`\n```\n{}\n```",
tool_info.tool_name,
truncate_str(&input_preview, 600),
);
let prompt = format!(
"{}\n\n{}\n\nReply *yes*, *always* (session), *yolo* (permanent), or *no* (5 min timeout).",
MSG_HEADER, body
);
let text_msg = if interactive_buttons {
super::interactive::build(
&prompt,
Some("Tap a button, or reply with the word."),
&[
super::interactive::Button::new("wa_approve_yes", "Yes"),
super::interactive::Button::new("wa_approve_always", "Always"),
super::interactive::Button::new("wa_approve_no", "No"),
],
)
} else {
waproto::whatsapp::Message {
conversation: Some(prompt),
..Default::default()
}
};
tracing::info!(
"WhatsApp approval: sending request for tool '{}' to {}",
tool_info.tool_name,
phone_key
);
if let Err(e) = client.send_message(chat_jid.clone(), text_msg).await {
tracing::error!("WhatsApp: failed to send approval request: {}", e);
return Ok((false, false));
}
let (tx, rx) = tokio::sync::oneshot::channel::<WaApproval>();
wa_state
.register_pending_approval(phone_key.clone(), tx)
.await;
tracing::info!(
"WhatsApp approval: registered pending for phone={}, waiting for response",
phone_key
);
match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
Ok(Ok(WaApproval::Yes)) => {
tracing::info!("WhatsApp approval: user approved (phone={})", phone_key);
Ok((true, false))
}
Ok(Ok(WaApproval::Always)) => {
tracing::info!(
"WhatsApp approval: user chose Always (phone={})",
phone_key
);
Ok((true, true))
}
Ok(Ok(WaApproval::Yolo)) => {
tracing::info!("WhatsApp approval: user chose YOLO (phone={})", phone_key);
Ok((true, true))
}
Ok(Ok(WaApproval::No)) => {
tracing::info!("WhatsApp approval: user denied (phone={})", phone_key);
Ok((false, false))
}
_ => {
tracing::warn!(
"WhatsApp: approval timed out or channel dropped — denying (phone={})",
phone_key
);
let timeout_msg = waproto::whatsapp::Message {
conversation: Some(format!(
"{}\n\n⏰ No response in 5 minutes — *{}* was denied.\n\nSend your message again and reply *yes*, *always*, or *no* when prompted.",
MSG_HEADER, tool_info.tool_name,
)),
..Default::default()
};
if let Err(e) = client.send_message(chat_jid, timeout_msg).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
Ok((false, false))
}
}
})
})
};
wa_state
.store_cancel_token(session_id, cancel_token.clone())
.await;
let wa_chat_id = format!("{}", info.source.chat);
let result = agent
.send_message_with_tools_and_display(
session_id,
agent_input,
Some(display_text),
None,
Some(cancel_token),
Some(approval_cb),
Some(progress_cb),
"whatsapp",
Some(&wa_chat_id),
None,
)
.await;
drop(stream_sink);
match tokio::time::timeout(std::time::Duration::from_secs(60), stream_task).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(error = %e, "WhatsApp streaming task panicked"),
Err(_) => tracing::warn!(
"WhatsApp streaming task still running after 60s; a progress callback \
clone outlived the agent call, so the final edit may be skipped"
),
}
wa_state.remove_cancel_token(session_id).await;
typing_cancel.cancel();
match result {
Ok(response) => {
let reply_jid = reply_target.clone();
let (text_content, img_paths) = crate::utils::extract_img_markers(&response.content);
let text_content = crate::utils::sanitize::strip_llm_artifacts(&text_content);
let text_content = redact_secrets(&text_content);
let text_content = crate::utils::slack_fmt::markdown_to_mrkdwn(&text_content);
for img_path in img_paths {
match tokio::fs::read(&img_path).await {
Ok(bytes) => {
use wacore::download::MediaType;
use waproto::whatsapp::message::ImageMessage;
use whatsapp_rust::upload::UploadOptions;
match client
.upload(bytes, MediaType::Image, UploadOptions::new())
.await
{
Ok(upload) => {
let mime = if img_path.ends_with(".png") {
"image/png"
} else {
"image/jpeg"
};
let img_msg = waproto::whatsapp::Message {
image_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.to_string()),
..Default::default()
}
.into(),
..Default::default()
};
if let Err(e) =
client.send_message(reply_jid.clone(), img_msg).await
{
tracing::error!(
"WhatsApp: failed to send generated image: {}",
e
);
}
}
Err(e) => {
tracing::error!(
"WhatsApp: image upload failed for {}: {}",
img_path,
e
);
}
}
}
Err(e) => {
tracing::error!("WhatsApp: failed to read image {}: {}", img_path, e);
}
}
}
let streamed = was_streamed.load(std::sync::atomic::Ordering::Relaxed);
if streamed && !text_content.trim().is_empty() {
let finished = format!("{}\n\n{}", MSG_HEADER, text_content.trim());
if !super::stream::finalize(&client, &reply_jid, &wa_state, session_id, &finished)
.await
{
tracing::debug!(
target: "whatsapp",
"edit-in-place unavailable; streamed intermediates stand as the final answer"
);
}
}
if !text_content.is_empty() && !streamed {
let ctx_max = agent.context_limit_for_session(session_id);
let footer = crate::utils::format_ctx_footer(
response.context_tokens,
ctx_max,
response.tokens_per_second,
);
let tagged = format!("{}\n\n{}", MSG_HEADER, text_content);
let mut chunks: Vec<String> = split_message(&tagged, 4000)
.into_iter()
.map(|s| s.to_string())
.collect();
if let Some(last) = chunks.last_mut() {
last.push_str("\n\n");
last.push_str(&footer);
} else if !footer.is_empty() {
chunks.push(footer.clone());
}
let rl_jid = reply_jid.to_string();
let rl_owner = wa_cfg.is_owner(rl_jid.split('@').next().unwrap_or(&rl_jid));
let mut rl_queued = 0usize;
for chunk in &chunks {
match wa_state
.rate_limiter
.gate(&wa_cfg.rate_limit, &rl_jid, chunk, rl_owner)
.await
{
super::rate_limit::GateOutcome::Queued { .. } => {
rl_queued += 1;
continue;
}
super::rate_limit::GateOutcome::SendNow => {}
}
let reply_msg = waproto::whatsapp::Message {
conversation: Some(chunk.to_string()),
..Default::default()
};
match send_resilient(&client, reply_jid.clone(), reply_msg).await {
Some(message_id) => {
wa_state
.record_outbound(
session_id,
super::outbox::OutboxEntry::new(message_id, chunk),
)
.await;
}
None => tracing::warn!(
target: "whatsapp",
"final-text chunk send failed; nothing tracked for this turn"
),
}
}
if rl_queued > 0 {
tracing::warn!(
target: "whatsapp",
"daily cap reached; {rl_queued} final-text chunk(s) queued for drainer flush"
);
}
}
if !text_content.trim().is_empty() {
let chat_id = format!("{}", info.source.chat);
let is_group = info.source.is_group;
let cm = DbChannelMessage::new(
"whatsapp".into(),
chat_id,
if is_group {
Some(format!("{}", info.source.chat))
} else {
None
},
"bot:opencrabs".to_string(),
"OpenCrabs".to_string(),
text_content.clone(),
"text".into(),
None,
);
if let Err(e) = channel_msg_repo.insert(&cm).await {
tracing::warn!(
"WhatsApp: failed to record bot reply in channel_messages: {}",
e
);
}
}
if streamed {
super::reaction::acknowledge_completion(
&client,
&reply_jid,
&wa_state,
session_id,
super::reaction::COMPLETION_EMOJI,
)
.await;
}
if has_aud && voice_config.tts_enabled {
if let Err(e) = client.chatstate().send_recording(&reply_jid).await {
tracing::warn!(error = %e, "WhatsApp: recording indicator failed");
}
match crate::channels::voice::synthesize(&response.content, &voice_config).await {
Ok(audio_bytes) => {
use wacore::download::MediaType;
use waproto::whatsapp::message::AudioMessage;
use whatsapp_rust::upload::UploadOptions;
match client
.upload(audio_bytes, MediaType::Audio, UploadOptions::new())
.await
{
Ok(upload) => {
let audio_msg = waproto::whatsapp::Message {
audio_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("audio/ogg; codecs=opus".to_string()),
ptt: Some(true),
..Default::default()
}
.into(),
..Default::default()
};
if let Err(e) =
client.send_message(reply_jid.clone(), audio_msg).await
{
tracing::error!("WhatsApp: failed to send TTS voice: {}", e);
}
}
Err(e) => {
tracing::error!("WhatsApp: TTS audio upload failed: {}", e);
}
}
}
Err(e) => {
tracing::error!("WhatsApp: TTS synthesis error: {}", e);
}
}
if let Err(e) = client.chatstate().send_paused(&reply_jid).await {
tracing::warn!(error = %e, "WhatsApp: clearing recording indicator failed");
}
}
}
Err(ref e) if matches!(e, crate::brain::agent::AgentError::Cancelled) => {
tracing::info!("WhatsApp: agent call cancelled for session {}", session_id);
}
Err(e) => {
tracing::error!("WhatsApp: agent error: {}", e);
let error_msg = waproto::whatsapp::Message {
conversation: Some(format!(
"{}\n\n❌ Error\n\n{}",
MSG_HEADER,
crate::brain::agent::format_user_error(&e)
)),
..Default::default()
};
if let Err(e) = client.send_message(reply_target.clone(), error_msg).await {
tracing::warn!(error = %e, "failed to send WhatsApp message");
}
}
}
drop(turn_guard);
super::turns::flush_after_turn(wa_state.clone(), agent.clone(), session_id);
}
pub(crate) async fn send_connection_greeting(
client: Arc<Client>,
agent: Arc<AgentService>,
session_svc: SessionService,
wa_state: Arc<WhatsAppState>,
owner_number: String,
) {
let owner_number = owner_number.trim_start_matches('+').to_string();
if owner_number.is_empty() {
wa_state.broadcast_error(
"WhatsApp connected but the owner number is empty — cannot send confirmation.",
);
return;
}
let jid_str = format!("{owner_number}@s.whatsapp.net");
let jid: wacore_binary::jid::Jid = match jid_str.parse() {
Ok(j) => j,
Err(e) => {
wa_state.broadcast_error(&format!(
"WhatsApp connected but owner JID '{jid_str}' is invalid: {e}"
));
return;
}
};
let idle_timeout_hours = Config::load()
.ok()
.and_then(|c| c.channels.whatsapp.session_idle_hours);
let session_id = {
use crate::channels::session_resolve;
let legacy_title = format!("WhatsApp: {}", owner_number);
let suffix = session_resolve::chat_id_suffix(&format!("wa-{owner_number}"));
let session_title = format!("{legacy_title} {suffix}");
match session_resolve::resolve_or_create_channel_session(
&session_svc,
&suffix,
&legacy_title,
&session_title,
idle_timeout_hours,
"WhatsApp",
)
.await
{
Ok(id) => id,
Err(e) => {
wa_state.broadcast_error(&format!(
"WhatsApp connected but failed to resolve owner session for the \
confirmation greeting: {e}"
));
return;
}
}
};
let session_meta = session_svc.get_session(session_id).await.ok().flatten();
crate::channels::commands::sync_provider_for_session(
&agent,
session_id,
session_meta
.as_ref()
.and_then(|s| s.provider_name.as_deref()),
session_meta.as_ref().and_then(|s| s.model.as_deref()),
)
.await;
let prompt = "[Channel: WhatsApp — your text response is automatically sent to this chat. \
Do NOT call whatsapp_send to deliver your answer; it exists only for media, polls, \
reactions, quote-replies, and sends to a different chat_id. Just reply with text.]\n\
You have just connected to the owner over WhatsApp. Send one short, \
natural first message in your own voice letting them know you are here. \
Do not use a generic canned status line."
.to_string();
let result = agent
.send_message_with_tools_and_display(
session_id,
prompt,
None,
None,
None,
None,
None,
"whatsapp",
Some(&jid_str),
None,
)
.await;
match result {
Ok(response) => {
let (text_content, _imgs) = crate::utils::extract_img_markers(&response.content);
let text_content = crate::utils::sanitize::strip_llm_artifacts(&text_content);
let text_content = redact_secrets(&text_content);
let text_content = crate::utils::slack_fmt::markdown_to_mrkdwn(&text_content);
if text_content.trim().is_empty() {
wa_state.broadcast_error(
"WhatsApp connected but the agent produced no greeting — onboarding \
round trip failed.",
);
return;
}
let tagged = format!("{}\n\n{}", MSG_HEADER, text_content.trim());
for chunk in split_message(&tagged, 4000) {
let msg = waproto::whatsapp::Message {
conversation: Some(chunk.to_string()),
..Default::default()
};
if send_resilient(&client, jid.clone(), msg).await.is_none() {
tracing::warn!(
target: "whatsapp",
"onboarding greeting chunk failed to send"
);
}
}
tracing::info!("WhatsApp: sent connection greeting to owner self-chat {jid_str}");
}
Err(e) => {
wa_state.broadcast_error(&format!(
"WhatsApp connected but the agent could not generate a greeting: {e}"
));
}
}
}