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
+23 -11
View File
@@ -519,10 +519,10 @@ async function getL1L2ReplyLine(called = false) {
}
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event
* @param {DiscordTypes.APIGuild} guild
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise<Buffer | undefined>, pollEnd?: {messageID: string}}} di simple-as-nails dependency injection for the matrix API
*/
async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender
@@ -553,8 +553,8 @@ async function eventToMessage(event, guild, channel, di) {
const pendingFiles = []
/** @type {DiscordTypes.APIUser[]} */
const ensureJoined = []
/** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody?} */
let pollMessage = null
/** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody[]} */
const pollMessages = []
// Convert content depending on what the message is
// Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor
@@ -644,7 +644,17 @@ async function eventToMessage(event, guild, channel, di) {
count: 0 // no votes initially
}))
content = ""
pollMessage = pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions)
pollMessages.push(pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions))
} else if (event.type === "org.matrix.msc3381.poll.end") {
assert(di.pollEnd)
content = ""
messageIDsToEdit.push(di.pollEnd.messageID)
pollMessages.push(pollComponents.getPollComponentsFromDatabase(di.pollEnd.messageID))
pollMessages.push({
...await pollComponents.getPollEndMessageFromDatabase(channel.id, di.pollEnd.messageID),
avatar_url: `${reg.ooye.bridge_origin}/discord/poll-star-avatar.png`
})
} else {
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
@@ -1001,12 +1011,14 @@ async function eventToMessage(event, guild, channel, di) {
messages[0].pendingFiles = pendingFiles
}
if (pollMessage) {
messages.push({
...pollMessage,
username: displayNameShortened,
avatar_url: avatarURL
})
if (pollMessages.length) {
for (const pollMessage of pollMessages) {
messages.push({
username: displayNameShortened,
avatar_url: avatarURL,
...pollMessage,
})
}
}
const messagesToEdit = []
+151 -27
View File
@@ -1,6 +1,28 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const {sync, db, discord, select, from} = require("../../passthrough")
/** @type {import("../actions/setup-emojis")} */
const setupEmojis = sync.require("../actions/setup-emojis")
/**
* @param {{count: number}[]} topAnswers
* @param {number} count
* @returns {string}
*/
function getMedal(topAnswers, count) {
const winningOrTied = count && topAnswers[0].count === count
const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count
const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count
const medal =
( winningOrTied ? "🥇"
: secondOrTied ? "🥈"
: thirdOrTied ? "🥉"
: "")
return medal
}
/**
* @param {boolean} isClosed
@@ -11,20 +33,20 @@ function optionsToComponents(isClosed, pollOptions) {
const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count)
/** @type {DiscordTypes.APIMessageTopLevelComponent[]} */
return pollOptions.map(option => {
const winningOrTied = option.count && topAnswers[0].count === option.count
const medal = getMedal(topAnswers, option.count)
return {
type: DiscordTypes.ComponentType.Container,
components: [{
type: DiscordTypes.ComponentType.Section,
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: option.option_text
content: medal && isClosed ? `${medal} ${option.option_text}` : option.option_text
}],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: winningOrTied ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
label: option.count.toString(),
custom_id: option.matrix_option,
custom_id: `POLL_OPTION#${option.matrix_option}`,
disabled: isClosed
}
}]
@@ -32,6 +54,34 @@ function optionsToComponents(isClosed, pollOptions) {
})
}
/**
* @param {number} maxSelections
* @param {number} optionCount
*/
function getMultiSelectString(maxSelections, optionCount) {
if (maxSelections === 1) {
return "Select one answer"
} else if (maxSelections >= optionCount) {
return "Select one or more answers"
} else {
return `Select up to ${maxSelections} answers`
}
}
/**
* @param {number} maxSelections
* @param {number} optionCount
*/
function getMultiSelectClosedString(maxSelections, optionCount) {
if (maxSelections === 1) {
return "Single choice"
} else if (maxSelections >= optionCount) {
return "Multiple choice"
} else {
return `Multiple choice (up to ${maxSelections})`
}
}
/**
* @param {boolean} isClosed
* @param {number} maxSelections
@@ -40,39 +90,31 @@ function optionsToComponents(isClosed, pollOptions) {
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
*/
function getPollComponents(isClosed, maxSelections, questionText, pollOptions) {
/** @type {DiscordTypes.APIMessageTopLevelComponent[]} array because it can move around */
const multiSelectInfoComponent = [{
type: DiscordTypes.ComponentType.TextDisplay,
content: isClosed ? `-# ${getMultiSelectClosedString(maxSelections, pollOptions.length)}` : `-# ${getMultiSelectString(maxSelections, pollOptions.length)}`
}]
/** @type {DiscordTypes.APIMessageTopLevelComponent} */
let headingComponent
if (isClosed) {
const multiSelectString =
( maxSelections === 1 ? "-# ~~Select one answer~~"
: maxSelections >= pollOptions.length ? "-# ~~Select one or more answers~~"
: `-# ~~Select up to ${maxSelections} answers~~`)
headingComponent = { // This one is for the poll heading.
type: DiscordTypes.ComponentType.Section,
components: [
{
type: DiscordTypes.ComponentType.TextDisplay,
content: `## ${questionText}`
},
{
type: DiscordTypes.ComponentType.TextDisplay,
content: multiSelectString
}
],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: DiscordTypes.ButtonStyle.Secondary,
custom_id: "vote",
label: "Voting closed!",
custom_id: "POLL_VOTE",
label: "Voting closed",
disabled: true
}
}
}
else {
const multiSelectString =
( maxSelections === 1 ? "-# Select one answer"
: maxSelections >= pollOptions.length ? "-# Select one or more answers"
: `-# Select up to ${maxSelections} answers`)
} else {
headingComponent = { // This one is for the poll heading.
type: DiscordTypes.ComponentType.Section,
components: [
@@ -80,15 +122,13 @@ function getPollComponents(isClosed, maxSelections, questionText, pollOptions) {
type: DiscordTypes.ComponentType.TextDisplay,
content: `## ${questionText}`
},
{
type: DiscordTypes.ComponentType.TextDisplay,
content: multiSelectString
}
// @ts-ignore
multiSelectInfoComponent.pop()
],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: DiscordTypes.ButtonStyle.Primary,
custom_id: "vote",
custom_id: "POLL_VOTE",
label: "Vote!"
}
}
@@ -96,8 +136,92 @@ function getPollComponents(isClosed, maxSelections, questionText, pollOptions) {
const optionComponents = optionsToComponents(isClosed, pollOptions)
return {
flags: DiscordTypes.MessageFlags.IsComponentsV2,
components: [headingComponent, ...optionComponents]
components: [headingComponent, ...optionComponents, ...multiSelectInfoComponent]
}
}
module.exports.getPollComponents = getPollComponents
/** @param {string} messageID */
function getPollComponentsFromDatabase(messageID) {
const pollRow = select("poll", ["max_selections", "is_closed", "question_text"], {message_id: messageID}).get()
assert(pollRow)
/** @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(messageID)
return getPollComponents(!!pollRow.is_closed, pollRow.max_selections, pollRow.question_text, pollResults)
}
/**
* @param {string} channelID
* @param {string} messageID
* @param {string} questionText
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
*/
function getPollEndMessage(channelID, messageID, questionText, pollOptions) {
const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count)
const totalVotes = pollOptions.reduce((a, c) => a + c.count, 0)
const tied = topAnswers[0].count === topAnswers[1].count
const titleString = `-# The poll **${questionText}** has closed.`
let winnerString = ""
let resultsString = ""
if (totalVotes == 0) {
winnerString = "There was no winner"
} else if (tied) {
winnerString = "It's a draw!"
resultsString = `${Math.round((topAnswers[0].count/totalVotes)*100)}%`
} else {
const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get()
winnerString = `${topAnswers[0].option_text} <:${pollWin?.name}:${pollWin?.emoji_id}>`
resultsString = `Winning answer • ${Math.round((topAnswers[0].count/totalVotes)*100)}%`
}
// @ts-ignore
const guildID = discord.channels.get(channelID).guild_id
let mainContent = `**${winnerString}**`
if (resultsString) {
mainContent += `\n-# ${resultsString}`
}
return {
flags: DiscordTypes.MessageFlags.IsComponentsV2,
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: titleString
}, {
type: DiscordTypes.ComponentType.Container,
components: [{
type: DiscordTypes.ComponentType.Section,
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: `**${winnerString}**\n-# ${resultsString}`
}],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: DiscordTypes.ButtonStyle.Link,
url: `https://discord.com/channels/${guildID}/${channelID}/${messageID}`,
label: "View Poll"
}
}]
}]
}
}
/**
* @param {string} channelID
* @param {string} messageID
*/
async function getPollEndMessageFromDatabase(channelID, messageID) {
const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get()
if (!pollWin) {
await setupEmojis.setupEmojis()
}
const pollRow = select("poll", ["max_selections", "question_text"], {message_id: messageID}).get()
assert(pollRow)
/** @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(messageID)
return getPollEndMessage(channelID, messageID, pollRow.question_text, pollResults)
}
module.exports.getMultiSelectString = getMultiSelectString
module.exports.getPollComponents = getPollComponents
module.exports.getPollComponentsFromDatabase = getPollComponentsFromDatabase
module.exports.getPollEndMessageFromDatabase = getPollEndMessageFromDatabase
module.exports.getMedal = getMedal