diff --git a/d2m/converters/message-to-event.js b/d2m/converters/message-to-event.js index 0bc2a49..a64201f 100644 --- a/d2m/converters/message-to-event.js +++ b/d2m/converters/message-to-event.js @@ -6,7 +6,7 @@ const pb = require("prettier-bytes") const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const { sync, db, discord } = passthrough +const {sync, db, discord, select, from} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") /** @type {import("./lottie")} */ @@ -19,7 +19,7 @@ function getDiscordParseCallbacks(message, useHTML) { return { /** @param {{id: string, type: "discordUser"}} node */ user: node => { - const mxid = db.prepare("SELECT mxid FROM sim WHERE discord_id = ?").pluck().get(node.id) + const mxid = select("sim", "mxid", "WHERE discord_id = ?").pluck().get(node.id) const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id if (mxid && useHTML) { return `@${username}` @@ -29,7 +29,7 @@ function getDiscordParseCallbacks(message, useHTML) { }, /** @param {{id: string, type: "discordChannel"}} node */ channel: node => { - const row = db.prepare("SELECT room_id, name, nick FROM channel_room WHERE channel_id = ?").get(node.id) + const row = select("channel_room", ["room_id", "name", "nick"], "WHERE channel_id = ?").get(node.id) if (!row) { return `<#${node.id}>` // fallback for when this channel is not bridged } else if (useHTML) { @@ -81,8 +81,8 @@ async function messageToEvent(message, guild, options = {}, di) { const ref = message.message_reference assert(ref) assert(ref.message_id) - const eventID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ?").pluck().get(ref.message_id) - const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(ref.channel_id) + const eventID = select("event_message", "event_id", "WHERE message_id = ?").pluck().get(ref.message_id) + const roomID = select("channel_room", "room_id", "WHERE channel_id = ?").pluck().get(ref.channel_id) if (!eventID || !roomID) return [] const event = await di.api.getEvent(roomID, eventID) return [{ @@ -108,10 +108,8 @@ async function messageToEvent(message, guild, options = {}, di) { - So make sure we don't do anything in this case. */ const mentions = {} - let repliedToEventId = null - let repliedToEventRoomId = null + let repliedToEventRow = null let repliedToEventSenderMxid = null - let repliedToEventOriginallyFromMatrix = false function addMention(mxid) { if (!mentions.user_ids) mentions.user_ids = [] @@ -121,16 +119,14 @@ async function messageToEvent(message, guild, options = {}, di) { // Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions // (Still need to do scenarios 1 and 2 part B, and scenario 3.) if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) { - const row = db.prepare("SELECT event_id, room_id, source FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id) WHERE message_id = ? AND part = 0").get(message.message_reference.message_id) + const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id) if (row) { - repliedToEventId = row.event_id - repliedToEventRoomId = row.room_id - repliedToEventOriginallyFromMatrix = row.source === 0 // source 0 = matrix + repliedToEventRow = row } } - if (repliedToEventOriginallyFromMatrix) { + if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix // Need to figure out who sent that event... - const event = await di.api.getEvent(repliedToEventRoomId, repliedToEventId) + const event = await di.api.getEvent(repliedToEventRow.room_id, repliedToEventRow.event_id) repliedToEventSenderMxid = event.sender // Need to add the sender to m.mentions addMention(repliedToEventSenderMxid) @@ -147,8 +143,8 @@ async function messageToEvent(message, guild, options = {}, di) { if (message.content) { let content = message.content content = content.replace(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/([0-9]+)\/([0-9]+)\/([0-9]+)/, (whole, guildID, channelID, messageID) => { - const eventID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ?").pluck().get(messageID) - const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(channelID) + const eventID = select("event_message", "event_id", "WHERE message_id = ?").pluck().get(messageID) + const roomID = select("channel_room", "room_id", "WHERE channel_id = ?").pluck().get(channelID) if (eventID && roomID) { return `https://matrix.to/#/${roomID}/${eventID}` } else { @@ -171,7 +167,8 @@ async function messageToEvent(message, guild, options = {}, di) { const matches = [...content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) { const writtenMentionsText = matches.map(m => m[1].toLowerCase()) - const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(message.channel_id) + const roomID = select("channel_room", "room_id", "WHERE channel_id = ?").pluck().get(message.channel_id) + assert(roomID) const {joined} = await di.api.getJoinedMembers(roomID) for (const [mxid, member] of Object.entries(joined)) { if (!userRegex.some(rx => mxid.match(rx))) { @@ -191,10 +188,10 @@ async function messageToEvent(message, guild, options = {}, di) { // Fallback body/formatted_body for replies // This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run - if (repliedToEventId && options.includeReplyFallback !== false) { + if (repliedToEventRow && options.includeReplyFallback !== false) { let repliedToDisplayName let repliedToUserHtml - if (repliedToEventOriginallyFromMatrix && repliedToEventSenderMxid) { + if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { const match = repliedToEventSenderMxid.match(/^@([^:]*)/) assert(match) repliedToDisplayName = match[1] || "a Matrix user" // grab the localpart as the display name, whatever @@ -214,7 +211,7 @@ async function messageToEvent(message, guild, options = {}, di) { discordOnly: true, escapeHTML: false, }, null, null) - html = `
In reply to ${repliedToUserHtml}` + html = `
In reply to ${repliedToUserHtml}` + `
${repliedToHtml}
` + html body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions @@ -383,11 +380,11 @@ async function messageToEvent(message, guild, options = {}, di) { } // Rich replies - if (repliedToEventId) { + if (repliedToEventRow) { Object.assign(events[0], { "m.relates_to": { "m.in_reply_to": { - event_id: repliedToEventId + event_id: repliedToEventRow.event_id } } }) diff --git a/db/orm-utils.d.ts b/db/orm-utils.d.ts new file mode 100644 index 0000000..5eb87c4 --- /dev/null +++ b/db/orm-utils.d.ts @@ -0,0 +1,70 @@ +export type Models = { + channel_room: { + channel_id: string + room_id: string + name: string + nick: string | null + thread_parent: string | null + custom_avatar: string | null + } + + event_message: { + event_id: string + message_id: string + event_type: string | null + event_subtype: string | null + part: number + source: number + } + + file: { + discord_url: string + mxc_url: string + } + + guild_space: { + guild_id: string + space_id: string + } + + member_cache: { + room_id: string + mxid: string + displayname: string | null + avatar_url: string | null + } + + message_channel: { + message_id: string + channel_id: string + } + + sim: { + discord_id: string + sim_name: string + localpart: string + mxid: string + } + + sim_member: { + mxid: string + room_id: string + profile_event_content_hash: string + } + + webhook: { + channel_id: string + webhook_id: string + webhook_token: string + } +} + +export type Prepared = { + pluck: () => Prepared + all: (..._: any[]) => Row[] + get: (..._: any[]) => Row? +} + +export type AllKeys = U extends any ? keyof U : never +export type PickTypeOf> = T extends { [k in K]?: any } ? T[K] : never +export type Merge = {[x in AllKeys]: PickTypeOf} diff --git a/db/orm-utils.js b/db/orm-utils.js new file mode 100644 index 0000000..4ba52ba --- /dev/null +++ b/db/orm-utils.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/db/orm.js b/db/orm.js new file mode 100644 index 0000000..424ede5 --- /dev/null +++ b/db/orm.js @@ -0,0 +1,140 @@ +// @ts-check + +const {db} = require("../passthrough") +const U = require("./orm-utils") + +/** + * @template {keyof U.Models} Table + * @template {keyof U.Models[Table]} Col + * @param {Table} table + * @param {Col[] | Col} cols + * @param {string} [e] + */ +function select(table, cols, e = "") { + if (!Array.isArray(cols)) cols = [cols] + /** @type {U.Prepared>} */ + const prepared = db.prepare(`SELECT ${cols.map(k => `"${String(k)}"`).join(", ")} FROM ${table} ${e}`) + return prepared +} + +/** + * @template {keyof U.Models} Table + * @template {keyof U.Merge} Col + */ +class From { + /** + * @param {Table} table + */ + constructor(table) { + /** @type {Table[]} */ + this.tables = [table] + + this.sql = "" + this.cols = [] + this.using = [] + } + + /** + * @template {keyof U.Models} Table2 + * @param {Table2} table + * @param {Col & (keyof U.Models[Table2])} col + */ + join(table, col) { + /** @type {From>} */ + // @ts-ignore + const r = this + r.tables.push(table) + r.using.push(col) + return r + } + + /** + * @template {Col} Select + * @param {Col[] | Select[]} cols + */ + select(...cols) { + /** @type {From} */ + const r = this + r.cols = cols + return r + } + + /** + * @template {Col} Select + * @param {Select} col + */ + pluck(col) { + /** @type {Pluck} */ + // @ts-ignore + const r = this + r.constructor = Pluck + r.cols = [col] + return r + } + + /** + * @param {string} sql + */ + and(sql) { + this.sql = sql + return this + } + + prepare() { + let sql = `SELECT ${this.cols.map(k => `"${k}"`).join(", ")} FROM ${this.tables[0]} ` + for (let i = 1; i < this.tables.length; i++) { + const table = this.tables[i] + const col = this.using[i-1] + sql += `INNER JOIN ${table} USING (${col}) ` + } + sql += this.sql + /** @type {U.Prepared, Col>>} */ + const prepared = db.prepare(sql) + return prepared + } + + get(..._) { + const prepared = this.prepare() + return prepared.get(..._) + } + + all(..._) { + const prepared = this.prepare() + return prepared.all(..._) + } +} + +/** + * @template {keyof U.Models} Table + * @template {keyof U.Merge} Col + */ +class Pluck extends From { + // @ts-ignore + prepare() { + /** @type {U.Prepared[Col]>} */ + // @ts-ignore + const prepared = super.prepare() + return prepared + } + + get(..._) { + const prepared = this.prepare() + return prepared.get(..._) + } + + all(..._) { + const prepared = this.prepare() + return prepared.all(..._) + } +} + +/** + * @template {keyof U.Models} Table + * @param {Table} table + */ +function from(table) { + return new From(table) +} + +module.exports.from = from +module.exports.select = select diff --git a/m2d/converters/event-to-message.test.js b/m2d/converters/event-to-message.test.js index 3619e8b..00c64ed 100644 --- a/m2d/converters/event-to-message.test.js +++ b/m2d/converters/event-to-message.test.js @@ -1227,6 +1227,64 @@ test("event2message: with layered rich replies, the preview should only be the r ) }) +test("event2message: raw mentioning discord users in plaintext body works", async t => { + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: "<@114147806469554185> what do you think?" + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }), + { + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "<@114147806469554185> what do you think?", + avatar_url: undefined + }] + } + ) +}) + +test("event2message: raw mentioning discord users in formatted body works", async t => { + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: `<@114147806469554185> what do you think?` + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }), + { + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "<@114147806469554185> what do you think?", + avatar_url: undefined + }] + } + ) +}) + test("event2message: mentioning discord users works", async t => { t.deepEqual( await eventToMessage({ diff --git a/passthrough.js b/passthrough.js index 16de1bb..fd21381 100644 --- a/passthrough.js +++ b/passthrough.js @@ -8,6 +8,8 @@ * @property {import("heatsync")} sync * @property {import("better-sqlite3/lib/database")} db * @property {import("matrix-appservice").AppService} as + * @property {import("./db/orm").from} from + * @property {import("./db/orm").select} select */ /** @type {Passthrough} */ // @ts-ignore diff --git a/start.js b/start.js index 78f3c84..49cfadb 100644 --- a/start.js +++ b/start.js @@ -19,6 +19,10 @@ passthrough.discord = discord const as = require("./matrix/appservice") passthrough.as = as +const orm = sync.require("./db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + sync.require("./m2d/event-dispatcher") discord.snow.requestHandler.on("requestError", data => { diff --git a/test/test.js b/test/test.js index 31beb08..f158ddc 100644 --- a/test/test.js +++ b/test/test.js @@ -18,6 +18,10 @@ const sync = new HeatSync({watchFS: false}) Object.assign(passthrough, { config, sync, db }) +const orm = sync.require("../db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + const file = sync.require("../matrix/file") file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) }