UI for linking existing space

This commit is contained in:
Cadence Ember 2025-02-04 02:45:38 +13:00
parent 3d0609f8f1
commit d45a0bdc10
15 changed files with 293 additions and 116 deletions

View file

@ -69,3 +69,9 @@ So here's all the technical changes needed to support self-service in v3:
- When bot is added through "self-service" web button, REPLACE INTO state 0.
- Event dispatcher will only ensureRoom if the guild_active state is 1.
- createRoom will only create other dependencies if the guild is autocreate.
## Enough with your theory. How do rooms actually get bridged now?
After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord.
Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated.

View file

@ -4,6 +4,9 @@ CREATE TABLE "invite" (
"mxid" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
"type" TEXT,
"name" TEXT,
"topic" TEXT,
"avatar" TEXT,
PRIMARY KEY("mxid","room_id")
) WITHOUT ROWID;

View file

@ -44,6 +44,8 @@ export type Models = {
mxid: string
room_id: string
type: string | null
name: string | null
avatar: string | null
}
lottie: {

View file

@ -64,7 +64,9 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
}]
}
]
}])
}]).catch(e => {
console.error(e)
})
async function dispatchInteraction(interaction) {
const interactionId = interaction.data.custom_id || interaction.data.name

View file

@ -208,6 +208,26 @@ async event => {
await api.ackEvent(event)
}))
function getFromInviteRoomState(inviteRoomState, nskey, key) {
if (!Array.isArray(inviteRoomState)) return null
for (const event of inviteRoomState) {
if (event.type === nskey && event.state_key === "") {
return event.content[key]
}
}
return null
}
sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Space_Child>} event
*/
async event => {
if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added
await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites
}
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
@ -217,9 +237,14 @@ async event => {
if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) {
// We were invited to a room. We should join, and register the invite details for future reference in web.
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!")
await api.joinRoom(event.room_id)
const creation = await api.getStateEvent(event.room_id, "m.room.create", "")
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type) VALUES (?, ?, ?)").run(event.sender, event.room_id, creation.type || null)
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
}
if (utils.eventSenderIsFromDiscord(event.state_key)) return

View file

@ -82,6 +82,16 @@ async function leaveRoom(roomID, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
}
/**
* @param {string} roomID
* @param {string} reason
* @param {string} [mxid]
*/
async function leaveRoomWithReason(roomID, reason, mxid) {
console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`)
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason})
}
/**
* @param {string} roomID
* @param {string} eventID
@ -404,6 +414,7 @@ module.exports.createRoom = createRoom
module.exports.joinRoom = joinRoom
module.exports.inviteToRoom = inviteToRoom
module.exports.leaveRoom = leaveRoom
module.exports.leaveRoomWithReason = leaveRoomWithReason
module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState

5
src/types.d.ts vendored
View file

@ -281,6 +281,11 @@ export namespace Event {
users_default?: number
}
export type M_Space_Child = {
via?: string[]
suggested?: boolean
}
export type M_Reaction = {
"m.relates_to": {
rel_type: "m.annotation"

View file

@ -71,23 +71,24 @@ block body
div
!= svg
h2.mt48.fs-headline1 Matrix setup
if space_id
h2.mt48.fs-headline1 Matrix setup
h3.mt32.fs-category Linked channels
h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0
form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
.s-card.bs-sm.p0
form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
h3.mt32.fs-category Auto-create
.s-card
@ -97,95 +98,96 @@ block body
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value)
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off")
#autocreate-loading
h3.mt32.fs-category Privacy level
.s-card
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
if space_id
h3.mt32.fs-category Privacy level
.s-card
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.ai-center.mb4
label.s-label.fl-grow1
| How people can join on Matrix
span#privacy-level-loading
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconInternationalSm
.fl-grow1 Directory
input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconLinkSm
.fl-grow1 Link
input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
!= icons.Icons.IconLockSm
.fl-grow1 Invite
p.s-description.m0 In-app direct invite from another user
p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.ai-center.mb4
label.s-label.fl-grow1
| How people can join on Matrix
span#privacy-level-loading
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconInternationalSm
.fl-grow1 Directory
input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconLinkSm
.fl-grow1 Link
input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
!= icons.Icons.IconLockSm
.fl-grow1 Invite
p.s-description.m0 In-app direct invite from another user
p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button
!= icons.Icons.IconMerge
= ` Link`
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
div
h3.mt24 Channels
p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
div
h3.mt24 Rooms
p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
div
h3.mt24 Unavailable channels: Deleted from Discord
.s-card.p0
ul.my8.ml24
each row in removedUncachedChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`)= row.nick || row.name
h3.mt24 Unavailable channels: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`) (#{row.type}) #{row.name}
div- // Rooms
h3.mt24 Unavailable rooms: Already linked
.s-card.p0
ul.my8.ml24
each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
button.s-btn.s-btn__icon.s-btn__filled#link-button
!= icons.Icons.IconMerge
= ` Link`
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
div
h3.mt24 Channels
p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
div
h3.mt24 Rooms
p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
div
h3.mt24 Unavailable channels: Deleted from Discord
.s-card.p0
ul.my8.ml24
each row in removedUncachedChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`)= row.nick || row.name
h3.mt24 Unavailable channels: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`) (#{row.type}) #{row.name}
div- // Rooms
h3.mt24 Unavailable rooms: Already linked
.s-card.p0
ul.my8.ml24
each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name

View file

@ -1,7 +1,7 @@
extends includes/template.pug
block body
if !managed
if !session.data.user_id
.s-empty-state.wmx4.p48
!= icons.Spots.SpotEmptyXL
p You need to log in to manage your servers.

View file

@ -0,0 +1,52 @@
extends includes/template.pug
mixin space(space)
.s-user-card.flex__1
span.s-avatar.s-avatar__32.s-user-card--avatar
if space.avatar
img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar))
else
.s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0]
.s-user-card--info.ai-start
strong= space.name
if space.topic
ul.s-user-card--awards
li space.topic
block body
.s-notice.s-notice__info.d-flex.g16
div
!= icons.Icons.IconInfo
div
strong You picked self-service mode
.mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name].
h3.mt32.fs-category Choose a space
form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action="/api/link-space")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each space in spaces
tr
td.p0: +space(space)
td: button.s-btn(name="space_id" value=space.room_id hx-post="/api/link-space" hx-trigger="click" hx-disabled-elt="this") Link with this space
else
if session.data.mxid
tr
- const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
td.p16 On Matrix, invite #[code.s-code-block: a.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick it from this list.
else
tr
td.d-flex.ai-center.pl16.g16
| You need to log in with Matrix first.
a.s-btn.s-btn__matrix.s-btn__outlined(href=rel("/log-in-with-matrix")) Log in with Matrix
h3.mt48.fs-category Auto-create
.s-card
form.d-flex.ai-center.g8(method="post" action="/api/autocreate" hx-post="/api/autocreate" hx-indicator="#easy-mode-button")
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="autocreate" value="true")
label.s-label.fl-grow1
| Changed your mind?
p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to.
button.s-btn.s-btn__outlined#easy-mode-button Use easy mode

View file

@ -2,9 +2,9 @@
const assert = require("assert/strict")
const {z} = require("zod")
const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3")
const {defineEventHandler, useSession, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect} = require("h3")
const {as, db, sync} = require("../../passthrough")
const {as, db, sync, select} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../d2m/actions/create-space")} */
@ -29,6 +29,17 @@ as.router.post("/api/autocreate", defineEventHandler(async event => {
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id)
// If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version
const spaceID = select("guild_space", "space_id", {guild_id: parsedBody.guild_id}).pluck().get()
if (!spaceID) {
if (getRequestHeader(event, "HX-Request")) {
setResponseHeader(event, "HX-Refresh", "true")
} else {
return sendRedirect(event, "", 302)
}
}
return null // 204
}))

View file

@ -8,7 +8,7 @@ const {LRUCache} = require("lru-cache")
const Ty = require("../../types")
const uqr = require("uqr")
const {discord, as, sync, select} = require("../../passthrough")
const {discord, as, sync, select, from, db} = require("../../passthrough")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/create-space")} */
@ -109,15 +109,21 @@ function getChannelRoomsLinks(guildID, rooms) {
as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const session = await useSession(event, {password: reg.as_token})
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get()
const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
// @ts-ignore
const guild = discord.guilds.get(guild_id)
// Permission problems
if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id)) {
if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id) || !row) {
return pugSync.render(event, "guild_access_denied.pug", {guild_id})
}
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
if (!row.space_id && row.autocreate === 0) {
const spaces = db.prepare("SELECT room_id, type, name, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL and type = 'm.space'").all(session.data.mxid)
return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
}
const nonce = randomUUID()
validNonce.set(nonce, guild_id)
@ -128,10 +134,10 @@ as.router.get("/guild", defineEventHandler(async event => {
const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`)
assert.notEqual(svg, generatedSvg)
// Unlinked guild
if (!row) {
// Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space
if (!row.space_id) {
const links = getChannelRoomsLinks(guild_id, [])
return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links})
return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links, ...row})
}
// Linked guild

View file

@ -18,6 +18,7 @@ test("web guild: access denied when not logged in", async t => {
test("web guild: asks to select guild if not selected", async t => {
const content = await router.test("get", "/guild", {
sessionData: {
user_id: "1",
managedGuilds: []
},
})
@ -27,6 +28,7 @@ test("web guild: asks to select guild if not selected", async t => {
test("web guild: access denied when guild id messed up", async t => {
const content = await router.test("get", "/guild?guild_id=1", {
sessionData: {
user_id: "1",
managedGuilds: []
},
})
@ -43,6 +45,7 @@ test("web invite: access denied with invalid nonce", async t => {
test("web guild: can view unbridged guild", async t => {
const content = await router.test("get", "/guild?guild_id=66192955777486848", {
sessionData: {
user_id: "1",
managedGuilds: ["66192955777486848"]
},
api: {

View file

@ -16,6 +16,10 @@ const {reg} = require("../../matrix/read-registration")
const api = sync.require("../../matrix/api")
const schema = {
linkSpace: z.object({
guild_id: z.string(),
space_id: z.string()
}),
link: z.object({
guild_id: z.string(),
matrix: z.string(),
@ -27,6 +31,48 @@ const schema = {
})
}
as.router.post("/api/link-space", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.linkSpace.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID
const guildID = parsedBody.guild_id
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check space ID
if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"})
const spaceID = parsedBody.space_id
const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "No past invitations detected from your Matrix account for that space."})
// Check they are not already bridged
const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID)
if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`})
// Check space exists and bridge is joined and bridge has PL 100
const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
/** @type {Ty.Event.M_Room_Member} */
const memberEvent = await api.getStateEvent(spaceID, "m.room.member", self)
if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist"})
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "")
const selfPowerLevel = powerLevelsStateContent.users?.[self] || powerLevelsStateContent.users_default || 0
if (selfPowerLevel < (powerLevelsStateContent.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"})
// Check inviting user is a moderator in the space
const invitingPowerLevel = powerLevelsStateContent.users?.[session.data.mxid] || powerLevelsStateContent.users_default || 0
if (invitingPowerLevel < (powerLevelsStateContent.state_default || 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to use OOYE, but you are currently power level ${invitingPowerLevel}.`})
// Insert database entry
db.transaction(() => {
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID)
db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID)
})()
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))
as.router.post("/api/link", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.link.parse)
const session = await useSession(event, {password: reg.as_token})

View file

@ -7,15 +7,18 @@ const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHead
const icons = require("@stackoverflow/stacks-icons")
const DiscordTypes = require("discord-api-types/v10")
const dUtils = require("../discord/utils")
const reg = require("../matrix/read-registration")
const {sync, discord, as, select} = require("../passthrough")
/** @type {import("./pug-sync")} */
const pugSync = sync.require("./pug-sync")
/** @type {import("../m2d/converters/utils")} */
const mUtils = sync.require("../m2d/converters/utils")
const {id} = require("../../addbot")
// Pug
pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons})
pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg})
pugSync.createRoute(as.router, "/", "home.pug")
pugSync.createRoute(as.router, "/ok", "ok.pug")