Compare commits

..

4 commits

14 changed files with 90 additions and 35 deletions

15
package-lock.json generated
View file

@ -14,7 +14,7 @@
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.0.0", "@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.4",
@ -281,9 +281,10 @@
} }
}, },
"node_modules/@cloudrac3r/mixin-deep": { "node_modules/@cloudrac3r/mixin-deep": {
"version": "3.0.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.1.tgz",
"integrity": "sha512-yQz1wHSZbHfbKaGSjrV3wIG0e9MnElKlmekMKJPRdTn2jhF2Mt8wfMPX8U7v6rTyzR/7BTrX8CCUcrJMLgoQqw==", "integrity": "sha512-awxfIraHjJ/URNlZ0ROc78Tdjtfk/fM/Gnj1embfrSN08h/HpRtLmPc3xlG3T2vFAy1AkONaebd52u7o6kDaYw==",
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -3217,9 +3218,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.19.8", "version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
"integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"

View file

@ -23,7 +23,7 @@
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.0.0", "@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.4",

View file

@ -11,6 +11,8 @@ const {discord, sync, db, select, from} = passthrough
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/kstate")} */ /** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate") const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
@ -87,9 +89,10 @@ async function channelToKState(channel, guild, di) {
assert(typeof parentSpaceID === "string") assert(typeof parentSpaceID === "string")
} }
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get()
const customName = channelRow?.nick const customName = channelRow?.nick
const customAvatar = channelRow?.custom_avatar const customAvatar = channelRow?.custom_avatar
const hasCustomTopic = channelRow?.custom_topic
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {} const avatarEventContent = {}
@ -165,6 +168,8 @@ async function channelToKState(channel, guild, di) {
} }
} }
if (hasCustomTopic) delete channelKState["m.room.topic/"]
return {spaceID: parentSpaceID, privacyLevel, channelKState} return {spaceID: parentSpaceID, privacyLevel, channelKState}
} }
@ -412,9 +417,20 @@ async function unbridgeDeletedChannel(channel, guildID) {
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
assert.ok(row) assert.ok(row)
let botInRoom = true
// remove declaration that the room is bridged // remove declaration that the room is bridged
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) try {
if ("topic" in channel) { await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
} catch (e) {
if (String(e).includes("not in room")) {
botInRoom = false
} else {
throw e
}
}
if (botInRoom && "topic" in channel) {
// previously the Matrix topic would say the channel ID. we should remove that // previously the Matrix topic would say the channel ID. we should remove that
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""})
} }
@ -430,6 +446,8 @@ async function unbridgeDeletedChannel(channel, guildID) {
db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID)
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages
if (!botInRoom) return
// demote admins in room // demote admins in room
/** @type {Ty.Event.M_Power_Levels} */ /** @type {Ty.Event.M_Power_Levels} */
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "") const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")

View file

@ -94,6 +94,26 @@ test("channel2room: room where limited people can mention everyone", async t =>
t.equal(called, 1) t.equal(called, 1)
}) })
test("channel2room: matrix room that already has a custom topic set", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {}
}
db.prepare("UPDATE channel_room SET custom_topic = 1 WHERE channel_id = ?").run(testData.channel.general.id)
const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}})
// @ts-ignore
delete expected["m.room.topic/"]
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
expected
)
t.equal(called, 1)
})
test("convertNameAndTopic: custom name and topic", t => { test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual( t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN custom_topic INTEGER DEFAULT 0;
COMMIT;

View file

@ -10,6 +10,8 @@ export type Models = {
speedbump_id: string | null speedbump_id: string | null
speedbump_webhook_id: string | null speedbump_webhook_id: string | null
speedbump_checked: number | null speedbump_checked: number | null
guild_id: string | null
custom_topic: number
} }
event_message: { event_message: {

View file

@ -164,6 +164,17 @@ async event => {
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id) db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
})) }))
sync.addTemporaryListener(as, "type:m.room.topic", guard("m.room.topic",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Topic>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const customTopic = +!!event.content.topic
db.prepare("UPDATE channel_room SET custom_topic = ? WHERE room_id = ?").run(customTopic, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.pinned_events", guard("m.room.pinned_events", sync.addTemporaryListener(as, "type:m.room.pinned_events", guard("m.room.pinned_events",
/** /**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_PinnedEvents>} event * @param {Ty.Event.StateOuter<Ty.Event.M_Room_PinnedEvents>} event

4
src/types.d.ts vendored
View file

@ -243,6 +243,10 @@ export namespace Event {
name?: string name?: string
} }
export type M_Room_Topic = {
topic?: string
}
export type M_Room_PinnedEvents = { export type M_Room_PinnedEvents = {
pinned: string[] pinned: string[]
} }

View file

@ -56,7 +56,7 @@ block body
.fl-grow1 .fl-grow1
h2.fs-headline1 Invite a Matrix user h2.fs-headline1 Invite a Matrix user
form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" hx-post="/api/invite" hx-indicator="#invite-button")
label.s-label(for="mxid") Matrix ID label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
label.s-label(for="permissions") Permissions label.s-label(for="permissions") Permissions
@ -67,12 +67,10 @@ block body
option(value="admin") Admin option(value="admin") Admin
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
.grid--row-start2 .grid--row-start2
button.s-btn.s-btn__filled Invite button.s-btn.s-btn__filled#invite-button Invite
div div
!= svg != svg
h2.mt48.fs-headline1 Moderation
h2.mt48.fs-headline1 Matrix setup h2.mt48.fs-headline1 Matrix setup
h3.mt32.fs-category Linked channels h3.mt32.fs-category Linked channels

View file

@ -13,11 +13,11 @@ block body
.s-page-title.mb24 .s-page-title.mb24
h1.s-page-title--header= guild.name h1.s-page-title--header= guild.name
.d-flex.g16 .d-flex.g16#form-container
.fl-grow1 .fl-grow1
h2.fs-headline1 Invite a Matrix user h2.fs-headline1 Invite a Matrix user
form.d-flex.gy16.fd-column(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") form.d-flex.gy16.fd-column(method="post" action="/api/invite" hx-post="/api/invite" hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
.d-flex.gy4.fd-column .d-flex.gy4.fd-column
label.s-label(for="mxid") Matrix ID label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
@ -30,4 +30,4 @@ block body
option(value="admin") Admin option(value="admin") Admin
input(type="hidden" name="nonce" value=nonce) input(type="hidden" name="nonce" value=nonce)
div div
button.s-btn.s-btn__filled.htmx-indicator Invite button.s-btn.s-btn__filled#invite-button Invite

View file

@ -1,6 +1,6 @@
extends includes/template.pug extends includes/template.pug
block body block body
.ta-center.wmx5.p48.mx-auto .ta-center.wmx5.p48.mx-auto#ok
!= icons.Spots.SpotApproveXL != icons.Spots.SpotApproveXL
p.mt24.fs-body2= msg p.mt24.fs-body2= msg

View file

@ -2,7 +2,7 @@
const assert = require("assert/strict") const assert = require("assert/strict")
const {z} = require("zod") const {z} = require("zod")
const {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3") const {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3")
const {randomUUID} = require("crypto") const {randomUUID} = require("crypto")
const {LRUCache} = require("lru-cache") const {LRUCache} = require("lru-cache")
const Ty = require("../../types") const Ty = require("../../types")
@ -191,9 +191,10 @@ as.router.post("/api/invite", defineEventHandler(async event => {
( parsedBody.permissions === "admin" ? 100 ( parsedBody.permissions === "admin" ? 100
: parsedBody.permissions === "moderator" ? 50 : parsedBody.permissions === "moderator" ? 50
: 0) : 0)
await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel) if (powerLevel) await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel)
if (parsedBody.guild_id) { if (parsedBody.guild_id) {
setResponseHeader(event, "HX-Refresh", true)
return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
} else { } else {
return sendRedirect(event, "/ok?msg=User has been invited.", 302) return sendRedirect(event, "/ok?msg=User has been invited.", 302)

View file

@ -177,16 +177,12 @@ test("api invite: can invite to a moderated guild", async t => {
async inviteToRoom(roomID, mxidToInvite, mxid) { async inviteToRoom(roomID, mxidToInvite, mxid) {
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
called++ called++
},
async setUserPowerCascade(roomID, mxid, power) {
t.equal(power, 0)
called++
} }
} }
}) })
) )
t.notOk(error) t.notOk(error)
t.equal(called, 3) t.equal(called, 2)
}) })
test("api invite: does not reinvite joined users", async t => { test("api invite: does not reinvite joined users", async t => {
@ -205,14 +201,10 @@ test("api invite: does not reinvite joined users", async t => {
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
return {membership: "join"} return {membership: "join"}
},
async setUserPowerCascade(roomID, mxid, power) {
t.equal(power, 0)
called++
} }
} }
}) })
) )
t.notOk(error) t.notOk(error)
t.equal(called, 2) t.equal(called, 1)
}) })

View file

@ -3,6 +3,7 @@
const {z} = require("zod") const {z} = require("zod")
const {defineEventHandler, useSession, createError, readValidatedBody, setResponseHeader} = require("h3") const {defineEventHandler, useSession, createError, readValidatedBody, setResponseHeader} = require("h3")
const Ty = require("../../types") const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {discord, db, as, sync, select, from} = require("../../passthrough") const {discord, db, as, sync, select, from} = require("../../passthrough")
/** @type {import("../../d2m/actions/create-space")} */ /** @type {import("../../d2m/actions/create-space")} */
@ -64,10 +65,12 @@ as.router.post("/api/link", defineEventHandler(async event => {
await createRoom.syncRoom(parsedBody.discord) await createRoom.syncRoom(parsedBody.discord)
// Send a notification in the room // Send a notification in the room
await api.sendEvent(parsedBody.matrix, "m.room.message", { if (channel.type === DiscordTypes.ChannelType.GuildText) {
msgtype: "m.notice", await api.sendEvent(parsedBody.matrix, "m.room.message", {
body: "👋 This room is now bridged with Discord. Say hi!" msgtype: "m.notice",
}) body: "👋 This room is now bridged with Discord. Say hi!"
})
}
setResponseHeader(event, "HX-Refresh", "true") setResponseHeader(event, "HX-Refresh", "true")
return null // 204 return null // 204