Compare commits

..

4 commits

Author SHA1 Message Date
0c781f9b72 Fixes to vote counting 2026-01-26 20:51:30 +13:00
f3ae7ba792 Rename poll files a bit better 2026-01-26 02:35:58 +13:00
90606d9176 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>
2026-01-26 02:30:05 +13:00
afca4de6b6 Bridge polls from Matrix as pseudo-polls on Discord (with an embed). Not 100% working.
Co-authored-by: Cadence Ember <cloudrac3r@vivaldi.net>
2026-01-26 02:29:59 +13:00
25 changed files with 912 additions and 335 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
docs/img/poll_win.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

8
package-lock.json generated
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"

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",

View file

@ -1,81 +0,0 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough
/** @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")
const inFlightPollVotes = new Set()
/**
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} 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.
if (!parentID) 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.
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)
return modifyVote(data, parentID)
}
/**
* @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} 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()
if (!parentID) 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.
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)
return modifyVote(data, parentID)
}
/**
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
* @param {string} parentID
*/
async function modifyVote(data, parentID) {
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.
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 roomID = await createRoom.ensureRoom(data.channel_id)
const senderMxid = await registerUser.ensureSimJoined(user, roomID)
let answersArray = select("poll_vote", "vote", {discord_or_matrix_user_id: data.user_id, message_id: data.message_id}).pluck().all()
const eventID = await api.sendEvent(roomID, "org.matrix.msc3381.poll.response", {
"m.relates_to": {
rel_type: "m.reference",
event_id: parentID,
},
"org.matrix.msc3381.poll.response": {
answers: answersArray
}
}, senderMxid)
inFlightPollVotes.delete(data.user_id+data.message_id)
return eventID
}
module.exports.addVote = addVote
module.exports.removeVote = removeVote
module.exports.modifyVote = modifyVote

View file

@ -21,7 +21,7 @@ async function addReaction(data) {
const user = data.member?.user const user = data.member?.user
assert.ok(user && user.username) 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. if (!parentID) return // Nothing can be done if the parent message was never bridged.
assert.equal(typeof parentID, "string") assert.equal(typeof parentID, "string")

View file

@ -1,139 +0,0 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const {isDeepStrictEqual} = require("util")
const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough
/** @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("./add-or-remove-vote.js")} */
const vote = sync.require("../actions/add-or-remove-vote")
/** @type {import("../../m2d/actions/channel-webhook")} */
const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
// 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
// * sending a message to Discord if a vote in that poll has been cast on Matrix
// This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js.
/**
* @param {number} percent
*/
function barChart(percent){
let bars = Math.floor(percent*10)
return "█".repeat(bars) + "▒".repeat(10-bars)
}
async function getAllVotes(channel_id, message_id, answer_id){
let voteUsers = []
let after = 0;
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")
if (curVotes.users.length == 0 && after == 0){ // Zero votes.
break
}
if (curVotes.users[99]){
after = curVotes.users[99].id
}
voteUsers = voteUsers.concat(curVotes.users)
}
return voteUsers
}
/**
* @param {typeof import("../../../test/data.js")["poll_close"]} message
* @param {DiscordTypes.APIGuild} guild
*/
async function closePoll(message, guild){
const pollCloseObject = message.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()
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 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.
// 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.
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()
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.
// 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]}].
for (let i=0;i<pollOptions.length;i++){
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.
optionUsers.map(user=>{
let userLocation = updatedAnswers.findIndex(item=>item.id===user.id)
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.
} else { // This user already voted for another option on the poll.
updatedAnswers[userLocation].votes.push(pollOptions[i])
}
})
}
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.
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, message.message_reference.message_id)
await vote.modifyVote({user_id: user_id, message_id: message.message_reference.message_id, channel_id: message.channel_id, answer_id: 0}, parentID)
})
}
let combinedVotes = 0;
let pollResults = pollOptions.map(option => {
let votes = Number(db.prepare("SELECT COUNT(*) FROM poll_vote WHERE message_id = ? AND vote = ?").get(message.message_reference.message_id, option)["COUNT(*)"])
combinedVotes = combinedVotes + votes
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!
let winningAnswer = 0
let unique = true
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"
for (let i=0;i<pollResults.length;i++){
if (i == winningAnswer && unique){
messageString = messageString + barChart(pollResults[i].votes/combinedVotes) + " **" + pollAnswersObject[i].poll_media.text + "** (**" + pollResults[i].votes + "**)\n"
} else{
messageString = messageString + barChart(pollResults[i].votes/combinedVotes) + " " + pollAnswersObject[i].poll_media.text + " (" + pollResults[i].votes + ")\n"
}
}
const messageResponse = await channelWebhook.sendMessageWithWebhook(message.channel_id, {content: messageString}, message.thread_id)
db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, message.thread_id || message.channel_id)
}
}
module.exports.closePoll = closePoll

141
src/d2m/actions/poll-end.js Normal file
View file

@ -0,0 +1,141 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const {isDeepStrictEqual} = require("util")
const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("./poll-vote")} */
const vote = sync.require("../actions/poll-vote")
/** @type {import("../../m2d/converters/poll-components")} */
const pollComponents = sync.require("../../m2d/converters/poll-components")
// 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
// * sending a message to Discord if a vote in that poll has been cast on Matrix
// This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js.
/**
* @param {number} percent
*/
function barChart(percent) {
const width = 12
const bars = Math.floor(percent*width)
return "█".repeat(bars) + "▒".repeat(width-bars)
}
/**
* @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 after = undefined
while (true) {
const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit})
voteUsers = voteUsers.concat(curVotes.users)
if (curVotes.users.length >= limit) { // Loop again for the next page.
// @ts-ignore - stupid
after = curVotes.users.at(-1).id
} else { // Reached the end.
return voteUsers
}
}
}
/**
* @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage
*/
async function endPoll(closeMessage) {
const pollCloseObject = closeMessage.embeds[0]
const pollMessageID = closeMessage.message_reference.message_id
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
// 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 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.
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*.
/** @type {{user: DiscordTypes.APIUser, matrixOptionVotes: string[]}[]} This will be our new array of answers */
const updatedAnswers = []
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.
for (const user of optionUsers) {
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.
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.
updatedAnswers[userLocation].matrixOptionVotes.push(matrixOption)
}
}
}
// Check for inconsistencies in what was cached in database vs final confirmed poll answers
// If different, sync the final confirmed answers to Matrix-side to make it accurate there too
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.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.
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.sendVotes(answer.user, closeMessage.channel_id, pollMessageID, pollEventID)
}
}))
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.sendVotes(user_id, closeMessage.channel_id, pollMessageID, pollEventID)
}))
}
/** @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!
// Now that we've corrected the vote totals, we can get the results again and post them to Discord!
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
}
}
}
module.exports.endPoll = endPoll

View file

@ -0,0 +1,99 @@
// @ts-check
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 {discord, sync, db, select, from} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
const inFlightPollSema = new Semaphore()
/**
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} 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.
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.
assert(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 debounceSendVotes(data, pollEventID)
}
/**
* @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} 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()
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.
assert(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 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 {string} pollEventID
* @return {Promise<string>} event ID of Matrix vote
*/
async function debounceSendVotes(data, pollEventID) {
return await inFlightPollSema.request(async () => {
await scheduler.wait(1000) // Wait for votes to be collected
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}`)
}
/**
* @param {DiscordTypes.APIUser | string} userOrID
* @param {string} channelID
* @param {string} pollMessageID
* @param {string} pollEventID
*/
async function sendVotes(userOrID, 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
}
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: userID, message_id: pollMessageID}).pluck().all()
const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", {
"m.relates_to": {
rel_type: "m.reference",
event_id: pollEventID,
},
"org.matrix.msc3381.poll.response": {
answers: answersArray
}
}, senderMxid)
return eventID
}
module.exports.addVote = addVote
module.exports.removeVote = removeVote
module.exports.debounceSendVotes = debounceSendVotes
module.exports.sendVotes = sendVotes

View file

@ -22,7 +22,7 @@ async function removeSomeReactions(data) {
if (!row) return if (!row) return
const eventReactedTo = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") 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 if (!eventReactedTo) return
// Due to server restrictions, all relations (i.e. reactions) have to be in the same room as the original event. // Due to server restrictions, all relations (i.e. reactions) have to be in the same room as the original event.

View file

@ -4,7 +4,7 @@ const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const { discord, sync, db, select } = passthrough const { discord, sync, db, select, from} = passthrough
/** @type {import("../converters/message-to-event")} */ /** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event") const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
@ -17,10 +17,12 @@ const registerPkUser = sync.require("./register-pk-user")
const registerWebhookUser = sync.require("./register-webhook-user") const registerWebhookUser = sync.require("./register-webhook-user")
/** @type {import("../actions/create-room")} */ /** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room") const createRoom = sync.require("../actions/create-room")
/** @type {import("../actions/close-poll")} */ /** @type {import("../actions/poll-end")} */
const closePoll = sync.require("../actions/close-poll") const pollEnd = sync.require("../actions/poll-end")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../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 * @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() const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
assert(historicalRoomIndex) 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 let senderMxid = null
if (dUtils.isWebhookMessage(message)) { if (dUtils.isWebhookMessage(message)) {
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0
@ -57,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) {
@ -86,13 +94,34 @@ 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
)
} }
})()
}
// part/reaction_part consistency for polls
if (sentResultsMessage) {
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("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)
} }
return eventIDs return eventIDs

View file

@ -32,8 +32,8 @@ const speedbump = sync.require("./actions/speedbump")
const retrigger = sync.require("./actions/retrigger") const retrigger = sync.require("./actions/retrigger")
/** @type {import("./actions/set-presence")} */ /** @type {import("./actions/set-presence")} */
const setPresence = sync.require("./actions/set-presence") const setPresence = sync.require("./actions/set-presence")
/** @type {import("./actions/add-or-remove-vote")} */ /** @type {import("./actions/poll-vote")} */
const vote = sync.require("./actions/add-or-remove-vote") const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */ /** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../discord/interactions/matrix-info")} */ /** @type {import("../discord/interactions/matrix-info")} */
@ -372,11 +372,17 @@ module.exports = {
await createSpace.syncSpaceExpressions(data, false) await createSpace.syncSpaceExpressions(data, false)
}, },
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
*/
async MESSAGE_POLL_VOTE_ADD(client, 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)
}, },

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;

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;

17
src/db/orm-defs.d.ts vendored
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
} }
} }

View file

@ -0,0 +1,150 @@
// @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/poll-vote")} */
const vote = sync.require("../../d2m/actions/poll-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}) {
if (!member?.user) return
const userID = member.user.id
const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get()
if (!pollRow) return
// Definitely supposed to be a poll button click. We can use assertions now.
const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get()
assert(matrixPollEvent)
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 or if requested)
if (data.custom_id === "POLL_VOTE" || (maxSelections > 1 && alreadySelected.length === maxSelections)) {
const options = select("poll_option", ["matrix_option", "option_text", "seq"], {message_id: message.id}, "ORDER BY seq").all().map(option => ({
value: option.matrix_option,
label: option.option_text,
default: alreadySelected.includes(option.matrix_option)
}))
const checkboxGroupExtras = maxSelections === 1 && options.length > 1 ? {} : {
type: 22, // DiscordTypes.ComponentType.CheckboxGroup
min_values: 0,
max_values: maxSelections
}
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.Modal,
data: {
custom_id: "POLL_MODAL",
title: "Poll",
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}`
}, {
type: DiscordTypes.ComponentType.Label,
label: pollRow.question_text,
component: /* {
type: 21, // DiscordTypes.ComponentType.RadioGroup
custom_id: "POLL_MODAL_SELECTION",
options,
required: false,
...checkboxGroupExtras
} */
{
type: DiscordTypes.ComponentType.StringSelect,
custom_id: "POLL_MODAL_SELECTION",
options,
required: false,
min_values: 0,
max_values: maxSelections,
}
}]
}
}}
}
if (data.custom_id === "POLL_MODAL") {
// Clicked options via modal
/** @type {DiscordTypes.APIMessageStringSelectInteractionData} */ // @ts-ignore - close enough to the real thing
const component = data.components[1].component
assert.equal(component.custom_id, "POLL_MODAL_SELECTION")
// Replace votes with selection
db.transaction(() => {
db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID)
for (const option of component.values) {
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, option)
}
})()
// Update counts on message
yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.UpdateMessage,
data: pollComponents.getPollComponentsFromDatabase(message.id)
}}
// Sync changes to Matrix
await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent)
} else {
// Clicked buttons on message
const optionPrefix = "POLL_OPTION#" // we use a prefix to prevent someone from sending a Matrix poll that intentionally collides with other elements of the embed
const matrixOption = select("poll_option", "matrix_option", {matrix_option: data.custom_id.substring(optionPrefix.length), message_id: message.id}).pluck().get()
assert(matrixOption)
// Remove a vote
if (alreadySelected.includes(matrixOption)) {
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(userID, message.id, matrixOption)
}
// 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, matrixOption)
})()
}
// Add a vote (if capacity)
else if (alreadySelected.length < maxSelections) {
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption)
}
// Update counts on message
yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.UpdateMessage,
data: pollComponents.getPollComponentsFromDatabase(message.id)
}}
// Sync changes to Matrix
await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent)
}
}
/* 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)
} else if (response.editOriginalInteractionResponse) {
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
}
}
}
module.exports.interact = interact
module.exports._interact = _interact

View file

@ -9,6 +9,7 @@ const invite = sync.require("./interactions/invite.js")
const permissions = sync.require("./interactions/permissions.js") const permissions = sync.require("./interactions/permissions.js")
const reactions = sync.require("./interactions/reactions.js") const reactions = sync.require("./interactions/reactions.js")
const privacy = sync.require("./interactions/privacy.js") const privacy = sync.require("./interactions/privacy.js")
const poll = sync.require("./interactions/poll.js")
// User must have EVERY permission in default_member_permissions to be able to use the command // User must have EVERY permission in default_member_permissions to be able to use the command
@ -68,9 +69,19 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
console.error(e) console.error(e)
}) })
/** @param {DiscordTypes.APIInteraction} interaction */
async function dispatchInteraction(interaction) { async function dispatchInteraction(interaction) {
const interactionId = interaction.data.custom_id || interaction.data.name const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
try { try {
if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) {
// All we get is custom_id, don't know which context the button was clicked in.
// So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_.
if (interaction.data.custom_id.startsWith("POLL_")) {
await poll.interact(interaction)
} else {
throw new Error(`Unknown message component ${interaction.data.custom_id}`)
}
} else {
if (interactionId === "Matrix info") { if (interactionId === "Matrix info") {
await matrixInfo.interact(interaction) await matrixInfo.interact(interaction)
} else if (interactionId === "invite") { } else if (interactionId === "invite") {
@ -88,6 +99,7 @@ async function dispatchInteraction(interaction) {
} else { } else {
throw new Error(`Unknown interaction ${interactionId}`) throw new Error(`Unknown interaction ${interactionId}`)
} }
}
} catch (e) { } catch (e) {
let stackLines = null let stackLines = null
if (e.stack) { if (e.stack) {
@ -97,12 +109,16 @@ async function dispatchInteraction(interaction) {
stackLines = stackLines.slice(0, cloudstormLine - 2) stackLines = stackLines.slice(0, cloudstormLine - 2)
} }
} }
try {
await discord.snow.interaction.createFollowupMessage(id, interaction.token, { await discord.snow.interaction.createFollowupMessage(id, interaction.token, {
content: `Interaction failed: **${interactionId}**` content: `Interaction failed: **${interactionId}**`
+ `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\`` + `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\``
+ `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``, + `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``,
flags: DiscordTypes.MessageFlags.Ephemeral flags: DiscordTypes.MessageFlags.Ephemeral
}) })
} catch (_) {
throw e
}
} }
} }

View file

@ -14,6 +14,8 @@ const channelWebhook = sync.require("./channel-webhook")
const eventToMessage = sync.require("../converters/event-to-message") const eventToMessage = sync.require("../converters/event-to-message")
/** @type {import("../../matrix/api")}) */ /** @type {import("../../matrix/api")}) */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")}) */
const utils = sync.require("../../matrix/utils")
/** @type {import("../../d2m/actions/register-user")} */ /** @type {import("../../d2m/actions/register-user")} */
const registerUser = sync.require("../../d2m/actions/register-user") const registerUser = sync.require("../../d2m/actions/register-user")
/** @type {import("../../d2m/actions/edit-message")} */ /** @type {import("../../d2m/actions/edit-message")} */
@ -22,8 +24,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
@ -59,7 +61,7 @@ async function resolvePendingFiles(message) {
return newMessage return newMessage
} }
/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | 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_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event */
async function sendEvent(event) { async function sendEvent(event) {
const row = from("channel_room").where({room_id: event.room_id}).select("channel_id", "thread_parent").get() const row = from("channel_room").where({room_id: event.room_id}).select("channel_id", "thread_parent").get()
if (!row) return [] // allow the bot to exist in unbridged rooms, just don't do anything with it if (!row) return [] // allow the bot to exist in unbridged rooms, just don't do anything with it
@ -71,6 +73,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()
@ -78,7 +81,19 @@ async function sendEvent(event) {
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, channel, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}) const di = {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}
if (event.type === "org.matrix.msc3381.poll.end") {
// Validity already checked by dispatcher. Poll is definitely closed. Update it and DI necessary data.
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get()
assert(messageID)
db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(messageID)
di.pollEnd = {
messageID
}
}
let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, channel, di)
messagesToEdit = await Promise.all(messagesToEdit.map(async e => { messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
e.message = await resolvePendingFiles(e.message) e.message = await resolvePendingFiles(e.message)
@ -104,8 +119,16 @@ async function sendEvent(event) {
await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID) await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
} }
// Poll ends do not follow the normal laws of parts.
// Normally when editing and adding extra parts, the new parts should always have part = 1 and reaction_part = 1 (because the existing part, which is being edited, already took 0).
// However for polls, the edit is actually for a different message. The message being sent is truly a new message, and should have parts = 0.
// So in that case, just override these variables to have the right values.
if (di.pollEnd) {
eventPart = 0
}
for (const message of messagesToSend) { for (const message of messagesToSend) {
const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 const reactionPart = (messagesToEdit.length === 0 || di.pollEnd) && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
db.transaction(() => { db.transaction(() => {
db.prepare("INSERT INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(messageResponse.id, historicalRoomIndex) db.prepare("INSERT INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(messageResponse.id, historicalRoomIndex)
@ -133,12 +156,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) {

View file

@ -9,7 +9,7 @@ async function setupEmojis() {
const {id} = require("../../../addbot") const {id} = require("../../../addbot")
const {discord, db} = passthrough const {discord, db} = passthrough
const emojis = await discord.snow.assets.getAppEmojis(id) const emojis = await discord.snow.assets.getAppEmojis(id)
for (const name of ["L1", "L2"]) { for (const name of ["L1", "L2", "poll_win"]) {
const existing = emojis.items.find(e => e.name === name) const existing = emojis.items.find(e => e.name === name)
if (existing) { if (existing) {
db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id)

View file

@ -8,17 +8,35 @@ const crypto = require("crypto")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, discord, db, select} = passthrough const {sync, discord, db, select} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../converters/poll-components")} */
const pollComponents = sync.require("../converters/poll-components")
/** @type {import("./channel-webhook")} */
const webhook = sync.require("./channel-webhook")
/** @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event */ /** @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event */
async function updateVote(event) { async function updateVote(event) {
const messageRow = select("event_message", ["message_id", "source"], {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start"}).get()
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start"}).pluck().get() const messageID = messageRow?.message_id
if (!messageID) return // Nothing can be done if the parent message was never bridged. if (!messageID) return // Nothing can be done if the parent message was never bridged.
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.transaction(() => {
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.
for (const answer of event.content["org.matrix.msc3381.poll.response"].answers) {
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer)
}
})()
event.content["org.matrix.msc3381.poll.response"].answers.map(answer=>{ // If poll was started on Matrix, the Discord version is using components, so we can update that to the current status
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(event.sender, messageID, answer) if (messageRow.source === 0) {
}) const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
assert(channelID)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID))
}
} }
module.exports.updateVote = updateVote module.exports.updateVote = updateVote

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")} */
@ -517,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.APIGuild} guild
* @param {DiscordTypes.APIGuildTextChannel} channel * @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) { async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender let displayName = event.sender
@ -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 const pollMessages = []
// 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,27 @@ 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 => ({
matrix_option: answer.id,
option_text: answer["org.matrix.msc1767.text"],
count: 0 // no votes initially
}))
content = ""
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`
}) })
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
}
} 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 +988,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 +1011,14 @@ async function eventToMessage(event, guild, channel, di) {
messages[0].pendingFiles = pendingFiles messages[0].pendingFiles = pendingFiles
} }
if (poll) { if (pollMessages.length) {
if (!messages.length) messages.push({ for (const pollMessage of pollMessages) {
content: " ", // stopgap, remove when library updates messages.push({
username: displayNameShortened, username: displayNameShortened,
avatar_url: avatarURL avatar_url: avatarURL,
...pollMessage,
}) })
messages[0].poll = poll }
} }
const messagesToEdit = [] const messagesToEdit = []

View file

@ -0,0 +1,227 @@
// @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
* @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 medal = getMedal(topAnswers, option.count)
return {
type: DiscordTypes.ComponentType.Container,
components: [{
type: DiscordTypes.ComponentType.Section,
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: medal && isClosed ? `${medal} ${option.option_text}` : option.option_text
}],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
label: option.count.toString(),
custom_id: `POLL_OPTION#${option.matrix_option}`,
disabled: isClosed
}
}]
}
})
}
/**
* @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
* @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[]} 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) {
headingComponent = { // This one is for the poll heading.
type: DiscordTypes.ComponentType.Section,
components: [
{
type: DiscordTypes.ComponentType.TextDisplay,
content: `## ${questionText}`
}
],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: DiscordTypes.ButtonStyle.Secondary,
custom_id: "POLL_VOTE",
label: "Voting closed",
disabled: true
}
}
} else {
headingComponent = { // This one is for the poll heading.
type: DiscordTypes.ComponentType.Section,
components: [
{
type: DiscordTypes.ComponentType.TextDisplay,
content: `## ${questionText}`
},
// @ts-ignore
multiSelectInfoComponent.pop()
],
accessory: {
type: DiscordTypes.ComponentType.Button,
style: DiscordTypes.ButtonStyle.Primary,
custom_id: "POLL_VOTE",
label: "Vote!"
}
}
}
const optionComponents = optionsToComponents(isClosed, pollOptions)
return {
flags: DiscordTypes.MessageFlags.IsComponentsV2,
components: [headingComponent, ...optionComponents, ...multiSelectInfoComponent]
}
}
/** @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

View file

@ -237,6 +237,35 @@ sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("or
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
await vote.updateVote(event) // Matrix votes can't be bridged, so all we do is store it in the database. await vote.updateVote(event) // Matrix votes can't be bridged, so all we do is store it in the database.
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.end", guard("org.matrix.msc3381.poll.end",
/**
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event it is a org.matrix.msc3381.poll.end because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const pollEventID = event.content["m.relates_to"]?.event_id
if (!pollEventID) return // Validity check
const messageID = select("event_message", "message_id", {event_id: pollEventID, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get()
if (!messageID) return // Nothing can be done if the parent message was never bridged. Also, Discord-native polls cannot be ended by others, so this only works for polls started on Matrix.
try {
var pollEvent = await api.getEvent(event.room_id, pollEventID) // Poll start event must exist for this to be valid
} catch (e) {
return
}
// According to the rules, the poll end is only allowed if it was sent by the poll starter, or by someone with redact powers.
if (pollEvent.sender !== event.sender) {
const {powerLevels, powers: {[event.sender]: enderPower}} = await utils.getEffectivePower(event.room_id, [event.sender], api)
if (enderPower < (powerLevels.redact ?? 50)) {
return // Not allowed
}
}
const messageResponses = await sendEvent.sendEvent(event)
await api.ackEvent(event)
})) }))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",

16
src/types.d.ts vendored
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
@ -306,6 +302,18 @@ export namespace Event {
export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer<Org_Matrix_Msc3381_Poll_Response> & {type: "org.matrix.msc3381.poll.response"} export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer<Org_Matrix_Msc3381_Poll_Response> & {type: "org.matrix.msc3381.poll.response"}
export type Org_Matrix_Msc3381_Poll_End = {
"org.matrix.msc3381.poll.end": {},
"org.matrix.msc1767.text": string,
body: string,
"m.relates_to": {
rel_type: string
event_id: string
}
}
export type Outer_Org_Matrix_Msc3381_Poll_End = Outer<Org_Matrix_Msc3381_Poll_End> & {type: "org.matrix.msc3381.poll.end"}
export type M_Room_Member = { export type M_Room_Member = {
membership: string membership: string
displayname?: string displayname?: string

View file

@ -69,3 +69,8 @@ as.router.get("/icon.png", defineEventHandler(event => {
handleCacheHeaders(event, {maxAge: 86400}) handleCacheHeaders(event, {maxAge: 86400})
return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png")) return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png"))
})) }))
as.router.get("/discord/poll-star-avatar.png", defineEventHandler(event => {
handleCacheHeaders(event, {maxAge: 86400})
return fs.promises.readFile(join(__dirname, "../../docs/img/poll-star-avatar.png"))
}))