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} */
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
powerLevelContent.users ??= {}
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
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]
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)
if (row.autocreate === 0) {
// 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 = ?")
for (const mxid of members) {
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")
/** @type {import("../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")} */
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()
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
if (dUtils.isWebhookMessage(message)) {
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.
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)
}
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") {
return false
}
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
// 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" || ev.old.event_type === "org.matrix.msc3381.poll.start") {
return false
}
// 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.APIGuild} guild
@ -226,6 +266,20 @@ async function messageToEvent(message, guild, options = {}, di) {
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) {
// 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.
@ -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
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
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.
}
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/")) {
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": {}
}])
})
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"]} */
const intents = [
"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"
]
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
@ -31,7 +31,6 @@ class DiscordClient {
this.snow = new SnowTransfer(discordToken)
this.cloud = new CloudStorm(discordToken, {
shards: [0],
reconnect: true,
snowtransferInstance: this.snow,
intents,
ws: {

View file

@ -32,6 +32,8 @@ const speedbump = sync.require("./actions/speedbump")
const retrigger = sync.require("./actions/retrigger")
/** @type {import("./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")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../discord/interactions/matrix-info")} */
@ -370,6 +372,14 @@ module.exports = {
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 {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
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> = {

View file

@ -22,8 +22,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
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
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
* @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<{poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
*/
async function resolvePendingFiles(message) {
if (!message.pendingFiles) return message
@ -59,7 +59,7 @@ async function resolvePendingFiles(message) {
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) {
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
@ -133,6 +133,12 @@ async function sendEvent(event) {
}, 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) {

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.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
@ -544,13 +544,15 @@ async function eventToMessage(event, guild, channel, di) {
displayNameRunoff = ""
}
let content = event.content.body // ultimate fallback
let content = event.content["body"] || "" // ultimate fallback
/** @type {{id: string, filename: string}[]} */
const attachments = []
/** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
const pendingFiles = []
/** @type {DiscordTypes.APIUser[]} */
const ensureJoined = []
/** @type {Ty.SendingPoll} */
let poll = null
// Convert content depending on what the message is
// Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor
@ -628,6 +630,24 @@ async function eventToMessage(event, guild, channel, di) {
}
attachments.push({id: "0", filename})
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 {
// 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
@ -828,7 +848,7 @@ async function eventToMessage(event, guild, channel, di) {
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
);
const root = doc.getElementById("turndown-root");
async function forEachNode(node) {
async function forEachNode(event, node) {
for (; node; node = node.nextSibling) {
// Check written mentions
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", "")
}
}
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.
// 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
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 => ({
content,
allowed_mentions: {
@ -983,6 +1003,15 @@ async function eventToMessage(event, guild, channel, di) {
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 messagesToSend = []
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")
/** @type {import("./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")} */
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("../matrix/utils")} */
@ -173,7 +175,7 @@ async function onRetryReactionAdd(reactionEvent) {
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
if (reactionEvent.sender !== event.sender) {
if (reactionEvent.sender !== error.payload.sender) {
// Check if it's a room moderator
const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api)
if (senderPower < (powerLevels.state_default ?? 50)) return
@ -218,6 +220,25 @@ async 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",
/**
* @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 => {
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)
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.
let attemptedApiMessage = "According to unsigned invite data."
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)
// 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)
}
}

View file

@ -162,7 +162,7 @@ function getStateEventOuter(roomID, type, key) {
*/
async function getInviteState(roomID) {
/** @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: {
[roomID]: {
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 = {
id: string
as_token: string
@ -81,6 +83,10 @@ export type WebhookAuthor = {
id: string
}
export type SendingPoll = DiscordTypes.RESTAPIPoll & {
answers: (DiscordTypes.APIBasePollAnswer & {matrix_option: string})[]
}
export type PkSystem = {
id: string
uuid: string
@ -269,6 +275,37 @@ export namespace Event {
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 = {
membership: 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 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)
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
await createRoom.syncRoom(parsedBody.discord)

View file

@ -3593,7 +3593,233 @@ module.exports = {
},
attachments: [],
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_reply_to_matrix: {