Fixes to vote counting

This commit is contained in:
Cadence Ember
2026-01-26 20:51:30 +13:00
parent f3ae7ba792
commit 0c781f9b72
6 changed files with 63 additions and 57 deletions
+4 -4
View File
@@ -35,7 +35,7 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"snowtransfer": "^0.17.0", "snowtransfer": "^0.17.1",
"stream-mime-type": "^1.0.2", "stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1", "try-to-catch": "^3.0.1",
"uqr": "^0.1.2", "uqr": "^0.1.2",
@@ -2727,9 +2727,9 @@
} }
}, },
"node_modules/snowtransfer": { "node_modules/snowtransfer": {
"version": "0.17.0", "version": "0.17.1",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.0.tgz", "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.1.tgz",
"integrity": "sha512-H6Avpsco+HlVIkN+MbX34Q7+9g9Wci0wZQwGsvfw20VqEb7jnnk73iUcWytNMYtKZ72Ud58n6cFnQ3apTEamxw==", "integrity": "sha512-WSXj055EJhzzfD7B3oHVyRTxkqFCaxcVhwKY6B3NkBSHRyM6wHxZLq6VbFYhopUg+lMtd7S1ZO8JM+Ut+js2iA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.38.37" "discord-api-types": "^0.38.37"
+1 -1
View File
@@ -44,7 +44,7 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"snowtransfer": "^0.17.0", "snowtransfer": "^0.17.1",
"stream-mime-type": "^1.0.2", "stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1", "try-to-catch": "^3.0.1",
"uqr": "^0.1.2", "uqr": "^0.1.2",
+16 -26
View File
@@ -7,18 +7,10 @@ const {isDeepStrictEqual} = require("util")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough const {discord, sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/** @type {import("./create-room")} */
const createRoom = sync.require("../actions/create-room")
/** @type {import("./poll-vote")} */ /** @type {import("./poll-vote")} */
const vote = sync.require("../actions/poll-vote") const vote = sync.require("../actions/poll-vote")
/** @type {import("../../m2d/converters/poll-components")} */ /** @type {import("../../m2d/converters/poll-components")} */
const pollComponents = sync.require("../../m2d/converters/poll-components") const pollComponents = sync.require("../../m2d/converters/poll-components")
/** @type {import("../../m2d/actions/channel-webhook")} */
const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
// This handles, in the following order: // 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 // * verifying Matrix-side votes are accurate for a poll originating on Discord, sending missed votes to Matrix if necessary
@@ -28,7 +20,7 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
/** /**
* @param {number} percent * @param {number} percent
*/ */
function barChart(percent){ function barChart(percent) {
const width = 12 const width = 12
const bars = Math.floor(percent*width) const bars = Math.floor(percent*width)
return "█".repeat(bars) + "▒".repeat(width-bars) return "█".repeat(bars) + "▒".repeat(width-bars)
@@ -40,31 +32,27 @@ function barChart(percent){
* @param {string} answerID * @param {string} answerID
* @returns {Promise<DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]>} * @returns {Promise<DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]>}
*/ */
async function getAllVotesOnAnswer(channelID, messageID, answerID){ async function getAllVotesOnAnswer(channelID, messageID, answerID) {
const limit = 100 const limit = 100
/** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */ /** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */
let voteUsers = [] let voteUsers = []
let after = undefined let after = undefined
while (!voteUsers.length || after) { while (true) {
const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit}) const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit})
if (curVotes.users.length === 0) { // Reached the end. voteUsers = voteUsers.concat(curVotes.users)
break
}
if (curVotes.users.length >= limit) { // Loop again for the next page. if (curVotes.users.length >= limit) { // Loop again for the next page.
// @ts-ignore - stupid // @ts-ignore - stupid
after = curVotes.users.at(-1).id after = curVotes.users.at(-1).id
} } else { // Reached the end.
voteUsers = voteUsers.concat(curVotes.users)
}
return voteUsers return voteUsers
}
}
} }
/** /**
* @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage * @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage
* @param {DiscordTypes.APIGuild} guild
*/ */
async function endPoll(closeMessage, guild){ async function endPoll(closeMessage) {
const pollCloseObject = closeMessage.embeds[0] const pollCloseObject = closeMessage.embeds[0]
const pollMessageID = closeMessage.message_reference.message_id const pollMessageID = closeMessage.message_reference.message_id
@@ -91,16 +79,16 @@ async function endPoll(closeMessage, guild){
for (const discordPollOption of discordPollOptions) { for (const discordPollOption of discordPollOptions) {
const optionUsers = await getAllVotesOnAnswer(closeMessage.channel_id, pollMessageID, discordPollOption) // Array of user IDs who voted for the option we're testing. const optionUsers = await getAllVotesOnAnswer(closeMessage.channel_id, pollMessageID, discordPollOption) // Array of user IDs who voted for the option we're testing.
optionUsers.map(user => { for (const user of optionUsers) {
const userLocation = updatedAnswers.findIndex(answer => answer.user.id === user.id) const userLocation = updatedAnswers.findIndex(answer => answer.user.id === user.id)
const matrixOption = select("poll_option", "matrix_option", {message_id: pollMessageID, discord_option: discordPollOption}).pluck().get() const matrixOption = select("poll_option", "matrix_option", {message_id: pollMessageID, discord_option: discordPollOption}).pluck().get()
assert(matrixOption) assert(matrixOption)
if (userLocation === -1){ // We haven't seen this user yet, so we need to add them. if (userLocation === -1) { // We haven't seen this user yet, so we need to add them.
updatedAnswers.push({user, matrixOptionVotes: [matrixOption]}) // toString as this is what we store and get from the database and send to Matrix. updatedAnswers.push({user, matrixOptionVotes: [matrixOption]}) // toString as this is what we store and get from the database and send to Matrix.
} else { // This user already voted for another option on the poll. } else { // This user already voted for another option on the poll.
updatedAnswers[userLocation].matrixOptionVotes.push(matrixOption) updatedAnswers[userLocation].matrixOptionVotes.push(matrixOption)
} }
}) }
} }
// Check for inconsistencies in what was cached in database vs final confirmed poll answers // Check for inconsistencies in what was cached in database vs final confirmed poll answers
@@ -109,18 +97,20 @@ async function endPoll(closeMessage, guild){
await Promise.all(updatedAnswers.map(async answer => { await Promise.all(updatedAnswers.map(async answer => {
voteUsers = voteUsers.filter(item => item !== answer.user.id) // Remove any users we have updated answers for from voteUsers. The only remaining entries in this array will be users who voted, but then removed their votes before the poll ended. voteUsers = voteUsers.filter(item => item !== answer.user.id) // Remove any users we have updated answers for from voteUsers. The only remaining entries in this array will be users who voted, but then removed their votes before the poll ended.
const cachedAnswers = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: answer.user.id, message_id: pollMessageID}).pluck().all() const cachedAnswers = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: answer.user.id, message_id: pollMessageID}).pluck().all()
if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))){ if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))) {
db.transaction(() => {
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes. db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes.
for (const matrixOption of answer.matrixOptionVotes) { for (const matrixOption of answer.matrixOptionVotes) {
db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption) db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption)
} }
await vote.debounceSendVotes({user_id: answer.user.id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID) // Fake answer ID, not actually needed (but we're sorta faking the datatype to call this function). })()
await vote.sendVotes(answer.user, closeMessage.channel_id, pollMessageID, pollEventID)
} }
})) }))
await Promise.all(voteUsers.map(async user_id => { // Remove these votes. await Promise.all(voteUsers.map(async user_id => { // Remove these votes.
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, pollMessageID) db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, pollMessageID)
await vote.debounceSendVotes({user_id: user_id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID) await vote.sendVotes(user_id, closeMessage.channel_id, pollMessageID, pollEventID)
})) }))
} }
+13 -8
View File
@@ -11,15 +11,13 @@ const {discord, sync, db, select, from} = passthrough
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */ /** @type {import("./register-user")} */
const registerUser = sync.require("./register-user") const registerUser = sync.require("./register-user")
/** @type {import("./create-room")} */
const createRoom = sync.require("../actions/create-room")
const inFlightPollSema = new Semaphore() const inFlightPollSema = new Semaphore()
/** /**
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
*/ */
async function addVote(data){ async function addVote(data) {
const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() // Currently Discord doesn't allow sending a poll with anything else, but we bridge it after all other content so reaction_part: 0 is the part that will have the poll. const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() // Currently Discord doesn't allow sending a poll with anything else, but we bridge it after all other content so reaction_part: 0 is the part that will have the poll.
if (!pollEventID) return // Nothing can be done if the parent message was never bridged. if (!pollEventID) return // Nothing can be done if the parent message was never bridged.
@@ -32,7 +30,7 @@ async function addVote(data){
/** /**
* @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} data * @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} data
*/ */
async function removeVote(data){ async function removeVote(data) {
const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get()
if (!pollEventID) return if (!pollEventID) return
@@ -59,12 +57,12 @@ async function debounceSendVotes(data, pollEventID) {
} }
/** /**
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser | string} userOrID
* @param {string} channelID * @param {string} channelID
* @param {string} pollMessageID * @param {string} pollMessageID
* @param {string} pollEventID * @param {string} pollEventID
*/ */
async function sendVotes(user, channelID, pollMessageID, pollEventID) { async function sendVotes(userOrID, channelID, pollMessageID, pollEventID) {
const latestRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const latestRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
const matchingRoomID = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: pollMessageID}).pluck("room_id").get() const matchingRoomID = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: pollMessageID}).pluck("room_id").get()
if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll?? if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll??
@@ -72,9 +70,16 @@ async function sendVotes(user, channelID, pollMessageID, pollEventID) {
return return
} }
const senderMxid = await registerUser.ensureSimJoined(user, matchingRoomID) if (typeof userOrID === "string") { // just a string when double-checking a vote removal - good thing the unvoter is already here from having voted
var userID = userOrID
var senderMxid = from("sim").join("sim_member", "mxid").where({user_id: userOrID, room_id: matchingRoomID}).pluck("mxid").get()
if (!senderMxid) return
} else { // sent in full when double-checking adding a vote, so we can properly ensure joined
var userID = userOrID.id
var senderMxid = await registerUser.ensureSimJoined(userOrID, matchingRoomID)
}
const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: user.id, message_id: pollMessageID}).pluck().all() const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: pollMessageID}).pluck().all()
const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", { const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", {
"m.relates_to": { "m.relates_to": {
rel_type: "m.reference", rel_type: "m.reference",
+13 -8
View File
@@ -55,6 +55,16 @@ async function sendMessage(message, channel, guild, row) {
} }
} }
if (message.type === DiscordTypes.MessageType.PollResult) { // ensure all Discord-side votes were pushed to Matrix before a poll is closed
const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const channelID = threadParent ? threadParent : message.channel_id
const threadID = threadParent ? message.channel_id : undefined
var sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
}
}
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow})
const eventIDs = [] const eventIDs = []
if (events.length) { if (events.length) {
@@ -102,19 +112,14 @@ async function sendMessage(message, channel, guild, row) {
})() })()
} }
if (message.type === DiscordTypes.MessageType.PollResult) { // We might need to send a message to Discord (if there were any Matrix-side votes). // part/reaction_part consistency for polls
const detailedResultsMessage = await pollEnd.endPoll(message, guild) if (sentResultsMessage) {
if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const channelID = threadParent ? threadParent : message.channel_id
const threadID = threadParent ? message.channel_id : undefined
const sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
db.transaction(() => { db.transaction(() => {
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(sentResultsMessage.id, historicalRoomIndex)
db.prepare("UPDATE event_message SET reaction_part = 1 WHERE event_id = ?").run(eventID) db.prepare("UPDATE event_message SET reaction_part = 1 WHERE event_id = ?").run(eventID)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, sentResultsMessage.id, 1, 0) // part = 1, reaction_part = 0 db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, sentResultsMessage.id, 1, 0) // part = 1, reaction_part = 0
})() })()
} }
}
eventIDs.push(eventID) eventIDs.push(eventID)
} }
+8 -2
View File
@@ -372,11 +372,17 @@ module.exports = {
await createSpace.syncSpaceExpressions(data, false) await createSpace.syncSpaceExpressions(data, false)
}, },
async MESSAGE_POLL_VOTE_ADD(client, data){ /**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
*/
async MESSAGE_POLL_VOTE_ADD(client, data) {
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return
await vote.addVote(data) await vote.addVote(data)
}, },
async MESSAGE_POLL_VOTE_REMOVE(client, data){ async MESSAGE_POLL_VOTE_REMOVE(client, data) {
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return
await vote.removeVote(data) await vote.removeVote(data)
}, },