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
+41 -28
View File
@@ -1,6 +1,9 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const {Semaphore} = require("@chriscdn/promise-semaphore")
const {scheduler} = require("timers/promises")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough const {discord, sync, db, select, from} = passthrough
@@ -11,71 +14,81 @@ const registerUser = sync.require("./register-user")
/** @type {import("./create-room")} */ /** @type {import("./create-room")} */
const createRoom = sync.require("../actions/create-room") const createRoom = sync.require("../actions/create-room")
const inFlightPollVotes = new Set() 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 parentID = 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 (!parentID) 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.
let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls. let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
assert(realAnswer) assert(realAnswer)
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer) db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer)
return modifyVote(data, parentID) return debounceSendVotes(data, pollEventID)
} }
/** /**
* @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 parentID = 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 (!parentID) return if (!pollEventID) return
let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls. let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
assert(realAnswer) assert(realAnswer)
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND vote = ?").run(data.user_id, data.message_id, realAnswer) db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(data.user_id, data.message_id, realAnswer)
return modifyVote(data, parentID) return debounceSendVotes(data, pollEventID)
} }
/** /**
* Multiple-choice polls send all the votes at the same time. This debounces and sends the combined votes.
* In the meantime, the combined votes are assembled in the `poll_vote` database table by the above functions.
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
* @param {string} parentID * @param {string} pollEventID
* @return {Promise<string>} event ID of Matrix vote
*/ */
async function modifyVote(data, parentID) { async function debounceSendVotes(data, pollEventID) {
return await inFlightPollSema.request(async () => {
if (inFlightPollVotes.has(data.user_id+data.message_id)) { // Multiple votes on a poll, and this function has already been called on at least one of them. Need to add these together so we don't ignore votes if someone is voting rapid-fire on a bunch of different polls. await scheduler.wait(1000) // Wait for votes to be collected
return;
}
inFlightPollVotes.add(data.user_id+data.message_id)
await new Promise(resolve => setTimeout(resolve, 1000)) // Wait a second.
const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID. const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID.
return await sendVotes(user, data.channel_id, data.message_id, pollEventID)
}, `${data.user_id}/${data.message_id}`)
}
const roomID = await createRoom.ensureRoom(data.channel_id) /**
const senderMxid = await registerUser.ensureSimJoined(user, roomID) * @param {DiscordTypes.APIUser} user
* @param {string} channelID
* @param {string} pollMessageID
* @param {string} pollEventID
*/
async function sendVotes(user, channelID, pollMessageID, pollEventID) {
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()
if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll??
db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(pollMessageID)
return
}
let answersArray = select("poll_vote", "vote", {discord_or_matrix_user_id: data.user_id, message_id: data.message_id}).pluck().all() const senderMxid = await registerUser.ensureSimJoined(user, matchingRoomID)
const eventID = await api.sendEvent(roomID, "org.matrix.msc3381.poll.response", { const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: user.id, message_id: pollMessageID}).pluck().all()
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",
event_id: parentID, event_id: pollEventID,
}, },
"org.matrix.msc3381.poll.response": { "org.matrix.msc3381.poll.response": {
answers: answersArray answers: answersArray
} }
}, senderMxid) }, senderMxid)
inFlightPollVotes.delete(data.user_id+data.message_id)
return eventID return eventID
} }
module.exports.addVote = addVote module.exports.addVote = addVote
module.exports.removeVote = removeVote module.exports.removeVote = removeVote
module.exports.modifyVote = modifyVote module.exports.debounceSendVotes = debounceSendVotes
module.exports.sendVotes = sendVotes
+70 -63
View File
@@ -30,16 +30,25 @@ function barChart(percent){
return "█".repeat(bars) + "▒".repeat(10-bars) return "█".repeat(bars) + "▒".repeat(10-bars)
} }
async function getAllVotes(channel_id, message_id, answer_id){ /**
* @param {string} channelID
* @param {string} messageID
* @param {string} answerID
* @returns {Promise<DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]>}
*/
async function getAllVotesOnAnswer(channelID, messageID, answerID){
const limit = 100
/** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */
let voteUsers = [] let voteUsers = []
let after = 0; let after = undefined
while (!voteUsers.length || after) { while (!voteUsers.length || after) {
let curVotes = await discord.snow.requestHandler.request("/channels/"+channel_id+"/polls/"+message_id+"/answers/"+answer_id, {after: after, limit: 100}, "get", "json") const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit})
if (curVotes.users.length == 0 && after == 0){ // Zero votes. if (curVotes.users.length === 0) { // Reached the end.
break break
} }
if (curVotes.users[99]){ if (curVotes.users.length >= limit) { // Loop again for the next page.
after = curVotes.users[99].id // @ts-ignore - stupid
after = curVotes.users.at(-1).id
} }
voteUsers = voteUsers.concat(curVotes.users) voteUsers = voteUsers.concat(curVotes.users)
} }
@@ -48,91 +57,89 @@ async function getAllVotes(channel_id, message_id, answer_id){
/** /**
* @param {typeof import("../../../test/data.js")["poll_close"]} message * @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
*/ */
async function closePoll(message, guild){ async function closePoll(closeMessage, guild){
const pollCloseObject = message.embeds[0] const pollCloseObject = closeMessage.embeds[0]
const parentID = select("event_message", "event_id", {message_id: message.message_reference.message_id, event_type: "org.matrix.msc3381.poll.start"}).pluck().get() const pollMessageID = closeMessage.message_reference.message_id
if (!parentID) return // Nothing we can send Discord-side if we don't have the original poll. We will still send a results message Matrix-side. const pollEventID = select("event_message", "event_id", {message_id: pollMessageID, event_type: "org.matrix.msc3381.poll.start"}).pluck().get()
if (!pollEventID) return // Nothing we can send Discord-side if we don't have the original poll. We will still send a results message Matrix-side.
const discordPollOptions = select("poll_option", "discord_option", {message_id: pollMessageID}).pluck().all()
assert(discordPollOptions.every(x => typeof x === "string")) // This poll originated on Discord so it will have Discord option IDs
const pollOptions = select("poll_option", "discord_option", {message_id: message.message_reference.message_id}).pluck().all()
// 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. // 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. // Database reads are cheap, and API calls are expensive, so we will only query Discord when the totals don't match.
let 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.
let databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "vote"], {message_id: message.message_reference.message_id}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all() const databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "matrix_option"], {message_id: pollMessageID}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all()
if (databaseVotes.length != totalVotes) { // Matching length should be sufficient for most cases. if (databaseVotes.length !== totalVotes) { // Matching length should be sufficient for most cases.
let voteUsers = [...new Set(databaseVotes.map(vote => vote.discord_or_matrix_user_id))] // Unique array of all users we have votes for in the database. let voteUsers = [...new Set(databaseVotes.map(vote => vote.discord_or_matrix_user_id))] // Unique array of all users we have votes for in the database.
// Main design challenge here: we get the data by *answer*, but we need to send it to Matrix by *user*. // Main design challenge here: we get the data by *answer*, but we need to send it to Matrix by *user*.
let updatedAnswers = [] // This will be our new array of answers: [{user: ID, votes: [1, 2, 3]}]. /** @type {{user: DiscordTypes.APIUser, matrixOptionVotes: string[]}[]} This will be our new array of answers */
for (let i=0;i<pollOptions.length;i++){ const updatedAnswers = []
let optionUsers = await getAllVotes(message.channel_id, message.message_reference.message_id, pollOptions[i]) // Array of user IDs who voted for the option we're testing.
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.
optionUsers.map(user => { optionUsers.map(user => {
let userLocation = updatedAnswers.findIndex(item=>item.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()
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({id: user.id, votes: [pollOptions[i].toString()]}) // 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].votes.push(pollOptions[i]) updatedAnswers[userLocation].matrixOptionVotes.push(matrixOption)
} }
}) })
} }
updatedAnswers.map(async user=>{
voteUsers = voteUsers.filter(item => item != 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.
let userAnswers = select("poll_vote", "vote", {discord_or_matrix_user_id: user.id, message_id: message.message_reference.message_id}).pluck().all().sort()
let updatedUserAnswers = user.votes.sort() // Sorting both just in case.
if (isDeepStrictEqual(userAnswers,updatedUserAnswers)){
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user.id, message.message_reference.message_id) // Delete existing stored votes.
updatedUserAnswers.map(vote=>{
db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(user.id, message.message_reference.message_id, vote)
})
await vote.modifyVote({user_id: user.id, message_id: message.message_reference.message_id, channel_id: message.channel_id, answer_id: 0}, parentID) // Fake answer ID, not actually needed (but we're sorta faking the datatype to call this function).
}
})
voteUsers.map(async user_id=>{ // Remove these votes. // Check for inconsistencies in what was cached in database vs final confirmed poll answers
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, message.message_reference.message_id) // If different, sync the final confirmed answers to Matrix-side to make it accurate there too
await vote.modifyVote({user_id: user_id, message_id: message.message_reference.message_id, channel_id: message.channel_id, answer_id: 0}, parentID)
}) 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.
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))){
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) {
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 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)
await vote.debounceSendVotes({user_id: user_id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID)
}))
} }
let combinedVotes = 0; /** @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()
const combinedVotes = pollResults.reduce((a, c) => a + c.count, 0)
let pollResults = pollOptions.map(option => { if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix!
let votes = Number(db.prepare("SELECT COUNT(*) FROM poll_vote WHERE message_id = ? AND vote = ?").get(message.message_reference.message_id, option)["COUNT(*)"]) const message = await discord.snow.channel.getChannelMessage(closeMessage.channel_id, pollMessageID)
combinedVotes = combinedVotes + votes assert(message?.poll?.answers)
return {answer: option, votes: votes}
})
if (combinedVotes!=totalVotes){ // This means some votes were cast on Matrix!
let pollAnswersObject = (await discord.snow.channel.getChannelMessage(message.channel_id, message.message_reference.message_id)).poll.answers
// Now that we've corrected the vote totals, we can get the results again and post them to Discord! // Now that we've corrected the vote totals, we can get the results again and post them to Discord!
let winningAnswer = 0 const topAnswers = pollResults.toSorted()
let unique = true const unique = topAnswers.length > 1 && topAnswers[0].count === topAnswers[1].count
for (let i=1;i<pollResults.length;i++){
if (pollResults[i].votes>pollResults[winningAnswer].votes){
winningAnswer = i
unique = true
} else if (pollResults[i].votes==pollResults[winningAnswer].votes){
unique = false
}
}
let messageString = "📶 Results with Matrix votes\n" let messageString = "📶 Results including Matrix votes\n"
for (let i=0;i<pollResults.length;i++){ for (const result of pollResults) {
if (i == winningAnswer && unique){ if (result === topAnswers[0] && unique) {
messageString = messageString + barChart(pollResults[i].votes/combinedVotes) + " **" + pollAnswersObject[i].poll_media.text + "** (**" + pollResults[i].votes + "**)\n" messageString = messageString + `${barChart(result.count/combinedVotes)} **${result.option_text}** (**${result.count}**)\n`
} else { } else {
messageString = messageString + barChart(pollResults[i].votes/combinedVotes) + " " + pollAnswersObject[i].poll_media.text + " (" + pollResults[i].votes + ")\n" messageString = messageString + `${barChart(result.count/combinedVotes)} ${result.option_text} (${result.count})\n`
} }
} }
const messageResponse = await channelWebhook.sendMessageWithWebhook(message.channel_id, {content: messageString}, message.thread_id) await channelWebhook.sendMessageWithWebhook(closeMessage.channel_id, {content: messageString}, closeMessage.thread_id)
db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, message.thread_id || message.channel_id)
} }
} }
+15 -3
View File
@@ -86,13 +86,25 @@ async function sendMessage(message, channel, guild, row) {
if (eventType === "org.matrix.msc3381.poll.start") { if (eventType === "org.matrix.msc3381.poll.start") {
for (let i=0; i<event["org.matrix.msc3381.poll.start"].answers.length;i++){ db.transaction(() => {
db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option) VALUES (?, ?, ?)").run(message.id, event["org.matrix.msc3381.poll.start"].answers[i].id, event["org.matrix.msc3381.poll.start"].answers[i].id) // Since we can set the ID on Matrix, we use the same ID that Discord gives us. db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run(
message.id,
event["org.matrix.msc3381.poll.start"].max_selections,
event["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
)
for (const [index, option] of Object.entries(event["org.matrix.msc3381.poll.start"].answers)) {
db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option, option_text, seq) VALUES (?, ?, ?, ?, ?)").run(
message.id,
option.id,
option.id,
option["org.matrix.msc1767.text"],
index
)
} }
})()
} }
eventIDs.push(eventID) eventIDs.push(eventID)
} }
return eventIDs return eventIDs
-19
View File
@@ -1,19 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE "poll_option" (
"message_id" TEXT NOT NULL,
"matrix_option" TEXT NOT NULL,
"discord_option" TEXT NOT NULL,
PRIMARY KEY("message_id","matrix_option")
FOREIGN KEY ("message_id") REFERENCES "message_channel" ("message_id") ON DELETE CASCADE
) WITHOUT ROWID;
CREATE TABLE "poll_vote" (
"vote" TEXT NOT NULL,
"message_id" TEXT NOT NULL,
"discord_or_matrix_user_id" TEXT NOT NULL,
PRIMARY KEY("vote","message_id","discord_or_matrix_user_id"),
FOREIGN KEY("message_id") REFERENCES "message_channel" ("message_id") ON DELETE CASCADE
) WITHOUT ROWID;
COMMIT;
+34
View File
@@ -0,0 +1,34 @@
BEGIN TRANSACTION;
DROP TABLE IF EXISTS "poll";
DROP TABLE IF EXISTS "poll_option";
DROP TABLE IF EXISTS "poll_vote";
CREATE TABLE "poll" (
"message_id" TEXT NOT NULL,
"max_selections" INTEGER NOT NULL,
"question_text" TEXT NOT NULL,
"is_closed" INTEGER NOT NULL,
PRIMARY KEY ("message_id"),
FOREIGN KEY ("message_id") REFERENCES "message_room" ("message_id") ON DELETE CASCADE
) WITHOUT ROWID;
CREATE TABLE "poll_option" (
"message_id" TEXT NOT NULL,
"matrix_option" TEXT NOT NULL,
"discord_option" TEXT,
"option_text" TEXT NOT NULL,
"seq" INTEGER NOT NULL,
PRIMARY KEY ("message_id", "matrix_option"),
FOREIGN KEY ("message_id") REFERENCES "poll" ("message_id") ON DELETE CASCADE
) WITHOUT ROWID;
CREATE TABLE "poll_vote" (
"message_id" TEXT NOT NULL,
"matrix_option" TEXT NOT NULL,
"discord_or_matrix_user_id" TEXT NOT NULL,
PRIMARY KEY ("message_id", "matrix_option", "discord_or_matrix_user_id"),
FOREIGN KEY ("message_id", "matrix_option") REFERENCES "poll_option" ("message_id", "matrix_option") ON DELETE CASCADE
) WITHOUT ROWID;
COMMIT;
+13 -4
View File
@@ -140,16 +140,25 @@ export type Models = {
original_encoding: string | null original_encoding: string | null
} }
poll_vote: { poll: { // not actually in database yet
vote: string
message_id: string message_id: string
discord_or_matrix_user_id: string max_selections: number
question_text: string
is_closed: number
} }
poll_option: { poll_option: {
message_id: string message_id: string
matrix_option: string matrix_option: string
discord_option: string discord_option: string | null
option_text: string // not actually in database yet
seq: number // not actually in database yet
}
poll_vote: {
message_id: string
matrix_option: string
discord_or_matrix_user_id: string
} }
} }
+95
View File
@@ -0,0 +1,95 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, select, from, db} = require("../../passthrough")
const assert = require("assert/strict")
const {id: botID} = require("../../../addbot")
const {InteractionMethods} = require("snowtransfer")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../../m2d/converters/poll-components")} */
const pollComponents = sync.require("../../m2d/converters/poll-components")
/** @type {import("../../d2m/actions/add-or-remove-vote")} */
const vote = sync.require("../../d2m/actions/add-or-remove-vote")
/**
* @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction
* @param {{api: typeof api}} di
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/
async function* _interact({data, message, member, user}, {api}) {
const discordUser = member?.user || user
assert(discordUser)
const userID = discordUser.id
const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get()
assert(matrixPollEvent)
const matrixOption = select("poll_option", "matrix_option", {discord_option: data.custom_id, message_id: message.id}).pluck().get()
assert(matrixOption)
const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get()
assert(pollRow)
const maxSelections = pollRow.max_selections
const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all()
// Show modal (if no capacity)
if (maxSelections > 1 && alreadySelected.length === maxSelections) {
// TODO: show modal
return
}
// We are going to do a server operation so need to show loading state
yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.DeferredMessageUpdate,
}}
// Remove a vote
if (alreadySelected.includes(data.custom_id)) {
db.prepare("DELETE FROM poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id)
}
// Replace votes (if only one selection is allowed)
else if (maxSelections === 1 && alreadySelected.length === 1) {
db.transaction(() => {
db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID)
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id)
})()
}
// Add a vote (if capacity)
else if (alreadySelected.length < maxSelections) {
db.transaction(() => {
db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID)
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id)
})()
}
// Sync changes to Matrix
await vote.sendVotes(discordUser, message.channel_id, message.id, matrixPollEvent)
// Check the poll is not closed (it may have been closed by sendVotes if we discover we can't send)
const isClosed = select("poll", "is_closed", {message_id: message.id}).pluck().get()
/** @type {{matrix_option: string, option_text: string, count: number}[]} */
const pollResults = db.prepare("SELECT matrix_option, option_text, count(*) as count FROM poll_option INNER JOIN poll_vote USING (message_id, matrix_option) GROUP BY matrix_option").all()
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.UpdateMessage,
data: pollComponents.getPollComponents(!!isClosed, maxSelections, pollRow.question_text, pollResults)
}}
}
/* c8 ignore start */
/** @param {DiscordTypes.APIMessageComponentButtonInteraction} 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
+20 -6
View File
@@ -22,8 +22,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
const emojiSheet = sync.require("../actions/emoji-sheet") const emojiSheet = sync.require("../actions/emoji-sheet")
/** /**
* @param {{poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message
* @returns {Promise<{poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>} * @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
*/ */
async function resolvePendingFiles(message) { async function resolvePendingFiles(message) {
if (!message.pendingFiles) return message if (!message.pendingFiles) return message
@@ -71,6 +71,7 @@ async function sendEvent(event) {
} }
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore /** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
const channel = discord.channels.get(channelID) const channel = discord.channels.get(channelID)
// @ts-ignore
const guild = discord.guilds.get(channel.guild_id) const guild = discord.guilds.get(channel.guild_id)
assert(guild) assert(guild)
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: event.room_id}).pluck().get() const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: event.room_id}).pluck().get()
@@ -133,12 +134,25 @@ async function sendEvent(event) {
}, guild, null) }, guild, null)
) )
} }
}
if (message.poll){ // Need to store answer mapping in the database. if (event.type === "org.matrix.msc3381.poll.start") { // Need to store answer mapping in the database.
for (let i=0; i<message.poll.answers.length; i++){ db.transaction(() => {
db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option) VALUES (?, ?, ?)").run(messageResponse.id, message.poll.answers[i].matrix_option, messageResponse.poll.answers[i].answer_id.toString()) const messageID = messageResponses[0].id
} db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run(
messageID,
event.content["org.matrix.msc3381.poll.start"].max_selections,
event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
)
for (const [i, option] of Object.entries(event.content["org.matrix.msc3381.poll.start"].answers)) {
db.prepare("INSERT INTO poll_option (message_id, matrix_option, option_text, seq) VALUES (?, ?, ?, ?)").run(
messageID,
option.id,
option["org.matrix.msc1767.text"],
i
)
} }
})()
} }
for (const user of ensureJoined) { for (const user of ensureJoined) {
+1 -1
View File
@@ -17,7 +17,7 @@ async function updateVote(event) {
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites. Technically we could check and only overwrite the changes, but the complexity isn't worth it. db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites. Technically we could check and only overwrite the changes, but the complexity isn't worth it.
event.content["org.matrix.msc3381.poll.response"].answers.map(answer=>{ event.content["org.matrix.msc3381.poll.response"].answers.map(answer=>{
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(event.sender, messageID, answer) db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer)
}) })
} }
+18 -21
View File
@@ -22,6 +22,8 @@ const dUtils = sync.require("../../discord/utils")
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")
/** @type {import("./emoji-sheet")} */ /** @type {import("./emoji-sheet")} */
const emojiSheet = sync.require("./emoji-sheet") const emojiSheet = sync.require("./emoji-sheet")
/** @type {import("./poll-components")} */
const pollComponents = sync.require("./poll-components")
/** @type {import("../actions/setup-emojis")} */ /** @type {import("../actions/setup-emojis")} */
const setupEmojis = sync.require("../actions/setup-emojis") const setupEmojis = sync.require("../actions/setup-emojis")
/** @type {import("../../d2m/converters/user-to-mxid")} */ /** @type {import("../../d2m/converters/user-to-mxid")} */
@@ -551,8 +553,8 @@ async function eventToMessage(event, guild, channel, di) {
const pendingFiles = [] const pendingFiles = []
/** @type {DiscordTypes.APIUser[]} */ /** @type {DiscordTypes.APIUser[]} */
const ensureJoined = [] const ensureJoined = []
/** @type {Ty.SendingPoll} */ /** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody?} */
let poll = null let pollMessage = null
// Convert content depending on what the message is // 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 // 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}) pendingFiles.push({name: filename, mxc: event.content.url})
} else if (event.type === "org.matrix.msc3381.poll.start") { } else if (event.type === "org.matrix.msc3381.poll.start") {
content = ""
const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience
let allowMultiselect = (pollContent.max_selections != 1) const isClosed = false;
let answers = pollContent.answers.map(answer=>{ const maxSelections = pollContent.max_selections || 1
return {poll_media: {text: answer["org.matrix.msc1767.text"]}, matrix_option: answer["id"]} const questionText = pollContent.question["org.matrix.msc1767.text"]
}) const pollOptions = pollContent.answers.map(answer => ({
poll = { matrix_option: answer.id,
question: { option_text: answer["org.matrix.msc1767.text"],
text: event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"] count: 0 // no votes initially
}, }))
answers: answers, content = ""
duration: 768, // Maximum duration (32 days). Matrix doesn't allow automatically-expiring polls, so this is the only thing that makes sense to send. pollMessage = pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions)
allow_multiselect: allowMultiselect,
layout_type: 1
}
} else { } 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. // 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 // Split into 2000 character chunks
const chunks = chunk(content, 2000) 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 => ({ const messages = chunks.map(content => ({
content, content,
allowed_mentions: { allowed_mentions: {
@@ -1003,13 +1001,12 @@ async function eventToMessage(event, guild, channel, di) {
messages[0].pendingFiles = pendingFiles messages[0].pendingFiles = pendingFiles
} }
if (poll) { if (pollMessage) {
if (!messages.length) messages.push({ messages.push({
content: " ", // stopgap, remove when library updates ...pollMessage,
username: displayNameShortened, username: displayNameShortened,
avatar_url: avatarURL avatar_url: avatarURL
}) })
messages[0].poll = poll
} }
const messagesToEdit = [] 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
-4
View File
@@ -83,10 +83,6 @@ export type WebhookAuthor = {
id: string id: string
} }
export type SendingPoll = DiscordTypes.RESTAPIPoll & {
answers: (DiscordTypes.APIBasePollAnswer & {matrix_option: string})[]
}
export type PkSystem = { export type PkSystem = {
id: string id: string
uuid: string uuid: string