Compare commits

...

8 commits

15 changed files with 319 additions and 36 deletions

View file

@ -17,6 +17,7 @@ const ks = sync.require("../../matrix/kstate")
const inflightRoomCreate = new Map()
/**
* Async because it gets all room state from the homeserver.
* @param {string} roomID
*/
async function roomToKState(roomID) {
@ -36,12 +37,17 @@ function applyKStateDiffToRoom(roomID, kstate) {
}
/**
* @param {{id: string, name: string, topic?: string?}} channel
* @param {{id: string, name: string, topic?: string?, type: number}} channel
* @param {{id: string}} guild
* @param {string?} customName
*/
function convertNameAndTopic(channel, guild, customName) {
const convertedName = customName || channel.name;
let channelPrefix =
( channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
: "")
const chosenName = customName || (channelPrefix + channel.name);
const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : '';
const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : '';
const channelIDPart = `Channel ID: ${channel.id}`;
@ -51,10 +57,11 @@ function convertNameAndTopic(channel, guild, customName) {
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}`
: `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`;
return [convertedName, convertedTopic];
return [chosenName, convertedTopic];
}
/**
* Async because it may upload the guild icon to mxc.
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
* @param {DiscordTypes.APIGuild} guild
*/
@ -118,13 +125,21 @@ async function createRoom(channel, guild, spaceID, kstate) {
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
const invite = threadParent ? [] : ["@cadence:cadence.moe"] // TODO
// Name and topic can be done earlier in room creation rather than in initial_state
// https://spec.matrix.org/latest/client-server-api/#creation
const name = kstate["m.room.name/"].name
delete kstate["m.room.name/"]
assert(name)
const topic = kstate["m.room.topic/"].topic
delete kstate["m.room.topic/"]
assert(topic)
const roomID = await postApplyPowerLevels(kstate, async kstate => {
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, null)
const roomID = await api.createRoom({
name: convertedName,
topic: convertedTopic,
preset: "private_chat",
visibility: "private",
name,
topic,
preset: "private_chat", // This is closest to what we want, but properties from kstate override it anyway
visibility: "private", // Not shown in the room directory
invite,
initial_state: ks.kstateToState(kstate)
})
@ -187,10 +202,10 @@ function channelToGuild(channel) {
3. Get kstate for channel
4. Create room, return new ID
New combined flow with ensure / sync:
Ensure + sync flow:
1. Get IDs
2. Does room exist?
2.5: If room does exist AND don't need to sync: return here
2.5: If room does exist AND wasn't asked to sync: return here
3. Get kstate for channel
4. Create room with kstate if room doesn't exist
5. Get and update room state with kstate if room does exist
@ -233,7 +248,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
console.log(`[room sync] to matrix: ${channel.name}`)
const {spaceID, channelKState} = await channelToKState(channel, guild)
const {spaceID, channelKState} = await channelToKState(channel, guild) // calling this in both branches because we don't want to calculate this if not syncing
// sync channel state to room
const roomKState = await roomToKState(roomID)
@ -248,6 +263,16 @@ async function _syncRoom(channelID, shouldActuallySync) {
return roomID
}
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */
function ensureRoom(channelID) {
return _syncRoom(channelID, false)
}
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */
function syncRoom(channelID) {
return _syncRoom(channelID, true)
}
async function _unbridgeRoom(channelID) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
@ -276,6 +301,7 @@ async function _unbridgeRoom(channelID) {
/**
* Async because it gets all space state from the homeserver, then if necessary sends one state event back.
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param {string} spaceID
* @param {string} roomID
@ -298,14 +324,6 @@ async function _syncSpaceMember(channel, spaceID, roomID) {
return applyKStateDiffToRoom(spaceID, spaceDiff)
}
function ensureRoom(channelID) {
return _syncRoom(channelID, false)
}
function syncRoom(channelID) {
return _syncRoom(channelID, true)
}
async function createAllForGuild(guildID) {
const channelIDs = discord.guildChannelMap.get(guildID)
assert.ok(channelIDs)

View file

@ -14,28 +14,49 @@ test("channel2room: general", async t => {
test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:"}, {id: "456"}, "hauntings"),
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: custom name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone"}, {id: "456"}, "hauntings"),
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:"}, {id: "456"}, null),
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone"}, {id: "456"}, null),
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: public thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null),
["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: private thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null),
["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: voice channel icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null),
["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})

View file

@ -1,6 +1,6 @@
// @ts-check
const assert = require("assert")
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
@ -84,11 +84,31 @@ async function syncSpace(guildID) {
console.log(`[space sync] to matrix: ${guild.name}`)
// sync channel state to room
// sync guild state to space
const spaceKState = await createRoom.roomToKState(spaceID)
const spaceDiff = ks.diffKState(spaceKState, guildKState)
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
// guild icon was changed, so room avatars need to be updated as well as the space ones
// doing it this way rather than calling syncRoom for great efficiency gains
const newAvatarState = spaceDiff["m.room.avatar/"]
if (guild.icon && newAvatarState?.url) {
// don't try to update rooms with custom avatars though
const roomsWithCustomAvatars = db.prepare("SELECT room_id FROM channel_room WHERE custom_avatar IS NOT NULL").pluck().all()
const childRooms = ks.kstateToState(spaceKState).filter(({type, state_key, content}) => {
return type === "m.space.child" && "via" in content && roomsWithCustomAvatars.includes(state_key)
}).map(({state_key}) => state_key)
for (const roomID of childRooms) {
const avatarEventContent = await api.getStateEvent(roomID, "m.room.avatar", "")
if (avatarEventContent.url !== newAvatarState.url) {
await api.sendState(roomID, "m.room.avatar", "", newAvatarState)
}
}
}
return spaceID
}

View file

@ -3,7 +3,27 @@ const {_memberToStateContent} = require("./register-user")
const {test} = require("supertape")
const testData = require("../../test/data")
test("member2state: general", async t => {
test("member2state: without member nick or avatar", async t => {
t.deepEqual(
await _memberToStateContent(testData.member.kumaccino.user, testData.member.kumaccino, testData.guild.general.id),
{
avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL",
displayname: "kumaccino",
membership: "join",
"moe.cadence.ooye.member": {
avatar: "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024"
},
"uk.half-shot.discord.member": {
bot: false,
displayColor: 10206929,
id: "113340068197859328",
username: "@kumaccino"
}
}
)
})
test("member2state: with member nick and avatar", async t => {
t.deepEqual(
await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id),
{

View file

@ -39,6 +39,7 @@ function* generateLocalpartAlternatives(preferences) {
let i = 2
while (true) {
yield best + (i++)
/* c8 ignore next */
}
}
@ -69,7 +70,7 @@ function userToSimName(user) {
for (const suggestion of generateLocalpartAlternatives(preferences)) {
if (!matches.includes(suggestion)) return suggestion
}
/* c8 ignore next */
throw new Error(`Ran out of suggestions when generating sim name. downcased: "${downcased}"`)
}

View file

@ -0,0 +1,142 @@
// @ts-check
const assert = require("assert").strict
const util = require("util")
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db} = require("../passthrough")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../matrix/file")} */
const file = sync.require("../matrix/file")
const PREFIX = "//"
let buttons = []
/**
* @param {string} channelID where to add the button
* @param {string} messageID where to add the button
* @param {string} emoji emoji to add as a button
* @param {string} userID only listen for responses from this user
* @returns {Promise<import("discord-api-types/v10").GatewayMessageReactionAddDispatchData>}
*/
async function addButton(channelID, messageID, emoji, userID) {
await discord.snow.channel.createReaction(channelID, messageID, emoji)
return new Promise(resolve => {
buttons.push({channelID, messageID, userID, resolve, created: Date.now()})
})
}
// Clear out old buttons every so often to free memory
setInterval(() => {
const now = Date.now()
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
}, 10*60*1000)
/** @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */
function onReactionAdd(data) {
const button = buttons.find(b => b.channelID === data.channel_id && b.messageID === data.message_id && b.userID === data.user_id)
if (button) {
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
button.resolve(data)
}
}
/**
* @callback CommandExecute
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {Partial<DiscordTypes.RESTPostAPIChannelMessageJSONBody>} [ctx]
*/
/**
* @typedef Command
* @property {string[]} aliases
* @property {(message: DiscordTypes.GatewayMessageCreateDispatchData, channel: DiscordTypes.APIGuildTextChannel, guild: DiscordTypes.APIGuild) => Promise<any>} execute
*/
/** @param {CommandExecute} execute */
function replyctx(execute) {
/** @type {CommandExecute} */
return function(message, channel, guild, ctx = {}) {
ctx.message_reference = {
message_id: message.id,
channel_id: channel.id,
guild_id: guild.id,
fail_if_not_exists: false
}
return execute(message, channel, guild, ctx)
}
}
/** @type {Command[]} */
const commands = [{
aliases: ["icon", "avatar", "roomicon", "roomavatar", "channelicon", "channelavatar"],
execute: replyctx(
async (message, channel, guild, ctx) => {
// Guard
const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(channel.id)
if (!roomID) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This channel isn't bridged to the other side."
})
// Current avatar
const avatarEvent = await api.getStateEvent(roomID, "m.room.avatar", "")
const avatarURLParts = avatarEvent?.url.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
let currentAvatarMessage =
( avatarURLParts ? `Current room-specific avatar: https://matrix.cadence.moe/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
: "No avatar. Now's your time to strike. Use `//icon` again with a link or upload to set the room-specific avatar.")
// Next potential avatar
const nextAvatarURL = message.attachments.find(a => a.content_type?.startsWith("image/"))?.url || message.content.match(/https?:\/\/[^ ]+\.[^ ]+\.(?:png|jpg|jpeg|webp)\b/)?.[0]
let nextAvatarMessage =
( nextAvatarURL ? `\nYou want to set it to: ${nextAvatarURL}\nHit ✅ to make it happen.`
: "")
const sent = await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: currentAvatarMessage + nextAvatarMessage
})
if (nextAvatarURL) {
addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
const mxcUrl = await file.uploadDiscordFileToMxc(nextAvatarURL)
await api.sendState(roomID, "m.room.avatar", "", {
url: mxcUrl
})
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE channel_id = ?").run(mxcUrl, channel.id)
await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "Your creation is unleashed. Any complaints will be redirected to Grelbo."
})
})
}
}
)
}, {
aliases: ["invite"],
execute: replyctx(
async (message, channel, guild, ctx) => {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This command isn't implemented yet."
})
}
)
}]
/** @type {CommandExecute} */
async function execute(message, channel, guild) {
if (!message.content.startsWith(PREFIX)) return
const words = message.content.slice(PREFIX.length).split(" ")
const commandName = words[0]
const command = commands.find(c => c.aliases.includes(commandName))
if (!command) return
await command.execute(message, channel, guild)
}
module.exports.execute = execute
module.exports.onReactionAdd = onReactionAdd

View file

@ -18,6 +18,8 @@ const createRoom = sync.require("./actions/create-room")
const createSpace = sync.require("./actions/create-space")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("./discord-command-handler")}) */
const discordCommandHandler = sync.require("./discord-command-handler")
let lastReportedEvent = 0
@ -156,7 +158,9 @@ module.exports = {
if (!channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id)
if (!isGuildAllowed(guild.id)) return
await sendMessage.sendMessage(message, guild)
await sendMessage.sendMessage(message, guild),
await discordCommandHandler.execute(message, channel, guild)
},
/**
@ -191,6 +195,7 @@ module.exports = {
*/
async onReactionAdd(client, data) {
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
discordCommandHandler.onReactionAdd(data)
if (data.emoji.id !== null) return // TODO: image emoji reactions
await addReaction.addReaction(data)
},

View file

@ -93,6 +93,7 @@ INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/piper_2.png', 'mxc://cadence.moe/KQYdXKRcHWjDYDLPkTOOWOjA'),
('https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg', 'mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa'),
('https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024', 'mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl'),
('https://cdn.discordapp.com/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024', 'mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF');
('https://cdn.discordapp.com/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024', 'mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF'),
('https://cdn.discordapp.com/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024', 'mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL');
COMMIT;

View file

@ -32,7 +32,7 @@ function eventToMessage(event) {
})
} else if (event.content.msgtype === "m.emote") {
messages.push({
content: `*${displayName} ${event.content.body}*`,
content: `\* _${displayName} ${event.content.body}_`,
username: displayName,
avatar_url: avatarURL
})

View file

@ -190,6 +190,7 @@ module.exports.inviteToRoom = inviteToRoom
module.exports.leaveRoom = leaveRoom
module.exports.getEvent = getEvent
module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent
module.exports.getJoinedMembers = getJoinedMembers
module.exports.sendState = sendState
module.exports.sendEvent = sendEvent

View file

@ -27,15 +27,15 @@ async function uploadDiscordFileToMxc(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
const existingInflight = inflight.get(url)
if (existingInflight) {
return existingInflight
}
// 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
const existingFromDb = db.prepare("SELECT mxc_url FROM file WHERE discord_url = ?").pluck().get(url)
if (typeof existingFromDb === "string") {
return existingFromDb
}
// Download from Discord

12
matrix/txnid.test.js Normal file
View file

@ -0,0 +1,12 @@
// @ts-check
const {test} = require("supertape")
const txnid = require("./txnid")
test("txnid: generates different values each run", t => {
const one = txnid.makeTxnId()
t.ok(one)
const two = txnid.makeTxnId()
t.ok(two)
t.notEqual(two, one)
})

View file

@ -12,6 +12,7 @@ const createRoom = sync.require("./d2m/actions/create-room")
const registerUser = sync.require("./d2m/actions/register-user")
const mreq = sync.require("./matrix/mreq")
const api = sync.require("./matrix/api")
const file = sync.require("./matrix/file")
const sendEvent = sync.require("./m2d/actions/send-event")
const eventDispatcher = sync.require("./d2m/event-dispatcher")
const ks = sync.require("./matrix/kstate")

View file

@ -98,6 +98,46 @@ module.exports = {
}
},
member: {
kumaccino: {
avatar: null,
communication_disabled_until: null,
flags: 0,
joined_at: "2015-11-11T09:55:40.321000+00:00",
nick: null,
pending: false,
premium_since: null,
roles: [
"112767366235959296", "118924814567211009",
"199995902742626304", "204427286542417920",
"222168467627835392", "238028326281805825",
"259806643414499328", "265239342648131584",
"271173313575780353", "287733611912757249",
"225744901915148298", "305775031223320577",
"318243902521868288", "348651574924541953",
"349185088157777920", "378402925128712193",
"392141548932038658", "393912152173576203",
"482860581670486028", "495384759074160642",
"638988388740890635", "373336013109461013",
"530220455085473813", "454567553738473472",
"790724320824655873", "1040735082610167858",
"695946570482450442", "849737964090556488"
],
user: {
id: "113340068197859328",
username: "kumaccino",
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: 10206929,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: "#9bbed1"
},
mute: false,
deaf: false
},
sheep: {
avatar: "38dd359aa12bcd52dd3164126c587f8c",
communication_disabled_until: null,

View file

@ -20,6 +20,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../matrix/kstate.test")
require("../matrix/api.test")
require("../matrix/read-registration.test")
require("../matrix/txnid.test")
require("../d2m/converters/message-to-event.test")
require("../d2m/converters/message-to-event.embeds.test")
require("../d2m/converters/edit-to-changes.test")