use std::collections::HashMap;
use tokio::sync::Mutex;
use wacore::poll::compute_option_hash;
pub(crate) fn resolve_options(selected: &[Vec<u8>], options: &[String]) -> Vec<String> {
options
.iter()
.filter(|option| {
let hash = compute_option_hash(option);
selected.iter().any(|s| s.as_slice() == hash.as_slice())
})
.cloned()
.collect()
}
pub(crate) fn describe_vote(voter: &str, chosen: &[String]) -> Option<String> {
if chosen.is_empty() {
return None;
}
Some(format!("[poll vote] {voter} chose: {}", chosen.join(", ")))
}
pub(crate) const POLL_CAPACITY: usize = 100;
#[derive(Default)]
struct Inner {
order: Vec<String>,
options: HashMap<String, Vec<String>>,
}
#[derive(Default)]
pub(crate) struct PollOptions {
inner: Mutex<Inner>,
}
impl PollOptions {
pub async fn remember(&self, message_id: impl Into<String>, options: Vec<String>) {
let id = message_id.into();
if id.is_empty() || options.is_empty() {
return;
}
let mut inner = self.inner.lock().await;
if inner.options.insert(id.clone(), options).is_some() {
inner.order.retain(|existing| existing != &id);
}
inner.order.push(id);
while inner.order.len() > POLL_CAPACITY {
let evicted = inner.order.remove(0);
inner.options.remove(&evicted);
}
}
pub async fn get(&self, message_id: &str) -> Option<Vec<String>> {
self.inner.lock().await.options.get(message_id).cloned()
}
#[cfg(test)]
pub async fn len(&self) -> usize {
self.inner.lock().await.options.len()
}
}
pub(crate) struct Vote {
pub line: String,
pub chosen: Vec<String>,
}
pub(crate) async fn decode_vote(
client: &whatsapp_rust::client::Client,
state: &super::WhatsAppState,
update: &waproto::whatsapp::message::PollUpdateMessage,
chat_jid: &wacore_binary::jid::Jid,
voter_jid: &wacore_binary::jid::Jid,
voter_label: &str,
) -> Option<Vote> {
let key = update.poll_creation_message_key.as_option()?;
let poll_id = key.id.as_deref()?;
let Some(options) = state.polls.get(poll_id).await else {
tracing::debug!(
target: "whatsapp",
poll_id,
"poll vote for a poll outside the tracked window; cannot label the options"
);
return None;
};
let vote = update.vote.as_option()?;
let (payload, iv) = (vote.enc_payload.as_deref()?, vote.enc_iv.as_deref()?);
let chat = chat_jid.to_non_ad_string();
let creator = key
.remote_jid
.as_deref()
.and_then(|j| j.parse::<wacore_binary::jid::Jid>().ok())
.map(|j| j.to_non_ad_string())
.unwrap_or_else(|| chat.clone());
let secret = match client
.persistence_manager()
.backend()
.get_msg_secret(&chat, &creator, poll_id)
.await
{
Ok(Some(secret)) => secret,
Ok(None) => {
tracing::debug!(
target: "whatsapp",
poll_id,
"no message secret stored for this poll; vote cannot be decrypted"
);
return None;
}
Err(e) => {
tracing::warn!(
target: "whatsapp",
error = %e,
poll_id,
"message-secret lookup failed; poll vote dropped"
);
return None;
}
};
let creator_jid = key
.remote_jid
.as_deref()
.and_then(|j| j.parse::<wacore_binary::jid::Jid>().ok())
.unwrap_or_else(|| chat_jid.clone());
let selected = match client
.polls()
.decrypt_vote(
wacore::poll::PollVoteCiphertext {
enc_payload: payload,
enc_iv: iv,
},
&secret,
poll_id,
&creator_jid,
voter_jid,
)
.await
{
Ok(selected) => selected,
Err(e) => {
tracing::warn!(
target: "whatsapp",
error = %e,
poll_id,
"poll vote failed to decrypt"
);
return None;
}
};
let chosen = resolve_options(&selected, &options);
let line = describe_vote(voter_label, &chosen)?;
Some(Vote { line, chosen })
}