diff --git a/db/migrations/0012-add-member-power.sql b/db/migrations/0012-add-member-power.sql new file mode 100644 index 00000000..b0bb8d60 --- /dev/null +++ b/db/migrations/0012-add-member-power.sql @@ -0,0 +1,14 @@ +BEGIN TRANSACTION; + +-- the power we want them to have +CREATE TABLE IF NOT EXISTS member_power ( + mxid TEXT NOT NULL, + room_id TEXT NOT NULL, + power_level INTEGER NOT NULL, + PRIMARY KEY(mxid, room_id) +) WITHOUT ROWID; + +-- the power they have +ALTER TABLE member_cache ADD COLUMN power_level INTEGER NOT NULL DEFAULT 0; + +COMMIT; diff --git a/db/orm-defs.d.ts b/db/orm-defs.d.ts index 02230582..e481f958 100644 --- a/db/orm-defs.d.ts +++ b/db/orm-defs.d.ts @@ -42,7 +42,14 @@ export type Models = { room_id: string mxid: string displayname: string | null - avatar_url: string | null + avatar_url: string | null, + power_level: number + } + + member_power: { + mxid: string + room_id: string + power_level: number } message_channel: { diff --git a/db/orm.js b/db/orm.js index c6cab960..09e4bc76 100644 --- a/db/orm.js +++ b/db/orm.js @@ -44,6 +44,8 @@ class From { /** @private */ this.cols = [] /** @private */ + this.makeColsSafe = true + /** @private */ this.using = [] /** @private */ this.isPluck = false @@ -78,6 +80,12 @@ class From { return r } + selectUnsafe(...cols) { + this.cols = cols + this.makeColsSafe = false + return this + } + /** * @template {Col} Select * @param {Select} col @@ -112,7 +120,8 @@ class From { } prepare() { - let sql = `SELECT ${this.cols.map(k => `"${k}"`).join(", ")} FROM ${this.tables[0]} ` + if (this.makeColsSafe) this.cols = this.cols.map(k => `"${k}"`) + let sql = `SELECT ${this.cols.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] diff --git a/m2d/event-dispatcher.js b/m2d/event-dispatcher.js index 5529a26c..7aa3b703 100644 --- a/m2d/event-dispatcher.js +++ b/m2d/event-dispatcher.js @@ -6,7 +6,7 @@ const util = require("util") const Ty = require("../types") -const {discord, db, sync, as} = require("../passthrough") +const {discord, db, sync, as, select} = require("../passthrough") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") @@ -167,5 +167,29 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", async event => { if (event.state_key[0] !== "@") return if (utils.eventSenderIsFromDiscord(event.state_key)) return - db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(event.room_id, event.state_key, event.content.displayname || null, event.content.avatar_url || null) + if (event.content.membership === "leave" || event.content.membership === "ban") { + // Member is gone + db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + } else { + // Member is here + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?") + .run( + event.room_id, event.state_key, + event.content.displayname || null, event.content.avatar_url || null, + event.content.displayname || null, event.content.avatar_url || null + ) + } +})) + +sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels", +/** + * @param {Ty.Event.StateOuter} event + */ +async event => { + if (event.state_key !== "") return + const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all() + const newPower = event.content.users || {} + for (const mxid of existingPower) { + db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid) + } })) diff --git a/matrix/api.js b/matrix/api.js index 82b1c10e..65ec8910 100644 --- a/matrix/api.js +++ b/matrix/api.js @@ -70,6 +70,7 @@ async function inviteToRoom(roomID, mxidToInvite, mxid) { } async function leaveRoom(roomID, mxid) { + console.log(`[api] leave: ${roomID}: ${mxid}`) await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) } diff --git a/matrix/power.js b/matrix/power.js new file mode 100644 index 00000000..5dac5502 --- /dev/null +++ b/matrix/power.js @@ -0,0 +1,29 @@ +// @ts-check + +const {db, from} = require("../passthrough") +const api = require("./api") +const reg = require("./read-registration") +const ks = require("./kstate") +const {applyKStateDiffToRoom, roomToKState} = require("../d2m/actions/create-room") + +// Migrate reg.ooye.invite setting to database +for (const mxid of reg.ooye.invite) { + db.prepare("INSERT OR IGNORE INTO member_power (mxid, room_id, power_level) VALUES (?, ?, 100)").run(mxid, "*") +} + +// Apply global power level requests across ALL rooms where the member cache entry exists but the power level has not been applied yet. +const rows = from("member_cache").join("member_power", "mxid") + .and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level") + .selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level") + .all() + +;(async () => { + for (const row of rows) { + const kstate = await roomToKState(row.room_id) + const diff = ks.diffKState(kstate, {"m.room.power_levels/": {users: {[row.mxid]: row.power_level}}}) + await applyKStateDiffToRoom(row.room_id, diff) + // There is a listener on m.room.power_levels to do this same update, + // but we update it here anyway since the homeserver does not always deliver the event round-trip. + db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(row.power_level, row.room_id, row.mxid) + } +})() diff --git a/start.js b/start.js index 2b6e3dfb..c7ae0eb6 100644 --- a/start.js +++ b/start.js @@ -34,6 +34,7 @@ discord.snow.requestHandler.on("requestError", data => { await migrate.migrate(db) await discord.cloud.connect() console.log("Discord gateway started") + require("./matrix/power.js") require("./stdin") })() diff --git a/types.d.ts b/types.d.ts index 60368671..c710e12a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -209,6 +209,36 @@ export namespace Event { name?: string } + export type M_Power_Levels = { + /** The level required to ban a user. Defaults to 50 if unspecified. */ + ban?: number, + /** The level required to send specific event types. This is a mapping from event type to power level required. */ + events?: { + [event_id: string]: number + }, + /** The default level required to send message events. Can be overridden by the `events` key. Defaults to 0 if unspecified. */ + events_default?: number, + /** The level required to invite a user. Defaults to 0 if unspecified. */ + invite?: number, + /** The level required to kick a user. Defaults to 50 if unspecified. */ + kick?: number, + /** The power level requirements for specific notification types. This is a mapping from `key` to power level for that notifications key. */ + notifications?: { + room: number, + [key: string]: number + }, + /** The level required to redact an event sent by another user. Defaults to 50 if unspecified. */ + redact?: number, + /** The default level required to send state events. Can be overridden by the `events` key. Defaults to 50 if unspecified. */ + state_default?: number, + /** The power levels for specific users. This is a mapping from `user_id` to power level for that user. */ + users?: { + [mxid: string]: number + }, + /**The power level for users in the room whose `user_id` is not mentioned in the `users` key. Defaults to 0 if unspecified. */ + users_default?: number + } + export type M_Reaction = { "m.relates_to": { rel_type: "m.annotation"