diff --git a/d2m/actions/create-room.js b/d2m/actions/create-room.js index 7e00651c..b4934dc9 100644 --- a/d2m/actions/create-room.js +++ b/d2m/actions/create-room.js @@ -1,22 +1,99 @@ // @ts-check -const reg = require("../../matrix/read-registration.js") -const fetch = require("node-fetch") +const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") -fetch("https://matrix.cadence.moe/_matrix/client/v3/createRoom?user_id=@_ooye_example:cadence.moe", { - method: "POST", - body: JSON.stringify({ - invite: ["@cadence:cadence.moe"], - is_direct: false, - name: "New Bot User Room", - preset: "trusted_private_chat" - }), - headers: { - Authorization: `Bearer ${reg.as_token}` +const passthrough = require("../../passthrough") +const { discord, sync, db } = passthrough +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") +/** @type {import("../../matrix/file")} */ +const file = sync.require("../../matrix/file") + +/** + * @param {import("discord-api-types/v10").APIGuildTextChannel} channel + */ +async function createRoom(channel) { + const guildID = channel.guild_id + assert.ok(guildID) + const guild = discord.guilds.get(guildID) + assert.ok(guild) + const spaceID = db.prepare("SELECT space_id FROM guild_space WHERE guild_id = ?").pluck().get(guildID) + assert.ok(typeof spaceID === "string") + + const avatarEventContent = {} + if (guild.icon) { + avatarEventContent.url = await file.uploadDiscordFileToMxc(file.guildIcon(guild)) } -}).then(res => res.text()).then(text => { - // {"room_id":"!aAVaqeAKwChjWbsywj:cadence.moe"} - console.log(text) -}).catch(err => { - console.log(err) -}) + + /** @type {import("../../types").R_RoomCreated} */ + const root = await mreq.mreq("POST", "/client/v3/createRoom", { + name: channel.name, + topic: channel.topic || undefined, + preset: "private_chat", + visibility: "private", + invite: ["@cadence:cadence.moe"], // TODO + initial_state: [ + { + type: "m.room.avatar", + state_key: "", + content: avatarEventContent + }, + { + type: "m.room.guest_access", + state_key: "", + content: { + guest_access: "can_join" + } + }, + { + type: "m.room.history_visibility", + state_key: "", + content: { + history_visibility: "invited" + } + }, + { + type: "m.space.parent", + state_key: spaceID, + content: { + via: ["cadence.moe"], // TODO: put the proper server here + canonical: true + } + }, + { + type: "m.room.join_rules", + content: { + join_rule: "restricted", + allow: [{ + type: "m.room.membership", + room_id: spaceID + }] + } + } + ] + }) + + db.prepare("INSERT INTO channel_room (channel_id, room_id) VALUES (?, ?)").run(channel.id, root.room_id) + + // Put the newly created child into the space + await mreq.mreq("PUT", `/client/v3/rooms/${spaceID}/state/m.space.child/${root.room_id}`, { + via: ["cadence.moe"] // TODO: use the proper server + }) +} + +async function createAllForGuild(guildID) { + const channelIDs = discord.guildChannelMap.get(guildID) + assert.ok(channelIDs) + for (const channelID of channelIDs) { + const channel = discord.channels.get(channelID) + assert.ok(channel) + const existing = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(channel.id) + if (channel.type === DiscordTypes.ChannelType.GuildText && !existing) { + await createRoom(channel) + } + } +} + +module.exports.createRoom = createRoom +module.exports.createAllForGuild = createAllForGuild diff --git a/d2m/actions/create-space.js b/d2m/actions/create-space.js new file mode 100644 index 00000000..6d3c327a --- /dev/null +++ b/d2m/actions/create-space.js @@ -0,0 +1,46 @@ +// @ts-check + +const passthrough = require("../../passthrough") +const { sync, db } = passthrough +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") + +/** + * @param {import("discord-api-types/v10").RESTGetAPIGuildResult} guild + */ +function createSpace(guild) { + return mreq.mreq("POST", "/client/v3/createRoom", { + name: guild.name, + preset: "private_chat", + visibility: "private", + power_level_content_override: { + events_default: 100, + invite: 50 + }, + invite: ["@cadence:cadence.moe"], // TODO + topic: guild.description || undefined, + creation_content: { + type: "m.space" + }, + initial_state: [ + { + type: "m.room.guest_access", + state_key: "", + content: { + guest_access: "can_join" + } + }, + { + type: "m.room.history_visibility", + content: { + history_visibility: "invited" + } + } + ] + }).then(/** @param {import("../../types").R_RoomCreated} root */ root => { + db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, root.room_id) + return root + }) +} + +module.exports.createSpace = createSpace diff --git a/d2m/actions/send-message.js b/d2m/actions/send-message.js index a4dd9a28..b736a503 100644 --- a/d2m/actions/send-message.js +++ b/d2m/actions/send-message.js @@ -10,7 +10,7 @@ const messageToEvent = require("../converters/message-to-event.js") */ function sendMessage(message) { const event = messageToEvent(message) - fetch(`https://matrix.cadence.moe/_matrix/client/v3/rooms/!VwVlIAjOjejUpDhlbA:cadence.moe/send/m.room.message/${makeTxnId()}?user_id=@_ooye_example:cadence.moe`, { + return fetch(`https://matrix.cadence.moe/_matrix/client/v3/rooms/!VwVlIAjOjejUpDhlbA:cadence.moe/send/m.room.message/${makeTxnId()}?user_id=@_ooye_example:cadence.moe`, { method: "PUT", body: JSON.stringify(event), headers: { @@ -24,4 +24,4 @@ function sendMessage(message) { }) } -module.exports = sendMessage +module.exports.sendMessage = sendMessage diff --git a/d2m/discord-packets.js b/d2m/discord-packets.js index fe97d201..0d16cdce 100644 --- a/d2m/discord-packets.js +++ b/d2m/discord-packets.js @@ -2,6 +2,7 @@ // Discord library internals type beat +const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../passthrough") const { sync } = passthrough @@ -27,6 +28,8 @@ const utils = { const arr = [] client.guildChannelMap.set(message.d.id, arr) for (const channel of message.d.channels || []) { + // @ts-ignore + channel.guild_id = message.d.id arr.push(channel.id) client.channels.set(channel.id, channel) } diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js index 13626f4a..e4de37e2 100644 --- a/d2m/event-dispatcher.js +++ b/d2m/event-dispatcher.js @@ -1,8 +1,14 @@ // @ts-check -// Grab Discord events we care about for the bridge, check them, and pass them on +const {sync} = require("../passthrough") -const sendMessage = require("./actions/send-message") +/** @type {import("./actions/create-space")}) */ +const createSpace = sync.require("./actions/create-space") + +/** @type {import("./actions/send-message")}) */ +const sendMessage = sync.require("./actions/send-message") + +// Grab Discord events we care about for the bridge, check them, and pass them on module.exports = { /** @@ -10,10 +16,7 @@ module.exports = { * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message */ onMessageCreate(client, message) { - console.log(message) - console.log(message.guild_id) - console.log(message.member) - sendMessage(message) + sendMessage.sendMessage(message) }, /** diff --git a/index.js b/index.js index c6340f85..d1c721c9 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,15 @@ // @ts-check +const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") const config = require("./config") const passthrough = require("./passthrough") +const db = new sqlite("db/ooye.db") const sync = new HeatSync() -Object.assign(passthrough, { config, sync }) +Object.assign(passthrough, { config, sync, db }) const DiscordClient = require("./d2m/discord-client") diff --git a/matrix/file.js b/matrix/file.js new file mode 100644 index 00000000..f5e81ce1 --- /dev/null +++ b/matrix/file.js @@ -0,0 +1,63 @@ +// @ts-check + +const fetch = require("node-fetch") + +const passthrough = require("../passthrough") +const { sync, db } = passthrough +/** @type {import("./mreq")} */ +const mreq = sync.require("./mreq") + +const DISCORD_IMAGES_BASE = "https://cdn.discordapp.com" +const IMAGE_SIZE = 1024 + +/** @type {Map>} */ +const inflight = new Map() + +/** + * @param {string} path + */ +async function uploadDiscordFileToMxc(path) { + const url = DISCORD_IMAGES_BASE + path + + // Are we uploading this file RIGHT NOW? Return the same inflight promise with the same resolution + let existing = inflight.get(url) + if (typeof existing === "string") { + return existing + } + + // Has this file already been uploaded in the past? Grab the existing copy from the database. + existing = db.prepare("SELECT mxc_url FROM file WHERE discord_url = ?").pluck().get(url) + if (typeof existing === "string") { + return existing + } + + // Download from Discord + const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => { + /** @ts-ignore @type {import("stream").Readable} body */ + const body = res.body + + // Upload to Matrix + /** @type {import("../types").R_FileUploaded} */ + const root = await mreq.mreq("POST", "/media/v3/upload", body, { + headers: { + "Content-Type": res.headers.get("content-type") + } + }) + + // Store relationship in database + db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(url, root.content_uri) + inflight.delete(url) + + return root.content_uri + }) + inflight.set(url, promise) + + return promise +} + +function guildIcon(guild) { + return `/icons/${guild.id}/${guild.icon}?size=${IMAGE_SIZE}` +} + +module.exports.guildIcon = guildIcon +module.exports.uploadDiscordFileToMxc = uploadDiscordFileToMxc diff --git a/matrix/mreq.js b/matrix/mreq.js new file mode 100644 index 00000000..ad7fa465 --- /dev/null +++ b/matrix/mreq.js @@ -0,0 +1,47 @@ +// @ts-check + +const fetch = require("node-fetch") +const mixin = require("mixin-deep") + +const passthrough = require("../passthrough") +const { sync } = passthrough +/** @type {import("./read-registration")} */ +const reg = sync.require("./read-registration.js") + +const baseUrl = "https://matrix.cadence.moe/_matrix" + +class MatrixServerError { + constructor(data) { + this.data = data + /** @type {string} */ + this.errcode = data.errcode + /** @type {string} */ + this.error = data.error + } +} + +/** + * @param {string} method + * @param {string} url + * @param {any} [body] + * @param {any} [extra] + */ +function mreq(method, url, body, extra = {}) { + const opts = mixin({ + method, + body: (body == undefined || Object.is(body.constructor, Object)) ? JSON.stringify(body) : body, + headers: { + Authorization: `Bearer ${reg.as_token}` + } + }, extra) + console.log(baseUrl + url, opts) + return fetch(baseUrl + url, opts).then(res => { + return res.json().then(root => { + if (!res.ok || root.errcode) throw new MatrixServerError(root) + return root + }) + }) +} + +module.exports.MatrixServerError = MatrixServerError +module.exports.mreq = mreq diff --git a/matrix/read-registration.js b/matrix/read-registration.js index ee17f281..cc3fed85 100644 --- a/matrix/read-registration.js +++ b/matrix/read-registration.js @@ -3,11 +3,5 @@ const fs = require("fs") const yaml = require("js-yaml") -/** - * @typedef AppServiceRegistrationConfig - * @property {string} id - * @property {string} as_token - * @property {string} hs_token - */ - +/** @type {import("../types").AppServiceRegistrationConfig} */ module.exports = yaml.load(fs.readFileSync("registration.yaml", "utf8")) diff --git a/notes.md b/notes.md index eed59908..e63d9e5e 100644 --- a/notes.md +++ b/notes.md @@ -36,6 +36,13 @@ Public channels in that server should then use the following settings, so that t - Find & join access: Space members (so users must have been invited to the space already, even if they find out the room ID to join) - Who can read history: Anyone (so that people can see messages during the preview before joining) +Step by step process: + +1. Create a space room for the guild. Store the guild-space ID relationship in the database. Configure the space room to act like a space. + - `{"name":"NAME","preset":"private_chat","visibility":"private","power_level_content_override":{"events_default":100,"invite":50},"topic":"TOPIC","creation_content":{"type":"m.space"},"initial_state":[{"type":"m.room.guest_access","state_key":"","content":{"guest_access":"can_join"}},{"type":"m.room.history_visibility","content":{"history_visibility":"invited"}}]}` +2. Create channel rooms for the channels. Store the channel-room ID relationship in the database. (Probably no need to store parent-child relationships in the database?) +3. Send state events to put the channel rooms in the space. + ### Private channels Discord **channels** that disallow view permission to @everyone should instead have the following **room** settings in Matrix: @@ -58,6 +65,13 @@ The context-sensitive /invite command will invite Matrix users to the correspond 1. Transform content. 2. Send to matrix. +## Webhook message sent + +- Consider using the _ooye_bot account to send all webhook messages to prevent extraneous joins? + - Downside: the profile information from the most recently sent message would stick around in the member list. This is toleable. +- Otherwise, could use an account per webhook ID, but if webhook IDs are often deleted and re-created, this could still end up leaving too many accounts in the room. +- The original bridge uses an account per webhook display name, which does the most sense in terms of canonical accounts, but leaves too many accounts in the room. + ## Message deleted 1. Look up equivalents on matrix. @@ -91,4 +105,13 @@ The context-sensitive /invite command will invite Matrix users to the correspond 1. Create the corresponding room. 2. Add to database. 3. Update room details to match. -4. Add to space. +4. Make sure the permissions are correct according to the rules above! +5. Add to space. + +## Emojis updated + +1. Upload any newly added images to msc. +2. Create or replace state event for the bridged pack. (Can just use key "ooye" and display name "Discord", or something, for this pack.) +3. The emojis may now be sent by Matrix users! + +TOSPEC: m2d emoji uploads?? diff --git a/package-lock.json b/package-lock.json index a6532030..31df4c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "js-yaml": "^4.1.0", "matrix-appservice": "^2.0.0", "matrix-js-sdk": "^24.1.0", + "mixin-deep": "^2.0.1", "node-fetch": "^2.6.7", "snowtransfer": "^0.7.0", "supertape": "^8.3.0" @@ -1696,6 +1697,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mixin-deep": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-2.0.1.tgz", + "integrity": "sha512-imbHQNRglyaplMmjBLL3V5R6Bfq5oM+ivds3SKgc6oRtzErEnBUUc5No11Z2pilkUvl42gJvi285xTNswcKCMA==", + "engines": { + "node": ">=6" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", diff --git a/package.json b/package.json index 7a7de4a5..6755f9f9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "js-yaml": "^4.1.0", "matrix-appservice": "^2.0.0", "matrix-js-sdk": "^24.1.0", + "mixin-deep": "^2.0.1", "node-fetch": "^2.6.7", "snowtransfer": "^0.7.0", "supertape": "^8.3.0" diff --git a/passthrough.js b/passthrough.js index b6bc56fa..8ef75dbc 100644 --- a/passthrough.js +++ b/passthrough.js @@ -6,6 +6,7 @@ * @property {typeof import("./config")} config * @property {import("./d2m/discord-client")} discord * @property {import("heatsync")} sync + * @property {import("better-sqlite3/lib/database")} db */ /** @type {Passthrough} */ // @ts-ignore diff --git a/stdin.js b/stdin.js index be38b06e..fb958090 100644 --- a/stdin.js +++ b/stdin.js @@ -4,7 +4,12 @@ const repl = require("repl") const util = require("util") const passthrough = require("./passthrough") -const { discord, config, sync } = passthrough +const { discord, config, sync, db } = passthrough + +const createSpace = sync.require("./d2m/actions/create-space.js") +const createRoom = sync.require("./d2m/actions/create-room.js") +const mreq = sync.require("./matrix/mreq.js") +const guildID = "112760669178241024" const extraContext = {} diff --git a/types.d.ts b/types.d.ts index 180a559f..d5713595 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,6 +1,24 @@ +export type AppServiceRegistrationConfig = { + id: string + as_token: string + hs_token: string + url: string + sender_localpart: string + protocols: [string] + rate_limited: boolean +} + export type M_Room_Message_content = { msgtype: "m.text" body: string formatted_body?: "org.matrix.custom.html" format?: string } + +export type R_RoomCreated = { + room_id: string +} + +export type R_FileUploaded = { + content_uri: string +}