Compare commits
4 commits
e565342ac8
...
0c781f9b72
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c781f9b72 | |||
| f3ae7ba792 | |||
| 90606d9176 | |||
| afca4de6b6 |
25 changed files with 912 additions and 335 deletions
BIN
docs/img/poll-star-avatar.png
Normal file
BIN
docs/img/poll-star-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
docs/img/poll_win.png
Normal file
BIN
docs/img/poll_win.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
141
src/d2m/actions/poll-end.js
Normal 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
|
||||||
99
src/d2m/actions/poll-vote.js
Normal file
99
src/d2m/actions/poll-vote.js
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -85,14 +93,35 @@ async function sendMessage(message, channel, guild, row) {
|
||||||
// The last event gets reaction_part = 0. Reactions are managed there because reactions are supposed to appear at the bottom.
|
// The last event gets reaction_part = 0. Reactions are managed there because reactions are supposed to appear at the bottom.
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
||||||
async MESSAGE_POLL_VOTE_ADD(client, data){
|
/**
|
||||||
|
* @param {import("./discord-client")} client
|
||||||
|
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} 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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
src/db/migrations/0032-add-polls.sql
Normal file
34
src/db/migrations/0032-add-polls.sql
Normal 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
17
src/db/orm-defs.d.ts
vendored
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
150
src/discord/interactions/poll.js
Normal file
150
src/discord/interactions/poll.js
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
227
src/m2d/converters/poll-components.js
Normal file
227
src/m2d/converters/poll-components.js
Normal 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
|
||||||
|
|
@ -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
16
src/types.d.ts
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue