Compare commits
101 commits
cae8d7c2f2
...
5898c94963
Author | SHA1 | Date | |
---|---|---|---|
5898c94963 | |||
07afd07207 | |||
fa1e01215c | |||
6d9a58a31b | |||
fd65a57a4c | |||
2eb9abef40 | |||
300197c157 | |||
936c9820ec | |||
a7ecdcd7db | |||
deb63a79d3 | |||
4a5e76c493 | |||
7186b824f5 | |||
712a525fbf | |||
1643a46812 | |||
e9fe250211 | |||
d3d9195f72 | |||
d2c3e7eaa3 | |||
dc3a234038 | |||
e8c172a753 | |||
d41062218f | |||
58d15d205a | |||
7712bc34a6 | |||
587267250d | |||
97ab77a060 | |||
96e125d8c0 | |||
164c0354bf | |||
415f2e9020 | |||
20fd58418a | |||
25cd9c851b | |||
5810fb3955 | |||
17d79df009 | |||
f7be5f1582 | |||
b8e0ddc79a | |||
63ae6607bf | |||
97ef72252c | |||
ccdd2f0c9c | |||
a6f27a1144 | |||
8a20f98925 | |||
3baf007829 | |||
92d8f57875 | |||
a38e54dcd4 | |||
425a4d3110 | |||
c24eb3075c | |||
232a9b7cae | |||
de610f08f3 | |||
9de940471d | |||
750a8cd60a | |||
417f935b9d | |||
09b7ba570c | |||
58d8ccf6a7 | |||
67fd5ff016 | |||
036a9f6a04 | |||
cfa1856118 | |||
6a452bd935 | |||
75414678c8 | |||
749f721aac | |||
de2ede5f0d | |||
85bdb98891 | |||
5ac72df7d0 | |||
aa09db1169 | |||
24bafaf41f | |||
8a50959064 | |||
e123baa226 | |||
3ffd9086e2 | |||
3baf97be8a | |||
00bdab713a | |||
15cba11ebd | |||
30d28f92c7 | |||
83c4d21811 | |||
07f24db413 | |||
9f3efcd10d | |||
4fd408d62a | |||
e2adff145f | |||
7a5b857d0a | |||
8144e6abb8 | |||
4ec2e6bc63 | |||
78144279c5 | |||
883a05303c | |||
b727a55862 | |||
e1072de53d | |||
ed9ea57f12 | |||
cd4d777090 | |||
109b3d2560 | |||
5b15f710d7 | |||
d8b8375909 | |||
ae640a8fde | |||
63e5ae3e0e | |||
28455ba4c8 | |||
a8e1f95897 | |||
cb09c70e48 | |||
6df728dcc7 | |||
725f00a98a | |||
eee5b001bc | |||
646d91c043 | |||
427e82f2d4 | |||
672fe7c2fb | |||
ec3c1a61ff | |||
6ddf19e8cd | |||
9395a85e9b | |||
d21617e2d3 | |||
5716366b3f |
62 changed files with 7685 additions and 282 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
node_modules
|
node_modules
|
||||||
config.js
|
config.js
|
||||||
registration.yaml
|
registration.yaml
|
||||||
|
coverage
|
||||||
|
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"editor.insertSpaces": false,
|
||||||
|
"editor.detectIndentation": false,
|
||||||
|
"editor.tabSize": 3
|
||||||
|
}
|
16
.vscode/tasks.json
vendored
Normal file
16
.vscode/tasks.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
35
d2m/actions/add-reaction.js
Normal file
35
d2m/actions/add-reaction.js
Normal 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
|
26
d2m/actions/announce-thread.js
Normal file
26
d2m/actions/announce-thread.js
Normal 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
|
|
@ -1,22 +1,350 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const reg = require("../../matrix/read-registration.js")
|
const assert = require("assert").strict
|
||||||
const fetch = require("node-fetch")
|
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", {
|
const passthrough = require("../../passthrough")
|
||||||
method: "POST",
|
const { discord, sync, db } = passthrough
|
||||||
body: JSON.stringify({
|
/** @type {import("../../matrix/file")} */
|
||||||
invite: ["@cadence:cadence.moe"],
|
const file = sync.require("../../matrix/file")
|
||||||
is_direct: false,
|
/** @type {import("../../matrix/api")} */
|
||||||
name: "New Bot User Room",
|
const api = sync.require("../../matrix/api")
|
||||||
preset: "trusted_private_chat"
|
/** @type {import("../../matrix/kstate")} */
|
||||||
}),
|
const ks = sync.require("../../matrix/kstate")
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
/** @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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
}
|
}
|
||||||
}).then(res => res.text()).then(text => {
|
|
||||||
// {"room_id":"!aAVaqeAKwChjWbsywj:cadence.moe"}
|
let history_visibility = "invited"
|
||||||
console.log(text)
|
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
||||||
}).catch(err => {
|
|
||||||
console.log(err)
|
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
|
||||||
|
|
62
d2m/actions/create-room.test.js
Normal file
62
d2m/actions/create-room.test.js
Normal 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
116
d2m/actions/create-space.js
Normal 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
|
26
d2m/actions/delete-message.js
Normal file
26
d2m/actions/delete-message.js
Normal 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
|
51
d2m/actions/edit-message.js
Normal file
51
d2m/actions/edit-message.js
Normal 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
|
|
@ -1,20 +1,170 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const reg = require("../../matrix/read-registration.js")
|
const assert = require("assert")
|
||||||
const fetch = require("node-fetch")
|
const reg = require("../../matrix/read-registration")
|
||||||
|
|
||||||
fetch("https://matrix.cadence.moe/_matrix/client/v3/register", {
|
const passthrough = require("../../passthrough")
|
||||||
method: "POST",
|
const { discord, sync, db } = passthrough
|
||||||
body: JSON.stringify({
|
/** @type {import("../../matrix/api")} */
|
||||||
type: "m.login.application_service",
|
const api = sync.require("../../matrix/api")
|
||||||
username: "_ooye_example"
|
/** @type {import("../../matrix/file")} */
|
||||||
}),
|
const file = sync.require("../../matrix/file")
|
||||||
headers: {
|
/** @type {import("../converters/user-to-mxid")} */
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
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 => {
|
return mxid
|
||||||
// {"user_id":"@_ooye_example:cadence.moe","home_server":"cadence.moe","access_token":"XXX","device_id":"XXX"}
|
}
|
||||||
console.log(text)
|
|
||||||
}).catch(err => {
|
/**
|
||||||
console.log(err)
|
* 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
|
||||||
|
|
44
d2m/actions/register-user.test.js
Normal file
44
d2m/actions/register-user.test.js
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
|
@ -1,27 +1,51 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const reg = require("../../matrix/read-registration.js")
|
const assert = require("assert")
|
||||||
const makeTxnId = require("../../matrix/txnid.js")
|
|
||||||
const fetch = require("node-fetch")
|
const passthrough = require("../../passthrough")
|
||||||
const messageToEvent = require("../converters/message-to-event.js")
|
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").GatewayMessageCreateDispatchData} message
|
||||||
|
* @param {import("discord-api-types/v10").APIGuild} guild
|
||||||
*/
|
*/
|
||||||
function sendMessage(message) {
|
async function sendMessage(message, guild) {
|
||||||
const event = messageToEvent(message)
|
const roomID = await createRoom.ensureRoom(message.channel_id)
|
||||||
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",
|
let senderMxid = null
|
||||||
body: JSON.stringify(event),
|
if (!message.webhook_id) {
|
||||||
headers: {
|
if (message.member) { // available on a gateway message create event
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
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)
|
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
|
||||||
}).catch(err => {
|
const eventIDs = []
|
||||||
console.log(err)
|
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
|
module.exports.sendMessage = sendMessage
|
||||||
|
|
140
d2m/converters/edit-to-changes.js
Normal file
140
d2m/converters/edit-to-changes.js
Normal 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
|
159
d2m/converters/edit-to-changes.test.js
Normal file
159
d2m/converters/edit-to-changes.test.js
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
40
d2m/converters/message-to-event.embeds.test.js
Normal file
40
d2m/converters/message-to-event.embeds.test.js
Normal 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"
|
||||||
|
}])
|
||||||
|
})
|
|
@ -1,26 +1,344 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
const markdown = require("discord-markdown")
|
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
|
* @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) {
|
async function messageToEvent(message, guild, options = {}, di) {
|
||||||
const body = message.content
|
const events = []
|
||||||
const html = markdown.toHTML(body, {
|
|
||||||
/* discordCallback: {
|
if (message.type === DiscordTypes.MessageType.ThreadCreated) {
|
||||||
user: Function,
|
// 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.
|
||||||
channel: Function,
|
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
|
||||||
role: Function,
|
// [#] NICKNAME started a thread: __THREAD NAME__. __See all threads__
|
||||||
everyone: Function,
|
// We're already bridging the THREAD_CREATED gateway event to make a comparable message, so drop this one.
|
||||||
here: Function
|
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)
|
}, null, null)
|
||||||
return {
|
|
||||||
msgtype: "m.text",
|
// TODO: add a string return type to my discord-markdown library
|
||||||
body: body,
|
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",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: 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
|
||||||
|
|
365
d2m/converters/message-to-event.test.js
Normal file
365
d2m/converters/message-to-event.test.js
Normal 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": {}
|
||||||
|
}])
|
||||||
|
})
|
46
d2m/converters/thread-to-announcement.js
Normal file
46
d2m/converters/thread-to-announcement.js
Normal 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
|
150
d2m/converters/thread-to-announcement.test.js
Normal file
150
d2m/converters/thread-to-announcement.test.js
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
77
d2m/converters/user-to-mxid.js
Normal file
77
d2m/converters/user-to-mxid.js
Normal 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
|
41
d2m/converters/user-to-mxid.test.js
Normal file
41
d2m/converters/user-to-mxid.test.js
Normal 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")
|
||||||
|
})
|
|
@ -12,8 +12,9 @@ const discordPackets = sync.require("./discord-packets")
|
||||||
class DiscordClient {
|
class DiscordClient {
|
||||||
/**
|
/**
|
||||||
* @param {string} discordToken
|
* @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.discordToken = discordToken
|
||||||
this.snow = new SnowTransfer(discordToken)
|
this.snow = new SnowTransfer(discordToken)
|
||||||
this.cloud = new CloudStorm(discordToken, {
|
this.cloud = new CloudStorm(discordToken, {
|
||||||
|
@ -43,7 +44,9 @@ class DiscordClient {
|
||||||
this.guilds = new Map()
|
this.guilds = new Map()
|
||||||
/** @type {Map<string, Array<string>>} */
|
/** @type {Map<string, Array<string>>} */
|
||||||
this.guildChannelMap = new Map()
|
this.guildChannelMap = new Map()
|
||||||
|
if (listen) {
|
||||||
this.cloud.on("event", message => discordPackets.onPacket(this, message))
|
this.cloud.on("event", message => discordPackets.onPacket(this, message))
|
||||||
|
}
|
||||||
this.cloud.on("error", console.error)
|
this.cloud.on("error", console.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
142
d2m/discord-command-handler.js
Normal file
142
d2m/discord-command-handler.js
Normal 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
|
|
@ -2,18 +2,21 @@
|
||||||
|
|
||||||
// Discord library internals type beat
|
// Discord library internals type beat
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const { sync } = passthrough
|
const { sync } = passthrough
|
||||||
|
|
||||||
/** @type {typeof import("./event-dispatcher")} */
|
|
||||||
const eventDispatcher = sync.require("./event-dispatcher")
|
|
||||||
|
|
||||||
const utils = {
|
const utils = {
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {import("cloudstorm").IGatewayMessage} message
|
* @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 (message.t === "READY") {
|
||||||
if (client.ready) return
|
if (client.ready) return
|
||||||
client.ready = true
|
client.ready = true
|
||||||
|
@ -27,9 +30,26 @@ const utils = {
|
||||||
const arr = []
|
const arr = []
|
||||||
client.guildChannelMap.set(message.d.id, arr)
|
client.guildChannelMap.set(message.d.id, arr)
|
||||||
for (const channel of message.d.channels || []) {
|
for (const channel of message.d.channels || []) {
|
||||||
|
// @ts-ignore
|
||||||
|
channel.guild_id = message.d.id
|
||||||
arr.push(channel.id)
|
arr.push(channel.id)
|
||||||
client.channels.set(channel.id, channel)
|
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") {
|
} 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") {
|
} 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") {
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
// Grab Discord events we care about for the bridge, check them, and pass them on
|
||||||
|
|
||||||
const sendMessage = require("./actions/send-message")
|
|
||||||
|
|
||||||
module.exports = {
|
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-client")} client
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
||||||
*/
|
*/
|
||||||
onMessageCreate(client, message) {
|
async onMessageCreate(client, message) {
|
||||||
console.log(message)
|
if (message.webhook_id) {
|
||||||
console.log(message.guild_id)
|
const row = db.prepare("SELECT webhook_id FROM webhook WHERE webhook_id = ?").pluck().get(message.webhook_id)
|
||||||
console.log(message.member)
|
if (row) {
|
||||||
sendMessage(message)
|
// 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-client")} client
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data
|
||||||
*/
|
*/
|
||||||
onReactionAdd(client, data) {
|
async onReactionAdd(client, data) {
|
||||||
console.log(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.
|
||||||
return {}
|
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
116
db/data-for-test.sql
Normal 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;
|
BIN
db/ooye.db
Normal file
BIN
db/ooye.db
Normal file
Binary file not shown.
18
index.js
18
index.js
|
@ -1,25 +1,33 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const config = require("./config")
|
const config = require("./config")
|
||||||
const passthrough = require("./passthrough")
|
const passthrough = require("./passthrough")
|
||||||
|
const db = new sqlite("db/ooye.db")
|
||||||
|
|
||||||
const sync = new HeatSync()
|
const sync = new HeatSync()
|
||||||
|
|
||||||
Object.assign(passthrough, { config, sync })
|
Object.assign(passthrough, {config, sync, db})
|
||||||
|
|
||||||
const DiscordClient = require("./d2m/discord-client")
|
const DiscordClient = require("./d2m/discord-client")
|
||||||
|
|
||||||
const discord = new DiscordClient(config.discordToken)
|
const discord = new DiscordClient(config.discordToken, true)
|
||||||
passthrough.discord = discord
|
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 () => {
|
;(async () => {
|
||||||
await discord.cloud.connect()
|
await discord.cloud.connect()
|
||||||
console.log("Discord gateway started")
|
console.log("Discord gateway started")
|
||||||
|
|
||||||
require("./stdin")
|
require("./stdin")
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// process.on("unhandledRejection", console.error)
|
|
||||||
// process.on("uncaughtException", console.error)
|
|
||||||
|
|
40
m2d/actions/add-reaction.js
Normal file
40
m2d/actions/add-reaction.js
Normal 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
|
63
m2d/actions/channel-webhook.js
Normal file
63
m2d/actions/channel-webhook.js
Normal 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
49
m2d/actions/send-event.js
Normal 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
8
m2d/appservice.js
Normal 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
|
241
m2d/converters/event-to-message.js
Normal file
241
m2d/converters/event-to-message.js
Normal 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, " ")
|
||||||
|
// 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
|
586
m2d/converters/event-to-message.test.js
Normal file
586
m2d/converters/event-to-message.test.js
Normal 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(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n</code></pre>\n<p><code>input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/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
33
m2d/converters/utils.js
Normal 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
|
16
m2d/converters/utils.test.js
Normal file
16
m2d/converters/utils.test.js
Normal 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
103
m2d/event-dispatcher.js
Normal 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
200
matrix/api.js
Normal 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
22
matrix/api.test.js
Normal 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
109
matrix/file.js
Normal 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
81
matrix/kstate.js
Normal 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
148
matrix/kstate.test.js
Normal 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
47
matrix/mreq.js
Normal 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
|
|
@ -1,13 +1,12 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
|
const assert = require("assert").strict
|
||||||
const yaml = require("js-yaml")
|
const yaml = require("js-yaml")
|
||||||
|
|
||||||
/**
|
/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */
|
||||||
* @typedef AppServiceRegistrationConfig
|
const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
|
||||||
* @property {string} id
|
assert(reg.ooye.max_file_size)
|
||||||
* @property {string} as_token
|
assert(reg.ooye.namespace_prefix)
|
||||||
* @property {string} hs_token
|
assert(reg.ooye.server_name)
|
||||||
*/
|
module.exports = reg
|
||||||
|
|
||||||
module.exports = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
|
|
||||||
|
|
10
matrix/read-registration.test.js
Normal file
10
matrix/read-registration.test.js
Normal 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
|
||||||
|
)
|
||||||
|
})
|
|
@ -2,6 +2,6 @@
|
||||||
|
|
||||||
let now = Date.now()
|
let now = Date.now()
|
||||||
|
|
||||||
module.exports = function makeTxnId() {
|
module.exports.makeTxnId = function makeTxnId() {
|
||||||
return now++
|
return now++
|
||||||
}
|
}
|
||||||
|
|
12
matrix/txnid.test.js
Normal file
12
matrix/txnid.test.js
Normal 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
136
notes.md
|
@ -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.
|
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
|
## Transforming content
|
||||||
|
|
||||||
1. Upload attachments to mxc if they are small enough.
|
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)
|
- 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)
|
- 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
|
### Private channels
|
||||||
|
|
||||||
Discord **channels** that disallow view permission to @everyone should instead have the following **room** settings in Matrix:
|
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
|
# 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
|
## Message sent
|
||||||
|
|
||||||
1. Transform content.
|
1. Transform content.
|
||||||
2. Send to matrix.
|
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
|
## Message deleted
|
||||||
|
|
||||||
1. Look up equivalents on matrix.
|
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
|
## Message edited / embeds added
|
||||||
|
|
||||||
1. Look up equivalents on matrix.
|
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
|
## Reaction added
|
||||||
|
|
||||||
|
@ -74,7 +161,7 @@ The context-sensitive /invite command will invite Matrix users to the correspond
|
||||||
|
|
||||||
## Reaction removed
|
## Reaction removed
|
||||||
|
|
||||||
1. Remove reaction on matrix.
|
1. Remove reaction on matrix. Just redact the event.
|
||||||
|
|
||||||
## Member data changed
|
## Member data changed
|
||||||
|
|
||||||
|
@ -91,4 +178,47 @@ The context-sensitive /invite command will invite Matrix users to the correspond
|
||||||
1. Create the corresponding room.
|
1. Create the corresponding room.
|
||||||
2. Add to database.
|
2. Add to database.
|
||||||
3. Update room details to match.
|
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
1224
package-lock.json
generated
File diff suppressed because it is too large
Load diff
26
package.json
26
package.json
|
@ -16,17 +16,31 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^8.3.0",
|
"better-sqlite3": "^8.3.0",
|
||||||
"cloudstorm": "^0.7.0",
|
"chunk-text": "^2.0.1",
|
||||||
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#24508e701e91d5a00fa5e773ced874d9ee8c889b",
|
"cloudstorm": "^0.8.0",
|
||||||
"heatsync": "^2.4.0",
|
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#440130ef343c8183a81c7c09809731484aa3a182",
|
||||||
|
"heatsync": "^2.4.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"matrix-appservice": "^2.0.0",
|
"matrix-appservice": "^2.0.0",
|
||||||
"matrix-js-sdk": "^24.1.0",
|
"matrix-js-sdk": "^24.1.0",
|
||||||
|
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
"snowtransfer": "^0.7.0",
|
"prettier-bytes": "^1.0.4",
|
||||||
"supertape": "^8.3.0"
|
"snowtransfer": "^0.8.0",
|
||||||
|
"try-to-catch": "^3.0.1",
|
||||||
|
"turndown": "^7.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
* @property {typeof import("./config")} config
|
* @property {typeof import("./config")} config
|
||||||
* @property {import("./d2m/discord-client")} discord
|
* @property {import("./d2m/discord-client")} discord
|
||||||
* @property {import("heatsync")} sync
|
* @property {import("heatsync")} sync
|
||||||
|
* @property {import("better-sqlite3/lib/database")} db
|
||||||
|
* @property {import("matrix-appservice").AppService} as
|
||||||
*/
|
*/
|
||||||
/** @type {Passthrough} */
|
/** @type {Passthrough} */
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
51
scripts/capture-message-update-events.js
Normal file
51
scripts/capture-message-update-events.js
Normal 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
BIN
scripts/events.db
Normal file
Binary file not shown.
58
scripts/save-channel-names-to-db.js
Normal file
58
scripts/save-channel-names-to-db.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
scripts/save-event-types-to-db.js
Normal file
30
scripts/save-event-types-to-db.js
Normal 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
35
seed.js
Normal 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...
|
||||||
|
|
||||||
|
})()
|
16
stdin.js
16
stdin.js
|
@ -4,7 +4,19 @@ const repl = require("repl")
|
||||||
const util = require("util")
|
const util = require("util")
|
||||||
|
|
||||||
const passthrough = require("./passthrough")
|
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 = {}
|
const extraContext = {}
|
||||||
|
|
||||||
|
@ -38,7 +50,7 @@ async function customEval(input, _context, _filename, callback) {
|
||||||
const output = util.inspect(result, false, depth, true)
|
const output = util.inspect(result, false, depth, true)
|
||||||
return callback(null, output)
|
return callback(null, output)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return callback(null, util.inspect(e, true, 100, true))
|
return callback(null, util.inspect(e, false, 100, true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
1523
test/data.js
Normal file
1523
test/data.js
Normal file
File diff suppressed because it is too large
Load diff
32
test/test.js
Normal file
32
test/test.js
Normal 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
142
types.d.ts
vendored
|
@ -1,6 +1,138 @@
|
||||||
export type M_Room_Message_content = {
|
export type AppServiceRegistrationConfig = {
|
||||||
msgtype: "m.text"
|
id: string
|
||||||
body: string
|
as_token: string
|
||||||
formatted_body?: "org.matrix.custom.html"
|
hs_token: string
|
||||||
format?: 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
1
types.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = {}
|
Loading…
Add table
Add a link
Reference in a new issue