Compare commits

...

4 commits

19 changed files with 767 additions and 24 deletions

View file

@ -0,0 +1,81 @@
// @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

@ -0,0 +1,139 @@
// @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

View file

@ -487,9 +487,8 @@ async function unbridgeDeletedChannel(channel, guildID) {
/** @type {Ty.Event.M_Power_Levels} */ /** @type {Ty.Event.M_Power_Levels} */
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "") const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
powerLevelContent.users ??= {} powerLevelContent.users ??= {}
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
for (const mxid of Object.keys(powerLevelContent.users)) { for (const mxid of Object.keys(powerLevelContent.users)) {
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) { if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) {
delete powerLevelContent.users[mxid] delete powerLevelContent.users[mxid]
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid) await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
} }
@ -513,7 +512,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
// (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else) // (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
if (row.autocreate === 0) { if (row.autocreate === 0) {
// remove sim members // remove sim members
const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot) const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, mUtils.bot)
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?") const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
for (const mxid of members) { for (const mxid of members) {
await api.leaveRoom(roomID, mxid) await api.leaveRoom(roomID, mxid)

View file

@ -17,6 +17,8 @@ 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")} */
const closePoll = sync.require("../actions/close-poll")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
@ -31,6 +33,10 @@ 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
@ -78,7 +84,15 @@ 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"){
for (let i=0; i<event["org.matrix.msc3381.poll.start"].answers.length;i++){
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.
}
}
eventIDs.push(eventID) eventIDs.push(eventID)
} }
return eventIDs return eventIDs

View file

@ -16,8 +16,8 @@ function eventCanBeEdited(ev) {
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") { if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
return false return false
} }
// Discord does not allow stickers to be edited. // Discord does not allow stickers to be edited. Poll closures are sent as "edits", but not in a way we care about.
if (ev.old.event_type === "m.sticker") { if (ev.old.event_type === "m.sticker" || ev.old.event_type === "org.matrix.msc3381.poll.start") {
return false return false
} }
// Anything else is fair game. // Anything else is fair game.

View file

@ -203,6 +203,46 @@ async function attachmentToEvent(mentions, attachment) {
} }
} }
/** @param {DiscordTypes.APIPoll} poll */
async function pollToEvent(poll) {
let fallbackText = poll.question.text
if (poll.allow_multiselect) {
var maxSelections = poll.answers.length;
} else {
var maxSelections = 1;
}
let answers = poll.answers.map(answer=>{
let matrixText = answer.poll_media.text
if (answer.poll_media.emoji) {
if (answer.poll_media.emoji.id) {
// Custom emoji. It seems like no Matrix client allows custom emoji in poll answers, so leaving this unimplemented.
} else {
matrixText = "[" + answer.poll_media.emoji.name + "] " + matrixText
}
}
let matrixAnswer = {
id: answer.answer_id.toString(),
"org.matrix.msc1767.text": matrixText
}
fallbackText = fallbackText + "\n" + answer.answer_id.toString() + ". " + matrixText
return matrixAnswer;
})
return {
$type: "org.matrix.msc3381.poll.start",
"org.matrix.msc3381.poll.start": {
question: {
"org.matrix.msc1767.text": poll.question.text,
body: poll.question.text,
msgtype: "m.text"
},
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
max_selections: maxSelections,
answers: answers
},
"org.matrix.msc1767.text": fallbackText
}
}
/** /**
* @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
@ -226,6 +266,20 @@ async function messageToEvent(message, guild, options = {}, di) {
return [] return []
} }
if (message.type === DiscordTypes.MessageType.PollResult) {
const event_id = select("event_message", "event_id", {message_id: message.message_reference?.message_id}).pluck().get()
return [{
$type: "org.matrix.msc3381.poll.end",
"m.relates_to": {
rel_type: "m.reference",
event_id
},
"org.matrix.msc3381.poll.end": {},
body: "This poll has ended.",
msgtype: "m.text"
}]
}
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) { if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
// This is the message that appears at the top of a thread when the thread was based off an existing message. // This is the message that appears at the top of a thread when the thread was based off an existing message.
// It's just a message reference, no content. // It's just a message reference, no content.
@ -702,6 +756,12 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} }
// Then polls
if (message.poll) {
const pollEvent = await pollToEvent(message.poll)
events.push(pollEvent)
}
// Then embeds // Then embeds
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
for (const embed of message.embeds || []) { for (const embed of message.embeds || []) {
@ -713,6 +773,10 @@ async function messageToEvent(message, guild, options = {}, di) {
continue // Matrix's own URL previews are fine for images. continue // Matrix's own URL previews are fine for images.
} }
if (embed.type === "poll_result") {
// The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in actions/poll-close.js.
}
if (embed.url?.startsWith("https://discord.com/")) { if (embed.url?.startsWith("https://discord.com/")) {
continue // If discord creates an embed preview for a discord channel link, don't copy that embed continue // If discord creates an embed preview for a discord channel link, don't copy that embed
} }

View file

@ -1551,3 +1551,57 @@ test("message2event: forwarded message with unreferenced mention", async t => {
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
test("message2event: single-choice poll", async t => {
const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {})
t.deepEqual(events, [{
$type: "org.matrix.msc3381.poll.start",
"org.matrix.msc3381.poll.start": {
question: {
"org.matrix.msc1767.text": "only one answer allowed!",
body: "only one answer allowed!",
msgtype: "m.text"
},
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
max_selections: 1,
answers: [{
id: "1",
"org.matrix.msc1767.text": "[\ud83d\udc4d] answer one"
}, {
id: "2",
"org.matrix.msc1767.text": "[\ud83d\udc4e] answer two"
}, {
id: "3",
"org.matrix.msc1767.text": "answer three"
}]
},
"org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three"
}])
})
test("message2event: multiple-choice poll", async t => {
const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {})
t.deepEqual(events, [{
$type: "org.matrix.msc3381.poll.start",
"org.matrix.msc3381.poll.start": {
question: {
"org.matrix.msc1767.text": "more than one answer allowed",
body: "more than one answer allowed",
msgtype: "m.text"
},
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
max_selections: 3,
answers: [{
id: "1",
"org.matrix.msc1767.text": "[😭] no"
}, {
id: "2",
"org.matrix.msc1767.text": "oh no"
}, {
id: "3",
"org.matrix.msc1767.text": "oh noooooo"
}]
},
"org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo"
}])
})

View file

@ -23,7 +23,7 @@ class DiscordClient {
/** @type {import("cloudstorm").IClientOptions["intents"]} */ /** @type {import("cloudstorm").IClientOptions["intents"]} */
const intents = [ const intents = [
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
"MESSAGE_CONTENT" "MESSAGE_CONTENT"
] ]
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
@ -31,7 +31,6 @@ class DiscordClient {
this.snow = new SnowTransfer(discordToken) this.snow = new SnowTransfer(discordToken)
this.cloud = new CloudStorm(discordToken, { this.cloud = new CloudStorm(discordToken, {
shards: [0], shards: [0],
reconnect: true,
snowtransferInstance: this.snow, snowtransferInstance: this.snow,
intents, intents,
ws: { ws: {

View file

@ -32,6 +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")} */
const vote = sync.require("./actions/add-or-remove-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")} */
@ -370,6 +372,14 @@ module.exports = {
await createSpace.syncSpaceExpressions(data, false) await createSpace.syncSpaceExpressions(data, false)
}, },
async MESSAGE_POLL_VOTE_ADD(client, data){
await vote.addVote(data)
},
async MESSAGE_POLL_VOTE_REMOVE(client, data){
await vote.removeVote(data)
},
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data * @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data

View file

@ -0,0 +1,19 @@
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;

12
src/db/orm-defs.d.ts vendored
View file

@ -139,6 +139,18 @@ export type Models = {
encoded_emoji: string encoded_emoji: string
original_encoding: string | null original_encoding: string | null
} }
poll_vote: {
vote: string
message_id: string
discord_or_matrix_user_id: string
}
poll_option: {
message_id: string
matrix_option: string
discord_option: string
}
} }
export type Prepared<Row> = { export type Prepared<Row> = {

View file

@ -22,8 +22,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
const emojiSheet = sync.require("../actions/emoji-sheet") const emojiSheet = sync.require("../actions/emoji-sheet")
/** /**
* @param {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 {{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
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>} * @returns {Promise<{poll?: Ty.SendingPoll} & 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 +59,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} 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} 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
@ -133,6 +133,12 @@ async function sendEvent(event) {
}, guild, null) }, guild, null)
) )
} }
if (message.poll){ // Need to store answer mapping in the database.
for (let i=0; i<message.poll.answers.length; i++){
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())
}
}
} }
for (const user of ensureJoined) { for (const user of ensureJoined) {

24
src/m2d/actions/vote.js Normal file
View file

@ -0,0 +1,24 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const assert = require("assert").strict
const crypto = require("crypto")
const passthrough = require("../../passthrough")
const {sync, discord, db, select} = passthrough
/** @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event */
async function updateVote(event) {
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()
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.
event.content["org.matrix.msc3381.poll.response"].answers.map(answer=>{
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(event.sender, messageID, answer)
})
}
module.exports.updateVote = updateVote

View file

@ -517,7 +517,7 @@ 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} 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} 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>}} di simple-as-nails dependency injection for the matrix API
@ -544,13 +544,15 @@ async function eventToMessage(event, guild, channel, di) {
displayNameRunoff = "" displayNameRunoff = ""
} }
let content = event.content.body // ultimate fallback let content = event.content["body"] || "" // ultimate fallback
/** @type {{id: string, filename: string}[]} */ /** @type {{id: string, filename: string}[]} */
const attachments = [] const attachments = []
/** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ /** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
const pendingFiles = [] const pendingFiles = []
/** @type {DiscordTypes.APIUser[]} */ /** @type {DiscordTypes.APIUser[]} */
const ensureJoined = [] const ensureJoined = []
/** @type {Ty.SendingPoll} */
let poll = null
// Convert content depending on what the message is // Convert content depending on what the message is
// Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor // Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor
@ -628,6 +630,24 @@ async function eventToMessage(event, guild, channel, di) {
} }
attachments.push({id: "0", filename}) attachments.push({id: "0", filename})
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") {
content = ""
const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience
let allowMultiselect = (pollContent.max_selections != 1)
let answers = pollContent.answers.map(answer=>{
return {poll_media: {text: answer["org.matrix.msc1767.text"]}, matrix_option: answer["id"]}
})
poll = {
question: {
text: event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
},
answers: answers,
duration: 768, // Maximum duration (32 days). Matrix doesn't allow automatically-expiring polls, so this is the only thing that makes sense to send.
allow_multiselect: allowMultiselect,
layout_type: 1
}
} 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.
// this event ---is an edit of--> original event ---is a reply to--> past event // this event ---is an edit of--> original event ---is a reply to--> past event
@ -828,7 +848,7 @@ async function eventToMessage(event, guild, channel, di) {
'<x-turndown id="turndown-root">' + input + '</x-turndown>' '<x-turndown id="turndown-root">' + input + '</x-turndown>'
); );
const root = doc.getElementById("turndown-root"); const root = doc.getElementById("turndown-root");
async function forEachNode(node) { async function forEachNode(event, node) {
for (; node; node = node.nextSibling) { for (; node; node = node.nextSibling) {
// Check written mentions // Check written mentions
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
@ -876,10 +896,10 @@ async function eventToMessage(event, guild, channel, di) {
node.setAttribute("data-suppress", "") node.setAttribute("data-suppress", "")
} }
} }
await forEachNode(node.firstChild) await forEachNode(event, node.firstChild)
} }
} }
await forEachNode(root) await forEachNode(event, root)
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end. // First we need to determine which emojis are at the end.
@ -960,7 +980,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 {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ /** @type {({poll?: Ty.SendingPoll} & 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: {
@ -983,6 +1003,15 @@ async function eventToMessage(event, guild, channel, di) {
messages[0].pendingFiles = pendingFiles messages[0].pendingFiles = pendingFiles
} }
if (poll) {
if (!messages.length) messages.push({
content: " ", // stopgap, remove when library updates
username: displayNameShortened,
avatar_url: avatarURL
})
messages[0].poll = poll
}
const messagesToEdit = [] const messagesToEdit = []
const messagesToSend = [] const messagesToSend = []
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {

View file

@ -18,6 +18,8 @@ const addReaction = sync.require("./actions/add-reaction")
const redact = sync.require("./actions/redact") const redact = sync.require("./actions/redact")
/** @type {import("./actions/update-pins")}) */ /** @type {import("./actions/update-pins")}) */
const updatePins = sync.require("./actions/update-pins") const updatePins = sync.require("./actions/update-pins")
/** @type {import("./actions/vote")}) */
const vote = sync.require("./actions/vote")
/** @type {import("../matrix/matrix-command-handler")} */ /** @type {import("../matrix/matrix-command-handler")} */
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("../matrix/utils")} */ /** @type {import("../matrix/utils")} */
@ -173,7 +175,7 @@ async function onRetryReactionAdd(reactionEvent) {
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
if (reactionEvent.sender !== event.sender) { if (reactionEvent.sender !== error.payload.sender) {
// Check if it's a room moderator // Check if it's a room moderator
const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api) const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api)
if (senderPower < (powerLevels.state_default ?? 50)) return if (senderPower < (powerLevels.state_default ?? 50)) return
@ -218,6 +220,25 @@ async event => {
await api.ackEvent(event) await api.ackEvent(event)
})) }))
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.start", guard("org.matrix.msc3381.poll.start",
/**
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event it is a org.matrix.msc3381.poll.start because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("org.matrix.msc3381.poll.response",
/**
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event it is a org.matrix.msc3381.poll.response because that's what this listener is filtering for
*/
async event => {
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.
}))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
/** /**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for * @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for
@ -333,14 +354,20 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
*/ */
async event => { async event => {
if (event.state_key[0] !== "@") return if (event.state_key[0] !== "@") return
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
if (event.state_key === bot) { if (event.state_key === utils.bot) {
const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom) const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom)
if (upgraded) return if (upgraded) return
} }
if (event.content.membership === "invite" && event.state_key === bot) { if (event.content.membership === "invite" && event.state_key === utils.bot) {
// Supposed to be here already?
const guildID = select("guild_space", "guild_id", {space_id: event.room_id}).pluck().get()
if (guildID) {
await api.joinRoom(event.room_id)
return
}
// We were invited to a room. We should join, and register the invite details for future reference in web. // We were invited to a room. We should join, and register the invite details for future reference in web.
let attemptedApiMessage = "According to unsigned invite data." let attemptedApiMessage = "According to unsigned invite data."
let inviteRoomState = event.unsigned?.invite_room_state let inviteRoomState = event.unsigned?.invite_room_state
@ -369,7 +396,7 @@ async event => {
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// Unregister room's use as a direct chat if the bot itself left // Unregister room's use as a direct chat if the bot itself left
if (event.state_key === bot) { if (event.state_key === utils.bot) {
db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id)
} }
} }

View file

@ -162,7 +162,7 @@ function getStateEventOuter(roomID, type, key) {
*/ */
async function getInviteState(roomID) { async function getInviteState(roomID) {
/** @type {Ty.R.SSS} */ /** @type {Ty.R.SSS} */
const root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`), { const root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), {
room_subscriptions: { room_subscriptions: {
[roomID]: { [roomID]: {
timeline_limit: 0, timeline_limit: 0,

37
src/types.d.ts vendored
View file

@ -1,3 +1,5 @@
import * as DiscordTypes from "discord-api-types/v10"
export type AppServiceRegistrationConfig = { export type AppServiceRegistrationConfig = {
id: string id: string
as_token: string as_token: string
@ -81,6 +83,10 @@ 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
@ -269,6 +275,37 @@ export namespace Event {
export type Outer_M_Sticker = Outer<M_Sticker> & {type: "m.sticker"} export type Outer_M_Sticker = Outer<M_Sticker> & {type: "m.sticker"}
export type Org_Matrix_Msc3381_Poll_Start = {
"org.matrix.msc3381.poll.start": {
question: {
"org.matrix.msc1767.text": string
body: string
msgtype: string
},
kind: string
max_selections: number
answers: {
id: string
"org.matrix.msc1767.text": string
}[]
"org.matrix.msc1767.text": string
}
}
export type Outer_Org_Matrix_Msc3381_Poll_Start = Outer<Org_Matrix_Msc3381_Poll_Start> & {type: "org.matrix.msc3381.poll.start"}
export type Org_Matrix_Msc3381_Poll_Response = {
"org.matrix.msc3381.poll.response": {
answers: string[]
}
"m.relates_to": {
rel_type: string
event_id: string
}
}
export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer<Org_Matrix_Msc3381_Poll_Response> & {type: "org.matrix.msc3381.poll.response"}
export type M_Room_Member = { export type M_Room_Member = {
membership: string membership: string
displayname?: string displayname?: string

View file

@ -169,7 +169,10 @@ as.router.post("/api/link", defineEventHandler(async event => {
const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null) const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null)
const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null) const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null)
const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null) const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null)
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic) db.transaction(() => {
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic)
db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, parsedBody.matrix)
})()
// Sync room data and space child // Sync room data and space child
await createRoom.syncRoom(parsedBody.discord) await createRoom.syncRoom(parsedBody.discord)

View file

@ -3593,7 +3593,233 @@ module.exports = {
}, },
attachments: [], attachments: [],
guild_id: "286888431945252874" guild_id: "286888431945252874"
} },
poll_single_choice: {
type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2025-02-15T23:19:04.127000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1340462414176718889",
channel_id: "1340048919589158986",
author: {
id: "307894326028140546",
username: "ellienyaa",
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "unambiguously boring username",
avatar_decoration_data: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
position: 0,
poll: {
question: {
text: "only one answer allowed!"
},
answers: [
{
answer_id: 1,
poll_media: {
text: "answer one",
emoji: {
id: null,
name: "\ud83d\udc4d"
}
}
},
{
answer_id: 2,
poll_media: {
text: "answer two",
emoji: {
id: null,
name: "\ud83d\udc4e"
}
}
},
{
answer_id: 3,
poll_media: {
text: "answer three"
}
}
],
expiry: "2025-02-16T23:19:04.122364+00:00",
allow_multiselect: false,
layout_type: 1,
results: {
answer_counts: [],
is_finalized: false
}
}
},
poll_multiple_choice: {
type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2025-02-16T00:47:12.310000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1340484594423562300",
channel_id: "1340048919589158986",
author: {
id: "307894326028140546",
username: "ellienyaa",
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "unambiguously boring username",
avatar_decoration_data: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
position: 0,
poll: {
question: {
text: "more than one answer allowed"
},
answers: [
{
answer_id: 1,
poll_media: {
text: "no",
emoji: {
id: null,
name: "😭"
}
}
},
{
answer_id: 2,
poll_media: {
text: "oh no",
emoji: {
id: "891723675261366292",
name: "this"
}
}
},
{
answer_id: 3,
poll_media: {
text: "oh noooooo",
emoji: {
id: "964520120682680350",
name: "disapprove"
}
}
}
],
expiry: "2025-02-17T00:47:12.307985+00:00",
allow_multiselect: true,
layout_type: 1,
results: {
answer_counts: [],
is_finalized: false
}
}
},
poll_close: {
type: 46,
content: "",
mentions: [
{
id: "307894326028140546",
username: "ellienyaa",
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "unambiguously boring username",
avatar_decoration_data: null,
banner_color: null,
clan: null,
primary_guild: null
}
],
mention_roles: [],
attachments: [],
embeds: [
{
type: "poll_result",
fields: [
{
name: "poll_question_text",
value: "test poll that's being closed",
inline: false
},
{
name: "victor_answer_votes",
value: "0",
inline: false
},
{
name: "total_votes",
value: "0",
inline: false
}
],
content_scan_version: 0
}
],
timestamp: "2025-02-20T23:07:12.178000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1342271367374049351",
channel_id: "1340048919589158986",
author: {
id: "307894326028140546",
username: "ellienyaa",
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "unambiguously boring username",
avatar_decoration_data: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 0,
channel_id: "1340048919589158986",
message_id: "1342271353990021206"
},
position: 0
}
}, },
pk_message: { pk_message: {
pk_reply_to_matrix: { pk_reply_to_matrix: {