From 564d564490548d5ef062de7512bf8d9059e84c86 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Tue, 3 Feb 2026 19:23:01 -0600 Subject: [PATCH 1/2] Add command to see Matrix results mid-poll Co-authored-by: Cadence Ember --- src/d2m/actions/poll-end.js | 36 ++------- src/discord/interactions/poll-responses.js | 94 ++++++++++++++++++++++ src/discord/register-interactions.js | 13 ++- 3 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 src/discord/interactions/poll-responses.js diff --git a/src/d2m/actions/poll-end.js b/src/d2m/actions/poll-end.js index 936dedf..9ffcaf6 100644 --- a/src/d2m/actions/poll-end.js +++ b/src/d2m/actions/poll-end.js @@ -9,22 +9,15 @@ const {discord, sync, db, select, from} = passthrough const {reg} = require("../../matrix/read-registration") /** @type {import("./poll-vote")} */ const vote = sync.require("../actions/poll-vote") -/** @type {import("../../m2d/converters/poll-components")} */ -const pollComponents = sync.require("../../m2d/converters/poll-components") - -// This handles, in the following order: -// * verifying Matrix-side votes are accurate for a poll originating on Discord, sending missed votes to Matrix if necessary -// * sending a message to Discord if a vote in that poll has been cast on Matrix -// This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js. +/** @type {import("../../discord/interactions/poll-responses")} */ +const pollResponses = sync.require("../../discord/interactions/poll-responses") /** - * @param {number} percent + * @file This handles, in the following order: + * * verifying Matrix-side votes are accurate for a poll originating on Discord, sending missed votes to Matrix if necessary + * * sending a message to Discord if a vote in that poll has been cast on Matrix + * This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js. */ -function barChart(percent) { - const width = 12 - const bars = Math.floor(percent*width) - return "█".repeat(bars) + "▒".repeat(width-bars) -} /** * @param {string} channelID @@ -114,22 +107,9 @@ async function endPoll(closeMessage) { })) } - /** @type {{matrix_option: string, option_text: string, count: number}[]} */ - const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(pollMessageID) - const combinedVotes = pollResults.reduce((a, c) => a + c.count, 0) - const totalVoters = db.prepare("SELECT count(DISTINCT discord_or_matrix_user_id) as count FROM poll_vote WHERE message_id = ?").pluck().get(pollMessageID) + const {combinedVotes, messageString} = pollResponses.getCombinedResults(pollMessageID, true) - if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix! - // Now that we've corrected the vote totals, we can get the results again and post them to Discord! - const topAnswers = pollResults.toSorted((a, b) => b.count - a.count) - let messageString = "" - for (const option of pollResults) { - const medal = pollComponents.getMedal(topAnswers, option.count) - const countString = `${String(option.count).padStart(String(topAnswers[0].count).length)}` - const votesString = option.count === 1 ? "vote " : "votes" - const label = medal === "🥇" ? `**${option.option_text}**` : option.option_text - messageString += `\`\u200b${countString} ${votesString}\u200b\` ${barChart(option.count/totalVoters)} ${label} ${medal}\n` - } + if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix. Now that we've corrected the vote totals, we can get the results again and post them to Discord. return { username: "Total results including Matrix votes", avatar_url: `${reg.ooye.bridge_origin}/discord/poll-star-avatar.png`, diff --git a/src/discord/interactions/poll-responses.js b/src/discord/interactions/poll-responses.js new file mode 100644 index 0000000..86277c2 --- /dev/null +++ b/src/discord/interactions/poll-responses.js @@ -0,0 +1,94 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, db, select, from} = require("../../passthrough") +const {id: botID} = require("../../../addbot") +const {InteractionMethods} = require("snowtransfer") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../m2d/converters/poll-components")} */ +const pollComponents = sync.require("../../m2d/converters/poll-components") +const {reg} = require("../../matrix/read-registration") + +/** + * @param {number} percentc + */ +function barChart(percent) { + const width = 12 + const bars = Math.floor(percent*width) + return "█".repeat(bars) + "▒".repeat(width-bars) +} + +/** + * @param {string} pollMessageID + * @param {boolean} isClosed + */ +function getCombinedResults(pollMessageID, isClosed) { + /** @type {{matrix_option: string, option_text: string, count: number}[]} */ + const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(pollMessageID) + const combinedVotes = pollResults.reduce((a, c) => a + c.count, 0) + const totalVoters = db.prepare("SELECT count(DISTINCT discord_or_matrix_user_id) as count FROM poll_vote WHERE message_id = ?").pluck().get(pollMessageID) + const topAnswers = pollResults.toSorted((a, b) => b.count - a.count) + + let messageString = "" + for (const option of pollResults) { + const medal = isClosed ? pollComponents.getMedal(topAnswers, option.count) : "" + const countString = `${String(option.count).padStart(String(topAnswers[0].count).length)}` + const votesString = option.count === 1 ? "vote " : "votes" + const label = medal === "🥇" ? `**${option.option_text}**` : option.option_text + messageString += `\`\u200b${countString} ${votesString}\u200b\` ${barChart(option.count/totalVoters)} ${label} ${medal}\n` + } + + return {messageString, combinedVotes, totalVoters} +} + +/** + * @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interact({data}, {api}) { + const row = select("poll", "is_closed", {message_id: data.target_id}).get() + + if (!row) { + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This poll hasn't been bridged to Matrix.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + } + + const {messageString} = getCombinedResults(data.target_id, !!row.is_closed) + + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + embeds: [{ + author: { + name: "Current results including Matrix votes", + icon_url: `${reg.ooye.bridge_origin}/discord/poll-star-avatar.png` + }, + description: messageString + }], + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} +} + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ +async function interact(interaction) { + for await (const response of _interact(interaction, {api})) { + if (response.createInteractionResponse) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } + } +} + +module.exports.interact = interact +module.exports._interact = _interact +module.exports.getCombinedResults = getCombinedResults \ No newline at end of file diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index b37f28e..a909393 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -10,6 +10,7 @@ const permissions = sync.require("./interactions/permissions.js") const reactions = sync.require("./interactions/reactions.js") const privacy = sync.require("./interactions/privacy.js") const poll = sync.require("./interactions/poll.js") +const pollResponses = sync.require("./interactions/poll-responses.js") const ping = sync.require("./interactions/ping.js") // User must have EVERY permission in default_member_permissions to be able to use the command @@ -24,7 +25,7 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ type: DiscordTypes.ApplicationCommandType.Message, default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles) }, { - name: "Reactions", + name: "Responses", contexts: [DiscordTypes.InteractionContextType.Guild], type: DiscordTypes.ApplicationCommandType.Message }, { @@ -107,8 +108,14 @@ async function dispatchInteraction(interaction) { await permissions.interact(interaction) } else if (interactionId === "permissions_edit") { await permissions.interactEdit(interaction) - } else if (interactionId === "Reactions") { - await reactions.interact(interaction) + } else if (interactionId === "Responses") { + /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore + const messageInteraction = interaction + if (messageInteraction.data.resolved.messages[messageInteraction.data.target_id]?.poll) { + await pollResponses.interact(messageInteraction) + } else { + await reactions.interact(messageInteraction) + } } else if (interactionId === "ping") { await ping.interact(interaction) } else if (interactionId === "privacy") { From b463e1173ba7209d79447b1703e3e6e54f96d64c Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 5 Feb 2026 01:00:06 +1300 Subject: [PATCH 2/2] Fallback text for Matrix poll end events Right now this doesn't seem to show up on any clients because extensible events is a total mess, but if you did want to code a client that shows this fallback without bothering to code real support for polls, you are easily able to do that. Just pretend the poll end event is a m.room.message and render it like usual. --- src/d2m/actions/poll-end.js | 3 ++- src/d2m/converters/edit-to-changes.js | 8 ++++++-- src/d2m/converters/message-to-event.js | 24 ++++++++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/d2m/actions/poll-end.js b/src/d2m/actions/poll-end.js index 9ffcaf6..b0d29b9 100644 --- a/src/d2m/actions/poll-end.js +++ b/src/d2m/actions/poll-end.js @@ -113,7 +113,8 @@ async function endPoll(closeMessage) { return { username: "Total results including Matrix votes", avatar_url: `${reg.ooye.bridge_origin}/discord/poll-star-avatar.png`, - content: messageString + content: messageString, + flags: DiscordTypes.MessageFlags.SuppressEmbeds } } } diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index 82b9417..b73d6e0 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -16,8 +16,12 @@ function eventCanBeEdited(ev) { if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") { return false } - // Discord does not allow stickers to be edited. Poll closures are sent as "edits", but not in a way we care about. - if (ev.old.event_type === "m.sticker" || ev.old.event_type === "org.matrix.msc3381.poll.start") { + // Discord does not allow stickers to be edited. + if (ev.old.event_type === "m.sticker") { + return false + } + // Discord does not allow the data of polls to be edited, they may only be responded to. + if (ev.old.event_type === "org.matrix.msc3381.poll.start" || ev.old.event_type === "org.matrix.msc3381.poll.end") { return false } // Anything else is fair game. diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 9236c89..2f12958 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -20,6 +20,8 @@ const mxUtils = sync.require("../../matrix/utils") const dUtils = sync.require("../../discord/utils") /** @type {import("./find-mentions")} */ const findMentions = sync.require("./find-mentions") +/** @type {import("../../discord/interactions/poll-responses")} */ +const pollResponses = sync.require("../../discord/interactions/poll-responses") const {reg} = require("../../matrix/read-registration") /** @@ -269,7 +271,21 @@ async function messageToEvent(message, guild, options = {}, di) { } if (message.type === DiscordTypes.MessageType.PollResult) { - const event_id = select("event_message", "event_id", {message_id: message.message_reference?.message_id}).pluck().get() + const pollMessageID = message.message_reference?.message_id + if (!pollMessageID) return [] + const event_id = select("event_message", "event_id", {message_id: pollMessageID}).pluck().get() + const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() + const pollQuestionText = select("poll", "question_text", {message_id: pollMessageID}).pluck().get() + if (!event_id || !roomID || !pollQuestionText) return [] // drop it if the corresponding poll start was not bridged + + const rep = new mxUtils.MatrixStringBuilder() + rep.addLine(`The poll ${pollQuestionText} has closed.`, tag`The poll ${pollQuestionText} has closed.`) + + const {messageString} = pollResponses.getCombinedResults(pollMessageID, true) // poll results have already been double-checked before this point, so these totals will be accurate + rep.addLine(markdown.toHTML(messageString, {discordOnly: true, escapeHTML: false}), markdown.toHTML(messageString, {})) + + const {body, formatted_body} = rep.get() + return [{ $type: "org.matrix.msc3381.poll.end", "m.relates_to": { @@ -277,7 +293,11 @@ async function messageToEvent(message, guild, options = {}, di) { event_id }, "org.matrix.msc3381.poll.end": {}, - body: "This poll has ended.", + "org.matrix.msc1767.text": body, + "org.matrix.msc1767.html": formatted_body, + body: body, + format: "org.matrix.custom.html", + formatted_body: formatted_body, msgtype: "m.text" }] }