Finish room diffing and syncing. All tests pass

This commit is contained in:
Cadence Ember 2023-05-06 01:25:15 +12:00
parent d21617e2d3
commit 9395a85e9b
14 changed files with 658 additions and 529 deletions

View File

@ -1,8 +1,6 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const {test} = require("supertape")
const testData = require("../../test/data")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
@ -12,37 +10,62 @@ const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/file")} */ /** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")
function kstateStripConditionals(kstate) {
for (const [k, content] of Object.entries(kstate)) {
if ("$if" in content) {
if (content.$if) delete content.$if
else delete kstate[k]
}
}
return kstate
}
function kstateToState(kstate) { function kstateToState(kstate) {
return Object.entries(kstate).map(([k, content]) => { const events = []
console.log(k) for (const [k, content] of Object.entries(kstate)) {
// conditional for whether a key is even part of the kstate (doing this declaratively on json is hard, so represent it as a property instead.)
if ("$if" in content && !content.$if) continue
delete content.$if
const [type, state_key] = k.split("/") const [type, state_key] = k.split("/")
assert.ok(typeof type === "string") assert.ok(typeof type === "string")
assert.ok(typeof state_key === "string") assert.ok(typeof state_key === "string")
return {type, state_key, content} events.push({type, state_key, content})
}) }
return events
} }
test("kstate2state: general", t => { /**
t.deepEqual(kstateToState({ * @param {import("../../types").Event.BaseStateEvent[]} events
"m.room.name/": {name: "test name"}, * @returns {any}
"m.room.member/@cadence:cadence.moe": {membership: "join"} */
}), [ function stateToKState(events) {
{ const kstate = {}
type: "m.room.name", for (const event of events) {
state_key: "", kstate[event.type + "/" + event.state_key] = event.content
content: { }
name: "test name" return kstate
} }
},
{ /**
type: "m.room.member", * @param {string} roomID
state_key: "@cadence:cadence.moe", */
content: { async function roomToKState(roomID) {
membership: "join" /** @type {import("../../types").Event.BaseStateEvent[]} */
} const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`)
} return stateToKState(root)
]) }
})
/**
* @params {string} roomID
* @params {any} kstate
*/
function applyKStateDiffToRoom(roomID, kstate) {
const events = kstateToState(kstate)
return Promise.all(events.map(({type, state_key, content}) =>
mreq.mreq("PUT", `/client/v3/rooms/${roomID}/state/${type}/${state_key}`, content)
))
}
function diffKState(actual, target) { function diffKState(actual, target) {
const diff = {} const diff = {}
@ -60,39 +83,11 @@ function diffKState(actual, target) {
// not present, needs to be added // not present, needs to be added
diff[key] = target[key] diff[key] = target[key]
} }
// keys that are missing in "actual" will not be deleted on "target" (no action)
} }
return diff return diff
} }
test("diffKState: detects edits", t => {
t.deepEqual(
diffKState({
"m.room.name/": {name: "test name"},
"same/": {a: 2}
}, {
"m.room.name/": {name: "edited name"},
"same/": {a: 2}
}),
{
"m.room.name/": {name: "edited name"}
}
)
})
test("diffKState: detects new properties", t => {
t.deepEqual(
diffKState({
"m.room.name/": {name: "test name"},
}, {
"m.room.name/": {name: "test name"},
"new/": {a: 2}
}),
{
"new/": {a: 2}
}
)
})
/** /**
* @param {import("discord-api-types/v10").APIGuildTextChannel} channel * @param {import("discord-api-types/v10").APIGuildTextChannel} channel
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
@ -107,14 +102,14 @@ async function channelToKState(channel, guild) {
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path)
} }
const kstate = { const channelKState = {
"m.room.name/": {name: channel.name}, "m.room.name/": {name: channel.name},
"m.room.topic/": {topic: channel.topic || undefined}, "m.room.topic/": {$if: channel.topic, topic: channel.topic},
"m.room.avatar/": avatarEventContent, "m.room.avatar/": avatarEventContent,
"m.room.guest_access/": {guest_access: "can_join"}, "m.room.guest_access/": {guest_access: "can_join"},
"m.room.history_visibility/": {history_visibility: "invited"}, "m.room.history_visibility/": {history_visibility: "invited"},
[`m.space.parent/${spaceID}`]: { // TODO: put the proper server here [`m.space.parent/${spaceID}`]: {
via: ["cadence.moe"], via: ["cadence.moe"], // TODO: put the proper server here
canonical: true canonical: true
}, },
"m.room.join_rules/": { "m.room.join_rules/": {
@ -126,13 +121,9 @@ async function channelToKState(channel, guild) {
} }
} }
return {spaceID, kstate} return {spaceID, channelKState}
} }
test("channel2room: general", async t => {
t.deepEqual(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.kstate), {expected: true, ...testData.room.general})
})
/** /**
* @param {import("discord-api-types/v10").APIGuildTextChannel} channel * @param {import("discord-api-types/v10").APIGuildTextChannel} channel
* @param guild * @param guild
@ -140,7 +131,7 @@ test("channel2room: general", async t => {
* @param {any} kstate * @param {any} kstate
*/ */
async function createRoom(channel, guild, spaceID, kstate) { async function createRoom(channel, guild, spaceID, kstate) {
/** @type {import("../../types").R_RoomCreated} */ /** @type {import("../../types").R.RoomCreated} */
const root = await mreq.mreq("POST", "/client/v3/createRoom", { const root = await mreq.mreq("POST", "/client/v3/createRoom", {
name: channel.name, name: channel.name,
topic: channel.topic || undefined, topic: channel.topic || undefined,
@ -159,20 +150,46 @@ async function createRoom(channel, guild, spaceID, kstate) {
} }
/** /**
* @param {import("discord-api-types/v10").APIGuildTextChannel} channel * @param {import("discord-api-types/v10").APIGuildChannel} channel
*/ */
async function syncRoom(channel) { function channelToGuild(channel) {
const guildID = channel.guild_id const guildID = channel.guild_id
assert(guildID) assert(guildID)
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
return guild
}
const {spaceID, kstate} = await channelToKState(channel, guild) /**
* @param {string} channelID
*/
async function syncRoom(channelID) {
/** @ts-ignore @type {import("discord-api-types/v10").APIGuildChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
const guild = channelToGuild(channel)
const {spaceID, channelKState} = await channelToKState(channel, guild)
/** @type {string?} */ /** @type {string?} */
const existing = db.prepare("SELECT room_id from channel_room WHERE channel_id = ?").pluck().get(channel.id) const existing = db.prepare("SELECT room_id from channel_room WHERE channel_id = ?").pluck().get(channel.id)
if (!existing) { if (!existing) {
createRoom(channel, guild, spaceID, kstate) return createRoom(channel, guild, spaceID, channelKState)
} else {
// sync channel state to room
const roomKState = await roomToKState(existing)
const roomDiff = diffKState(roomKState, channelKState)
const roomApply = applyKStateDiffToRoom(existing, roomDiff)
// sync room as space member
const spaceKState = await roomToKState(spaceID)
const spaceDiff = diffKState(spaceKState, {
[`m.space.child/${existing}`]: {
via: ["cadence.moe"] // TODO: use the proper server
}
})
const spaceApply = applyKStateDiffToRoom(spaceID, spaceDiff)
return Promise.all([roomApply, spaceApply])
} }
} }
@ -180,14 +197,15 @@ async function createAllForGuild(guildID) {
const channelIDs = discord.guildChannelMap.get(guildID) const channelIDs = discord.guildChannelMap.get(guildID)
assert.ok(channelIDs) assert.ok(channelIDs)
for (const channelID of channelIDs) { for (const channelID of channelIDs) {
const channel = discord.channels.get(channelID) await syncRoom(channelID).then(r => console.log(`synced ${channelID}:`, r))
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.createRoom = createRoom
module.exports.syncRoom = syncRoom
module.exports.createAllForGuild = createAllForGuild module.exports.createAllForGuild = createAllForGuild
module.exports.kstateToState = kstateToState
module.exports.stateToKState = stateToKState
module.exports.diffKState = diffKState
module.exports.channelToKState = channelToKState
module.exports.kstateStripConditionals = kstateStripConditionals

View File

@ -0,0 +1,83 @@
const {kstateToState, stateToKState, diffKState, channelToKState, kstateStripConditionals} = require("./create-room")
const {test} = require("supertape")
const testData = require("../../test/data")
test("kstate2state: general", t => {
t.deepEqual(kstateToState({
"m.room.name/": {name: "test name"},
"m.room.member/@cadence:cadence.moe": {membership: "join"}
}), [
{
type: "m.room.name",
state_key: "",
content: {
name: "test name"
}
},
{
type: "m.room.member",
state_key: "@cadence:cadence.moe",
content: {
membership: "join"
}
}
])
})
test("state2kstate: general", t => {
t.deepEqual(stateToKState([
{
type: "m.room.name",
state_key: "",
content: {
name: "test name"
}
},
{
type: "m.room.member",
state_key: "@cadence:cadence.moe",
content: {
membership: "join"
}
}
]), {
"m.room.name/": {name: "test name"},
"m.room.member/@cadence:cadence.moe": {membership: "join"}
})
})
test("diffKState: detects edits", t => {
t.deepEqual(
diffKState({
"m.room.name/": {name: "test name"},
"same/": {a: 2}
}, {
"m.room.name/": {name: "edited name"},
"same/": {a: 2}
}),
{
"m.room.name/": {name: "edited name"}
}
)
})
test("diffKState: detects new properties", t => {
t.deepEqual(
diffKState({
"m.room.name/": {name: "test name"},
}, {
"m.room.name/": {name: "test name"},
"new/": {a: 2}
}),
{
"new/": {a: 2}
}
)
})
test("channel2room: general", async t => {
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
testData.room.general
)
})

View File

@ -37,7 +37,7 @@ function createSpace(guild) {
} }
} }
] ]
}).then(/** @param {import("../../types").R_RoomCreated} root */ root => { }).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) db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, root.room_id)
return root return root
}) })

View File

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const reg = require("../../matrix/read-registration.js") const reg = require("../../matrix/read-registration.js")
const fetch = require("node-fetch") const fetch = require("node-fetch").default
fetch("https://matrix.cadence.moe/_matrix/client/v3/register", { fetch("https://matrix.cadence.moe/_matrix/client/v3/register", {
method: "POST", method: "POST",

Binary file not shown.

View File

@ -22,6 +22,3 @@ passthrough.discord = discord
require("./stdin") require("./stdin")
})() })()
// process.on("unhandledRejection", console.error)
// process.on("uncaughtException", console.error)

View File

@ -36,7 +36,7 @@ async function uploadDiscordFileToMxc(path) {
const body = res.body const body = res.body
// Upload to Matrix // Upload to Matrix
/** @type {import("../types").R_FileUploaded} */ /** @type {import("../types").R.FileUploaded} */
const root = await mreq.mreq("POST", "/media/v3/upload", body, { const root = await mreq.mreq("POST", "/media/v3/upload", body, {
headers: { headers: {
"Content-Type": res.headers.get("content-type") "Content-Type": res.headers.get("content-type")

View File

@ -10,8 +10,9 @@ const reg = sync.require("./read-registration.js")
const baseUrl = "https://matrix.cadence.moe/_matrix" const baseUrl = "https://matrix.cadence.moe/_matrix"
class MatrixServerError { class MatrixServerError extends Error {
constructor(data) { constructor(data) {
super(data.error || data.errcode)
this.data = data this.data = data
/** @type {string} */ /** @type {string} */
this.errcode = data.errcode this.errcode = data.errcode
@ -35,7 +36,7 @@ async function mreq(method, url, body, extra = {}) {
} }
}, extra) }, extra)
console.log(baseUrl + url, opts) // console.log(baseUrl + url, opts)
const res = await fetch(baseUrl + url, opts) const res = await fetch(baseUrl + url, opts)
const root = await res.json() const root = await res.json()

857
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,13 +24,15 @@
"matrix-js-sdk": "^24.1.0", "matrix-js-sdk": "^24.1.0",
"mixin-deep": "^2.0.1", "mixin-deep": "^2.0.1",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"snowtransfer": "^0.7.0", "snowtransfer": "^0.7.0"
"supertape": "^8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^18.16.0" "@types/node": "^18.16.0",
"@types/node-fetch": "^2.6.3",
"supertape": "^8.3.0",
"tap-dot": "github:cloudrac3r/tap-dot#223a4e67a6f7daf015506a12a7af74605f06c7f4"
}, },
"scripts": { "scripts": {
"test": "supertape --format short test/test.js" "test": "FORCE_COLOR=true supertape --format tap test/test.js | tap-dot"
} }
} }

View File

@ -43,7 +43,7 @@ async function customEval(input, _context, _filename, callback) {
const output = util.inspect(result, false, depth, true) const output = util.inspect(result, false, depth, true)
return callback(null, output) return callback(null, output)
} catch (e) { } catch (e) {
return callback(null, util.inspect(e, true, 100, true)) return callback(null, util.inspect(e, false, 100, true))
} }
} }

View File

@ -39,7 +39,7 @@ module.exports = {
}, },
"m.room.avatar/": { "m.room.avatar/": {
discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024",
url: "mxc://cadence.moe/sZtPwbfOIsvfSoWCWPrGnzql" url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
} }
} }
}, },
@ -82,4 +82,4 @@ module.exports = {
system_channel_flags: 0|0 system_channel_flags: 0|0
} }
} }
} }

View File

@ -12,4 +12,4 @@ const sync = new HeatSync({persistent: false})
Object.assign(passthrough, { config, sync, db }) Object.assign(passthrough, { config, sync, db })
require("../d2m/actions/create-room") require("../d2m/actions/create-room.test")

45
types.d.ts vendored
View File

@ -8,17 +8,42 @@ export type AppServiceRegistrationConfig = {
rate_limited: boolean rate_limited: boolean
} }
export type M_Room_Message_content = { namespace Event {
msgtype: "m.text" export type BaseStateEvent = {
body: string type: string
formatted_body?: "org.matrix.custom.html" room_id: string
format?: string sender: string
content: any
state_key: string
origin_server_ts: number
unsigned: any
event_id: string
user_id: string
age: number
replaces_state: string
prev_content?: any
}
export type M_Room_Message = {
msgtype: "m.text"
body: string
formatted_body?: "org.matrix.custom.html"
format?: string
}
export type M_Room_Member = {
membership: string
display_name?: string
avatar_url?: string
}
} }
export type R_RoomCreated = { namespace R {
room_id: string export type RoomCreated = {
} room_id: string
}
export type R_FileUploaded = { export type FileUploaded = {
content_uri: string content_uri: string
}
} }