space and room creation

This commit is contained in:
Cadence Ember 2023-05-05 08:25:00 +12:00
parent 51480e21e5
commit c7868e9dbb
15 changed files with 328 additions and 36 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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)
},
/**

View file

@ -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")

63
matrix/file.js Normal file
View file

@ -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<string, Promise<string>>} */
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

47
matrix/mreq.js Normal file
View file

@ -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

View file

@ -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"))

View file

@ -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??

9
package-lock.json generated
View file

@ -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",

View file

@ -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"

View file

@ -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

View file

@ -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 = {}

18
types.d.ts vendored
View file

@ -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
}