Bridge polls from Matrix as pseudo-polls on Discord (with an embed). Not 100% working.

Co-authored-by: Cadence Ember <cloudrac3r@vivaldi.net>
This commit is contained in:
Ellie Algase
2026-01-26 02:29:59 +13:00
committed by Cadence Ember
co-authored by Cadence Ember
parent e565342ac8
commit afca4de6b6
12 changed files with 417 additions and 156 deletions
+18 -21
View File
@@ -22,6 +22,8 @@ const dUtils = sync.require("../../discord/utils")
const file = sync.require("../../matrix/file")
/** @type {import("./emoji-sheet")} */
const emojiSheet = sync.require("./emoji-sheet")
/** @type {import("./poll-components")} */
const pollComponents = sync.require("./poll-components")
/** @type {import("../actions/setup-emojis")} */
const setupEmojis = sync.require("../actions/setup-emojis")
/** @type {import("../../d2m/converters/user-to-mxid")} */
@@ -551,8 +553,8 @@ async function eventToMessage(event, guild, channel, di) {
const pendingFiles = []
/** @type {DiscordTypes.APIUser[]} */
const ensureJoined = []
/** @type {Ty.SendingPoll} */
let poll = null
/** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody?} */
let pollMessage = null
// 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
@@ -632,21 +634,17 @@ async function eventToMessage(event, guild, channel, di) {
pendingFiles.push({name: filename, mxc: event.content.url})
} else if (event.type === "org.matrix.msc3381.poll.start") {
content = ""
const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience
let allowMultiselect = (pollContent.max_selections != 1)
let answers = pollContent.answers.map(answer=>{
return {poll_media: {text: answer["org.matrix.msc1767.text"]}, matrix_option: answer["id"]}
})
poll = {
question: {
text: event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
},
answers: answers,
duration: 768, // Maximum duration (32 days). Matrix doesn't allow automatically-expiring polls, so this is the only thing that makes sense to send.
allow_multiselect: allowMultiselect,
layout_type: 1
}
const isClosed = false;
const maxSelections = pollContent.max_selections || 1
const questionText = pollContent.question["org.matrix.msc1767.text"]
const pollOptions = pollContent.answers.map(answer => ({
matrix_option: answer.id,
option_text: answer["org.matrix.msc1767.text"],
count: 0 // no votes initially
}))
content = ""
pollMessage = pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions)
} 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.
@@ -980,7 +978,7 @@ async function eventToMessage(event, guild, channel, di) {
// Split into 2000 character chunks
const chunks = chunk(content, 2000)
/** @type {({poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
const messages = chunks.map(content => ({
content,
allowed_mentions: {
@@ -1003,13 +1001,12 @@ async function eventToMessage(event, guild, channel, di) {
messages[0].pendingFiles = pendingFiles
}
if (poll) {
if (!messages.length) messages.push({
content: " ", // stopgap, remove when library updates
if (pollMessage) {
messages.push({
...pollMessage,
username: displayNameShortened,
avatar_url: avatarURL
})
messages[0].poll = poll
}
const messagesToEdit = []
+103
View File
@@ -0,0 +1,103 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
/**
* @param {boolean} isClosed
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
* @returns {DiscordTypes.APIMessageTopLevelComponent[]}
*/
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
return {
type: DiscordTypes.ComponentType.Container,
components: [{
type: DiscordTypes.ComponentType.Section,
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: option.option_text
}],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: winningOrTied ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
label: option.count.toString(),
custom_id: option.matrix_option,
disabled: isClosed
}
}]
}
})
}
/**
* @param {boolean} isClosed
* @param {number} maxSelections
* @param {string} questionText
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
*/
function getPollComponents(isClosed, maxSelections, questionText, pollOptions) {
/** @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!",
disabled: true
}
}
}
else {
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.Primary,
custom_id: "vote",
label: "Vote!"
}
}
}
const optionComponents = optionsToComponents(isClosed, pollOptions)
return {
flags: DiscordTypes.MessageFlags.IsComponentsV2,
components: [headingComponent, ...optionComponents]
}
}
module.exports.getPollComponents = getPollComponents