A Discord poll can be answered, not just read

`extract_poll` drew the question and the answers into the log and that was
as far as it went: `votePoll` dispatched to Matrix and to Kick and told a
Discord buffer it was not a Kick channel, so the one thing a poll exists for
could not be done. Kick and Matrix polls have both been votable here for a
while; Discord was the odd one out.

It lands on the card Kick and Matrix polls already land on. One question with
a set of answers and a tally is one shape whoever is asking, and the renderer
draws it - so this is a card builder, a place to announce from, and a branch
in the method the client already calls.

A Discord poll is part of a message rather than an event of its own, so the
card's id is the message id and that is what a vote is addressed to. Nothing
comes back but an acknowledgement; the recounted tally arrives as a
MESSAGE_UPDATE, which is why the announce sits above that handler's early
return - an update carrying a new tally has no edit stamp and no embeds, and
would otherwise be read as nothing having happened.

Two things the shape of Discord's payload forces. `answer_counts` is absent
entirely until the first vote, so a missing entry is nought rather than
missing data. And a poll is shut two ways: Discord saying the counting is
finalised, and a clock that has run out while Discord has not got round to
saying so - offering a vote that would be refused is worse than saying it is
closed.

History announces one too, but only while it is still running. Discord's
polls last hours or days, so a poll posted before the conversation was opened
is the commonest one to meet; a finished poll is history and stays in the log.

Closes #118.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-09 23:15:33 -04:00
co-authored by Claude Opus 5
parent 52f712c8a5
commit 02e08f8ff6
5 changed files with 300 additions and 0 deletions
+15
View File
@@ -759,6 +759,12 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// messages are nothing but their buttons.
if let Some(id) = d["id"].as_str() {
note_components(state, &thumb_target.0, id, d);
// A poll is drawn on a card as well as written
// into the log: the log records what was asked,
// the card is the thing that can be answered.
if polls::has_poll(d) {
polls::announce(state, &thumb_target.0, id, d);
}
}
if !thumb_target.1.is_empty() {
cache_thumbnails(state.clone(), thumb_target.0, thumb_target.1, thumb_target.2);
@@ -769,6 +775,15 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
let Some((buffer_name, _)) = channel_map.get(channel_id).cloned() else { continue };
let Some(msg_id) = d["id"].as_str() else { continue };
let buffer_id = model::buffer_id(&account_id, &buffer_name);
// A vote - anybody's - comes back as an update to the
// message carrying the poll, with a recounted
// `results` in it. Before everything below, because
// that update has no edit stamp and no embeds, and
// would otherwise be dropped as nothing having
// happened.
if polls::has_poll(d) {
polls::announce(state, &buffer_id, msg_id, d);
}
// No edited_timestamp means nobody edited anything:
// this is Discord re-sending the message once it has
// finished unfurling a link. That used to be dropped,
+7
View File
@@ -747,6 +747,13 @@ pub(super) fn store_history_messages(state: &AppState, buffer_id: &str, messages
continue;
}
note_components(state, buffer_id, msg_id, msg);
// A poll in the history may still be running - Discord's last hours
// or days, so a channel opened for the first time is the commonest
// place to meet one. Read oldest first, so the newest still-open poll
// is the one left on the card.
if polls::has_poll(msg) {
polls::announce_if_open(state, buffer_id, msg_id, msg);
}
// Who wrote a message somebody forwarded, which the snapshot does not
// carry. Backfill needs this as much as the live path: a conversation
// read for the first time is all history, and a forward in it would
+3
View File
@@ -32,6 +32,7 @@
//! - `history` - reading backwards through a conversation
//! - `media` - thumbnails, and re-signing links Discord expires
//! - `commands` - slash commands and the interactions that answer them
//! - `polls` - the card a poll is drawn on, and answering one
//! - `search` - Discord's own index, asked with its own filters
//! - `calls` - ringing and being rung; `voice` is the audio itself
//!
@@ -54,6 +55,7 @@ pub mod login;
pub mod media;
pub mod messages;
pub mod people;
pub mod polls;
pub mod presence;
pub mod search;
pub mod send;
@@ -70,6 +72,7 @@ pub use login::*;
pub use media::*;
pub use messages::*;
pub use people::*;
pub use polls::*;
pub use presence::*;
pub use search::*;
pub use send::*;
+256
View File
@@ -0,0 +1,256 @@
//! Discord polls: the card, and the one thing a poll exists for.
//!
//! A Discord poll is part of a message rather than an event of its own -
//! question, answers, an expiry, and a `results` block Discord recounts as
//! votes arrive. `extract_poll` in `messages` writes it into the log as text,
//! which is the record of what was asked; this is the thing that can be
//! answered.
//!
//! It lands on the same card a Kick poll or a Matrix one does. One question
//! with a set of answers and a tally is one shape, whoever is asking, and the
//! renderer already draws it.
//!
//! One poll per conversation is showing at a time, which is the card's own
//! rule rather than Discord's: a channel can hold several open polls, and the
//! most recent one to move is the one on screen. The log keeps the rest.
use super::*;
/// Whether this message carries a poll at all.
pub(super) fn has_poll(d: &Value) -> bool {
d.get("poll").filter(|p| p.is_object()).is_some()
}
/// Builds the card for a Discord poll.
///
/// The tally comes from `results.answer_counts`, which Discord omits entirely
/// until the first vote - an unanswered poll has no counts rather than a row
/// of zeroes, so a missing entry is nought and not missing data.
pub(super) fn card(buffer_id: &str, message_id: &str, poll: &Value) -> Option<Value> {
let question = poll["question"]["text"].as_str().unwrap_or("Poll");
let counts = &poll["results"]["answer_counts"];
let count_for = |id: i64| -> (u64, bool) {
counts
.as_array()
.into_iter()
.flatten()
.find(|c| c["id"].as_i64() == Some(id))
.map(|c| (c["count"].as_u64().unwrap_or(0), c["me_voted"].as_bool().unwrap_or(false)))
.unwrap_or((0, false))
};
let mut options: Vec<Value> = Vec::new();
let mut voted_for: Option<i64> = None;
for answer in poll["answers"].as_array().into_iter().flatten() {
let Some(id) = answer["answer_id"].as_i64() else { continue };
let text = answer["poll_media"]["text"].as_str().unwrap_or("");
let emoji = answer["poll_media"]["emoji"]["name"].as_str().unwrap_or("");
// An answer can be an emoji and nothing else, which is a label.
let label = match (emoji.is_empty(), text.is_empty()) {
(true, true) => continue,
(true, false) => text.to_string(),
(false, true) => emoji.to_string(),
(false, false) => format!("{emoji} {text}"),
};
let (votes, mine) = count_for(id);
if mine {
voted_for = Some(id);
}
options.push(json!({ "id": id, "label": label, "votes": votes }));
}
if options.is_empty() {
return None;
}
// Discord gives an absolute moment rather than a countdown, so the
// seconds left are worked out here and stamped with when that was true -
// the card counts down from the pair.
let now = now_secs();
let expiry = poll["expiry"].as_str().and_then(parse_expiry);
let remaining = expiry.map(|at| (at - now).max(0)).unwrap_or(0);
// `is_finalized` means the counting is over. A poll past its expiry that
// Discord has not finalised yet is closed too: the clock is what people
// read, and offering a vote that would be refused is worse than saying so.
let finalized = poll["results"]["is_finalized"].as_bool().unwrap_or(false);
let open = !finalized && expiry.map(|at| at > now).unwrap_or(true);
Some(json!({
"bufferId": buffer_id,
"kind": "poll",
// The message is the poll: it is what a vote is addressed to.
"id": message_id,
"title": question,
"options": options,
"open": open,
"duration": 0,
"remaining": remaining,
// Discord leaves a finished poll in the channel forever, so there is
// no window after which the result stops being shown.
"resultDisplayDuration": 0,
"hasVoted": voted_for.is_some(),
"votedOptionId": voted_for,
"asOf": now,
}))
}
/// This moment, in unix seconds.
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
/// Discord's expiry is an ISO 8601 instant; this wants a unix second.
fn parse_expiry(at: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(at).ok().map(|t| t.timestamp())
}
/// Draws the poll in a message, and writes it down.
///
/// Called for every message carrying one, arriving or updated - a vote by
/// anybody comes back as a MESSAGE_UPDATE with a new tally in it, which is
/// how the counts move without asking.
pub(super) fn announce(state: &AppState, buffer_id: &str, message_id: &str, d: &Value) {
let Some(card) = card(buffer_id, message_id, &d["poll"]) else { return };
state.runtime.set_live_card(buffer_id, "poll", card.clone());
// Kept as well as shown, so a channel's past polls are readable from the
// history panel the way a Kick channel's or a Matrix room's are.
let title = card["title"].as_str().unwrap_or_default().to_string();
let ts = now_secs();
if let Err(e) = state.store.record_live_card(buffer_id, "poll", message_id, &title, &card.to_string(), ts) {
tracing::debug!("discord: keeping the poll: {e:#}");
}
state
.events
.emit("pollCard", json!({ "bufferId": buffer_id, "kind": "poll", "poll": card }));
}
/// Draws a poll read back out of history, if it is still running.
///
/// A poll posted before this conversation was opened is the one a channel
/// read for the first time most often has open in it - Discord's run for
/// hours or days, so the live path alone would never show it. A finished one
/// is history and stays in the log where it was written.
pub(super) fn announce_if_open(state: &AppState, buffer_id: &str, message_id: &str, d: &Value) {
if card(buffer_id, message_id, &d["poll"]).is_some_and(|c| c["open"] == json!(true)) {
announce(state, buffer_id, message_id, d);
}
}
/// Answers a poll.
///
/// `PUT .../answers/@me` is what the official client sends, and it takes a
/// list because a poll may allow several answers. This client offers one, so
/// it sends one - a multi-select poll is read correctly and answered singly,
/// the same compromise the Matrix side makes.
///
/// Through `send_write`, so it is paced and waits out a rate limit like every
/// other thing this account does rather than sends.
pub async fn vote_in_poll(state: &AppState, account_id: &str, buffer_id: &str, message_id: &str, answer_id: i64) -> Result<()> {
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
let channel_id = state
.runtime
.get_discord_channel(buffer_id)
.context("no known Discord channel for this conversation")?;
let resp = send_write(
http_client()
.put(format!("{API_BASE}/channels/{channel_id}/polls/{message_id}/answers/@me"))
.header("Authorization", &cfg.token)
.json(&json!({ "answer_ids": [answer_id.to_string()] })),
)
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("{}", discord_error_text(status, &text, "voting in the poll"));
}
// Nothing comes back but a 204. The new tally arrives as a MESSAGE_UPDATE
// moments later and redraws the card, the same as anybody else's vote.
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A poll nobody has answered: Discord sends no `answer_counts` at all,
/// and every answer is nought votes rather than an empty card.
#[test]
fn a_poll_with_no_votes_is_still_a_poll() {
let poll = json!({
"question": { "text": "Where for lunch?" },
"answers": [
{ "answer_id": 1, "poll_media": { "text": "Pub" } },
{ "answer_id": 2, "poll_media": { "text": "Chips", "emoji": { "name": "🍟" } } }
],
"expiry": "2099-01-01T00:00:00+00:00"
});
let card = card("discord:me|#lunch", "700", &poll).expect("a card");
assert_eq!(card["title"], "Where for lunch?");
assert_eq!(card["options"][0]["votes"], 0);
assert_eq!(card["options"][1]["label"], "🍟 Chips");
assert_eq!(card["hasVoted"], false);
assert_eq!(card["open"], true);
assert!(card["remaining"].as_i64().unwrap_or(0) > 0);
}
/// The tally, and which answer is this account's - `me_voted` is the only
/// thing that says so, and it is what stops the card offering a vote
/// somebody has already cast.
#[test]
fn the_tally_and_your_own_answer_are_read() {
let poll = json!({
"question": { "text": "Tabs or spaces?" },
"answers": [
{ "answer_id": 1, "poll_media": { "text": "Tabs" } },
{ "answer_id": 2, "poll_media": { "text": "Spaces" } }
],
"results": { "is_finalized": false, "answer_counts": [
{ "id": 1, "count": 3, "me_voted": false },
{ "id": 2, "count": 9, "me_voted": true }
]}
});
let card = card("discord:me|#dev", "701", &poll).expect("a card");
assert_eq!(card["options"][0]["votes"], 3);
assert_eq!(card["options"][1]["votes"], 9);
assert_eq!(card["hasVoted"], true);
assert_eq!(card["votedOptionId"], 2);
}
/// Closed two ways: Discord saying the counting is over, and a clock that
/// has run out while Discord has not got round to saying so. Offering a
/// vote that would be refused is worse than saying it is shut.
#[test]
fn a_finished_poll_is_shut_however_it_finished() {
let base = json!({
"question": { "text": "Done?" },
"answers": [{ "answer_id": 1, "poll_media": { "text": "Yes" } }]
});
let mut finalized = base.clone();
finalized["results"] = json!({ "is_finalized": true });
finalized["expiry"] = json!("2099-01-01T00:00:00+00:00");
assert_eq!(card("b", "1", &finalized).expect("a card")["open"], false);
let mut expired = base.clone();
expired["expiry"] = json!("2020-01-01T00:00:00+00:00");
let card = card("b", "1", &expired).expect("a card");
assert_eq!(card["open"], false);
assert_eq!(card["remaining"], 0);
}
/// An answer that is nothing but an emoji is an answer, and a poll with
/// no answers at all is no card rather than an empty one.
#[test]
fn an_emoji_is_a_label_and_nothing_is_no_card() {
let emoji_only = json!({
"question": { "text": "?" },
"answers": [{ "answer_id": 1, "poll_media": { "emoji": { "name": "👍" } } }]
});
assert_eq!(card("b", "1", &emoji_only).expect("a card")["options"][0]["label"], "👍");
assert!(card("b", "1", &json!({ "question": { "text": "?" }, "answers": [] })).is_none());
assert!(card("b", "1", &json!({})).is_none());
}
}
+19
View File
@@ -3068,6 +3068,25 @@ pub async fn dispatch(
Err(e) => (None, Some(format!("{e:#}"))),
};
}
// Discord's poll is part of a message, so the card's id is
// the message id and the vote is addressed to it. Nothing
// comes back but an acknowledgement: the recounted tally
// arrives as a MESSAGE_UPDATE and redraws the card, the same
// as anybody else's vote does.
if buffer.account_id.starts_with("discord:") {
let Some(message_id) = state.runtime.live_card(buffer_id, "poll").and_then(|c| c["id"].as_str().map(|s| s.to_string()))
else {
return (None, Some("there is no poll open here".to_string()));
};
let answer = p_str_opt(params, "answerId")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(option_id);
return match backend::discord::vote_in_poll(state, &buffer.account_id, buffer_id, &message_id, answer).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
};
}
}
let Some(channel) = state.runtime.kick_channel(buffer_id) else {
return (None, Some("that is not a Kick channel".to_string()));