Add full support for polls, both m2d and d2m.

Mostly works, but a few edge-cases still need to be worked out.

Co-authored-by: Cadence Ember <cadence@disroot.org>
This commit is contained in:
Ellie Algase
2026-01-26 02:30:05 +13:00
committed by Cadence Ember
co-authored by Cadence Ember
parent afca4de6b6
commit 90606d9176
17 changed files with 501 additions and 191 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ async function addReaction(data) {
const user = data.member?.user
assert.ok(user && user.username)
const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
const parentID = select("event_message", "event_id", {message_id: data.message_id}, "ORDER BY reaction_part").pluck().get()
if (!parentID) return // Nothing can be done if the parent message was never bridged.
assert.equal(typeof parentID, "string")
+23 -18
View File
@@ -6,6 +6,7 @@ const {isDeepStrictEqual} = require("util")
const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
@@ -14,6 +15,8 @@ const registerUser = sync.require("./register-user")
const createRoom = sync.require("../actions/create-room")
/** @type {import("./add-or-remove-vote.js")} */
const vote = sync.require("../actions/add-or-remove-vote")
/** @type {import("../../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")
@@ -26,8 +29,9 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
* @param {number} percent
*/
function barChart(percent){
let bars = Math.floor(percent*10)
return "█".repeat(bars) + "▒".repeat(10-bars)
const width = 12
const bars = Math.floor(percent*width)
return "█".repeat(bars) + "▒".repeat(width-bars)
}
/**
@@ -73,7 +77,7 @@ async function closePoll(closeMessage, guild){
// If the closure came from Discord, we want to fetch all the votes there again and bridge over any that got lost to Matrix before posting the results.
// Database reads are cheap, and API calls are expensive, so we will only query Discord when the totals don't match.
const totalVotes = pollCloseObject.fields.find(element => element.name === "total_votes").value // We could do [2], but best not to rely on the ordering staying consistent.
const totalVotes = +pollCloseObject.fields.find(element => element.name === "total_votes").value // We could do [2], but best not to rely on the ordering staying consistent.
const databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "matrix_option"], {message_id: pollMessageID}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all()
@@ -120,26 +124,27 @@ async function closePoll(closeMessage, guild){
}))
}
/** @type {{discord_option: string, option_text: string, count: number}[]} */
const pollResults = db.prepare("SELECT discord_option, option_text, count(*) as count FROM poll_option INNER JOIN poll_vote USING (message_id, matrix_option) GROUP BY discord_option").all()
/** @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)
if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix!
const message = await discord.snow.channel.getChannelMessage(closeMessage.channel_id, pollMessageID)
assert(message?.poll?.answers)
// Now that we've corrected the vote totals, we can get the results again and post them to Discord!
const topAnswers = pollResults.toSorted()
const unique = topAnswers.length > 1 && topAnswers[0].count === topAnswers[1].count
let messageString = "📶 Results including Matrix votes\n"
for (const result of pollResults) {
if (result === topAnswers[0] && unique) {
messageString = messageString + `${barChart(result.count/combinedVotes)} **${result.option_text}** (**${result.count}**)\n`
} else {
messageString = messageString + `${barChart(result.count/combinedVotes)} ${result.option_text} (${result.count})\n`
}
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`
}
return {
username: "Total results including Matrix votes",
avatar_url: `${reg.ooye.bridge_origin}/discord/poll-star-avatar.png`,
content: messageString
}
await channelWebhook.sendMessageWithWebhook(closeMessage.channel_id, {content: messageString}, closeMessage.thread_id)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ async function removeSomeReactions(data) {
if (!row) return
const eventReactedTo = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.where({message_id: data.message_id, reaction_part: 0}).select("event_id", "room_id").get()
.where({message_id: data.message_id}).and("ORDER BY reaction_part").select("event_id", "room_id").get()
if (!eventReactedTo) return
// Due to server restrictions, all relations (i.e. reactions) have to be in the same room as the original event.
+17 -5
View File
@@ -4,7 +4,7 @@ const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const { discord, sync, db, select } = passthrough
const { discord, sync, db, select, from} = passthrough
/** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */
@@ -21,6 +21,8 @@ const createRoom = sync.require("../actions/create-room")
const closePoll = sync.require("../actions/close-poll")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../m2d/actions/channel-webhook")} */
const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
/**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
@@ -33,10 +35,6 @@ async function sendMessage(message, channel, guild, row) {
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
assert(historicalRoomIndex)
if (message.type === 46) { // This is a poll_result. We might need to send a message to Discord (if there were any Matrix-side votes), regardless of if this message was sent by the bridge or not.
await closePoll.closePoll(message, guild)
}
let senderMxid = null
if (dUtils.isWebhookMessage(message)) {
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0
@@ -104,6 +102,20 @@ 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).
const detailedResultsMessage = await closePoll.closePoll(message, guild)
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.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
})()
}
}
eventIDs.push(eventID)
}