Add command to see Matrix results mid-poll

Co-authored-by: Cadence Ember <cadence@disroot.org>
This commit is contained in:
Ellie Algase
2026-02-03 19:23:01 -06:00
committed by Cadence Ember
parent 52d9c6fea8
commit 564d564490
3 changed files with 112 additions and 31 deletions

View File

@@ -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`,

View File

@@ -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<InteractionMethods[k]>[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

View File

@@ -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") {