Compare commits

...

99 commits

Author SHA1 Message Date
cae8d7c2f2 m->d mentioning bridged users and rooms works 2023-08-26 23:22:23 +12:00
58f5c3edf7 m->d underline formatting support 2023-08-26 22:59:22 +12:00
7c2fc1536d trying to make reaction emojis consistent 2023-08-26 22:51:42 +12:00
3f2a8d959c preemptively cache members as we find them 2023-08-26 22:50:54 +12:00
3ebfa8e3a7 show member details on discord from cache 2023-08-26 22:22:54 +12:00
0ea2b4efc9 m->d rich replies 2023-08-26 20:30:22 +12:00
39fb4465f6 fix m->d formatting of quotes and code 2023-08-26 19:07:19 +12:00
df651241a5 store room name changes to nick in db 2023-08-26 18:58:41 +12:00
88eb8c2290 configure turndown for code blocks 2023-08-26 02:04:49 +12:00
7780ee806a 100% edit-to-changes code coverage 2023-08-26 01:44:50 +12:00
8c4e16e255 add turndown for m->d formatting 2023-08-26 01:43:17 +12:00
27b8c547e3 updating tap-dot for prettier output 2023-08-25 23:27:44 +12:00
c4bc079865 bug fix :( 2023-08-25 17:35:34 +12:00
20cd7ab38e sync child room avatars when guild is updated 2023-08-25 17:23:51 +12:00
9f717dc24f preserve order: finish bridge before command reply 2023-08-25 16:03:43 +12:00
69a01a0608 implemented //icon with button confirmation system 2023-08-25 16:01:19 +12:00
30bf87b106 channel name decoration for threads and voice-text 2023-08-25 12:05:16 +12:00
beeb27bbd8 test on a member that has no member props 2023-08-25 11:44:58 +12:00
1e9e9685c5 minor code coverage 2023-08-24 17:23:32 +12:00
c56e92ccfb start adding command handlers 2023-08-24 17:09:25 +12:00
40c3ef8e83 don't set the name and topic twice 2023-08-24 12:42:12 +12:00
4c0aa57ba7 add test case for m->d too-long message 2023-08-24 00:35:35 +12:00
f0ff89161a syncing all room power levels 2023-08-24 00:27:51 +12:00
a8fab062a4 workaround synapse bug that would make broken PLs 2023-08-23 17:08:20 +12:00
8d536d5ef2 using kstate with power levels should mix them 2023-08-23 16:16:36 +12:00
458a620f4a make hardcoded "cadence.moe" configurable 2023-08-23 13:02:32 +12:00
92cd628a6c remember any room avatars set on matrix-side 2023-08-23 12:39:37 +12:00
bfe9efe62e listen for guild updates and connect them 2023-08-23 12:37:25 +12:00
892bf4496d be able to sync space properties 2023-08-23 12:31:31 +12:00
6cd509d274 start using kstate for guild/spaces too 2023-08-22 17:11:07 +12:00
e04617eb04 remove console.log 2023-08-21 23:34:49 +12:00
6d1635539b finally got the thread's early messages working 2023-08-21 23:31:40 +12:00
180708b60e I WAS USING THE WRONG VARIABLE :GRIEF: 2023-08-21 21:16:10 +12:00
c8021cadec changing spaces to tabs 2023-08-21 21:04:41 +12:00
c22f434c1f explain inflight thread room creation 2023-08-21 17:25:51 +12:00
d666c0aedb attempt to support THREAD_CREATE but it explodes 2023-08-21 08:07:05 +12:00
08d3f3d804 synchronise channel updates 2023-08-19 23:12:36 +12:00
3436759504 catch up on missed d->m messages when logging in 2023-08-19 22:54:23 +12:00
0f20dcab6d guard for errors on matrix event dispatcher 2023-08-19 18:41:57 +12:00
4635200bcb only use nickname as displayname, not nick+user 2023-08-19 18:41:57 +12:00
3f187e8107 see how this different reactions format goes... 2023-08-19 18:41:57 +12:00
0fc8e68f15 support threads 2023-08-19 18:41:57 +12:00
213bf0a515 send a slightly more appropriate display name 2023-08-19 18:37:34 +12:00
36adc30990 only include necessary data for testing 2023-08-18 17:00:40 +12:00
e08262388b bug fix where errors weren't being sent 2023-08-18 16:58:46 +12:00
6246dc3a7f remove redactions from database in edit flow 2023-08-18 01:23:53 +12:00
2d14e84312 update discord-markdown to escape less stuff 2023-08-18 01:22:33 +12:00
6de13338a8 add error handler and message deleter 2023-08-18 01:22:14 +12:00
e3737997ec test that edits by webhook work 2023-08-17 19:03:09 +12:00
b9de84e586 actually call the function lol 2023-08-17 18:27:13 +12:00
e00eb15f35 support up to 1 space in written mentions 2023-08-17 18:17:53 +12:00
f5ef881cd0 update discord-markdown to remove html tags 2023-08-17 18:14:44 +12:00
040a2d253f write editMessage action, connected to dispatcher 2023-08-17 16:41:28 +12:00
2973170e87 test for editing a new caption onto an image 2023-08-17 12:36:51 +12:00
8f6bb86b92 record more update events 2023-08-17 12:36:47 +12:00
b1ca71f37c getting edits closer to working 2023-08-16 17:03:05 +12:00
cae591e5fd script for capturing message update events 2023-08-15 17:20:31 +12:00
f501718691 add code coverage 2023-08-11 01:11:58 +12:00
96dd488e39 progress on edits 2023-07-28 17:05:13 +12:00
f16900553a large files are now linked instead of uploaded 2023-07-13 17:36:20 +12:00
3a59d66626 move namespace_prefix into a sub-object 2023-07-13 17:11:24 +12:00
f4bfe54850 Include fallback text for replies to media 2023-07-12 14:33:38 +12:00
011f9c5ecb only need to do mentions scenario 2 part B now 2023-07-11 17:28:42 +12:00
437f04682c support written @mentions (scenario 3) 2023-07-11 17:27:40 +12:00
328ae74b61 support rich replies, support basic m.mentions 2023-07-11 16:51:30 +12:00
5326b7d6be get room mentions formatting working 2023-07-11 10:10:47 +12:00
0f4f404160 store the channel_id on event_message 2023-07-11 08:01:11 +12:00
1d6e833b22 update discord-markdown to fix tests 2023-07-07 19:28:23 +12:00
1a4f92db97 start getting d2m formatted body conversion 2023-07-07 17:31:39 +12:00
9569fda168 streamline the convertNameAndTopic function 2023-07-05 12:04:28 +12:00
bd32fe6c6d reactions working 2023-07-05 08:41:15 +12:00
4cd9da49fd add tests for convertNameAndTopic 2023-07-04 17:35:29 +12:00
61120d92c6 m2d reactions (untested) 2023-07-04 17:19:17 +12:00
39cdba9f90 bridge both ways and prevent reflections 2023-07-04 00:39:42 +12:00
3578ca28b5 glue 2023-07-03 17:20:24 +12:00
6e55e6d1b3 preparing for m2d 2023-07-03 01:06:05 +12:00
d592a3c82e don't send "" for attachments without content 2023-07-02 01:41:31 +12:00
1591bfc578 fix webhooks sending (upstream bug) 2023-07-02 01:40:54 +12:00
07b9bab5ec updating discord libraries per recommendation 2023-07-01 17:02:03 +12:00
5bd1bc9a5b add webhook creation utility 2023-06-30 15:15:34 +12:00
cc1210729f add test for member state content 2023-06-30 15:15:23 +12:00
d70199f890 add appservice listener 2023-06-29 08:08:17 +12:00
740ddc36d1 support d->m stickers 2023-06-29 00:06:56 +12:00
584f19a011 fix a variety of bugs that snuck in 2023-06-28 23:39:29 +12:00
512f61422d continue on stickers 2023-05-15 17:25:05 +12:00
e1d7ced87d begin converting discord attachments and stickers 2023-05-12 17:35:37 +12:00
38d7db5071 seed initial setup 2023-05-10 23:17:37 +12:00
22dde9faf7 completed user syncing. it occurs on message send 2023-05-10 22:15:20 +12:00
f418d51e55 refactor kstate, add stub user syncing function 2023-05-10 17:40:31 +12:00
4d8b74f61f adding basic reactions to discord messages 2023-05-09 17:13:59 +12:00
da6603d258 send sim messages to the proper rooms 2023-05-09 15:29:46 +12:00
7526d63690 get createRoom and ensureRoom interface working 2023-05-09 08:03:57 +12:00
1e7e66dc31 register -> invite -> join -> send flow 2023-05-09 00:58:46 +12:00
3bc29def41 switch to using api functions over mreq 2023-05-08 23:37:51 +12:00
7ee04d085f username sanitisation for registration 2023-05-08 17:22:20 +12:00
48c2ef76f5 preparations for creating users 2023-05-08 08:27:42 +12:00
3fbe7eed6e Finish room diffing and syncing. All tests pass 2023-05-06 01:25:15 +12:00
f09eeccef3 add tests, implement kstate and state diffing 2023-05-05 17:29:08 +12:00
c7868e9dbb space and room creation 2023-05-05 08:25:00 +12:00
61 changed files with 7685 additions and 282 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules
config.js
registration.yaml
coverage

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"editor.insertSpaces": false,
"editor.detectIndentation": false,
"editor.tabSize": 3
}

16
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,16 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "test",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [],
"label": "npm: test",
"detail": "cross-env FORCE_COLOR=true supertape --format tap test/test.js | tap-dot"
}
]
}

View file

@ -0,0 +1,35 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room")
/**
* @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data
*/
async function addReaction(data) {
const user = data.member?.user
assert.ok(user && user.username)
const parentID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ? AND part = 0").pluck().get(data.message_id) // 0 = primary
if (!parentID) return // Nothing can be done if the parent message was never bridged.
assert.equal(typeof parentID, "string")
const roomID = await createRoom.ensureRoom(data.channel_id)
const senderMxid = await registerUser.ensureSimJoined(user, roomID)
const eventID = await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: parentID,
key: data.emoji.name
}
}, senderMxid)
return eventID
}
module.exports.addReaction = addReaction

View file

@ -0,0 +1,26 @@
// @ts-check
const assert = require("assert")
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../converters/thread-to-announcement")} */
const threadToAnnouncement = sync.require("../converters/thread-to-announcement")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/**
* @param {string} parentRoomID
* @param {string} threadRoomID
* @param {import("discord-api-types/v10").APIThreadChannel} thread
*/
async function announceThread(parentRoomID, threadRoomID, thread) {
/** @type {string?} */
const creatorMxid = db.prepare("SELECT mxid FROM sim WHERE discord_id = ?").pluck().get(thread.owner_id)
const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api})
await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid)
}
module.exports.announceThread = announceThread

View file

@ -1,22 +1,350 @@
// @ts-check
const reg = require("../../matrix/read-registration.js")
const fetch = require("node-fetch")
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const reg = require("../../matrix/read-registration")
fetch("https://matrix.cadence.moe/_matrix/client/v3/createRoom?user_id=@_ooye_example:cadence.moe", {
method: "POST",
body: JSON.stringify({
invite: ["@cadence:cadence.moe"],
is_direct: false,
name: "New Bot User Room",
preset: "trusted_private_chat"
}),
headers: {
Authorization: `Bearer ${reg.as_token}`
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
/** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */
const inflightRoomCreate = new Map()
/**
* Async because it gets all room state from the homeserver.
* @param {string} roomID
*/
async function roomToKState(roomID) {
const root = await api.getAllState(roomID)
return ks.stateToKState(root)
}
}).then(res => res.text()).then(text => {
// {"room_id":"!aAVaqeAKwChjWbsywj:cadence.moe"}
console.log(text)
}).catch(err => {
console.log(err)
/**
* @param {string} roomID
* @param {any} kstate
*/
function applyKStateDiffToRoom(roomID, kstate) {
const events = ks.kstateToState(kstate)
return Promise.all(events.map(({type, state_key, content}) =>
api.sendState(roomID, type, state_key, content)
))
}
/**
* @param {{id: string, name: string, topic?: string?, type: number}} channel
* @param {{id: string}} guild
* @param {string?} customName
*/
function convertNameAndTopic(channel, guild, customName) {
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}`;
const guildIDPart = `Guild ID: ${guild.id}`;
const convertedTopic = customName
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}`
: `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`;
return [chosenName, convertedTopic];
}
/**
* Async because it may upload the guild icon to mxc.
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
* @param {DiscordTypes.APIGuild} guild
*/
async function channelToKState(channel, guild) {
const spaceID = db.prepare("SELECT space_id FROM guild_space WHERE guild_id = ?").pluck().get(guild.id)
assert.ok(typeof spaceID === "string")
const row = db.prepare("SELECT nick, custom_avatar FROM channel_room WHERE channel_id = ?").get(channel.id)
const customName = row?.nick
const customAvatar = row?.custom_avatar
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {}
if (customAvatar) {
avatarEventContent.url = customAvatar
} else if (guild.icon) {
avatarEventContent.discord_path = file.guildIcon(guild)
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
}
let history_visibility = "invited"
if (channel["thread_metadata"]) history_visibility = "world_readable"
const channelKState = {
"m.room.name/": {name: convertedName},
"m.room.topic/": {topic: convertedTopic},
"m.room.avatar/": avatarEventContent,
"m.room.guest_access/": {guest_access: "can_join"},
"m.room.history_visibility/": {history_visibility},
[`m.space.parent/${spaceID}`]: {
via: [reg.ooye.server_name],
canonical: true
},
"m.room.join_rules/": {
join_rule: "restricted",
allow: [{
type: "m.room_membership",
room_id: spaceID
}]
},
"m.room.power_levels/": {
events: {
"m.room.avatar": 0
}
}
}
return {spaceID, channelKState}
}
/**
* Create a bridge room, store the relationship in the database, and add it to the guild's space.
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param guild
* @param {string} spaceID
* @param {any} kstate
* @returns {Promise<string>} room ID
*/
async function createRoom(channel, guild, spaceID, kstate) {
let threadParent = null
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 roomID = await api.createRoom({
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)
})
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
return roomID
})
// Put the newly created child into the space, no need to await this
_syncSpaceMember(channel, spaceID, roomID)
return roomID
}
/**
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
* and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates.
* We don't want the `events` key to be overridden completely.
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https://github.com/matrix-org/matrix-spec/issues/492
* @param {any} kstate
* @param {(_: any) => Promise<string>} callback must return room ID
* @returns {Promise<string>} room ID
*/
async function postApplyPowerLevels(kstate, callback) {
const powerLevelContent = kstate["m.room.power_levels/"]
const kstateWithoutPowerLevels = {...kstate}
delete kstateWithoutPowerLevels["m.room.power_levels/"]
/** @type {string} */
const roomID = await callback(kstateWithoutPowerLevels)
// Now *really* apply the power level overrides on top of what Synapse *really* set
if (powerLevelContent) {
const newRoomKState = await roomToKState(roomID)
const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent})
await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
}
return roomID
}
/**
* @param {DiscordTypes.APIGuildChannel} channel
*/
function channelToGuild(channel) {
const guildID = channel.guild_id
assert(guildID)
const guild = discord.guilds.get(guildID)
assert(guild)
return guild
}
/*
Ensure flow:
1. Get IDs
2. Does room exist? If so great!
(it doesn't, so it needs to be created)
3. Get kstate for channel
4. Create room, return new ID
Ensure + sync flow:
1. Get IDs
2. Does room exist?
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
*/
/**
* @param {string} channelID
* @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow)
* @returns {Promise<string>} room ID
*/
async function _syncRoom(channelID, shouldActuallySync) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
const guild = channelToGuild(channel)
if (inflightRoomCreate.has(channelID)) {
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
}
/** @type {{room_id: string, thread_parent: string?}} */
const existing = db.prepare("SELECT room_id, thread_parent from channel_room WHERE channel_id = ?").get(channelID)
if (!existing) {
const creation = (async () => {
const {spaceID, channelKState} = await channelToKState(channel, guild)
const roomID = await createRoom(channel, guild, spaceID, channelKState)
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
return roomID
})()
inflightRoomCreate.set(channelID, creation)
return creation // Naturally, the newly created room is already up to date, so we can always skip syncing here.
}
const roomID = existing.room_id
if (!shouldActuallySync) {
return existing.room_id // only need to ensure room exists, and it does. return the room ID
}
console.log(`[room sync] to matrix: ${channel.name}`)
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)
const roomDiff = ks.diffKState(roomKState, channelKState)
const roomApply = applyKStateDiffToRoom(roomID, roomDiff)
db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID)
// sync room as space member
const spaceApply = _syncSpaceMember(channel, spaceID, roomID)
await Promise.all([roomApply, spaceApply])
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)
assert.ok(channel)
const roomID = db.prepare("SELECT room_id from channel_room WHERE channel_id = ?").pluck().get(channelID)
assert.ok(roomID)
const spaceID = db.prepare("SELECT space_id FROM guild_space WHERE guild_id = ?").pluck().get(channel.guild_id)
assert.ok(spaceID)
// remove room from being a space member
await api.sendState(roomID, "m.space.parent", spaceID, {})
await api.sendState(spaceID, "m.space.child", roomID, {})
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge."
})
// leave room
await api.leaveRoom(roomID)
// delete room from database
const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID)
assert.equal(changes, 1)
}
/**
* 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
* @returns {Promise<string[]>}
*/
async function _syncSpaceMember(channel, spaceID, roomID) {
const spaceKState = await roomToKState(spaceID)
let spaceEventContent = {}
if (
channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join)
&& !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant)
) {
spaceEventContent = {
via: [reg.ooye.server_name]
}
}
const spaceDiff = ks.diffKState(spaceKState, {
[`m.space.child/${roomID}`]: spaceEventContent
})
return applyKStateDiffToRoom(spaceID, spaceDiff)
}
async function createAllForGuild(guildID) {
const channelIDs = discord.guildChannelMap.get(guildID)
assert.ok(channelIDs)
for (const channelID of channelIDs) {
const allowedTypes = [DiscordTypes.ChannelType.GuildText, DiscordTypes.ChannelType.PublicThread]
// @ts-ignore
if (allowedTypes.includes(discord.channels.get(channelID)?.type)) {
const roomID = await syncRoom(channelID)
console.log(`synced ${channelID} <-> ${roomID}`)
}
}
}
module.exports.createRoom = createRoom
module.exports.ensureRoom = ensureRoom
module.exports.syncRoom = syncRoom
module.exports.createAllForGuild = createAllForGuild
module.exports.channelToKState = channelToKState
module.exports.roomToKState = roomToKState
module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom
module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic
module.exports._unbridgeRoom = _unbridgeRoom

View file

@ -0,0 +1,62 @@
// @ts-check
const {channelToKState, _convertNameAndTopic} = require("./create-room")
const {kstateStripConditionals} = require("../../matrix/kstate")
const {test} = require("supertape")
const testData = require("../../test/data")
test("channel2room: general", async t => {
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
testData.room.general
)
})
test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual(
_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", 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:", 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", 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"]
)
})

116
d2m/actions/create-space.js Normal file
View file

@ -0,0 +1,116 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("./create-room")} */
const createRoom = sync.require("./create-room")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
/**
* @param {import("discord-api-types/v10").RESTGetAPIGuildResult} guild
* @param {any} kstate
*/
async function createSpace(guild, kstate) {
const name = kstate["m.room.name/"].name
const topic = kstate["m.room.topic/"]?.topic || undefined
assert(name)
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
return api.createRoom({
name,
preset: "private_chat", // cannot join space unless invited
visibility: "private",
power_level_content_override: {
events_default: 100, // space can only be managed by bridge
invite: 0 // any existing member can invite others
},
invite: ["@cadence:cadence.moe"], // TODO
topic,
creation_content: {
type: "m.space"
},
initial_state: ks.kstateToState(kstate)
})
})
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
return roomID
}
/**
* @param {DiscordTypes.APIGuild} guild]
*/
async function guildToKState(guild) {
const avatarEventContent = {}
if (guild.icon) {
avatarEventContent.discord_path = file.guildIcon(guild)
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
}
let history_visibility = "invited"
if (guild["thread_metadata"]) history_visibility = "world_readable"
const guildKState = {
"m.room.name/": {name: guild.name},
"m.room.avatar/": avatarEventContent,
"m.room.guest_access/": {guest_access: "can_join"}, // guests can join space if other conditions are met
"m.room.history_visibility/": {history_visibility: "invited"} // any events sent after user was invited are visible
}
return guildKState
}
async function syncSpace(guildID) {
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord.guilds.get(guildID)
assert.ok(guild)
/** @type {string?} */
const spaceID = db.prepare("SELECT space_id from guild_space WHERE guild_id = ?").pluck().get(guildID)
const guildKState = await guildToKState(guild)
if (!spaceID) {
const spaceID = await createSpace(guild, guildKState)
return spaceID // Naturally, the newly created space is already up to date, so we can always skip syncing here.
}
console.log(`[space sync] to matrix: ${guild.name}`)
// 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
}
module.exports.createSpace = createSpace
module.exports.syncSpace = syncSpace
module.exports.guildToKState = guildToKState

View file

@ -0,0 +1,26 @@
// @ts-check
const passthrough = require("../../passthrough")
const { sync, db } = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/**
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
*/
async function deleteMessage(data) {
/** @type {string?} */
const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(data.channel_id)
if (!roomID) return
/** @type {string[]} */
const eventsToRedact = db.prepare("SELECT event_id FROM event_message WHERE message_id = ?").pluck().all(data.id)
for (const eventID of eventsToRedact) {
// Unfortuately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
await api.redactEvent(roomID, eventID)
db.prepare("DELETE from event_message WHERE event_id = ?").run(eventID)
}
}
module.exports.deleteMessage = deleteMessage

View file

@ -0,0 +1,51 @@
// @ts-check
const passthrough = require("../../passthrough")
const { sync, db } = passthrough
/** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../converters/edit-to-changes")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
*/
async function editMessage(message, guild) {
const {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid} = await editToChanges.editToChanges(message, guild, api)
// 1. Replace all the things.
for (const {oldID, newContent} of eventsToReplace) {
const eventType = newContent.$type
/** @type {Pick<typeof newContent, Exclude<keyof newContent, "$type">> & { $type?: string }} */
const newContentWithoutType = {...newContent}
delete newContentWithoutType.$type
await api.sendEvent(roomID, eventType, newContentWithoutType, senderMxid)
// Ensure the database is up to date.
// The columns are event_id, event_type, event_subtype, message_id, channel_id, part, source. Only event_subtype could potentially be changed by a replacement event.
const subtype = newContentWithoutType.msgtype || null
db.prepare("UPDATE event_message SET event_subtype = ? WHERE event_id = ?").run(subtype, oldID)
}
// 2. Redact all the things.
// Not redacting as the last action because the last action is likely to be shown in the room preview in clients, and we don't want it to look like somebody actually deleted a message.
for (const eventID of eventsToRedact) {
await api.redactEvent(roomID, eventID, senderMxid)
db.prepare("DELETE from event_message WHERE event_id = ?").run(eventID)
// TODO: If I just redacted part = 0, I should update one of the other events to make it the new part = 0, right?
}
// 3. Send all the things.
for (const content of eventsToSend) {
const eventType = content.$type
/** @type {Pick<typeof content, Exclude<keyof content, "$type">> & { $type?: string }} */
const contentWithoutType = {...content}
delete contentWithoutType.$type
const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES (?, ?, ?, ?, ?, 1, 1)").run(eventID, eventType, content.msgtype || null, message.id, message.channel_id) // part 1 = supporting; source 1 = discord
}
}
module.exports.editMessage = editMessage

View file

@ -1,20 +1,170 @@
// @ts-check
const reg = require("../../matrix/read-registration.js")
const fetch = require("node-fetch")
const assert = require("assert")
const reg = require("../../matrix/read-registration")
fetch("https://matrix.cadence.moe/_matrix/client/v3/register", {
method: "POST",
body: JSON.stringify({
type: "m.login.application_service",
username: "_ooye_example"
}),
headers: {
Authorization: `Bearer ${reg.as_token}`
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../converters/user-to-mxid")} */
const userToMxid = sync.require("../converters/user-to-mxid")
/**
* A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {import("discord-api-types/v10").APIUser} user
* @returns mxid
*/
async function createSim(user) {
// Choose sim name
const simName = userToMxid.userToSimName(user)
const localpart = reg.ooye.namespace_prefix + simName
const mxid = `@${localpart}:${reg.ooye.server_name}`
// Save chosen name in the database forever
// Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates
db.prepare("INSERT INTO sim (discord_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid)
// Register matrix user with that name
try {
await api.register(localpart)
} catch (e) {
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
// (A transaction would be preferable, but I don't think it's safe to leave transaction open across event loop ticks.)
db.prepare("DELETE FROM sim WHERE discord_id = ?").run(user.id)
throw e
}
}).then(res => res.text()).then(text => {
// {"user_id":"@_ooye_example:cadence.moe","home_server":"cadence.moe","access_token":"XXX","device_id":"XXX"}
console.log(text)
}).catch(err => {
console.log(err)
})
return mxid
}
/**
* Ensure a sim is registered for the user.
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
* @param {import("discord-api-types/v10").APIUser} user
* @returns {Promise<string>} mxid
*/
async function ensureSim(user) {
let mxid = null
const existing = db.prepare("SELECT mxid FROM sim WHERE discord_id = ?").pluck().get(user.id)
if (existing) {
mxid = existing
} else {
mxid = await createSim(user)
}
return mxid
}
/**
* Ensure a sim is registered for the user and is joined to the room.
* @param {import("discord-api-types/v10").APIUser} user
* @param {string} roomID
* @returns {Promise<string>} mxid
*/
async function ensureSimJoined(user, roomID) {
// Ensure room ID is really an ID, not an alias
assert.ok(roomID[0] === "!")
// Ensure user
const mxid = await ensureSim(user)
// Ensure joined
const existing = db.prepare("SELECT * FROM sim_member WHERE room_id = ? and mxid = ?").get(roomID, mxid)
if (!existing) {
await api.inviteToRoom(roomID, mxid)
await api.joinRoom(roomID, mxid)
db.prepare("INSERT INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
}
return mxid
}
/**
* @param {import("discord-api-types/v10").APIUser} user
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
*/
async function memberToStateContent(user, member, guildID) {
let displayname = user.username
// if (member.nick && member.nick !== displayname) displayname = member.nick + " | " + displayname // prepend nick if present
if (member.nick) displayname = member.nick
const content = {
displayname,
membership: "join",
"moe.cadence.ooye.member": {
},
"uk.half-shot.discord.member": {
bot: !!user.bot,
displayColor: user.accent_color,
id: user.id,
username: user.discriminator.length === 4 ? `${user.username}#${user.discriminator}` : `@${user.username}`
}
}
if (member.avatar || user.avatar) {
// const avatarPath = file.userAvatar(user) // the user avatar only
const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar
content["moe.cadence.ooye.member"].avatar = avatarPath
content.avatar_url = await file.uploadDiscordFileToMxc(avatarPath)
}
return content
}
function calculateProfileEventContentHash(content) {
return `${content.displayname}\u0000${content.avatar_url}`
}
/**
* Sync profile data for a sim user. This function follows the following process:
* 1. Join the sim to the room if needed
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
* 3. Compare against the previously known state content, which is helpfully stored in the database
* 4. If the state content has changes, send it to Matrix and update it in the database for next time
* @param {import("discord-api-types/v10").APIUser} user
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
* @returns {Promise<string>} mxid of the updated sim
*/
async function syncUser(user, member, guildID, roomID) {
const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guildID)
const profileEventContentHash = calculateProfileEventContentHash(content)
const existingHash = db.prepare("SELECT profile_event_content_hash FROM sim_member WHERE room_id = ? AND mxid = ?").pluck().get(roomID, mxid)
// only do the actual sync if the hash has changed since we last looked
if (existingHash !== profileEventContentHash) {
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
db.prepare("UPDATE sim_member SET profile_event_content_hash = ? WHERE room_id = ? AND mxid = ?").run(profileEventContentHash, roomID, mxid)
}
return mxid
}
async function syncAllUsersInRoom(roomID) {
const mxids = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ?").pluck().all(roomID)
const channelID = db.prepare("SELECT channel_id FROM channel_room WHERE room_id = ?").pluck().get(roomID)
assert.ok(typeof channelID === "string")
/** @ts-ignore @type {import("discord-api-types/v10").APIGuildChannel} */
const channel = discord.channels.get(channelID)
const guildID = channel.guild_id
assert.ok(typeof guildID === "string")
for (const mxid of mxids) {
const userID = db.prepare("SELECT discord_id FROM sim WHERE mxid = ?").pluck().get(mxid)
assert.ok(typeof userID === "string")
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIGuildMember>} */
const member = await discord.snow.guild.getGuildMember(guildID, userID)
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIUser>} user */
const user = member.user
assert.ok(user)
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
await syncUser(user, member, guildID, roomID)
}
}
module.exports._memberToStateContent = memberToStateContent
module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser
module.exports.syncAllUsersInRoom = syncAllUsersInRoom

View file

@ -0,0 +1,44 @@
const {channelToKState} = require("./create-room")
const {_memberToStateContent} = require("./register-user")
const {test} = require("supertape")
const testData = require("../../test/data")
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),
{
avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl",
displayname: "The Expert's Submarine",
membership: "join",
"moe.cadence.ooye.member": {
avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"
},
"uk.half-shot.discord.member": {
bot: false,
displayColor: null,
id: "134826546694193153",
username: "@aprilsong"
}
}
)
})

View file

@ -1,27 +1,51 @@
// @ts-check
const reg = require("../../matrix/read-registration.js")
const makeTxnId = require("../../matrix/txnid.js")
const fetch = require("node-fetch")
const messageToEvent = require("../converters/message-to-event.js")
const assert = require("assert")
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
*/
function sendMessage(message) {
const event = messageToEvent(message)
fetch(`https://matrix.cadence.moe/_matrix/client/v3/rooms/!VwVlIAjOjejUpDhlbA:cadence.moe/send/m.room.message/${makeTxnId()}?user_id=@_ooye_example:cadence.moe`, {
method: "PUT",
body: JSON.stringify(event),
headers: {
Authorization: `Bearer ${reg.as_token}`
async function sendMessage(message, guild) {
const roomID = await createRoom.ensureRoom(message.channel_id)
let senderMxid = null
if (!message.webhook_id) {
if (message.member) { // available on a gateway message create event
senderMxid = await registerUser.syncUser(message.author, message.member, message.guild_id, roomID)
} else { // well, good enough...
senderMxid = await registerUser.ensureSimJoined(message.author, roomID)
}
}).then(res => res.text()).then(text => {
// {"event_id":"$4Zxs0fMmYlbo-sTlMmSEvwIs9b4hcg6yORzK0Ems84Q"}
console.log(text)
}).catch(err => {
console.log(err)
})
}
module.exports = sendMessage
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
const eventIDs = []
let eventPart = 0 // 0 is primary, 1 is supporting
for (const event of events) {
const eventType = event.$type
/** @type {Pick<typeof event, Exclude<keyof event, "$type">> & { $type?: string }} */
const eventWithoutType = {...event}
delete eventWithoutType.$type
const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, new Date(message.timestamp).getTime())
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, message.channel_id, eventPart) // source 1 = discord
eventPart = 1 // TODO: use more intelligent algorithm to determine whether primary or supporting
eventIDs.push(eventID)
}
return eventIDs
}
module.exports.sendMessage = sendMessage

View file

@ -0,0 +1,140 @@
// @ts-check
const assert = require("assert")
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("./message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../actions/register-user")} */
const registerUser = sync.require("../actions/register-user")
/** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* IMPORTANT: This may not have all the normal fields! The API documentation doesn't provide possible types, just says it's all optional!
* Since I don't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don't change anything later...
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
*/
async function editToChanges(message, guild, api) {
// Figure out what events we will be replacing
const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(message.channel_id)
/** @type {string?} */
let senderMxid = db.prepare("SELECT mxid FROM sim WHERE discord_id = ?").pluck().get(message.author.id) || null
if (senderMxid) {
const senderIsInRoom = db.prepare("SELECT * FROM sim_member WHERE room_id = ? and mxid = ?").get(roomID, senderMxid)
if (!senderIsInRoom) {
senderMxid = null // just send as ooye bot
}
}
/** @type {{event_id: string, event_type: string, event_subtype: string?, part: number}[]} */
const oldEventRows = db.prepare("SELECT event_id, event_type, event_subtype, part FROM event_message WHERE message_id = ?").all(message.id)
// Figure out what we will be replacing them with
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api})
assert.ok(newFallbackContent.length === newInnerContent.length)
// Match the new events to the old events
/*
Rules:
+ The events must have the same type.
+ The events must have the same subtype.
Events will therefore be divided into four categories:
*/
/** 1. Events that are matched, and should be edited by sending another m.replace event */
let eventsToReplace = []
/** 2. Events that are present in the old version only, and should be blanked or redacted */
let eventsToRedact = []
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
let eventsToSend = []
// 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing.
function shift() {
newFallbackContent.shift()
newInnerContent.shift()
}
// For each old event...
outer: while (newFallbackContent.length) {
const newe = newFallbackContent[0]
// Find a new event to pair it with...
for (let i = 0; i < oldEventRows.length; i++) {
const olde = oldEventRows[i]
if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype || null)) { // The spec does allow subtypes to change, so I can change this condition later if I want to
// Found one!
// Set up the pairing
eventsToReplace.push({
old: olde,
newFallbackContent: newFallbackContent[0],
newInnerContent: newInnerContent[0]
})
// These events have been handled now, so remove them from the source arrays
shift()
oldEventRows.splice(i, 1)
// Go all the way back to the start of the next iteration of the outer loop
continue outer
}
}
// If we got this far, we could not pair it to an existing event, so it'll have to be a new one
eventsToSend.push(newInnerContent[0])
shift()
}
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
eventsToRedact = oldEventRows
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
eventsToReplace = eventsToReplace.filter(ev => {
// Discord does not allow files, images, attachments, or videos to be edited.
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote") {
return false
}
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
return false
}
// Anything else is fair game.
return true
})
// Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.event_id)
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid}
}
/**
* @template T
* @param {string} oldID
* @param {T} newFallbackContent
* @param {T} newInnerContent
* @returns {import("../../types").Event.ReplacementContent<T>} content
*/
function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) {
const content = {
...newFallbackContent,
"m.mentions": {},
"m.new_content": {
...newInnerContent
},
"m.relates_to": {
rel_type: "m.replace",
event_id: oldID
}
}
delete content["m.new_content"]["$type"]
// Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored.
delete content["m.new_content"]["m.relates_to"]
return content
}
module.exports.editToChanges = editToChanges
module.exports.makeReplacementEventContent = makeReplacementEventContent

View file

@ -0,0 +1,159 @@
const {test} = require("supertape")
const {editToChanges} = require("./edit-to-changes")
const data = require("../../test/data")
const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* test 2",
"m.mentions": {},
"m.new_content": {
// *** Replaced With: ***
msgtype: "m.text",
body: "test 2",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10"
}
}
}])
t.equal(senderMxid, null)
})
test("edit2changes: bot response", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.bot_response, data.guild.general, {
async getJoinedMembers(roomID) {
t.equal(roomID, "!uCtjHhfGlYbVnPVlkG:cadence.moe")
return new Promise(resolve => {
setTimeout(() => {
resolve({
joined: {
"@cadence:cadence.moe": {
display_name: "cadence [they]",
avatar_url: "whatever"
},
"@_ooye_botrac4r:cadence.moe": {
display_name: "botrac4r",
avatar_url: "whatever"
}
}
})
})
})
}
})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
formatted_body: '* <img src="mxc://cadence.moe/551636841284108289" data-mx-emoticon alt=":ae_botrac4r:" title=":ae_botrac4r:" height="24"> @cadence asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img src="mxc://cadence.moe/362741439211503616" data-mx-emoticon alt=":bn_re:" title=":bn_re:" height="24"> to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred*
},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
formatted_body: '<img src="mxc://cadence.moe/551636841284108289" data-mx-emoticon alt=":ae_botrac4r:" title=":ae_botrac4r:" height="24"> @cadence asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img src="mxc://cadence.moe/362741439211503616" data-mx-emoticon alt=":bn_re:" title=":bn_re:" height="24"> to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event
"user_ids": ["@cadence:cadence.moe"]
}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY"
}
}
}])
t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe")
})
test("edit2changes: remove caption from image", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {})
t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [])
})
test("edit2changes: add caption back to that image", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.text",
body: "some text",
"m.mentions": {}
}])
t.deepEqual(eventsToReplace, [])
})
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* only the content can be edited",
"m.mentions": {},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: "only the content can be edited",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
}
}
}])
})
test("edit2changes: edit of reply to skull webp attachment with content", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "> Extremity: Image\n\n* Edit",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
+ '<br>Image</blockquote></mx-reply>'
+ '* Edit',
"m.mentions": {},
"m.new_content": {
msgtype: "m.text",
body: "Edit",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M"
}
}
}])
})

View file

@ -0,0 +1,40 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Amanda"
}])
})

View file

@ -1,26 +1,344 @@
// @ts-check
const assert = require("assert").strict
const markdown = require("discord-markdown")
const pb = require("prettier-bytes")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const { sync, db, discord } = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
const reg = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
function getDiscordParseCallbacks(message, useHTML) {
return {
/** @param {{id: string, type: "discordUser"}} node */
user: node => {
const mxid = db.prepare("SELECT mxid FROM sim WHERE discord_id = ?").pluck().get(node.id)
const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id
if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
} else {
return `@${username}:`
}
},
/** @param {{id: string, type: "discordChannel"}} node */
channel: node => {
const row = db.prepare("SELECT room_id, name, nick FROM channel_room WHERE channel_id = ?").get(node.id)
if (!row) {
return `<#${node.id}>` // fallback for when this channel is not bridged
} else if (useHTML) {
return `<a href="https://matrix.to/#/${row.room_id}">#${row.nick || row.name}</a>`
} else {
return `#${row.nick || row.name}`
}
},
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
emoji: node => {
if (useHTML) {
// TODO: upload the emoji and actually use the right mxc!!
return `<img src="mxc://cadence.moe/${node.id}" data-mx-emoticon alt=":${node.name}:" title=":${node.name}:" height="24">`
} else {
return `:${node.name}:`
}
},
role: node =>
"@&" + node.id,
everyone: node =>
"@room",
here: node =>
"@here"
}
}
/**
* @param {import("discord-api-types/v10").APIMessage} message
* @returns {import("../../types").M_Room_Message_content}
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* - includeReplyFallback: true
* - includeEditFallbackStar: false
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
module.exports = function messageToEvent(message) {
const body = message.content
const html = markdown.toHTML(body, {
/* discordCallback: {
user: Function,
channel: Function,
role: Function,
everyone: Function,
here: Function
} */
async function messageToEvent(message, guild, options = {}, di) {
const events = []
if (message.type === DiscordTypes.MessageType.ThreadCreated) {
// This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off.
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
// [#] NICKNAME started a thread: __THREAD NAME__. __See all threads__
// We're already bridging the THREAD_CREATED gateway event to make a comparable message, so drop this one.
return []
}
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
// This is the message that appears at the top of a thread when the thread was based off an existing message.
// It's just a message reference, no content.
const ref = message.message_reference
assert(ref)
assert(ref.message_id)
const row = db.prepare("SELECT room_id, event_id FROM event_message INNER JOIN channel_room USING (channel_id) WHERE channel_id = ? AND message_id = ?").get(ref.channel_id, ref.message_id)
if (!row) return []
const event = await di.api.getEvent(row.room_id, row.event_id)
return [{
...event.content,
$type: event.type
}]
}
/**
@type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions:
1. A discord user rich-replies to a matrix user with a text post
+ The matrix user needs to be m.mentioned in the text event
+ The matrix user needs to have their name/mxid/link in the text event (notification fallback)
- So prepend their `@name:` to the start of the plaintext body
2. A discord user rich-replies to a matrix user with an image event only
+ The matrix user needs to be m.mentioned in the image event
+ TODO The matrix user needs to have their name/mxid in the image event's body field, alongside the filename (notification fallback)
- So append their name to the filename body, I guess!!!
3. A discord user `@`s a matrix user in the text body of their text box
+ The matrix user needs to be m.mentioned in the text event
+ No change needed to the text event content: it already has their name
- So make sure we don't do anything in this case.
*/
const mentions = {}
let repliedToEventId = null
let repliedToEventRoomId = null
let repliedToEventSenderMxid = null
let repliedToEventOriginallyFromMatrix = false
function addMention(mxid) {
if (!mentions.user_ids) mentions.user_ids = []
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
}
// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
// (Still need to do scenarios 1 and 2 part B, and scenario 3.)
if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
const row = db.prepare("SELECT event_id, room_id, source FROM event_message INNER JOIN channel_room USING (channel_id) WHERE message_id = ? AND part = 0").get(message.message_reference.message_id)
if (row) {
repliedToEventId = row.event_id
repliedToEventRoomId = row.room_id
repliedToEventOriginallyFromMatrix = row.source === 0 // source 0 = matrix
}
}
if (repliedToEventOriginallyFromMatrix) {
// Need to figure out who sent that event...
const event = await di.api.getEvent(repliedToEventRoomId, repliedToEventId)
repliedToEventSenderMxid = event.sender
// Need to add the sender to m.mentions
addMention(repliedToEventSenderMxid)
}
let msgtype = "m.text"
// Handle message type 4, channel name changed
if (message.type === DiscordTypes.MessageType.ChannelNameChange) {
msgtype = "m.emote"
message.content = "changed the channel name to **" + message.content + "**"
}
// Text content appears first
if (message.content) {
let content = message.content
content = content.replace(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/([0-9]+)\/([0-9]+)\/([0-9]+)/, (whole, guildID, channelID, messageID) => {
const row = db.prepare("SELECT room_id, event_id FROM event_message INNER JOIN channel_room USING (channel_id) WHERE channel_id = ? AND message_id = ? AND part = 0").get(channelID, messageID)
if (row) {
return `https://matrix.to/#/${row.room_id}/${row.event_id}`
} else {
return `${whole} [event not found]`
}
})
let html = markdown.toHTML(content, {
discordCallback: getDiscordParseCallbacks(message, true)
}, null, null)
return {
msgtype: "m.text",
body: body,
// TODO: add a string return type to my discord-markdown library
let body = markdown.toHTML(content, {
discordCallback: getDiscordParseCallbacks(message, false),
discordOnly: true,
escapeHTML: false,
}, null, null)
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [...content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(message.channel_id)
const {joined} = await di.api.getJoinedMembers(roomID)
for (const [mxid, member] of Object.entries(joined)) {
if (!userRegex.some(rx => mxid.match(rx))) {
const localpart = mxid.match(/@([^:]*)/)
assert(localpart)
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(member.display_name.toLowerCase())) addMention(mxid)
}
}
}
// Star * prefix for fallback edits
if (options.includeEditFallbackStar) {
body = "* " + body
html = "* " + html
}
// Fallback body/formatted_body for replies
// This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run
if (repliedToEventId && options.includeReplyFallback !== false) {
let repliedToDisplayName
let repliedToUserHtml
if (repliedToEventOriginallyFromMatrix && repliedToEventSenderMxid) {
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
assert(match)
repliedToDisplayName = match[1] || "a Matrix user" // grab the localpart as the display name, whatever
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
} else {
repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
repliedToUserHtml = repliedToDisplayName
}
let repliedToContent = message.referenced_message?.content
if (repliedToContent == "") repliedToContent = "[Media]"
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
const repliedToHtml = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, true)
}, null, null)
const repliedToBody = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, false),
discordOnly: true,
escapeHTML: false,
}, null, null)
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRoomId}/${repliedToEventId}">In reply to</a> ${repliedToUserHtml}`
+ `<br>${repliedToHtml}</blockquote></mx-reply>`
+ html
body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
+ "\n\n" + body
}
const newTextMessageEvent = {
$type: "m.room.message",
"m.mentions": mentions,
msgtype,
body: body
}
const isPlaintext = body === html
if (!isPlaintext) {
Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html",
formatted_body: html
})
}
events.push(newTextMessageEvent)
}
// Then attachments
const attachmentEvents = await Promise.all(message.attachments.map(async attachment => {
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️"
: attachment.content_type?.startsWith("video/") ? "🎞️"
: attachment.content_type?.startsWith("text/") ? "📝"
: attachment.content_type?.startsWith("audio/") ? "🎶"
: "📄"
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
if (attachment.size > reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${attachment.url}">${attachment.filename}</a> (${pb(attachment.size)})`
}
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.filename,
// TODO: filename: attachment.filename and then use body as the caption
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `Unsupported attachment:\n${JSON.stringify(attachment, null, 2)}\n${attachment.url}`
}
}
}))
events.push(...attachmentEvents)
// Then stickers
if (message.sticker_items) {
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
const format = file.stickerFormat.get(stickerItem.format_type)
if (format?.mime) {
let body = stickerItem.name
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}`
return {
$type: "m.sticker",
"m.mentions": mentions,
body,
info: {
mimetype: format.mime
},
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
}
} else {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: "Unsupported sticker format. Name: " + stickerItem.name
}
}
}))
events.push(...stickerEvents)
}
// Rich replies
if (repliedToEventId) {
Object.assign(events[0], {
"m.relates_to": {
"m.in_reply_to": {
event_id: repliedToEventId
}
}
})
}
return events
}
module.exports.messageToEvent = messageToEvent

View file

@ -0,0 +1,365 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
test("message2event: simple plaintext", async t => {
const events = await messageToEvent(data.message.simple_plaintext, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "ayy lmao"
}])
})
test("message2event: simple plaintext with quotes", async t => {
const events = await messageToEvent(data.message.simple_plaintext_with_quotes, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: `then he said, "you and her aren't allowed in here!"`
}])
})
test("message2event: simple user mention", async t => {
const events = await messageToEvent(data.message.simple_user_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "@crunch god: Tell me about Phil, renowned martial arts master and creator of the Chin Trick",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/@_ooye_crunch_god:cadence.moe">@crunch god</a> Tell me about Phil, renowned martial arts master and creator of the Chin Trick'
}])
})
test("message2event: simple room mention", async t => {
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#main",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe">#main</a>'
}])
})
test("message2event: simple message link", async t => {
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg</a>'
}])
})
test("message2event: attachment with no content", async t => {
const events = await messageToEvent(data.message.attachment_no_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png",
info: {
mimetype: "image/png",
w: 466,
h: 85,
size: 12919,
},
}])
})
test("message2event: stickers", async t => {
const events = await messageToEvent(data.message.sticker, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "can have attachments too"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
info: {
mimetype: "image/png",
w: 333,
h: 287,
size: 127373,
},
}, {
$type: "m.sticker",
"m.mentions": {},
body: "pomu puff - damn that tiny lil bitch really chuffing. puffing that fat ass dart",
info: {
mimetype: "image/png"
// thumbnail_url
// thumbnail_info
},
url: "mxc://cadence.moe/UuUaLwXhkxFRwwWCXipDlBHn"
}])
})
test("message2event: skull webp attachment with content", async t => {
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Image"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "skull.webp",
info: {
w: 1200,
h: 628,
mimetype: "image/webp",
size: 74290
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}])
})
test("message2event: reply to skull webp attachment with content", async t => {
const events = await messageToEvent(data.message.reply_to_skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q"
}
},
"m.mentions": {},
msgtype: "m.text",
body: "> Extremity: Image\n\nReply",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
+ '<br>Image</blockquote></mx-reply>'
+ 'Reply'
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "RDT_20230704_0936184915846675925224905.jpg",
info: {
w: 2048,
h: 1536,
mimetype: "image/jpeg",
size: 85906
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}])
})
test("message2event: simple reply to matrix user", async t => {
const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
},
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "> cadence: so can you reply to my webhook uwu\n\nReply",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence</a>'
+ '<br>so can you reply to my webhook uwu</blockquote></mx-reply>'
+ 'Reply'
}])
})
test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => {
const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
},
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "Reply"
}])
})
test("message2event: simple written @mentions for matrix users", async t => {
let called = 0
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
api: {
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return new Promise(resolve => {
setTimeout(() => {
resolve({
joined: {
"@cadence:cadence.moe": {
display_name: "cadence [they]",
avatar_url: "whatever"
},
"@huckleton:cadence.moe": {
display_name: "huck",
avatar_url: "whatever"
},
"@_ooye_botrac4r:cadence.moe": {
display_name: "botrac4r",
avatar_url: "whatever"
},
"@_ooye_bot:cadence.moe": {
display_name: "Out Of Your Element",
avatar_url: "whatever"
}
}
})
})
})
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {
user_ids: [
"@cadence:cadence.moe",
"@huckleton:cadence.moe"
]
},
msgtype: "m.text",
body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck"
}])
t.equal(called, 1, "should only look up the member list once")
})
test("message2event: very large attachment is linked instead of being uploaded", async t => {
const events = await messageToEvent({
content: "hey",
attachments: [{
filename: "hey.jpg",
url: "https://discord.com/404/hey.jpg",
content_type: "application/i-made-it-up",
size: 100e6
}]
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "hey"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)",
format: "org.matrix.custom.html",
formatted_body: '📄 Uploaded file: <a href="https://discord.com/404/hey.jpg">hey.jpg</a> (100 MB)'
}])
})
test("message2event: type 4 channel name change", async t => {
const events = await messageToEvent(data.special_message.thread_name_change, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.emote",
body: "changed the channel name to **worming**",
format: "org.matrix.custom.html",
formatted_body: "changed the channel name to <strong>worming</strong>"
}])
})
test("message2event: thread start message reference", async t => {
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!PnyBKvUBOhjuCucEfk:cadence.moe", "$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo", {
"type": "m.room.message",
"sender": "@_ooye_cadence:cadence.moe",
"content": {
"m.mentions": {},
"msgtype": "m.text",
"body": "layer 4"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "layer 4",
"m.mentions": {}
}])
})

View file

@ -0,0 +1,46 @@
// @ts-check
const assert = require("assert")
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../../matrix/read-registration")} */
const reg = sync.require("../../matrix/read-registration.js")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
/**
* @param {string} parentRoomID
* @param {string} threadRoomID
* @param {string?} creatorMxid
* @param {import("discord-api-types/v10").APIThreadChannel} thread
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
/** @type {string?} */
const branchedFromEventID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ?").pluck().get(thread.id)
/** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */
const context = {}
if (branchedFromEventID) {
// Need to figure out who sent that event...
const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}}
if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]}
}
const msgtype = creatorMxid ? "m.emote" : "m.text"
const template = creatorMxid ? "started a thread:" : "Thread started:"
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}`
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}">${thread.name}</a>`
return {
msgtype,
body,
format: "org.matrix.custom.html",
formatted_body: html,
"m.mentions": {},
...context
}
}
module.exports.threadToAnnouncement = threadToAnnouncement

View file

@ -0,0 +1,150 @@
const {test} = require("supertape")
const {threadToAnnouncement} = require("./thread-to-announcement")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
test("thread2announcement: no known creator, no branched from event", async t => {
const content = await threadToAnnouncement("!parent", "!thread", null, {
name: "test thread",
id: "-1"
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {}
})
})
test("thread2announcement: known creator, no branched from event", async t => {
const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", {
name: "test thread",
id: "-1"
})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {}
})
})
test("thread2announcement: no known creator, branched from discord event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, {
name: "test thread",
id: "1126786462646550579"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", {
type: 'm.room.message',
sender: '@_ooye_bot:cadence.moe',
content: {
msgtype: 'm.text',
body: 'testing testing testing'
}
})
}
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
}
}
})
})
test("thread2announcement: known creator, branched from discord event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", {
name: "test thread",
id: "1126786462646550579"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", {
type: 'm.room.message',
sender: '@_ooye_bot:cadence.moe',
content: {
msgtype: 'm.text',
body: 'testing testing testing'
}
})
}
})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
}
}
})
})
test("thread2announcement: no known creator, branched from matrix event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, {
name: "test thread",
id: "1128118177155526666"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
})
}
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
}
})
})

View file

@ -0,0 +1,77 @@
// @ts-check
const assert = require("assert")
const passthrough = require("../../passthrough")
const { sync, db } = passthrough
/**
* Downcased and stripped username. Can only include a basic set of characters.
* https://spec.matrix.org/v1.6/appendices/#user-identifiers
* @param {import("discord-api-types/v10").APIUser} user
* @returns {string} localpart
*/
function downcaseUsername(user) {
// First, try to convert the username to the set of allowed characters
let downcased = user.username.toLowerCase()
// spaces to underscores...
.replace(/ /g, "_")
// remove disallowed characters...
.replace(/[^a-z0-9._=/-]*/g, "")
// remove leading and trailing dashes and underscores...
.replace(/(?:^[_-]*|[_-]*$)/g, "")
// The new length must be at least 2 characters (in other words, it should have some content)
if (downcased.length < 2) {
downcased = user.id
}
return downcased
}
/** @param {string[]} preferences */
function* generateLocalpartAlternatives(preferences) {
const best = preferences[0]
assert.ok(best)
// First, suggest the preferences...
for (const localpart of preferences) {
yield localpart
}
// ...then fall back to generating number suffixes...
let i = 2
while (true) {
yield best + (i++)
/* c8 ignore next */
}
}
/**
* Whole process for checking the database and generating the right sim name.
* It is very important this is not an async function: once the name has been chosen, the calling function should be able to immediately claim that name into the database in the same event loop tick.
* @param {import("discord-api-types/v10").APIUser} user
* @returns {string}
*/
function userToSimName(user) {
assert.notEqual(user.discriminator, "0000", "cannot create user for a webhook")
// 1. Is sim user already registered?
const existing = db.prepare("SELECT sim_name FROM sim WHERE discord_id = ?").pluck().get(user.id)
if (existing) return existing
// 2. Register based on username (could be new or old format)
const downcased = downcaseUsername(user)
const preferences = [downcased]
if (user.discriminator.length === 4) { // Old style tag? If user.username is unavailable, try the full tag next
preferences.push(downcased + user.discriminator)
}
// Check for conflicts with already registered sims
/** @type {string[]} */
const matches = db.prepare("SELECT sim_name FROM sim WHERE sim_name LIKE ? ESCAPE '@'").pluck().all(downcased + "%")
// Keep generating until we get a suggestion that doesn't conflict
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}"`)
}
module.exports.userToSimName = userToSimName

View file

@ -0,0 +1,41 @@
const {test} = require("supertape")
const tryToCatch = require("try-to-catch")
const assert = require("assert")
const {userToSimName} = require("./user-to-mxid")
test("user2name: cannot create user for a webhook", async t => {
const [error] = await tryToCatch(() => userToSimName({discriminator: "0000"}))
t.ok(error instanceof assert.AssertionError, error.message)
})
test("user2name: works on normal name", t => {
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001"}), "harry_styles")
})
test("user2name: works on emojis", t => {
t.equal(userToSimName({username: "🍪 Cookie Monster 🍪", discriminator: "0001"}), "cookie_monster")
})
test("user2name: works on single emoji at the end", t => {
t.equal(userToSimName({username: "Amanda 🎵", discriminator: "2192"}), "amanda")
})
test("user2name: works on crazy name", t => {
t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7//")
})
test("user2name: adds discriminator if name is unavailable (old tag format)", t => {
t.equal(userToSimName({username: "BOT$", discriminator: "1234"}), "bot1234")
})
test("user2name: adds number suffix if name is unavailable (new username format)", t => {
t.equal(userToSimName({username: "bot", discriminator: "0"}), "bot2")
})
test("user2name: uses ID if name becomes too short", t => {
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "9"}), "9")
})
test("user2name: uses ID when name has only disallowed characters", t => {
t.equal(userToSimName({username: "!@#$%^&*", discriminator: "0001", id: "9"}), "9")
})

View file

@ -12,8 +12,9 @@ const discordPackets = sync.require("./discord-packets")
class DiscordClient {
/**
* @param {string} discordToken
* @param {boolean} listen whether to set up the event listeners for OOYE to operate
*/
constructor(discordToken) {
constructor(discordToken, listen = true) {
this.discordToken = discordToken
this.snow = new SnowTransfer(discordToken)
this.cloud = new CloudStorm(discordToken, {
@ -43,7 +44,9 @@ class DiscordClient {
this.guilds = new Map()
/** @type {Map<string, Array<string>>} */
this.guildChannelMap = new Map()
if (listen) {
this.cloud.on("event", message => discordPackets.onPacket(this, message))
}
this.cloud.on("error", console.error)
}
}

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

@ -2,18 +2,21 @@
// Discord library internals type beat
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough")
const { sync } = passthrough
/** @type {typeof import("./event-dispatcher")} */
const eventDispatcher = sync.require("./event-dispatcher")
const utils = {
/**
* @param {import("./discord-client")} client
* @param {import("cloudstorm").IGatewayMessage} message
*/
onPacket(client, message) {
async onPacket(client, message) {
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
/** @type {typeof import("./event-dispatcher")} */
const eventDispatcher = sync.require("./event-dispatcher")
// Client internals, keep track of the state we need
if (message.t === "READY") {
if (client.ready) return
client.ready = true
@ -27,9 +30,26 @@ const utils = {
const arr = []
client.guildChannelMap.set(message.d.id, arr)
for (const channel of message.d.channels || []) {
// @ts-ignore
channel.guild_id = message.d.id
arr.push(channel.id)
client.channels.set(channel.id, channel)
}
for (const thread of message.d.threads || []) {
// @ts-ignore
thread.guild_id = message.d.id
arr.push(thread.id)
client.channels.set(thread.id, thread)
}
eventDispatcher.checkMissedMessages(client, message.d)
} else if (message.t === "THREAD_CREATE") {
client.channels.set(message.d.id, message.d)
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
client.channels.set(message.d.id, message.d)
} else if (message.t === "GUILD_DELETE") {
@ -58,14 +78,38 @@ const utils = {
}
}
}
}
// Event dispatcher for OOYE bridge operations
try {
if (message.t === "GUILD_UPDATE") {
await eventDispatcher.onGuildUpdate(client, message.d)
} else if (message.t === "CHANNEL_UPDATE") {
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false)
} else if (message.t === "THREAD_CREATE") {
// @ts-ignore
await eventDispatcher.onThreadCreate(client, message.d)
} else if (message.t === "THREAD_UPDATE") {
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true)
} else if (message.t === "MESSAGE_CREATE") {
eventDispatcher.onMessageCreate(client, message.d)
await eventDispatcher.onMessageCreate(client, message.d)
} else if (message.t === "MESSAGE_UPDATE") {
await eventDispatcher.onMessageUpdate(client, message.d)
} else if (message.t === "MESSAGE_DELETE") {
await eventDispatcher.onMessageDelete(client, message.d)
} else if (message.t === "MESSAGE_REACTION_ADD") {
eventDispatcher.onReactionAdd(client, message.d)
await eventDispatcher.onReactionAdd(client, message.d)
}
} catch (e) {
// Let OOYE try to handle errors too
eventDispatcher.onError(client, e, message)
}
}
}

View file

@ -1,27 +1,210 @@
// @ts-check
const assert = require("assert").strict
const util = require("util")
const {sync, db} = require("../passthrough")
/** @type {import("./actions/send-message")}) */
const sendMessage = sync.require("./actions/send-message")
/** @type {import("./actions/edit-message")}) */
const editMessage = sync.require("./actions/edit-message")
/** @type {import("./actions/delete-message")}) */
const deleteMessage = sync.require("./actions/delete-message")
/** @type {import("./actions/add-reaction")}) */
const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./actions/announce-thread")}) */
const announceThread = sync.require("./actions/announce-thread")
/** @type {import("./actions/create-room")}) */
const createRoom = sync.require("./actions/create-room")
/** @type {import("./actions/create-space")}) */
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
function isGuildAllowed(guildID) {
return ["112760669178241024", "497159726455455754", "1100319549670301727"].includes(guildID)
}
// Grab Discord events we care about for the bridge, check them, and pass them on
const sendMessage = require("./actions/send-message")
module.exports = {
/**
* @param {import("./discord-client")} client
* @param {Error} e
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage
*/
onError(client, e, gatewayMessage) {
console.error("hit event-dispatcher's error handler with this exception:")
console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later
console.error(`while handling this ${gatewayMessage.t} gateway event:`)
console.dir(gatewayMessage.d, {depth: null})
if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now()
const channelID = gatewayMessage.d.channel_id
if (!channelID) return
const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(channelID)
if (!roomID) return
let stackLines = e.stack.split("\n")
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
if (cloudstormLine !== -1) {
stackLines = stackLines.slice(0, cloudstormLine - 2)
}
api.sendEvent(roomID, "m.room.message", {
msgtype: "m.text",
body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.",
format: "org.matrix.custom.html",
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
+ `<br>Gateway event: ${gatewayMessage.t}`
+ `<br>${e.toString()}`
+ `<details><summary>Error trace</summary>`
+ `<pre>${stackLines.join("\n")}</pre></details>`
+ `<details><summary>Original payload</summary>`
+ `<pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`,
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
}
})
},
/**
* When logging back in, check if we missed any conversations in any channels. Bridge up to 49 missed messages per channel.
* If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history?
* This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged.
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").GatewayGuildCreateDispatchData} guild
*/
async checkMissedMessages(client, guild) {
if (guild.unavailable) return
const bridgedChannels = db.prepare("SELECT channel_id FROM channel_room").pluck().all()
const prepared = db.prepare("SELECT message_id FROM event_message WHERE channel_id = ? AND message_id = ?").pluck()
for (const channel of guild.channels.concat(guild.threads)) {
if (!bridgedChannels.includes(channel.id)) continue
if (!channel.last_message_id) continue
const latestWasBridged = prepared.get(channel.id, channel.last_message_id)
if (latestWasBridged) continue
/** More recent messages come first. */
console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
const messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50})
let latestBridgedMessageIndex = messages.findIndex(m => {
return prepared.get(channel.id, m.id)
})
console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`)
if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date.
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
const simulatedGatewayDispatchData = {
guild_id: guild.id,
mentions: [],
...messages[i]
}
await module.exports.onMessageCreate(client, simulatedGatewayDispatchData)
}
}
},
/**
* Announces to the parent room that the thread room has been created.
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").APIThreadChannel} thread
*/
async onThreadCreate(client, thread) {
const parentRoomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(thread.parent_id)
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
},
/**
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").GatewayGuildUpdateDispatchData} guild
*/
async onGuildUpdate(client, guild) {
const spaceID = db.prepare("SELECT space_id FROM guild_space WHERE guild_id = ?").pluck().get(guild.id)
if (!spaceID) return
await createSpace.syncSpace(guild.id)
},
/**
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").GatewayChannelUpdateDispatchData} channelOrThread
* @param {boolean} isThread
*/
async onChannelOrThreadUpdate(client, channelOrThread, isThread) {
const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").get(channelOrThread.id)
if (!roomID) return // No target room to update the data on
await createRoom.syncRoom(channelOrThread.id)
},
/**
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
*/
onMessageCreate(client, message) {
console.log(message)
console.log(message.guild_id)
console.log(message.member)
sendMessage(message)
async onMessageCreate(client, message) {
if (message.webhook_id) {
const row = db.prepare("SELECT webhook_id FROM webhook WHERE webhook_id = ?").pluck().get(message.webhook_id)
if (row) {
// The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
return
}
}
/** @type {import("discord-api-types/v10").APIGuildChannel} */
const channel = client.channels.get(message.channel_id)
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 discordCommandHandler.execute(message, channel, guild)
},
/**
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").GatewayMessageUpdateDispatchData} data
*/
async onMessageUpdate(client, data) {
if (data.webhook_id) {
const row = db.prepare("SELECT webhook_id FROM webhook WHERE webhook_id = ?").pluck().get(data.webhook_id)
if (row) {
// The update was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
return
}
}
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
// If the message content is a string then it includes all interesting fields and is meaningful.
if (typeof data.content === "string") {
/** @type {import("discord-api-types/v10").GatewayMessageCreateDispatchData} */
const message = data
/** @type {import("discord-api-types/v10").APIGuildChannel} */
const channel = client.channels.get(message.channel_id)
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 editMessage.editMessage(message, guild)
}
},
/**
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data
*/
onReactionAdd(client, data) {
console.log(data)
return {}
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)
},
/**
* @param {import("./discord-client")} client
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
*/
async onMessageDelete(client, data) {
await deleteMessage.deleteMessage(data)
}
}

116
db/data-for-test.sql Normal file
View file

@ -0,0 +1,116 @@
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "guild_space" (
"guild_id" TEXT NOT NULL UNIQUE,
"space_id" TEXT NOT NULL UNIQUE,
PRIMARY KEY("guild_id")
);
CREATE TABLE IF NOT EXISTS "file" (
"discord_url" TEXT NOT NULL UNIQUE,
"mxc_url" TEXT NOT NULL UNIQUE,
PRIMARY KEY("discord_url")
);
CREATE TABLE IF NOT EXISTS "sim" (
"discord_id" TEXT NOT NULL UNIQUE,
"sim_name" TEXT NOT NULL UNIQUE,
"localpart" TEXT NOT NULL UNIQUE,
"mxid" TEXT NOT NULL UNIQUE,
PRIMARY KEY("discord_id")
);
CREATE TABLE IF NOT EXISTS "sim_member" (
"mxid" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
"profile_event_content_hash" BLOB,
PRIMARY KEY("mxid","room_id")
);
CREATE TABLE IF NOT EXISTS "webhook" (
"channel_id" TEXT NOT NULL UNIQUE,
"webhook_id" TEXT NOT NULL UNIQUE,
"webhook_token" TEXT NOT NULL,
PRIMARY KEY("channel_id")
);
CREATE TABLE IF NOT EXISTS "channel_room" (
"channel_id" TEXT NOT NULL UNIQUE,
"room_id" TEXT NOT NULL UNIQUE,
"name" TEXT,
"nick" TEXT,
"thread_parent" TEXT,
"custom_avatar" TEXT,
PRIMARY KEY("channel_id")
);
CREATE TABLE IF NOT EXISTS "event_message" (
"event_id" TEXT NOT NULL,
"event_type" TEXT,
"event_subtype" TEXT,
"message_id" TEXT NOT NULL,
"channel_id" TEXT,
"part" INTEGER NOT NULL,
"source" INTEGER NOT NULL,
PRIMARY KEY("event_id","message_id")
);
CREATE TABLE IF NOT EXISTS "member_cache" (
"room_id" TEXT NOT NULL,
"mxid" TEXT NOT NULL,
"displayname" TEXT,
"avatar_url" TEXT,
PRIMARY KEY("room_id", "mxid")
);
COMMIT;
BEGIN TRANSACTION;
INSERT INTO guild_space (guild_id, space_id) VALUES
('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe');
INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES
('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),
('497161350934560778', '!edUxjVdzgUvXDUIQCK:cadence.moe', 'amanda-spam', NULL, NULL, NULL),
('160197704226439168', '!uCtjHhfGlYbVnPVlkG:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL),
('1100319550446252084', '!PnyBKvUBOhjuCucEfk:cadence.moe', 'worm-farm', NULL, NULL, NULL);
INSERT INTO sim (discord_id, sim_name, localpart, mxid) VALUES
('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'),
('820865262526005258', 'crunch_god', '_ooye_crunch_god', '@_ooye_crunch_god:cadence.moe'),
('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'),
('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'),
('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'),
('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe');;
INSERT INTO sim_member (mxid, room_id, profile_event_content_hash) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!uCtjHhfGlYbVnPVlkG:cadence.moe', NULL);
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', '112760669178241024', 0, 1),
('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', '112760669178241024', 0, 0),
('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', '497161350934560778', 0, 1),
('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', '160197704226439168', 0, 1),
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', '112760669178241024', 0, 1),
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', '112760669178241024', 1, 1),
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ', 'm.room.message', 'm.image', '1141501302736695317', '112760669178241024', 0, 1),
('$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M', 'm.room.message', 'm.text', '1128084851279536279', '112760669178241024', 0, 1),
('$YUJFa5j0ZJe7PUvD2DykRt9g51RoadUEYmuJLdSEbJ0', 'm.room.message', 'm.image', '1128084851279536279', '112760669178241024', 1, 1),
('$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q', 'm.room.message', 'm.text', '1128084748338741392', '112760669178241024', 0, 1),
('$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo', 'm.room.message', 'm.text', '1143121514925928541', '1100319550446252084', 0, 1),
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4', 'm.room.message', 'm.text', '1106366167788044450', '122155380120748034', 0, 1),
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', '122155380120748034', 0, 0),
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', '122155380120748034', 0, 0),
('$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04', 'm.room.message', 'm.text', '1144865310588014633', '687028734322147344', 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
('https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png', 'mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus'),
('https://cdn.discordapp.com/stickers/1106323941183717586.png', 'mxc://cadence.moe/UuUaLwXhkxFRwwWCXipDlBHn'),
('https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp', 'mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes'),
('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/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024', 'mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL');
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL),
('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL),
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU');
COMMIT;

View file

@ -1,25 +1,33 @@
// @ts-check
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const config = require("./config")
const passthrough = require("./passthrough")
const db = new sqlite("db/ooye.db")
const sync = new HeatSync()
Object.assign(passthrough, { config, sync })
Object.assign(passthrough, {config, sync, db})
const DiscordClient = require("./d2m/discord-client")
const discord = new DiscordClient(config.discordToken)
const discord = new DiscordClient(config.discordToken, true)
passthrough.discord = discord
const as = require("./m2d/appservice")
passthrough.as = as
sync.require("./m2d/event-dispatcher")
discord.snow.requestHandler.on("requestError", data => {
console.error("request error", data)
})
;(async () => {
await discord.cloud.connect()
console.log("Discord gateway started")
require("./stdin")
})()
// process.on("unhandledRejection", console.error)
// process.on("uncaughtException", console.error)

View file

@ -0,0 +1,40 @@
// @ts-check
const assert = require("assert").strict
const Ty = require("../../types")
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event
*/
async function addReaction(event) {
const channelID = db.prepare("SELECT channel_id FROM channel_room WHERE room_id = ?").pluck().get(event.room_id)
if (!channelID) return // We just assume the bridge has already been created
const messageID = db.prepare("SELECT message_id FROM event_message WHERE event_id = ? AND part = 0").pluck().get(event.content["m.relates_to"].event_id) // 0 = primary
if (!messageID) return // Nothing can be done if the parent message was never bridged.
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
let emoji = event.content["m.relates_to"].key // TODO: handle custom text or emoji reactions
let encoded = encodeURIComponent(emoji)
let encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "")
// https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ????????????
const forceTrimmedList = [
"%E2%AD%90" // ⭐
]
let discordPreferredEncoding =
( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed
: encodedTrimmed !== encoded && [...emoji].length === 2 ? encoded
: encodedTrimmed)
console.log("add reaction from matrix:", emoji, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding)
return discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding)
}
module.exports.addReaction = addReaction

View file

@ -0,0 +1,63 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, db} = passthrough
/**
* Look in the database to find webhook credentials for a channel.
* (Note that the credentials may be invalid and need to be re-created if the webhook was interfered with from outside.)
* @param {string} channelID
* @param {boolean} forceCreate create a new webhook no matter what the database says about the state
* @returns id and token for a webhook for that channel
*/
async function ensureWebhook(channelID, forceCreate = false) {
if (!forceCreate) {
/** @type {{id: string, token: string} | null} */
const row = db.prepare("SELECT webhook_id as id, webhook_token as token FROM webhook WHERE channel_id = ?").get(channelID)
if (row) {
return {created: false, ...row}
}
}
// If we got here, we need to create a new webhook.
const webhook = await discord.snow.webhook.createWebhook(channelID, {name: "Out Of Your Element: Matrix Bridge"})
assert(webhook.token)
db.prepare("REPLACE INTO webhook (channel_id, webhook_id, webhook_token) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.token)
return {
id: webhook.id,
token: webhook.token,
created: true
}
}
/**
* @param {string} channelID
* @param {(webhook: import("../../types").WebhookCreds) => Promise<T>} callback
* @returns Promise<T>
* @template T
*/
async function withWebhook(channelID, callback) {
const webhook = await ensureWebhook(channelID, false)
return callback(webhook).catch(e => {
// TODO: check if the error was webhook-related and if webhook.created === false, then: const webhook = ensureWebhook(channelID, true); return callback(webhook)
throw e
})
}
/**
* @param {string} channelID
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]}} data
* @param {string} [threadID]
*/
async function sendMessageWithWebhook(channelID, data, threadID) {
const result = await withWebhook(channelID, async webhook => {
return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID, disableEveryone: true})
})
return result
}
module.exports.ensureWebhook = ensureWebhook
module.exports.withWebhook = withWebhook
module.exports.sendMessageWithWebhook = sendMessageWithWebhook

49
m2d/actions/send-event.js Normal file
View file

@ -0,0 +1,49 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {sync, discord, db} = passthrough
/** @type {import("./channel-webhook")} */
const channelWebhook = sync.require("./channel-webhook")
/** @type {import("../converters/event-to-message")} */
const eventToMessage = sync.require("../converters/event-to-message")
/** @type {import("../../matrix/api")}) */
const api = sync.require("../../matrix/api")
/** @param {import("../../types").Event.Outer<any>} event */
async function sendEvent(event) {
// TODO: we just assume the bridge has already been created
const row = db.prepare("SELECT channel_id, thread_parent FROM channel_room WHERE room_id = ?").get(event.room_id)
let channelID = row.channel_id
let threadID = undefined
if (row.thread_parent) {
threadID = channelID
channelID = row.thread_parent // it's the thread's parent... get with the times...
}
// @ts-ignore
const guildID = discord.channels.get(channelID).guild_id
const guild = discord.guilds.get(guildID)
assert(guild)
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
const messages = await eventToMessage.eventToMessage(event, guild, {api})
assert(Array.isArray(messages)) // sanity
/** @type {DiscordTypes.APIMessage[]} */
const messageResponses = []
let eventPart = 0 // 0 is primary, 1 is supporting
for (const message of messages) {
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content.msgtype || null, messageResponse.id, channelID, eventPart) // source 0 = matrix
eventPart = 1 // TODO: use more intelligent algorithm to determine whether primary or supporting?
messageResponses.push(messageResponse)
}
return messageResponses
}
module.exports.sendEvent = sendEvent

8
m2d/appservice.js Normal file
View file

@ -0,0 +1,8 @@
const reg = require("../matrix/read-registration")
const AppService = require("matrix-appservice").AppService
const as = new AppService({
homeserverToken: reg.hs_token
})
as.listen(+(new URL(reg.url).port))
module.exports = as

View file

@ -0,0 +1,241 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const chunk = require("chunk-text")
const TurndownService = require("turndown")
const passthrough = require("../../passthrough")
const { sync, db, discord } = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../converters/utils")} */
const utils = sync.require("../converters/utils")
const BLOCK_ELEMENTS = [
"ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
"CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
"FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
"HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
"TFOOT", "TH", "THEAD", "TR", "UL"
]
function cleanAttribute (attribute) {
return attribute ? attribute.replace(/(\n+\s*)+/g, "\n") : ""
}
const turndownService = new TurndownService({
hr: "----",
headingStyle: "atx",
preformattedCode: true,
codeBlockStyle: "fenced"
})
turndownService.remove("mx-reply")
turndownService.addRule("strikethrough", {
filter: ["del", "s", "strike"],
replacement: function (content) {
return "~~" + content + "~~"
}
})
turndownService.addRule("underline", {
filter: ["u"],
replacement: function (content) {
return "__" + content + "__"
}
})
turndownService.addRule("blockquote", {
filter: "blockquote",
replacement: function (content) {
content = content.replace(/^\n+|\n+$/g, "")
content = content.replace(/^/gm, "> ")
return content
}
})
turndownService.addRule("inlineLink", {
filter: function (node, options) {
return (
options.linkStyle === "inlined" &&
node.nodeName === "A" &&
node.getAttribute("href")
)
},
replacement: function (content, node) {
if (node.getAttribute("data-user-id")) return `<@${node.getAttribute("data-user-id")}>`
if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>`
const href = node.getAttribute("href")
let title = cleanAttribute(node.getAttribute("title"))
if (title) title = ` "` + title + `"`
let brackets = ["", ""]
if (href.startsWith("https://matrix.to")) brackets = ["<", ">"]
return "[" + content + "](" + brackets[0] + href + title + brackets[1] + ")"
}
})
turndownService.addRule("fencedCodeBlock", {
filter: function (node, options) {
return (
options.codeBlockStyle === "fenced" &&
node.nodeName === "PRE" &&
node.firstChild &&
node.firstChild.nodeName === "CODE"
)
},
replacement: function (content, node, options) {
const className = node.firstChild.getAttribute("class") || ""
const language = (className.match(/language-(\S+)/) || [null, ""])[1]
const code = node.firstChild
const visibleCode = code.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "")
var fence = "```"
return (
fence + language + "\n" +
visibleCode +
"\n" + fence
)
}
})
/**
* @param {string} roomID
* @param {string} mxid
* @returns {Promise<{displayname?: string?, avatar_url?: string?}>}
*/
async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
const row = db.prepare("SELECT displayname, avatar_url FROM member_cache WHERE room_id = ? AND mxid = ?").get(roomID, mxid)
if (row) return row
return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null)
return event
}).catch(() => {
return {displayname: null, avatar_url: null}
})
}
/**
* @param {Ty.Event.Outer<Ty.Event.M_Room_Message>} event
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function eventToMessage(event, guild, di) {
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]})[]} */
let messages = []
let displayName = event.sender
let avatarURL = undefined
let replyLine = ""
// Extract a basic display name from the sender
const match = event.sender.match(/^@(.*?):/)
if (match) displayName = match[1]
// Try to extract an accurate display name and avatar URL from the member event
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
if (member.displayname) displayName = member.displayname
if (member.avatar_url) avatarURL = utils.getPublicUrlForMxc(member.avatar_url)
// Convert content depending on what the message is
let content = event.content.body // ultimate fallback
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
let input = event.content.formatted_body
if (event.content.msgtype === "m.emote") {
input = `* ${displayName} ${input}`
}
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
// input = input.replace(/ /g, "&nbsp;")
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
// Handling replies. We'll look up the data of the replied-to event from the Matrix homeserver.
await (async () => {
const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"].event_id
if (!repliedToEventId) return
const repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
if (!repliedToEvent) return
const row = db.prepare("SELECT channel_id, message_id FROM event_message WHERE event_id = ? ORDER BY part").get(repliedToEventId)
if (row) {
replyLine = `<:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
} else {
replyLine = `<:L1:1144820033948762203><:L2:1144820084079087647>`
}
const sender = repliedToEvent.sender
const senderName = sender.match(/@([^:]*)/)?.[1] || sender
const authorID = db.prepare("SELECT discord_id FROM sim WHERE mxid = ?").pluck().get(repliedToEvent.sender)
if (authorID) {
replyLine += `<@${authorID}>: `
} else {
replyLine += `Ⓜ️**${senderName}**: `
}
const repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
const contentPreviewChunks = chunk(repliedToContent.replace(/.*<\/mx-reply>/, "").replace(/(?:\n|<br>)+/g, " ").replace(/<[^>]+>/g, ""), 24)
const contentPreview = contentPreviewChunks.length > 1 ? contentPreviewChunks[0] + "..." : contentPreviewChunks[0]
replyLine += contentPreview + "\n"
})()
// Handling mentions of Discord users
input = input.replace(/("https:\/\/matrix.to\/#\/(@[^"]+)")>/g, (whole, attributeValue, mxid) => {
if (!utils.eventSenderIsFromDiscord(mxid)) return whole
const userID = db.prepare("SELECT discord_id FROM sim WHERE mxid = ?").pluck().get(mxid)
if (!userID) return whole
return `${attributeValue} data-user-id="${userID}">`
})
// Handling mentions of Discord rooms
input = input.replace(/("https:\/\/matrix.to\/#\/(![^"]+)")>/g, (whole, attributeValue, roomID) => {
const channelID = db.prepare("SELECT channel_id FROM channel_room WHERE room_id = ?").pluck().get(roomID)
if (!channelID) return whole
return `${attributeValue} data-channel-id="${channelID}">`
})
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
// But I should not count it if it's between block elements.
input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
// console.error(beforeContext, beforeTag, afterContext, afterTag)
if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
return "<br>"
}
beforeContext = beforeContext || ""
beforeTag = beforeTag || ""
afterContext = afterContext || ""
afterTag = afterTag || ""
if (!BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
return beforeContext + "<br>" + afterContext
} else {
return whole
}
})
// @ts-ignore bad type from turndown
content = turndownService.turndown(input)
// It's optimised for commonmark, we need to replace the space-space-newline with just newline
content = content.replace(/ \n/g, "\n")
} else {
// Looks like we're using the plaintext body!
// Markdown needs to be escaped
content = content.replace(/([*_~`#])/g, `\\$1`)
}
content = replyLine + content
// Split into 2000 character chunks
const chunks = chunk(content, 2000)
messages = messages.concat(chunks.map(content => ({
content,
username: displayName,
avatar_url: avatarURL
})))
return messages
}
module.exports.eventToMessage = eventToMessage

View file

@ -0,0 +1,586 @@
const {test} = require("supertape")
const {eventToMessage} = require("./event-to-message")
const data = require("../../test/data")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
function sameFirstContentAndWhitespace(t, a, b) {
const a2 = JSON.stringify(a[0].content)
const b2 = JSON.stringify(b[0].content)
t.equal(a2, b2)
}
test("event2message: body is used when there is no formatted_body", async t => {
t.deepEqual(
await eventToMessage({
content: {
body: "testing plaintext",
msgtype: "m.text"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "testing plaintext",
avatar_url: undefined
}]
)
})
test("event2message: any markdown in body is escaped", async t => {
t.deepEqual(
await eventToMessage({
content: {
body: "testing **special** ~~things~~ which _should_ *not* `trigger` @any <effects>",
msgtype: "m.text"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "testing \\*\\*special\\*\\* \\~\\~things\\~\\~ which \\_should\\_ \\*not\\* \\`trigger\\` @any <effects>",
avatar_url: undefined
}]
)
})
test("event2message: basic html is converted to markdown", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "this <strong>is</strong> a <strong><em>test</em></strong> of <del>formatting</del>"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "this **is** a **_test_** of ~~formatting~~",
avatar_url: undefined
}]
)
})
test("event2message: markdown syntax is escaped", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "this **is** an <strong><em>extreme</em></strong> \\*test\\* of"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "this \\*\\*is\\*\\* an **_extreme_** \\\\\\*test\\\\\\* of",
avatar_url: undefined
}]
)
})
test("event2message: html lines are bridged correctly", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "<p>paragraph one<br>line <em>two</em><br>line three<br><br>paragraph two\nline <em>two</em>\nline three\n\nparagraph three</p><p>paragraph four\nline two<br>line three\nline four</p>paragraph five"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "paragraph one\nline _two_\nline three\n\nparagraph two\nline _two_\nline three\n\nparagraph three\n\nparagraph four\nline two\nline three\nline four\n\nparagraph five",
avatar_url: undefined
}]
)
})
/*test("event2message: whitespace is retained", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "line one: test test<br>line two: <strong>test</strong> <strong>test</strong><br>line three: <strong>test test</strong><br>line four: test<strong> </strong>test<br> line five"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\n line five",
avatar_url: undefined
}]
)
})*/
test("event2message: whitespace is collapsed", async t => {
sameFirstContentAndWhitespace(
t,
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "line one: test test<br>line two: <strong>test</strong> <strong>test</strong><br>line three: <strong>test test</strong><br>line four: test<strong> </strong>test<br> line five"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\nline five",
avatar_url: undefined
}]
)
})
test("event2message: lists are bridged correctly", async t => {
sameFirstContentAndWhitespace(
t,
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "* line one\n* line two\n* line three\n * nested one\n * nested two\n* line four",
"format": "org.matrix.custom.html",
"formatted_body": "<ul>\n<li>line one</li>\n<li>line two</li>\n<li>line three\n<ul>\n<li>nested one</li>\n<li>nested two</li>\n</ul>\n</li>\n<li>line four</li>\n</ul>\n"
},
"origin_server_ts": 1692967314062,
"unsigned": {
"age": 112,
"transaction_id": "m1692967313951.441"
},
"event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70",
"room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe"
}),
[{
username: "cadence [they]",
content: "* line one\n* line two\n* line three\n * nested one\n * nested two\n* line four",
avatar_url: undefined
}]
)
})
test("event2message: long messages are split", async t => {
t.deepEqual(
await eventToMessage({
content: {
body: ("a".repeat(130) + " ").repeat(19),
msgtype: "m.text"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: (("a".repeat(130) + " ").repeat(15)).slice(0, -1),
avatar_url: undefined
}, {
username: "cadence [they]",
content: (("a".repeat(130) + " ").repeat(4)).slice(0, -1),
avatar_url: undefined
}]
)
})
test("event2message: code blocks work", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "<p>preceding</p>\n<pre><code>code block\n</code></pre>\n<p>following <code>code</code> is inline</p>\n"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "preceding\n\n```\ncode block\n```\n\nfollowing `code` is inline",
avatar_url: undefined
}]
)
})
test("event2message: code block contents are formatted correctly and not escaped", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "wrong body",
"format": "org.matrix.custom.html",
"formatted_body": "<pre><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n_input_ = input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n</code></pre>\n<p><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,</code></p>\n"
},
"origin_server_ts": 1693031482275,
"unsigned": {
"age": 99,
"transaction_id": "m1693031482146.511"
},
"event_id": "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
"room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe"
}),
[{
username: "cadence [they]",
content: "```\ninput = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n```\n\n`input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,`",
avatar_url: undefined
}]
)
})
test("event2message: quotes have an appropriate amount of whitespace", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>Chancellor of Germany Angela Merkel, on March 17, 2017: they did not shake hands<br><br><br></blockquote><br>🤨"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "> Chancellor of Germany Angela Merkel, on March 17, 2017: they did not shake hands\n🤨",
avatar_url: undefined
}]
)
})
test("event2message: m.emote markdown syntax is escaped", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.emote",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "shows you **her** <strong><em>extreme</em></strong> \\*test\\* of"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "\\* cadence \\[they\\] shows you \\*\\*her\\*\\* **_extreme_** \\\\\\*test\\\\\\* of",
avatar_url: undefined
}]
)
})
test("event2message: rich reply to a sim user", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_kyuugryphon:cadence.moe\">@_ooye_kyuugryphon:cadence.moe</a><br>Slow news day.</blockquote></mx-reply>Testing this reply, ignore",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "Slow news day."
},
sender: "@_ooye_kyuugryphon:cadence.moe"
})
}
}),
[{
username: "cadence [they]",
content: "<:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>: Slow news day.\nTesting this reply, ignore",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU"
}]
)
})
test("event2message: rich reply to a matrix user's long message with formatting", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@cadence:cadence.moe> ```\n> i should have a little happy test\n> ```\n> * list **bold** _em_ ~~strike~~\n> # heading 1\n> ## heading 2\n> ### heading 3\n> https://cadence.moe\n> [legit website](https://cadence.moe)\n\nno you can't!!!",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">@cadence:cadence.moe</a><br><pre><code>i should have a little happy test\n</code></pre>\n<ul>\n<li>list <strong>bold</strong> <em>em</em> ~~strike~~</li>\n</ul>\n<h1>heading 1</h1>\n<h2>heading 2</h2>\n<h3>heading 3</h3>\n<p>https://cadence.moe<br /><a href=\"https://cadence.moe\">legit website</a></p>\n</blockquote></mx-reply><strong>no you can't!!!</strong>",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
}
},
"origin_server_ts": 1693037401693,
"unsigned": {
"age": 381,
"transaction_id": "m1693037401592.521"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "```\ni should have a little happy test\n```\n* list **bold** _em_ ~~strike~~\n# heading 1\n## heading 2\n### heading 3\nhttps://cadence.moe\n[legit website](https://cadence.moe)",
"format": "org.matrix.custom.html",
"formatted_body": "<pre><code>i should have a little happy test\n</code></pre>\n<ul>\n<li>list <strong>bold</strong> <em>em</em> ~~strike~~</li>\n</ul>\n<h1>heading 1</h1>\n<h2>heading 2</h2>\n<h3>heading 3</h3>\n<p>https://cadence.moe<br><a href=\"https://cadence.moe\">legit website</a></p>\n"
}
})
}
}),
[{
username: "cadence [they]",
content: "<:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 Ⓜ️**cadence**: i should have a little...\n**no you can't!!!**",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU"
}]
)
})
test("event2message: with layered rich replies, the preview should only be the real text", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "> <@cadence:cadence.moe> two\n\nthree",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!PnyBKvUBOhjuCucEfk:cadence.moe/$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU?via=cadence.moe\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">@cadence:cadence.moe</a><br>two</blockquote></mx-reply>three",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
}
},
event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@cadence:cadence.moe> one\n\ntwo",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!PnyBKvUBOhjuCucEfk:cadence.moe/$5UtboIC30EFlAYD_Oh0pSYVW8JqOp6GsDIJZHtT0Wls?via=cadence.moe\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">@cadence:cadence.moe</a><br>one</blockquote></mx-reply>two",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$5UtboIC30EFlAYD_Oh0pSYVW8JqOp6GsDIJZHtT0Wls"
}
}
}
})
}
}),
[{
username: "cadence [they]",
content: "<:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 Ⓜ️**cadence**: two\nthree",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU"
}]
)
})
test("event2message: mentioning discord users works", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: `I'm just <a href="https://matrix.to/#/@_ooye_extremity:cadence.moe">▲</a> testing mentions`
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "I'm just <@114147806469554185> testing mentions",
avatar_url: undefined
}]
)
})
test("event2message: mentioning matrix users works", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: `I'm just <a href="https://matrix.to/#/@rnl:cadence.moe">▲</a> testing mentions`
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "I'm just [▲](<https://matrix.to/#/@rnl:cadence.moe>) testing mentions",
avatar_url: undefined
}]
)
})
test("event2message: mentioning bridged rooms works", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: `I'm just <a href="https://matrix.to/#/@rnl:cadence.moe">▲</a> testing mentions`
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
[{
username: "cadence [they]",
content: "I'm just [▲](<https://matrix.to/#/@rnl:cadence.moe>) testing mentions",
avatar_url: undefined
}]
)
})

33
m2d/converters/utils.js Normal file
View file

@ -0,0 +1,33 @@
// @ts-check
const reg = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
/**
* Determine whether an event is the bridged representation of a discord message.
* Such messages shouldn't be bridged again.
* @param {string} sender
*/
function eventSenderIsFromDiscord(sender) {
// If it's from a user in the bridge's namespace, then it originated from discord
// This includes messages sent by the appservice's bot user, because that is what's used for webhooks
// TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
// TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
if (userRegex.some(x => sender.match(x))) {
return true
}
return false
}
/**
* @param {string} mxc
* @returns {string?}
*/
function getPublicUrlForMxc(mxc) {
const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (avatarURLParts) return `https://matrix.cadence.moe/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
else return null
}
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
module.exports.getPublicUrlForMxc = getPublicUrlForMxc

View file

@ -0,0 +1,16 @@
// @ts-check
const {test} = require("supertape")
const {eventSenderIsFromDiscord} = require("./utils")
test("sender type: matrix user", t => {
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
})
test("sender type: ooye bot", t => {
t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe"))
})
test("sender type: ooye puppet", t => {
t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe"))
})

103
m2d/event-dispatcher.js Normal file
View file

@ -0,0 +1,103 @@
// @ts-check
/*
* Grab Matrix events we care about, check them, and bridge them.
*/
const util = require("util")
const Ty = require("../types")
const {db, sync, as} = require("../passthrough")
/** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event")
/** @type {import("./actions/add-reaction")} */
const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./converters/utils")} */
const utils = sync.require("./converters/utils")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
let lastReportedEvent = 0
function guard(type, fn) {
return async function(event, ...args) {
try {
return await fn(event, ...args)
} catch (e) {
console.error("hit event-dispatcher's error handler with this exception:")
console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it?
console.error(`while handling this ${type} gateway event:`)
console.dir(event, {depth: null})
if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now()
let stackLines = e.stack.split("\n")
api.sendEvent(event.room_id, "m.room.message", {
msgtype: "m.text",
body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.",
format: "org.matrix.custom.html",
formatted_body: "\u26a0 <strong>Matrix event not delivered to Discord</strong>"
+ `<br>Event type: ${type}`
+ `<br>${e.toString()}`
+ `<details><summary>Error trace</summary>`
+ `<pre>${stackLines.join("\n")}</pre></details>`
+ `<details><summary>Original payload</summary>`
+ `<pre>${util.inspect(event, false, 4, false)}</pre></details>`,
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
}
})
}
}
}
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
/**
* @param {Ty.Event.Outer<Ty.Event.M_Room_Message>} event it is a m.room.message because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
}))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
await addReaction.addReaction(event)
}))
sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Avatar>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const url = event.content.url || null
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const name = event.content.name || null
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
*/
async event => {
if (event.state_key[0] !== "@") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(event.room_id, event.sender, event.content.displayname || null, event.content.avatar_url || null)
}))

200
matrix/api.js Normal file
View file

@ -0,0 +1,200 @@
// @ts-check
const Ty = require("../types")
const assert = require("assert")
const passthrough = require("../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
/** @type {import("./file")} */
const file = sync.require("./file")
/** @type {import("./txnid")} */
const makeTxnId = sync.require("./txnid")
/**
* @param {string} p endpoint to access
* @param {string?} [mxid] optional: user to act as, for the ?user_id parameter
* @param {{[x: string]: any}} [otherParams] optional: any other query parameters to add
* @returns {string} the new endpoint
*/
function path(p, mxid, otherParams = {}) {
if (!mxid) return p
const u = new URL(p, "http://localhost")
u.searchParams.set("user_id", mxid)
for (const entry of Object.entries(otherParams)) {
if (entry[1] != undefined) {
u.searchParams.set(entry[0], entry[1])
}
}
return u.pathname + "?" + u.searchParams.toString()
}
/**
* @param {string} username
* @returns {Promise<Ty.R.Registered>}
*/
function register(username) {
console.log(`[api] register: ${username}`)
return mreq.mreq("POST", "/client/v3/register", {
type: "m.login.application_service",
username
})
}
/**
* @returns {Promise<string>} room ID
*/
async function createRoom(content) {
console.log(`[api] create room:`, content)
/** @type {Ty.R.RoomCreated} */
const root = await mreq.mreq("POST", "/client/v3/createRoom", content)
return root.room_id
}
/**
* @returns {Promise<string>} room ID
*/
async function joinRoom(roomIDOrAlias, mxid) {
/** @type {Ty.R.RoomJoined} */
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
return root.room_id
}
async function inviteToRoom(roomID, mxidToInvite, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), {
user_id: mxidToInvite
})
}
async function leaveRoom(roomID, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
}
/**
* @param {string} roomID
* @param {string} eventID
* @template T
*/
async function getEvent(roomID, eventID) {
/** @type {Ty.Event.Outer<T>} */
const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${eventID}`)
return root
}
/**
* @param {string} roomID
* @returns {Promise<Ty.Event.BaseStateEvent[]>}
*/
function getAllState(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`)
}
/**
* @param {string} roomID
* @param {string} type
* @param {string} key
* @returns the *content* of the state event
*/
function getStateEvent(roomID, type, key) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`)
}
/**
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
* @param {string} roomID
* @returns {Promise<{joined: {[mxid: string]: Ty.R.RoomMember}}>}
*/
function getJoinedMembers(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
}
/**
* @param {string} roomID
* @param {string} type
* @param {string} stateKey
* @param {string} [mxid]
* @returns {Promise<string>} event ID
*/
async function sendState(roomID, type, stateKey, content, mxid) {
console.log(`[api] state: ${roomID}: ${type}/${stateKey}`)
assert.ok(type)
assert.ok(typeof stateKey === "string")
/** @type {Ty.R.EventSent} */
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${stateKey}`, mxid), content)
return root.event_id
}
/**
* @param {string} roomID
* @param {string} type
* @param {any} content
* @param {string?} [mxid]
* @param {number} [timestamp] timestamp of the newly created event, in unix milliseconds
*/
async function sendEvent(roomID, type, content, mxid, timestamp) {
console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`)
/** @type {Ty.R.EventSent} */
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content)
return root.event_id
}
/**
* @returns {Promise<string>} room ID
*/
async function redactEvent(roomID, eventID, mxid) {
/** @type {Ty.R.EventRedacted} */
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {})
return root.event_id
}
async function profileSetDisplayname(mxid, displayname) {
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), {
displayname
})
}
async function profileSetAvatarUrl(mxid, avatar_url) {
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), {
avatar_url
})
}
/**
* Set a user's power level within a room.
* @param {string} roomID
* @param {string} mxid
* @param {number} power
*/
async function setUserPower(roomID, mxid, power) {
assert(roomID[0] === "!")
assert(mxid[0] === "@")
// Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352
const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "")
const users = powerLevels.users || {}
if (power != null) {
users[mxid] = power
} else {
delete users[mxid]
}
powerLevels.users = users
await sendState(roomID, "m.room.power_levels", "", powerLevels)
return powerLevels
}
module.exports.path = path
module.exports.register = register
module.exports.createRoom = createRoom
module.exports.joinRoom = joinRoom
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
module.exports.redactEvent = redactEvent
module.exports.profileSetDisplayname = profileSetDisplayname
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
module.exports.setUserPower = setUserPower

22
matrix/api.test.js Normal file
View file

@ -0,0 +1,22 @@
const {test} = require("supertape")
const {path} = require("./api")
test("api path: no change for plain path", t => {
t.equal(path("/hello/world"), "/hello/world")
})
test("api path: add mxid to the URL", t => {
t.equal(path("/hello/world", "12345"), "/hello/world?user_id=12345")
})
test("api path: empty path with mxid", t => {
t.equal(path("", "12345"), "/?user_id=12345")
})
test("api path: existing query parameters with mxid", t => {
t.equal(path("/hello/world?foo=bar&baz=qux", "12345"), "/hello/world?foo=bar&baz=qux&user_id=12345")
})
test("api path: real world mxid", t => {
t.equal(path("/hello/world", "@cookie_monster:cadence.moe"), "/hello/world?user_id=%40cookie_monster%3Acadence.moe")
})

109
matrix/file.js Normal file
View file

@ -0,0 +1,109 @@
// @ts-check
const fetch = require("node-fetch").default
const passthrough = require("../passthrough")
const { sync, db } = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
const DISCORD_IMAGES_BASE = "https://cdn.discordapp.com"
const IMAGE_SIZE = 1024
/** @type {Map<string, Promise<string>>} */
const inflight = new Map()
/**
* @param {string} path
*/
async function uploadDiscordFileToMxc(path) {
let url
if (path.startsWith("http")) {
// TODO: this is cheating to make seed.js easier. due a refactor or a name change since it's not soley for discord?
// possibly could be good to save non-discord external URLs under a user-specified key rather than simply using the url?
url = path
} else {
url = DISCORD_IMAGES_BASE + path
}
// Are we uploading this file RIGHT NOW? Return the same inflight promise with the same resolution
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.
const existingFromDb = db.prepare("SELECT mxc_url FROM file WHERE discord_url = ?").pluck().get(url)
if (typeof existingFromDb === "string") {
return existingFromDb
}
// Download from Discord
const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => {
// Upload to Matrix
const root = await module.exports._actuallyUploadDiscordFileToMxc(url, res)
// Store relationship in database
db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(url, root.content_uri)
inflight.delete(url)
return root.content_uri
})
inflight.set(url, promise)
return promise
}
async function _actuallyUploadDiscordFileToMxc(url, res) {
const body = res.body
/** @type {import("../types").R.FileUploaded} */
const root = await mreq.mreq("POST", "/media/v3/upload", body, {
headers: {
"Content-Type": res.headers.get("content-type")
}
})
return root
}
function guildIcon(guild) {
return `/icons/${guild.id}/${guild.icon}.png?size=${IMAGE_SIZE}`
}
function userAvatar(user) {
return `/avatars/${user.id}/${user.avatar}.png?size=${IMAGE_SIZE}`
}
function memberAvatar(guildID, user, member) {
if (!member.avatar) return userAvatar(user)
return `/guilds/${guildID}/users/${user.id}/avatars/${member.avatar}.png?size=${IMAGE_SIZE}`
}
function emoji(emojiID, animated) {
const base = `/emojis/${emojiID}`
if (animated) return base + ".gif"
else return base + ".png"
}
const stickerFormat = new Map([
[1, {label: "PNG", ext: "png", mime: "image/png"}],
[2, {label: "APNG", ext: "png", mime: "image/apng"}],
[3, {label: "LOTTIE", ext: "json", mime: null}],
[4, {label: "GIF", ext: "gif", mime: "image/gif"}]
])
/** @param {{id: string, format_type: number}} sticker */
function sticker(sticker) {
const format = stickerFormat.get(sticker.format_type)
if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`)
const ext = format.ext
return `/stickers/${sticker.id}.${ext}`
}
module.exports.guildIcon = guildIcon
module.exports.userAvatar = userAvatar
module.exports.memberAvatar = memberAvatar
module.exports.emoji = emoji
module.exports.stickerFormat = stickerFormat
module.exports.sticker = sticker
module.exports.uploadDiscordFileToMxc = uploadDiscordFileToMxc
module.exports._actuallyUploadDiscordFileToMxc = _actuallyUploadDiscordFileToMxc

81
matrix/kstate.js Normal file
View file

@ -0,0 +1,81 @@
// @ts-check
const assert = require("assert").strict
const mixin = require("mixin-deep")
/** Mutates the input. */
function kstateStripConditionals(kstate) {
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) {
if (content.$if) delete content.$if
else delete kstate[k]
}
}
return kstate
}
function kstateToState(kstate) {
const events = []
kstateStripConditionals(kstate)
for (const [k, content] of Object.entries(kstate)) {
const [type, state_key] = k.split("/")
assert.ok(typeof type === "string")
assert.ok(typeof state_key === "string")
events.push({type, state_key, content})
}
return events
}
/**
* @param {import("../types").Event.BaseStateEvent[]} events
* @returns {any}
*/
function stateToKState(events) {
const kstate = {}
for (const event of events) {
kstate[event.type + "/" + event.state_key] = event.content
}
return kstate
}
function diffKState(actual, target) {
const diff = {}
// go through each key that it should have
for (const key of Object.keys(target)) {
if (!key.includes("/")) throw new Error(`target kstate's key "${key}" does not contain a slash separator; if a blank state_key was intended, add a trailing slash to the kstate key.`)
if (key === "m.room.power_levels/") {
// Special handling for power levels, we want to deep merge the actual and target into the final state.
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
const temp = mixin({}, actual[key], target[key])
try {
assert.deepEqual(actual[key], temp)
} catch (e) {
// they differ. use the newly prepared object as the diff.
diff[key] = temp
}
} else if (key in actual) {
// diff
try {
assert.deepEqual(actual[key], target[key])
} catch (e) {
// they differ. use the target as the diff.
diff[key] = target[key]
}
} else {
// not present, needs to be added
diff[key] = target[key]
}
// keys that are missing in "actual" will not be deleted on "target" (no action)
}
return diff
}
module.exports.kstateStripConditionals = kstateStripConditionals
module.exports.kstateToState = kstateToState
module.exports.stateToKState = stateToKState
module.exports.diffKState = diffKState

148
matrix/kstate.test.js Normal file
View file

@ -0,0 +1,148 @@
const {kstateToState, stateToKState, diffKState, kstateStripConditionals} = require("./kstate")
const {test} = require("supertape")
test("kstate strip: strips false conditions", t => {
t.deepEqual(kstateStripConditionals({
a: {$if: false, value: 2},
b: {value: 4}
}), {
b: {value: 4}
})
})
test("kstate strip: keeps true conditions while removing $if", t => {
t.deepEqual(kstateStripConditionals({
a: {$if: true, value: 2},
b: {value: 4}
}), {
a: {value: 2},
b: {value: 4}
})
})
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("diffKState: power levels are mixed together", t => {
const original = {
"m.room.power_levels/": {
"ban": 50,
"events": {
"m.room.name": 100,
"m.room.power_levels": 100
},
"events_default": 0,
"invite": 50,
"kick": 50,
"notifications": {
"room": 20
},
"redact": 50,
"state_default": 50,
"users": {
"@example:localhost": 100
},
"users_default": 0
}
}
const result = diffKState(original, {
"m.room.power_levels/": {
"events": {
"m.room.avatar": 0
}
}
})
t.deepEqual(result, {
"m.room.power_levels/": {
"ban": 50,
"events": {
"m.room.name": 100,
"m.room.power_levels": 100,
"m.room.avatar": 0
},
"events_default": 0,
"invite": 50,
"kick": 50,
"notifications": {
"room": 20
},
"redact": 50,
"state_default": 50,
"users": {
"@example:localhost": 100
},
"users_default": 0
}
})
t.notDeepEqual(original, result)
})

47
matrix/mreq.js Normal file
View file

@ -0,0 +1,47 @@
// @ts-check
const fetch = require("node-fetch").default
const mixin = require("mixin-deep")
const passthrough = require("../passthrough")
const { sync } = passthrough
/** @type {import("./read-registration")} */
const reg = sync.require("./read-registration.js")
const baseUrl = "https://matrix.cadence.moe/_matrix"
class MatrixServerError extends Error {
constructor(data, opts) {
super(data.error || data.errcode)
this.data = data
/** @type {string} */
this.errcode = data.errcode
this.opts = opts
}
}
/**
* @param {string} method
* @param {string} url
* @param {any} [body]
* @param {any} [extra]
*/
async function mreq(method, url, body, extra = {}) {
const opts = mixin({
method,
body: (body == undefined || Object.is(body.constructor, Object)) ? JSON.stringify(body) : body,
headers: {
Authorization: `Bearer ${reg.as_token}`
}
}, extra)
// console.log(baseUrl + url, opts)
const res = await fetch(baseUrl + url, opts)
const root = await res.json()
if (!res.ok || root.errcode) throw new MatrixServerError(root, opts)
return root
}
module.exports.MatrixServerError = MatrixServerError
module.exports.mreq = mreq

View file

@ -1,13 +1,12 @@
// @ts-check
const fs = require("fs")
const assert = require("assert").strict
const yaml = require("js-yaml")
/**
* @typedef AppServiceRegistrationConfig
* @property {string} id
* @property {string} as_token
* @property {string} hs_token
*/
module.exports = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */
const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
assert(reg.ooye.max_file_size)
assert(reg.ooye.namespace_prefix)
assert(reg.ooye.server_name)
module.exports = reg

View file

@ -0,0 +1,10 @@
const {test} = require("supertape")
const reg = require("./read-registration")
test("reg: has necessary parameters", t => {
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
t.deepEqual(
propertiesToCheck.filter(p => p in reg),
propertiesToCheck
)
})

View file

@ -2,6 +2,6 @@
let now = Date.now()
module.exports = function makeTxnId() {
module.exports.makeTxnId = function makeTxnId() {
return now++
}

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)
})

136
notes.md
View file

@ -9,6 +9,61 @@ A database will be used to store the discord id to matrix event id mapping. Tabl
There needs to be a way to easily manually trigger something later. For example, it should be easy to manually retry sending a message, or check all members for changes, etc.
## Discord's gateway when a new thread is created from an existing message:
1. Regular MESSAGE_CREATE of the message that it's going to branch off in the future. Example ID -6423
2. It MESSAGE_UPDATEd the ID -6423 with this whole data: {id:-6423,flags: 32,channel_id:-2084,guild_id:-1727} (ID is the message ID it's branching off, channel ID is the parent channel containing the message ID it's branching off)
3. It THREAD_CREATEd and gave us a channel object with type 11 (public thread) and parent ID -2084 and ID -6423.
4. It MESSAGE_CREATEd type 21 with blank content and a message reference pointing towards channel -2084 message -6423. (That's the message it branched from in the parent channel.) This MESSAGE_CREATE got ID -4631 (a new ID). Apart from that it's a regular message object.
5. Finally, as the first "real" message in that thread (which a user must send to create that thread!) it sent a regular message object with a new message ID and a channel ID of -6423.
When viewing this thread, it shows the message branched from at the top, and then the first "real" message right underneath, as separate groups.
### Problem 1
If THREAD_CREATE creates the matrix room, this will still be in-flight when MESSAGE_CREATE ensures the room exists and creates a room too. There will be two rooms created and the bridge falls over.
#### Possible solution: Ignore THREAD_CREATE
Then the room will be implicitly created by the two MESSAGE_CREATEs, which are in series.
#### Possible solution: Store in-flight room creations ✔️
Then the room will definitely only be created once, and we can still handle both events if we want to do special things for THREAD_CREATE.
#### Possible solution: Don't implicitly create rooms
But then old and current threads would never have their messages bridged unless I manually intervene. Don't like that.
### Problem 2
MESSAGE_UPDATE with flags=32 is telling that message to become an announcement of the new thread's creation, but this happens before THREAD_CREATE. The matrix room won't actually exist when we see MESSAGE_UPDATE, therefore we cannot make the MESSAGE_UPDATE link to the new thread.
#### Possible solution: Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement ✔️
When seeing THREAD_CREATE (if we use solution B above) we could react to it by creating the thread announcement message in the parent channel. This is possible because THREAD_CREATE gives a thread object and that includes the parent channel ID to send the announcement message to.
While the thread announcement message could look more like Discord-side by being an edit of the message it branched off:
> look at my cat
>
> Thread started: [#cat thread]
if the thread branched off a matrix user's message then the bridge wouldn't be able to edit it, so this wouldn't work.
Regardless, it would make the most sense to post a new message like this to the parent room:
> > Reply to: look at my cat
>
> [me] started a new thread: [#cat thread]
## Current manual process for setting up a server
1. Call createSpace.createSpace(discord.guilds.get(GUILD_ID))
2. Call createRoom.createAllForGuild(GUILD_ID) // TODO: Only create rooms that the bridge bot has read permissions in!
3. Edit source code of event-dispatcher.js isGuildAllowed() and add the guild ID to the list
4. If developing, make sure SSH port forward is activated, then wait for events to sync over!
## Transforming content
1. Upload attachments to mxc if they are small enough.
@ -36,6 +91,13 @@ Public channels in that server should then use the following settings, so that t
- Find & join access: Space members (so users must have been invited to the space already, even if they find out the room ID to join)
- Who can read history: Anyone (so that people can see messages during the preview before joining)
Step by step process:
1. Create a space room for the guild. Store the guild-space ID relationship in the database. Configure the space room to act like a space.
- `{"name":"NAME","preset":"private_chat","visibility":"private","power_level_content_override":{"events_default":100,"invite":50},"topic":"TOPIC","creation_content":{"type":"m.space"},"initial_state":[{"type":"m.room.guest_access","state_key":"","content":{"guest_access":"can_join"}},{"type":"m.room.history_visibility","content":{"history_visibility":"invited"}}]}`
2. Create channel rooms for the channels. Store the channel-room ID relationship in the database. (Probably no need to store parent-child relationships in the database?)
3. Send state events to put the channel rooms in the space.
### Private channels
Discord **channels** that disallow view permission to @everyone should instead have the following **room** settings in Matrix:
@ -53,11 +115,34 @@ The context-sensitive /invite command will invite Matrix users to the correspond
# d2m events
## Login - backfill
Need to backfill any messages that were missed while offline.
After logging in, check last_message_id on each channel and compare against database to see if anything has been missed. However, mustn't interpret old channels from before the bridge was created as being "new". So, something has been missed if:
- The last_message_id is not in the table of bridged messages
- The channel is already set up with a bridged room
- A message has been bridged in that channel before
(If either of the last two conditions is false, that means the channel predates the bridge and we haven't actually missed anything there.)
For channels that have missed messages, use the getChannelMessages function, and bridge each in turn.
Can use custom transaction ID (?) to send the original timestamps to Matrix. See appservice docs for details.
## Message sent
1. Transform content.
2. Send to matrix.
## Webhook message sent
- Consider using the _ooye_bot account to send all webhook messages to prevent extraneous joins?
- Downside: the profile information from the most recently sent message would stick around in the member list. This is tolerable.
- Otherwise, could use an account per webhook ID, but if webhook IDs are often deleted and re-created, this could still end up leaving too many accounts in the room.
- The original bridge uses an account per webhook display name, which makes the most sense in terms of canonical accounts, but leaves too many accounts in the room.
## Message deleted
1. Look up equivalents on matrix.
@ -66,7 +151,9 @@ The context-sensitive /invite command will invite Matrix users to the correspond
## Message edited / embeds added
1. Look up equivalents on matrix.
2. Replace content on matrix.
2. Transform content.
3. Build replacement event with fallbacks.
4. Send to matrix.
## Reaction added
@ -74,7 +161,7 @@ The context-sensitive /invite command will invite Matrix users to the correspond
## Reaction removed
1. Remove reaction on matrix.
1. Remove reaction on matrix. Just redact the event.
## Member data changed
@ -91,4 +178,47 @@ The context-sensitive /invite command will invite Matrix users to the correspond
1. Create the corresponding room.
2. Add to database.
3. Update room details to match.
4. Add to space.
4. Make sure the permissions are correct according to the rules above!
5. Add to space.
## Emojis updated
1. Upload any newly added images to msc.
2. Create or replace state event for the bridged pack. (Can just use key "ooye" and display name "Discord", or something, for this pack.)
3. The emojis may now be sent by Matrix users!
TOSPEC: m2d emoji uploads??
## Issues if the bridge database is rolled back
### channel_room table
- Duplicate rooms will be created on matrix.
### sim table
- Sims will already be registered, registration will fail, all events from those sims will fail.
### sim_member table
- Sims won't be invited because they are already joined, all events from those sims will fail.
### guild_space table
- channelToKState will fail, so channel data differences won't be calculated, so channel/thread creation and sync will fail.
### event_message table
- Events referenced by other events will be dropped, for example
- edits will be ignored
- deletes will be ignored
- reactions will be ignored
- replies won't generate a reply
### file
- Some files like avatars may be re-uploaded to the matrix content repository, secretly taking more storage space on the server.
### webhook
- Some duplicate webhooks may be created.

1224
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,17 +16,31 @@
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.3.0",
"cloudstorm": "^0.7.0",
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#24508e701e91d5a00fa5e773ced874d9ee8c889b",
"heatsync": "^2.4.0",
"chunk-text": "^2.0.1",
"cloudstorm": "^0.8.0",
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#440130ef343c8183a81c7c09809731484aa3a182",
"heatsync": "^2.4.1",
"js-yaml": "^4.1.0",
"matrix-appservice": "^2.0.0",
"matrix-js-sdk": "^24.1.0",
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
"node-fetch": "^2.6.7",
"snowtransfer": "^0.7.0",
"supertape": "^8.3.0"
"prettier-bytes": "^1.0.4",
"snowtransfer": "^0.8.0",
"try-to-catch": "^3.0.1",
"turndown": "^7.1.2"
},
"devDependencies": {
"@types/node": "^18.16.0"
"@types/node": "^18.16.0",
"@types/node-fetch": "^2.6.3",
"c8": "^8.0.1",
"cross-env": "^7.0.3",
"discord-api-types": "^0.37.53",
"supertape": "^8.3.0",
"tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d"
},
"scripts": {
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
"cover": "c8 --skip-full -r html -r text supertape --no-check-assertions-count --format fail test/test.js"
}
}

View file

@ -6,6 +6,8 @@
* @property {typeof import("./config")} config
* @property {import("./d2m/discord-client")} discord
* @property {import("heatsync")} sync
* @property {import("better-sqlite3/lib/database")} db
* @property {import("matrix-appservice").AppService} as
*/
/** @type {Passthrough} */
// @ts-ignore

View file

@ -0,0 +1,51 @@
// @ts-check
// ****
const interestingFields = ["author", "content", "edited_timestamp", "mentions", "attachments", "embeds", "type", "message_reference", "referenced_message", "sticker_items"]
// *****
function fieldToPresenceValue(field) {
if (field === undefined) return 0
else if (field === null) return 1
else if (Array.isArray(field) && field.length === 0) return 10
else if (typeof field === "object" && Object.keys(field).length === 0) return 20
else if (field === "") return 30
else return 99
}
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const config = require("../config")
const passthrough = require("../passthrough")
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {config, sync})
const DiscordClient = require("../d2m/discord-client", false)
const discord = new DiscordClient(config.discordToken, false)
passthrough.discord = discord
;(async () => {
await discord.cloud.connect()
console.log("Discord gateway started")
const f = event => onPacket(discord, event, () => discord.cloud.off("event", f))
discord.cloud.on("event", f)
})()
const events = new sqlite("scripts/events.db")
const sql = "INSERT INTO \"update\" (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")"
console.log(sql)
const prepared = events.prepare(sql)
/** @param {DiscordClient} discord */
function onPacket(discord, event, unsubscribe) {
if (event.t === "MESSAGE_UPDATE") {
const data = [JSON.stringify(event.d), ...interestingFields.map(f => fieldToPresenceValue(event.d[f]))]
console.log(data)
prepared.run(...data)
}
}

BIN
scripts/events.db Normal file

Binary file not shown.

View file

@ -0,0 +1,58 @@
// @ts-check
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const config = require("../config")
const passthrough = require("../passthrough")
const db = new sqlite("db/ooye.db")
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {config, sync, db})
const DiscordClient = require("../d2m/discord-client")
const discord = new DiscordClient(config.discordToken, false)
passthrough.discord = discord
;(async () => {
await discord.cloud.connect()
console.log("Discord gateway started")
const f = event => onPacket(discord, event, () => discord.cloud.off("event", f))
discord.cloud.on("event", f)
})()
const expectedGuilds = new Set()
const prepared = db.prepare("UPDATE channel_room SET name = ? WHERE channel_id = ?")
/** @param {DiscordClient} discord */
function onPacket(discord, event, unsubscribe) {
if (event.t === "READY") {
for (const obj of event.d.guilds) {
expectedGuilds.add(obj.id)
}
} else if (event.t === "GUILD_CREATE") {
expectedGuilds.delete(event.d.id)
// Store the channel.
for (const channel of event.d.channels || []) {
prepared.run(channel.name, channel.id)
}
// Checked them all?
if (expectedGuilds.size === 0) {
discord.cloud.disconnect()
unsubscribe()
// I don't know why node keeps running.
setTimeout(() => {
console.log("Stopping now.")
process.exit()
}, 1500).unref()
}
}
}

View file

@ -0,0 +1,30 @@
// @ts-check
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const passthrough = require("../passthrough")
const db = new sqlite("db/ooye.db")
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {sync, db})
const api = require("../matrix/api")
/** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore
const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN channel_room USING (channel_id)").all()
const preparedUpdate = db.prepare("UPDATE event_message SET event_type = ?, event_subtype = ? WHERE event_id = ?")
;(async () => {
for (const row of rows) {
if (row.event_type == null) {
const event = await api.getEvent(row.room_id, row.event_id)
const type = event.type
const subtype = event.content.msgtype || null
preparedUpdate.run(type, subtype, row.event_id)
console.log(`Updated ${row.event_id} -> ${type} + ${subtype}`)
}
}
})()

35
seed.js Normal file
View file

@ -0,0 +1,35 @@
// @ts-check
const assert = require("assert")
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const config = require("./config")
const passthrough = require("./passthrough")
const db = new sqlite("db/ooye.db")
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, { config, sync, db })
const api = require("./matrix/api")
const file = require("./matrix/file")
const reg = require("./matrix/read-registration")
;(async () => {
// ensure registration is correctly set...
// test connection to homeserver...
// upload initial images...
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element_rev_2.jpg")
// set profile data on homeserver...
await api.profileSetDisplayname(`@${reg.sender_localpart}:${reg.ooye.server_name}`, "Out Of Your Element")
await api.profileSetAvatarUrl(`@${reg.sender_localpart}:${reg.ooye.server_name}`, avatarUrl)
// database ddl...
// add initial rows to database, like adding the bot to sim...
})()

View file

@ -4,7 +4,19 @@ const repl = require("repl")
const util = require("util")
const passthrough = require("./passthrough")
const { discord, config, sync } = passthrough
const { discord, config, sync, db } = passthrough
const data = sync.require("./test/data")
const createSpace = sync.require("./d2m/actions/create-space")
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")
const guildID = "112760669178241024"
const extraContext = {}
@ -38,7 +50,7 @@ async function customEval(input, _context, _filename, callback) {
const output = util.inspect(result, false, depth, true)
return callback(null, output)
} catch (e) {
return callback(null, util.inspect(e, true, 100, true))
return callback(null, util.inspect(e, false, 100, true))
}
}

1523
test/data.js Normal file

File diff suppressed because it is too large Load diff

32
test/test.js Normal file
View file

@ -0,0 +1,32 @@
// @ts-check
const fs = require("fs")
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const config = require("../config")
const passthrough = require("../passthrough")
const db = new sqlite(":memory:")
db.exec(fs.readFileSync("db/data-for-test.sql", "utf8"))
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, { config, sync, db })
const file = sync.require("../matrix/file")
file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) }
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")
require("../d2m/converters/thread-to-announcement.test")
require("../d2m/actions/create-room.test")
require("../d2m/converters/user-to-mxid.test")
require("../d2m/actions/register-user.test")
require("../m2d/converters/event-to-message.test")
require("../m2d/converters/utils.test")

142
types.d.ts vendored
View file

@ -1,6 +1,138 @@
export type M_Room_Message_content = {
msgtype: "m.text"
body: string
formatted_body?: "org.matrix.custom.html"
format?: string
export type AppServiceRegistrationConfig = {
id: string
as_token: string
hs_token: string
url: string
sender_localpart: string
namespaces: {
users: {
exclusive: boolean
regex: string
}[]
aliases: {
exclusive: boolean
regex: string
}[]
}
protocols: [string]
rate_limited: boolean
ooye: {
namespace_prefix: string
max_file_size: number
server_name: string
}
}
export type WebhookCreds = {
id: string
token: string
}
export namespace Event {
export type Outer<T> = {
type: string
room_id: string
sender: string
content: T
origin_server_ts: number
unsigned: any
event_id: string
}
export type StateOuter<T> = Outer<T> & {
state_key: string
}
export type ReplacementContent<T> = T & {
"m.new_content": T
"m.relates_to": {
rel_type: string // "m.replace"
event_id: string
}
}
export type BaseStateEvent = {
type: string
room_id: 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" | "m.emote"
body: string
format?: "org.matrix.custom.html"
formatted_body?: string,
"m.relates_to"?: {
"m.in_reply_to": {
event_id: string
}
}
}
export type M_Room_Member = {
membership: string
displayname?: string
avatar_url?: string
}
export type M_Room_Avatar = {
discord_path?: string
url?: string
}
export type M_Room_Name = {
name?: string
}
export type M_Reaction = {
"m.relates_to": {
rel_type: "m.annotation"
event_id: string // the event that was reacted to
key: string // the unicode emoji, mxc uri, or reaction text
}
}
}
export namespace R {
export type RoomCreated = {
room_id: string
}
export type RoomJoined = {
room_id: string
}
export type RoomMember = {
avatar_url: string
display_name: string
}
export type FileUploaded = {
content_uri: string
}
export type Registered = {
/** "@localpart:domain.tld" */
user_id: string
home_server: string
access_token: string
device_id: string
}
export type EventSent = {
event_id: string
}
export type EventRedacted = {
event_id: string
}
}

1
types.js Normal file
View file

@ -0,0 +1 @@
module.exports = {}