use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::config::types::WaRateLimitConfig;
#[derive(Debug, Clone, PartialEq)]
pub struct QueuedSend {
pub jid: String,
pub text: String,
}
#[derive(Debug, PartialEq)]
pub enum Decision {
SendNow,
Pace(Duration),
Queue { alert: bool },
}
#[derive(Debug, Default)]
pub struct LimiterState {
tokens: f64,
last_refill: Option<Instant>,
window: VecDeque<Instant>,
saturation: bool,
pub(crate) queue: VecDeque<QueuedSend>,
}
impl LimiterState {
pub fn queue_len(&self) -> usize {
self.queue.len()
}
pub fn saturated(&self) -> bool {
self.saturation
}
}
pub fn admit(
state: &mut LimiterState,
cfg: &WaRateLimitConfig,
now: Instant,
is_owner: bool,
) -> Decision {
if is_owner {
return Decision::SendNow;
}
let bucket_on = cfg.messages_per_minute > 0;
let cap_on = cfg.daily_cap > 0;
if !bucket_on && !cap_on {
return Decision::SendNow; }
if bucket_on {
let capacity = cfg.messages_per_minute as f64;
state.tokens = match state.last_refill {
Some(last) => {
let elapsed = now.saturating_duration_since(last).as_secs_f64();
(state.tokens + elapsed * capacity / 60.0).min(capacity)
}
None => capacity,
};
state.last_refill = Some(now);
}
if cap_on {
let cutoff = now - Duration::from_secs(24 * 60 * 60);
while let Some(front) = state.window.front() {
if *front <= cutoff {
state.window.pop_front();
} else {
break;
}
}
if state.window.len() >= cfg.daily_cap as usize {
let first_of_episode = !state.saturation;
state.saturation = true;
return Decision::Queue {
alert: first_of_episode,
};
}
state.saturation = false;
}
if bucket_on {
if state.tokens < 1.0 {
let capacity = cfg.messages_per_minute as f64;
let wait = Duration::from_secs_f64((1.0 - state.tokens) * 60.0 / capacity);
return Decision::Pace(wait);
}
state.tokens -= 1.0;
}
if cap_on {
state.window.push_back(now);
}
Decision::SendNow
}
pub fn drain_ready(
state: &mut LimiterState,
cfg: &WaRateLimitConfig,
now: Instant,
) -> Vec<QueuedSend> {
let mut out = Vec::new();
while !state.queue.is_empty() {
match admit(state, cfg, now, false) {
Decision::SendNow => {
if let Some(item) = state.queue.pop_front() {
out.push(item);
}
}
_ => break,
}
}
out
}
#[derive(Debug, PartialEq)]
pub enum GateOutcome {
SendNow,
Queued { position: usize },
}
#[derive(Default)]
pub struct WhatsappRateLimiter {
state: Mutex<LimiterState>,
alert_pending: AtomicBool,
alert_sent: AtomicBool,
}
impl WhatsappRateLimiter {
pub fn new() -> Self {
Self::default()
}
pub async fn gate(
&self,
cfg: &WaRateLimitConfig,
jid: &str,
text: &str,
is_owner: bool,
) -> GateOutcome {
loop {
let decision = {
let mut state = self.state.lock().await;
admit(&mut state, cfg, Instant::now(), is_owner)
};
match decision {
Decision::SendNow => return GateOutcome::SendNow,
Decision::Pace(wait) => tokio::time::sleep(wait).await,
Decision::Queue { alert } => {
let position = {
let mut state = self.state.lock().await;
state.queue.push_back(QueuedSend {
jid: jid.to_string(),
text: text.to_string(),
});
state.queue.len()
};
if alert {
self.alert_pending.store(true, Ordering::SeqCst);
}
return GateOutcome::Queued { position };
}
}
}
}
pub async fn gate_ephemeral(&self, cfg: &WaRateLimitConfig, is_owner: bool) -> bool {
loop {
let decision = {
let mut state = self.state.lock().await;
admit(&mut state, cfg, Instant::now(), is_owner)
};
match decision {
Decision::SendNow => return true,
Decision::Pace(wait) => tokio::time::sleep(wait).await,
Decision::Queue { alert } => {
if alert {
self.alert_pending.store(true, Ordering::SeqCst);
}
return false;
}
}
}
}
pub async fn queue_len(&self) -> usize {
self.state.lock().await.queue_len()
}
pub async fn drain(&self, cfg: &WaRateLimitConfig) -> Vec<QueuedSend> {
let mut state = self.state.lock().await;
let ready = drain_ready(&mut state, cfg, Instant::now());
if !state.saturated() {
self.alert_pending.store(false, Ordering::SeqCst);
self.alert_sent.store(false, Ordering::SeqCst);
}
ready
}
pub async fn requeue_front(&self, item: QueuedSend) {
self.state.lock().await.queue.push_front(item);
}
pub fn alert_due(&self) -> bool {
self.alert_pending.load(Ordering::SeqCst) && !self.alert_sent.load(Ordering::SeqCst)
}
pub fn mark_alert_sent(&self) {
self.alert_sent.store(true, Ordering::SeqCst);
}
}
static DRAINER_SPAWNED: AtomicBool = AtomicBool::new(false);
pub(crate) fn spawn_drainer(wa_state: Arc<super::WhatsAppState>, cfg: WaRateLimitConfig) {
if DRAINER_SPAWNED
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(60));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
let limiter = &wa_state.rate_limiter;
if limiter.alert_due() {
let client = wa_state.client.lock().await.clone();
let owner_jid = wa_state
.owner_jid
.lock()
.await
.clone()
.and_then(|j| j.parse::<wacore_binary::jid::Jid>().ok());
if let (Some(client), Some(owner_jid)) = (client, owner_jid) {
let queued = limiter.queue_len().await;
let text = format!(
"{}\n\nWhatsApp daily cap reached ({} messages / rolling 24h). {} message(s) queued; they flush automatically as the window slides. Messages to you are never limited.",
super::handler::MSG_HEADER,
cfg.daily_cap,
queued
);
let msg = waproto::whatsapp::Message {
conversation: Some(text),
..Default::default()
};
match client.send_message(owner_jid, msg).await {
Ok(_) => limiter.mark_alert_sent(),
Err(e) => {
tracing::warn!("WhatsApp rate-limit: owner alert failed: {e}");
}
}
}
}
for item in limiter.drain(&cfg).await {
let client = wa_state.client.lock().await.clone();
let Some(client) = client else {
limiter.requeue_front(item).await;
break;
};
let Ok(jid) = item.jid.parse::<wacore_binary::jid::Jid>() else {
tracing::warn!(
"WhatsApp rate-limit: queued jid {} unparseable; dropping",
item.jid
);
continue;
};
let msg = waproto::whatsapp::Message {
conversation: Some(item.text.clone()),
..Default::default()
};
match client.send_message(jid.clone(), msg).await {
Ok(_) => {
crate::brain::tools::whatsapp_send::persist_outgoing(&jid, &item.text)
.await;
}
Err(e) => {
tracing::warn!(
"WhatsApp rate-limit: queued send to {} failed: {e}; re-queueing",
item.jid
);
limiter.requeue_front(item).await;
break; }
}
}
}
});
}