use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use uuid::Uuid;
use wacore_binary::jid::Jid;
use whatsapp_rust::client::Client;
use super::WhatsAppState;
use super::outbox::OutboxEntry;
pub(crate) const CHUNK_LIMIT: usize = 4000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Delivery {
Edit(String),
Send(String),
}
pub(crate) fn plan(current: Option<&str>, chunk: &str, limit: usize) -> Delivery {
let chunk = chunk.trim();
let Some(body) = current else {
return Delivery::Send(chunk.to_string());
};
let merged = format!("{body}\n\n{chunk}");
if merged.chars().count() <= limit {
Delivery::Edit(merged)
} else {
Delivery::Send(chunk.to_string())
}
}
pub(crate) fn coalesce(chunks: &[String]) -> String {
chunks
.iter()
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
#[derive(Clone)]
pub(crate) struct StreamSink {
tx: mpsc::UnboundedSender<String>,
}
impl StreamSink {
pub(crate) fn push(&self, text: String) {
if let Err(e) = self.tx.send(text) {
tracing::warn!(
target: "whatsapp",
error = %e,
"streamed chunk arrived after the delivery task exited; not delivered"
);
}
}
}
pub(crate) struct StreamConfig {
pub client: Arc<Client>,
pub jid: Jid,
pub session_id: Uuid,
pub state: Arc<WhatsAppState>,
pub rate_limit: crate::config::WaRateLimitConfig,
pub is_owner: bool,
pub header: String,
}
pub(crate) fn spawn(config: StreamConfig) -> (StreamSink, JoinHandle<()>) {
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let handle = tokio::spawn(async move {
while let Some(first) = rx.recv().await {
let mut batch = vec![first];
while let Ok(next) = rx.try_recv() {
batch.push(next);
}
let chunk = coalesce(&batch);
if chunk.is_empty() {
continue;
}
deliver(&config, &chunk).await;
}
});
(StreamSink { tx }, handle)
}
async fn deliver(config: &StreamConfig, chunk: &str) {
let tracked = config.state.editable_outbound(config.session_id).await;
let plan = plan(
tracked.as_ref().map(|e| e.body.as_str()),
chunk,
CHUNK_LIMIT,
);
match (plan, tracked) {
(Delivery::Edit(full), Some(entry)) => {
if !config
.state
.rate_limiter
.gate_ephemeral(&config.rate_limit, config.is_owner)
.await
{
tracing::debug!(
target: "whatsapp",
"daily cap reached; skipping streamed edit"
);
return;
}
let message = waproto::whatsapp::Message {
conversation: Some(full.clone()),
..Default::default()
};
match config
.client
.edit_message(config.jid.clone(), entry.message_id.clone(), message)
.await
{
Ok(_) => {
config
.state
.update_outbound_body(config.session_id, full)
.await;
}
Err(e) => {
tracing::warn!(
target: "whatsapp",
error = %e,
message_id = %entry.message_id,
"streamed edit rejected; falling back to append for the rest of this turn"
);
config.state.clear_outbound(config.session_id).await;
send_new(config, chunk, false).await;
}
}
}
(Delivery::Send(text), tracked) => {
let first_of_turn = tracked.is_none();
send_new(config, &text, first_of_turn).await;
}
(Delivery::Edit(full), None) => {
send_new(config, &full, true).await;
}
}
}
async fn send_new(config: &StreamConfig, text: &str, with_header: bool) {
let body = if with_header && !config.header.is_empty() {
format!("{}\n\n{}", config.header, text.trim())
} else {
text.trim().to_string()
};
for piece in super::handler::split_message(&body, CHUNK_LIMIT) {
if !config
.state
.rate_limiter
.gate_ephemeral(&config.rate_limit, config.is_owner)
.await
{
tracing::debug!(
target: "whatsapp",
"daily cap reached; dropping streamed chunk"
);
continue;
}
let message = waproto::whatsapp::Message {
conversation: Some(piece.to_string()),
..Default::default()
};
match config
.client
.send_message(config.jid.clone(), message)
.await
{
Ok(result) => {
config
.state
.record_outbound(
config.session_id,
OutboxEntry::new(result.message_id, piece),
)
.await;
}
Err(e) => {
tracing::error!(target: "whatsapp", error = %e, "streamed chunk send failed");
}
}
}
}
pub(crate) async fn finalize(
client: &Arc<Client>,
jid: &Jid,
state: &Arc<WhatsAppState>,
session_id: Uuid,
full_text: &str,
) -> bool {
let Some(entry) = state.editable_outbound(session_id).await else {
return false;
};
if entry.body.trim() == full_text.trim() {
return true;
}
if full_text.chars().count() > CHUNK_LIMIT {
tracing::debug!(
target: "whatsapp",
chars = full_text.chars().count(),
"final answer exceeds one message; leaving the streamed intermediates in place"
);
return false;
}
let message = waproto::whatsapp::Message {
conversation: Some(full_text.to_string()),
..Default::default()
};
match client
.edit_message(jid.clone(), entry.message_id.clone(), message)
.await
{
Ok(_) => {
state.update_outbound_body(session_id, full_text).await;
true
}
Err(e) => {
tracing::warn!(
target: "whatsapp",
error = %e,
message_id = %entry.message_id,
"final edit rejected; streamed intermediates stand as the delivery"
);
state.clear_outbound(session_id).await;
false
}
}
}