Compare commits
120 commits
Author | SHA1 | Date | |
---|---|---|---|
07d6eb3c12 | |||
15e5b17b0d | |||
14115c0e06 | |||
0d8b9d5705 | |||
1b539cfa64 | |||
b23b818192 | |||
49948ae2c1 | |||
ac165845d7 | |||
cce432aeee | |||
e5f7c7fdcb | |||
4167a01ed1 | |||
c127923f4d | |||
da5525a542 | |||
6f7ed829b8 | |||
5a86c07eb9 | |||
4287a329f5 | |||
086e8cdc25 | |||
9f9d1f615e | |||
3662ee5db6 | |||
d72b162fe7 | |||
b79b010568 | |||
f77602afa6 | |||
f5853ccf95 | |||
33915a595d | |||
61803c3838 | |||
bad8c5b8c2 | |||
65170c1282 | |||
5dbd79cf39 | |||
cf756cb0af | |||
034f8d6b55 | |||
0e6e5e61e4 | |||
bac2deb32f | |||
d629e666db | |||
69c93ca9d9 | |||
8743910c35 | |||
d6de57f0c3 | |||
734c9a5838 | |||
b0a0e62a86 | |||
312ea69d73 | |||
3af31385f0 | |||
8915e8d96c | |||
af68657ec4 | |||
dbbb8281e6 | |||
06f502dd89 | |||
f3eb1fbeb3 | |||
b6c23c30fb | |||
1d2daf2504 | |||
f011cac3c5 | |||
8277cc4a17 | |||
50bd9fd9d7 | |||
15472b4a46 | |||
1ebf9e193c | |||
a61ad30908 | |||
76caaa1468 | |||
6a15f4fc0f | |||
f35c5266b6 | |||
d5ec013648 | |||
01af302796 | |||
b8962780ae | |||
256196cc54 | |||
b8a992453b | |||
ae9acbcc52 | |||
6bc3eaf866 | |||
b3daa6b84c | |||
a6a1b7fb28 | |||
adf07ad736 | |||
c6175e09f8 | |||
d6dc5cb88f | |||
555d7cb126 | |||
cc7831a313 | |||
c0d92ea66d | |||
b45d0f3038 | |||
eaa3b87670 | |||
915c34646a | |||
4247a3114a | |||
decc32f7e6 | |||
96fd046530 | |||
23a7394ce7 | |||
7d42a530e7 | |||
37f3a59d8e | |||
e0bb19bfab | |||
1037fe0276 | |||
c689ec00b7 | |||
a218774c13 | |||
62edffbae0 | |||
a2e3f06e8e | |||
7afe3e7591 | |||
ee266f844a | |||
65de8aaf98 | |||
9e9f4819c7 | |||
a5518e562f | |||
5af7d6bf42 | |||
5d91f999f2 | |||
2c27879afb | |||
71c553a9cf | |||
607fd3808a | |||
818311bcb4 | |||
a6c961984d | |||
42bfd034cf | |||
4b7593d630 | |||
78a17b2de9 | |||
ddb211f8f3 | |||
3a84658e8b | |||
89696fd161 | |||
be405d3eed | |||
b8793dae0f | |||
df1296e579 | |||
74632c671c | |||
2a080b737c | |||
2c93ff1e6d | |||
0cf1d26764 | |||
5c48645334 | |||
283cf66906 | |||
5e4d539807 | |||
7e9005216d | |||
87cad6c613 | |||
2c3b653b54 | |||
f9eb043c10 | |||
742d34f15e | |||
a6251e850c |
155 changed files with 9356 additions and 2112 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
@ -1,7 +1,16 @@
|
||||||
node_modules
|
# Secrets
|
||||||
config.js
|
config.js
|
||||||
registration.yaml
|
registration.yaml
|
||||||
|
ooye.db*
|
||||||
|
events.db*
|
||||||
|
|
||||||
|
# Automatically generated
|
||||||
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
db/ooye.db*
|
|
||||||
test/res/*
|
test/res/*
|
||||||
!test/res/lottie*
|
!test/res/lottie*
|
||||||
|
icon.svg
|
||||||
|
*~
|
||||||
|
.#*
|
||||||
|
\#*#
|
||||||
|
launch.json
|
||||||
|
|
9
addbot.js
Normal file → Executable file
9
addbot.js
Normal file → Executable file
|
@ -1,15 +1,18 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const config = require("./config")
|
const {reg} = require("./src/matrix/read-registration")
|
||||||
|
const token = reg.ooye.discord_token
|
||||||
|
const id = Buffer.from(token.split(".")[0], "base64").toString()
|
||||||
|
|
||||||
function addbot() {
|
function addbot() {
|
||||||
const token = config.discordToken
|
|
||||||
const id = Buffer.from(token.split(".")[0], "base64")
|
|
||||||
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
|
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* c8 ignore next 3 */
|
||||||
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
|
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
|
||||||
console.log(addbot())
|
console.log(addbot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.id = id
|
||||||
module.exports.addbot = addbot
|
module.exports.addbot = addbot
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
echo "Open this link to add the bot to a Discord server:"
|
echo "Open this link to add the bot to a Discord server:"
|
||||||
echo "https://discord.com/oauth2/authorize?client_id=$(grep discordToken config.js | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072"
|
echo "https://discord.com/oauth2/authorize?client_id=$(grep discord_token registration.yaml | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072"
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
discordToken: "yes"
|
|
||||||
}
|
|
File diff suppressed because one or more lines are too long
|
@ -1,273 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
const util = require("util")
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const reg = require("../matrix/read-registration")
|
|
||||||
const {addbot} = require("../addbot")
|
|
||||||
|
|
||||||
const {discord, sync, db, select} = require("../passthrough")
|
|
||||||
/** @type {import("../matrix/api")}) */
|
|
||||||
const api = sync.require("../matrix/api")
|
|
||||||
/** @type {import("../matrix/file")} */
|
|
||||||
const file = sync.require("../matrix/file")
|
|
||||||
/** @type {import("../d2m/actions/create-space")} */
|
|
||||||
const createSpace = sync.require("../d2m/actions/create-space")
|
|
||||||
/** @type {import("./utils")} */
|
|
||||||
const utils = sync.require("./utils")
|
|
||||||
|
|
||||||
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 = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
|
||||||
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: ${reg.ooye.server_origin}/_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) => {
|
|
||||||
// Check guild is bridged
|
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
|
||||||
if (!spaceID || !roomID) return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: "This server isn't bridged to Matrix, so you can't invite Matrix users."
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check CREATE_INSTANT_INVITE permission
|
|
||||||
assert(message.member)
|
|
||||||
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
|
|
||||||
if (!(guildPermissions & DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
|
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: "You don't have permission to invite people to this Discord server."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against accidental mentions instead of the MXID
|
|
||||||
if (message.content.match(/<[@#:].*>/)) return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: "You have to say the Matrix ID of the person you want to invite, but you mentioned a Discord user in your message.\nOne way to fix this is by writing `` ` `` backticks `` ` `` around the Matrix ID."
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get named MXID
|
|
||||||
const mxid = message.content.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
|
|
||||||
if (!mxid) return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`"
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check for existing invite to the space
|
|
||||||
let spaceMember
|
|
||||||
try {
|
|
||||||
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
|
|
||||||
} catch (e) {}
|
|
||||||
if (spaceMember && spaceMember.membership === "invite") {
|
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invite Matrix user if not in space
|
|
||||||
if (!spaceMember || spaceMember.membership !== "join") {
|
|
||||||
await api.inviteToRoom(spaceID, mxid)
|
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: `You invited \`${mxid}\` to the server.`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
|
|
||||||
let roomMember
|
|
||||||
try {
|
|
||||||
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
|
|
||||||
} catch (e) {}
|
|
||||||
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
|
|
||||||
const sent = await discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?\nHit ✅ to make it happen.`
|
|
||||||
})
|
|
||||||
return addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
|
|
||||||
await api.inviteToRoom(roomID, mxid)
|
|
||||||
await discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: `You invited \`${mxid}\` to the channel.`
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Matrix user *is* in the space and in the channel.
|
|
||||||
await discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: `\`${mxid}\` is already in this server and this channel.`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}, {
|
|
||||||
aliases: ["addbot"],
|
|
||||||
execute: replyctx(
|
|
||||||
async (message, channel, guild, ctx) => {
|
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: addbot()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}, {
|
|
||||||
aliases: ["privacy", "discoverable", "publish", "published"],
|
|
||||||
execute: replyctx(
|
|
||||||
async (message, channel, guild, ctx) => {
|
|
||||||
const current = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get()
|
|
||||||
if (current == null) {
|
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: "This server isn't bridged to the other side."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const levels = ["invite", "link", "directory"]
|
|
||||||
const level = levels.findIndex(x => message.content.includes(x))
|
|
||||||
if (level === -1) {
|
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: "**Usage: `//privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
|
|
||||||
+ "\n`invite`: Can only join with a direct in-app invite from another Matrix user, or the //invite command."
|
|
||||||
+ "\n`link`: Matrix links can be created and shared like Discord's invite links. `invite` features also work."
|
|
||||||
+ "\n`directory`: Publishes to the Matrix in-app directory, like Server Discovery. Preview enabled. `invite` and `link` also work."
|
|
||||||
+ `\n**Current privacy level: \`${levels[current]}\`**`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(message.member)
|
|
||||||
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
|
|
||||||
if (guild.owner_id !== message.author.id && !(guildPermissions & BigInt(0x28))) { // MANAGE_GUILD | ADMINISTRATOR
|
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: "You don't have permission to change the privacy level. You need Manage Server or Administrator."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild.id)
|
|
||||||
discord.snow.channel.createMessage(channel.id, {
|
|
||||||
...ctx,
|
|
||||||
content: `Privacy level updated to \`${levels[level]}\`. Changes will apply shortly.`
|
|
||||||
})
|
|
||||||
await createSpace.syncSpaceFully(guild.id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}]
|
|
||||||
|
|
||||||
/** @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
|
|
74
docs/self-service-room-creation-rules.md
Normal file
74
docs/self-service-room-creation-rules.md
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
# Self-service room creation rules
|
||||||
|
|
||||||
|
Before version 3 of Out Of Your Element, new Matrix rooms would be created on-demand when a Discord channel is spoken in for the first time. This has worked pretty well.
|
||||||
|
|
||||||
|
This is done through functions like ensureRoom and ensureSpace in actions:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function sendMessage(message, channel, guild, row) {
|
||||||
|
const roomID = await createRoom.ensureRoom(message.channel_id)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the room exists. If it doesn't, creates the room with an accurate initial state.
|
||||||
|
* @param {string} channelID
|
||||||
|
* @returns {Promise<string>} Matrix room ID
|
||||||
|
*/
|
||||||
|
function ensureRoom(channelID) {
|
||||||
|
return _syncRoom(channelID, /* shouldActuallySync */ false) /* calls ensureSpace */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the space exists. If it doesn't, creates the space with an accurate initial state.
|
||||||
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @returns {Promise<string>} Matrix space ID
|
||||||
|
*/
|
||||||
|
function ensureSpace(guild) {
|
||||||
|
return _syncSpace(guild, /* shouldActuallySync */ false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With the introduction of self-service mode, we still want to retain this as a possible mode of operation, since some people prefer to have OOYE handle this administrative work. However, other people prefer to manage the links between channels and rooms themselves, and have control over what new rooms get linked up to.
|
||||||
|
|
||||||
|
Visibly, this is managed through the web interface. The web interface lets moderators enable/disable auto-creation of new rooms, as well as set which channels and rooms are linked together.
|
||||||
|
|
||||||
|
There is a small complication. Not only are Matrix rooms created automatically, their Matrix spaces are also created automatically during room sync: ensureRoom calls ensureSpace. If a user opts in to self-service mode by clicking the specific button in the web portal, we must ensure the _space is not created automatically either,_ because the Matrix user will provide a space to link to.
|
||||||
|
|
||||||
|
To solve this, we need a way to suppress specific guilds from having auto-created spaces. The natural way to represent this is a column on guild_space, but that doesn't work, because each guild_space row requires a guild and space to be linked, and we _don't want_ them to be linked.
|
||||||
|
|
||||||
|
So, internally, OOYE keeps track of this through a new table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "guild_active" (
|
||||||
|
"guild_id" TEXT NOT NULL, -- only guilds that are bridged are present in this table
|
||||||
|
"autocreate" INTEGER NOT NULL, -- 0 or 1
|
||||||
|
PRIMARY KEY("guild_id")
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
```
|
||||||
|
|
||||||
|
There is one more complication. When adding a Discord bot through web oauth with a redirect_uri, Discord adds the bot to the server normally, _then_ redirects back to OOYE, and only then does OOYE know which guild the bot was just added to. So, for a short time between the bot being added and the user being redirected, OOYE might receive Discord events in the server before it has the chance to create the guild_active database row.
|
||||||
|
|
||||||
|
So to prevent this, self-service behaviour needs to be an implicit default, and users must firmly choose one system or another to begin using OOYE. It is important for me to design this in a way that doesn't force users to do any extra work or make a choice they don't understand to keep the pre-v3 behaviour.
|
||||||
|
|
||||||
|
So there will be 3 states of whether a guild is self-service or not. At first, it could be absent from the table, in which case events for it will be dropped. Or it could be in the table with autocomplete = 0, in which case only rooms that already exist in channel_room will have messages bridged. Or it could have autocomplete = 1, in which case Matrix rooms will be created as needed, as per the pre-v3 behaviour.
|
||||||
|
|
||||||
|
| Auto-create | Meaning |
|
||||||
|
| -- | ------------ |
|
||||||
|
| 😶🌫️ | Unbridged - waiting |
|
||||||
|
| ❌ | Bridged - self-service |
|
||||||
|
| ✅ | Bridged - auto-create |
|
||||||
|
|
||||||
|
Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged.
|
||||||
|
|
||||||
|
One more thing. Before v3, when a Matrix room was autocreated it would autocreate the space as well, if it needed to. But now, since nothing will be created until the user takes an action, the guild will always be created directly in response to a request. So room creation can now trust that the guild exists already.
|
||||||
|
|
||||||
|
So here's all the technical changes needed to support self-service in v3:
|
||||||
|
|
||||||
|
- New guild_active table showing whether, and how, a guild is bridged.
|
||||||
|
- When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace.
|
||||||
|
- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace.
|
||||||
|
- When bot is added through "self-service" web button, REPLACE INTO state 0.
|
||||||
|
- Event dispatcher will only ensureRoom if the guild_active state is 1.
|
||||||
|
- createRoom can trust that the space exists because we check that in a calling function.
|
||||||
|
- createRoom will only create other dependencies if the guild is autocreate.
|
|
@ -1,8 +0,0 @@
|
||||||
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
|
|
|
@ -1,14 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const fs = require("fs")
|
|
||||||
const assert = require("assert").strict
|
|
||||||
const yaml = require("js-yaml")
|
|
||||||
|
|
||||||
/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */
|
|
||||||
const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
|
|
||||||
reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
|
|
||||||
assert(reg.ooye.max_file_size)
|
|
||||||
assert(reg.ooye.namespace_prefix)
|
|
||||||
assert(reg.ooye.server_name)
|
|
||||||
|
|
||||||
module.exports = reg
|
|
|
@ -1,10 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
})
|
|
2187
package-lock.json
generated
2187
package-lock.json
generated
File diff suppressed because it is too large
Load diff
41
package.json
41
package.json
|
@ -14,45 +14,58 @@
|
||||||
],
|
],
|
||||||
"author": "Cadence, PapiOphidian",
|
"author": "Cadence, PapiOphidian",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chriscdn/promise-semaphore": "^2.0.1",
|
"@chriscdn/promise-semaphore": "^2.0.1",
|
||||||
"@cloudrac3r/discord-markdown": "^2.6.2",
|
"@cloudrac3r/discord-markdown": "^2.6.3",
|
||||||
"@cloudrac3r/giframe": "^0.4.3",
|
"@cloudrac3r/giframe": "^0.4.3",
|
||||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||||
|
"@cloudrac3r/in-your-element": "^1.0.0",
|
||||||
"@cloudrac3r/mixin-deep": "^3.0.0",
|
"@cloudrac3r/mixin-deep": "^3.0.0",
|
||||||
"@cloudrac3r/pngjs": "^7.0.3",
|
"@cloudrac3r/pngjs": "^7.0.3",
|
||||||
"better-sqlite3": "^9.0.0",
|
"@cloudrac3r/pug": "^4.0.4",
|
||||||
|
"@cloudrac3r/turndown": "^7.1.4",
|
||||||
|
"@stackoverflow/stacks": "^2.5.7",
|
||||||
|
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||||
|
"ansi-colors": "^4.1.3",
|
||||||
|
"better-sqlite3": "^11.1.2",
|
||||||
"chunk-text": "^2.0.1",
|
"chunk-text": "^2.0.1",
|
||||||
"cloudstorm": "^0.10.8",
|
"cloudstorm": "^0.10.10",
|
||||||
"entities": "^4.5.0",
|
"domino": "^2.1.6",
|
||||||
|
"enquirer": "^2.4.1",
|
||||||
|
"entities": "^5.0.0",
|
||||||
"get-stream": "^6.0.1",
|
"get-stream": "^6.0.1",
|
||||||
"heatsync": "^2.5.3",
|
"h3": "^1.12.0",
|
||||||
"js-yaml": "^4.1.0",
|
"heatsync": "^2.5.5",
|
||||||
"matrix-appservice": "^2.0.0",
|
"lru-cache": "^10.4.3",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.33.4",
|
||||||
"snowtransfer": "^0.10.5",
|
"snowtransfer": "^0.10.5",
|
||||||
"stream-mime-type": "^1.0.2",
|
"stream-mime-type": "^1.0.2",
|
||||||
"try-to-catch": "^3.0.1",
|
"try-to-catch": "^3.0.1",
|
||||||
"turndown": "^7.1.2",
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2"
|
"xxhash-wasm": "^1.0.2",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudrac3r/tap-dot": "^2.0.2",
|
"@cloudrac3r/tap-dot": "^2.0.3",
|
||||||
"@types/node": "^18.16.0",
|
"@types/node": "^18.16.0",
|
||||||
"@types/node-fetch": "^2.6.3",
|
"@types/node-fetch": "^2.6.3",
|
||||||
"c8": "^8.0.1",
|
"c8": "^10.1.2",
|
||||||
"colorette": "^1.4.0",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"discord-api-types": "^0.37.60",
|
"discord-api-types": "^0.37.60",
|
||||||
"supertape": "^10.4.0"
|
"supertape": "^10.4.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "node start.js",
|
||||||
|
"setup": "node scripts/setup.js",
|
||||||
"addbot": "node addbot.js",
|
"addbot": "node addbot.js",
|
||||||
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
|
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
|
||||||
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
|
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
|
||||||
"cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
|
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/matrix/mreq.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
165
readme.md
165
readme.md
|
@ -41,7 +41,7 @@ Most features you'd expect in both directions, plus a little extra spice:
|
||||||
* Custom emoji list syncing
|
* Custom emoji list syncing
|
||||||
* Custom emojis in messages
|
* Custom emojis in messages
|
||||||
* Custom room names/avatars can be applied on Matrix-side
|
* Custom room names/avatars can be applied on Matrix-side
|
||||||
* Larger files from Discord are linked instead of reuploaded to Matrix
|
* Larger files from Discord are linked instead of reuploaded to Matrix (links don't expire)
|
||||||
* Simulated user accounts are named @the_persons_username rather than @112233445566778899
|
* Simulated user accounts are named @the_persons_username rather than @112233445566778899
|
||||||
|
|
||||||
For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md)
|
For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md)
|
||||||
|
@ -55,7 +55,7 @@ For more information about features, [see the user guide.](https://gitdab.com/ca
|
||||||
|
|
||||||
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
|
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
|
||||||
|
|
||||||
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 100,000 messages require 16.1 MB of storage space in the SQLite database.
|
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database.
|
||||||
|
|
||||||
Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need.
|
Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need.
|
||||||
|
|
||||||
|
@ -74,112 +74,127 @@ You'll need:
|
||||||
|
|
||||||
Follow these steps:
|
Follow these steps:
|
||||||
|
|
||||||
1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the better-sqlite3 and matrix-appservice dependencies)
|
1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer)
|
||||||
|
|
||||||
1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.)
|
1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.)
|
||||||
* The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black).
|
* The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black).
|
||||||
|
|
||||||
1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests)
|
1. Install dependencies: `npm install`
|
||||||
|
|
||||||
1. Copy `config.example.js` to `config.js` and fill in Discord token.
|
1. Run `npm run setup` to check your setup and set the bot's initial state. It will prompt you for information. You only need to run this once ever.
|
||||||
|
|
||||||
1. Copy `registration.example.yaml` to `registration.yaml` and fill in bracketed values. You could generate each hex string with `dd if=/dev/urandom bs=32 count=1 2> /dev/null | basenc --base16 | dd conv=lcase 2> /dev/null`. Register the registration in Synapse's `homeserver.yaml` through the usual appservice installation process, then restart Synapse.
|
1. Start the bridge: `npm run start`
|
||||||
|
|
||||||
1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. You only need to run this once ever.
|
|
||||||
1. Make sure the tests work by running `npm t`
|
|
||||||
|
|
||||||
1. Start the bridge: `node start.js`
|
|
||||||
|
|
||||||
1. Add the bot to a server - use any *one* of the following commands for an invite link:
|
1. Add the bot to a server - use any *one* of the following commands for an invite link:
|
||||||
* (in the REPL) `addbot`
|
* (in the REPL) `addbot`
|
||||||
* (in a chat) `//addbot`
|
|
||||||
* $ `node addbot.js`
|
* $ `node addbot.js`
|
||||||
* $ `npm run addbot`
|
* $ `npm run addbot`
|
||||||
* $ `./addbot.sh`
|
* $ `./addbot.sh`
|
||||||
|
|
||||||
Now any message on Discord will create the corresponding rooms on Matrix-side. After the rooms have been created, Matrix and Discord users can chat back and forth.
|
Now any message on Discord will create the corresponding rooms on Matrix-side. After the rooms have been created, Matrix and Discord users can chat back and forth.
|
||||||
|
|
||||||
To get into the rooms on your Matrix account, either add yourself to `invite` in `registration.yaml`, or use the `//invite [your mxid here]` command on Discord.
|
To get into the rooms on your Matrix account, use the `/invite [your mxid here]` command on Discord.
|
||||||
|
|
||||||
# Development setup
|
# Development setup
|
||||||
|
|
||||||
* Be sure to install dependencies with `--save-dev` so you can run the tests.
|
* Install development dependencies with `npm install --save-dev` so you can run the tests.
|
||||||
* Any files you change will automatically be reloaded, except for `stdin.js` and `d2m/discord-*.js`.
|
* Most files you change, such as actions, converters, and web, will automatically be reloaded.
|
||||||
* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe`
|
* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe`
|
||||||
* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference.
|
* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference.
|
||||||
|
|
||||||
## Repository structure
|
## Repository structure
|
||||||
|
|
||||||
.
|
.
|
||||||
* Run this to start the bridge:
|
|
||||||
├── start.js
|
|
||||||
* Runtime configuration, like tokens and user info:
|
* Runtime configuration, like tokens and user info:
|
||||||
├── config.js
|
|
||||||
├── registration.yaml
|
├── registration.yaml
|
||||||
* The bridge's SQLite database is stored here:
|
|
||||||
├── db
|
|
||||||
│ ├── *.sql, *.db
|
|
||||||
│ * Migrations change the database schema when you update to a newer version of OOYE:
|
|
||||||
│ └── migrations
|
|
||||||
│ └── *.sql, *.js
|
|
||||||
* Discord-to-Matrix bridging:
|
|
||||||
├── d2m
|
|
||||||
│ * Execute actions through the whole flow, like sending a Discord message to Matrix:
|
|
||||||
│ ├── actions
|
|
||||||
│ │ └── *.js
|
|
||||||
│ * Convert data from one form to another without depending on bridge state. Called by actions:
|
|
||||||
│ ├── converters
|
|
||||||
│ │ └── *.js
|
|
||||||
│ * Making Discord work:
|
|
||||||
│ ├── discord-*.js
|
|
||||||
│ * Listening to events from Discord and dispatching them to the correct `action`:
|
|
||||||
│ └── event-dispatcher.js
|
|
||||||
├── discord
|
|
||||||
│ └── discord-command-handler.js
|
|
||||||
* Matrix-to-Discord bridging:
|
|
||||||
├── m2d
|
|
||||||
│ * Execute actions through the whole flow, like sending a Matrix message to Discord:
|
|
||||||
│ ├── actions
|
|
||||||
│ │ └── *.js
|
|
||||||
│ * Convert data from one form to another without depending on bridge state. Called by actions:
|
|
||||||
│ ├── converters
|
|
||||||
│ │ └── *.js
|
|
||||||
│ * Listening to events from Matrix and dispatching them to the correct `action`:
|
|
||||||
│ └── event-dispatcher.js
|
|
||||||
* We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs:
|
|
||||||
├── matrix
|
|
||||||
│ └── *.js
|
|
||||||
* Various files you can run once if you need them.
|
|
||||||
├── scripts
|
|
||||||
│ * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge:
|
|
||||||
│ ├── seed.js
|
|
||||||
│ * Hopefully you won't need the rest of these. Code quality varies wildly.
|
|
||||||
│ └── *.js
|
|
||||||
* You are here! :)
|
* You are here! :)
|
||||||
└── readme.md
|
├── readme.md
|
||||||
|
* The bridge's SQLite database is stored here:
|
||||||
|
├── ooye.db*
|
||||||
|
* Source code
|
||||||
|
└── src
|
||||||
|
* Database schema:
|
||||||
|
├── db
|
||||||
|
│ ├── orm.js, orm-defs.d.ts
|
||||||
|
│ * Migrations change the database schema when you update to a newer version of OOYE:
|
||||||
|
│ ├── migrate.js
|
||||||
|
│ └── migrations
|
||||||
|
│ └── *.sql, *.js
|
||||||
|
* Discord-to-Matrix bridging:
|
||||||
|
├── d2m
|
||||||
|
│ * Execute actions through the whole flow, like sending a Discord message to Matrix:
|
||||||
|
│ ├── actions
|
||||||
|
│ │ └── *.js
|
||||||
|
│ * Convert data from one form to another without depending on bridge state. Called by actions:
|
||||||
|
│ ├── converters
|
||||||
|
│ │ └── *.js
|
||||||
|
│ * Making Discord work:
|
||||||
|
│ ├── discord-*.js
|
||||||
|
│ * Listening to events from Discord and dispatching them to the correct `action`:
|
||||||
|
│ └── event-dispatcher.js
|
||||||
|
* Discord bot commands and menus:
|
||||||
|
├── discord
|
||||||
|
│ ├── interactions
|
||||||
|
│ │ └── *.js
|
||||||
|
│ └── discord-command-handler.js
|
||||||
|
* Matrix-to-Discord bridging:
|
||||||
|
├── m2d
|
||||||
|
│ * Execute actions through the whole flow, like sending a Matrix message to Discord:
|
||||||
|
│ ├── actions
|
||||||
|
│ │ └── *.js
|
||||||
|
│ * Convert data from one form to another without depending on bridge state. Called by actions:
|
||||||
|
│ ├── converters
|
||||||
|
│ │ └── *.js
|
||||||
|
│ * Listening to events from Matrix and dispatching them to the correct `action`:
|
||||||
|
│ └── event-dispatcher.js
|
||||||
|
* We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs:
|
||||||
|
├── matrix
|
||||||
|
│ └── *.js
|
||||||
|
* Various files you can run once if you need them.
|
||||||
|
└── scripts
|
||||||
|
* First time running a new bridge? Run this file to set up prerequisites on the Matrix server:
|
||||||
|
├── setup.js
|
||||||
|
* Hopefully you won't need the rest of these. Code quality varies wildly.
|
||||||
|
└── *.js
|
||||||
|
|
||||||
## Dependency justification
|
## Dependency justification
|
||||||
|
|
||||||
(deduped transitive dependency count) dependency name: explanation
|
Total transitive production dependencies: 147
|
||||||
|
|
||||||
* (0) @chriscdn/promise-semaphore: It does what I want! I like it!
|
### <font size="+2">🦕</font>
|
||||||
* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it.
|
|
||||||
|
* (31) better-sqlite3: SQLite3 is the best database, and this is the best library for it.
|
||||||
|
* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
|
||||||
|
* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only.
|
||||||
|
* (14) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, and more. 14 transitive dependencies is on the low end for a web server.
|
||||||
|
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
|
||||||
|
|
||||||
|
### <font size="-1">🪱</font>
|
||||||
|
|
||||||
|
* (0) @chriscdn/promise-semaphore: It does what I want.
|
||||||
|
* (1) @cloudrac3r/discord-markdown: This is my fork.
|
||||||
|
* (0) @cloudrac3r/giframe: This is my fork.
|
||||||
|
* (1) @cloudrac3r/html-template-tag: This is my fork.
|
||||||
|
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
|
||||||
|
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
|
||||||
|
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
||||||
|
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
|
||||||
|
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
|
||||||
|
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
|
||||||
* (1) chunk-text: It does what I want.
|
* (1) chunk-text: It does what I want.
|
||||||
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
||||||
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
* (0) domino: DOM implementation that's already pulled in by turndown.
|
||||||
* (1) discord-markdown: This is my fork!
|
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
|
||||||
* (0) get-stream: Only needed if content_length_workaround is true.
|
|
||||||
* (0) giframe: This is my fork!
|
|
||||||
* (1) heatsync: Module hot-reloader that I trust.
|
|
||||||
* (0) entities: Looks fine. No dependencies.
|
* (0) entities: Looks fine. No dependencies.
|
||||||
* (1) html-template-tag: This is my fork!
|
* (0) get-stream: Only needed if content_length_workaround is true.
|
||||||
* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice.
|
* (1) heatsync: Module hot-reloader that I trust.
|
||||||
* (70) matrix-appservice: I wish it didn't pull in express :(
|
* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON.
|
||||||
|
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
||||||
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install.
|
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install.
|
||||||
* (0) mixin-deep: This is my fork! (It fixes a bug in regular mixin-deep.)
|
* (3) node-fetch@2: I like it and it does what I want. Version 2 is used because version 3 is ESM-only.
|
||||||
* (3) node-fetch@2: I like it and it does what I want.
|
|
||||||
* (0) pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
|
||||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||||
* (51) sharp: Jimp has fewer dependencies, but sharp is faster.
|
* (2) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||||
* (0) try-to-catch: Not strictly necessary, but it does what I want and has no dependencies.
|
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
||||||
* (1) turndown: I need an HTML-to-Markdown converter and this one looked suitable enough. It has some bugs that I've worked around, so I might switch away from it later.
|
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
|
||||||
|
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
||||||
|
* (0) zod: Input validation for the web server. It's popular and easy to use.
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7
|
|
||||||
hs_token: [a unique 64 character hex string]
|
|
||||||
as_token: [a unique 64 character hex string]
|
|
||||||
url: http://localhost:6693
|
|
||||||
sender_localpart: _ooye_bot
|
|
||||||
protocols:
|
|
||||||
- discord
|
|
||||||
namespaces:
|
|
||||||
users:
|
|
||||||
- exclusive: true
|
|
||||||
regex: '@_ooye_.*'
|
|
||||||
aliases:
|
|
||||||
- exclusive: true
|
|
||||||
regex: '#_ooye_.*'
|
|
||||||
rate_limited: false
|
|
||||||
ooye:
|
|
||||||
namespace_prefix: _ooye_
|
|
||||||
max_file_size: 5000000
|
|
||||||
server_name: [the part after the colon in your matrix id, like cadence.moe]
|
|
||||||
server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe]
|
|
||||||
content_length_workaround: false
|
|
||||||
include_user_id_in_mxid: false
|
|
||||||
invite:
|
|
||||||
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
|
|
||||||
# - '@cadence:cadence.moe'
|
|
11
scripts/capture-message-update-events.js
Normal file → Executable file
11
scripts/capture-message-update-events.js
Normal file → Executable file
|
@ -1,3 +1,4 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
// ****
|
// ****
|
||||||
|
@ -16,16 +17,16 @@ function fieldToPresenceValue(field) {
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const config = require("../config")
|
const {reg} = require("../src/matrix/read-registration")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../src/passthrough")
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, {config, sync})
|
Object.assign(passthrough, {sync})
|
||||||
|
|
||||||
const DiscordClient = require("../d2m/discord-client")
|
const DiscordClient = require("../src/d2m/discord-client")
|
||||||
|
|
||||||
const discord = new DiscordClient(config.discordToken, "no")
|
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
||||||
passthrough.discord = discord
|
passthrough.discord = discord
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
|
10
scripts/check-migrate.js
Normal file → Executable file
10
scripts/check-migrate.js
Normal file → Executable file
|
@ -1,3 +1,4 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
// Trigger the database migration flow and exit after committing.
|
// Trigger the database migration flow and exit after committing.
|
||||||
|
@ -5,11 +6,10 @@
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
|
|
||||||
const config = require("../config")
|
const passthrough = require("../src/passthrough")
|
||||||
const passthrough = require("../passthrough")
|
const db = new sqlite("ooye.db")
|
||||||
const db = new sqlite("db/ooye.db")
|
const migrate = require("../src/db/migrate")
|
||||||
const migrate = require("../db/migrate")
|
|
||||||
|
|
||||||
Object.assign(passthrough, {config, db })
|
Object.assign(passthrough, {db})
|
||||||
|
|
||||||
migrate.migrate(db)
|
migrate.migrate(db)
|
||||||
|
|
23
scripts/migrate-from-old-bridge.js
Normal file → Executable file
23
scripts/migrate-from-old-bridge.js
Normal file → Executable file
|
@ -1,3 +1,4 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
@ -6,19 +7,17 @@ const Semaphore = require("@chriscdn/promise-semaphore")
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const config = require("../config")
|
const passthrough = require("../src/passthrough")
|
||||||
const passthrough = require("../passthrough")
|
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
/** @type {import("../matrix/read-registration")} */
|
const {reg} = require("../src/matrix/read-registration")
|
||||||
const reg = sync.require("../matrix/read-registration")
|
|
||||||
assert(reg.old_bridge)
|
assert(reg.old_bridge)
|
||||||
const oldAT = reg.old_bridge.as_token
|
const oldAT = reg.old_bridge.as_token
|
||||||
const newAT = reg.as_token
|
const newAT = reg.as_token
|
||||||
|
|
||||||
const oldDB = new sqlite(reg.old_bridge.database)
|
const oldDB = new sqlite(reg.old_bridge.database)
|
||||||
const db = new sqlite("db/ooye.db")
|
const db = new sqlite("ooye.db")
|
||||||
|
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
|
db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
|
||||||
discord_channel TEXT NOT NULL,
|
discord_channel TEXT NOT NULL,
|
||||||
|
@ -26,19 +25,19 @@ db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
|
||||||
PRIMARY KEY("discord_channel")
|
PRIMARY KEY("discord_channel")
|
||||||
) WITHOUT ROWID;`)
|
) WITHOUT ROWID;`)
|
||||||
|
|
||||||
Object.assign(passthrough, {config, sync, db})
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
||||||
const DiscordClient = require("../d2m/discord-client")
|
const DiscordClient = require("../src/d2m/discord-client")
|
||||||
const discord = new DiscordClient(config.discordToken, "half")
|
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
||||||
passthrough.discord = discord
|
passthrough.discord = discord
|
||||||
|
|
||||||
/** @type {import("../d2m/actions/create-space")} */
|
/** @type {import("../src/d2m/actions/create-space")} */
|
||||||
const createSpace = sync.require("../d2m/actions/create-space")
|
const createSpace = sync.require("../d2m/actions/create-space")
|
||||||
/** @type {import("../d2m/actions/create-room")} */
|
/** @type {import("../src/d2m/actions/create-room")} */
|
||||||
const createRoom = sync.require("../d2m/actions/create-room")
|
const createRoom = sync.require("../d2m/actions/create-room")
|
||||||
/** @type {import("../matrix/mreq")} */
|
/** @type {import("../src/matrix/mreq")} */
|
||||||
const mreq = sync.require("../matrix/mreq")
|
const mreq = sync.require("../matrix/mreq")
|
||||||
/** @type {import("../matrix/api")} */
|
/** @type {import("../src/matrix/api")} */
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
|
|
||||||
const sema = new Semaphore()
|
const sema = new Semaphore()
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const { AppServiceRegistration } = require("matrix-appservice");
|
|
||||||
|
|
||||||
let id = AppServiceRegistration.generateToken()
|
|
||||||
try {
|
|
||||||
const reg = require("../matrix/read-registration")
|
|
||||||
if (reg.id) id = reg.id
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
// creating registration files
|
|
||||||
const newReg = new AppServiceRegistration(null);
|
|
||||||
newReg.setAppServiceUrl("http://localhost:6693");
|
|
||||||
newReg.setId(id);
|
|
||||||
newReg.setHomeserverToken(AppServiceRegistration.generateToken());
|
|
||||||
newReg.setAppServiceToken(AppServiceRegistration.generateToken());
|
|
||||||
newReg.setSenderLocalpart("_ooye_bot");
|
|
||||||
newReg.addRegexPattern("users", "@_ooye_.*", true);
|
|
||||||
newReg.addRegexPattern("aliases", "#_ooye_.*", true);
|
|
||||||
newReg.setProtocols(["discord"]); // For 3PID lookups
|
|
||||||
newReg.setRateLimited(false);
|
|
||||||
newReg.outputAsYaml("registration.yaml");
|
|
39
scripts/remove-old-bridged-users.js
Normal file
39
scripts/remove-old-bridged-users.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const HeatSync = require("heatsync")
|
||||||
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
|
const sqlite = require("better-sqlite3")
|
||||||
|
const db = new sqlite("db/ooye.db")
|
||||||
|
|
||||||
|
const passthrough = require("../src/passthrough")
|
||||||
|
Object.assign(passthrough, {db, sync})
|
||||||
|
|
||||||
|
const api = require("../src/matrix/api")
|
||||||
|
const mreq = require("../src/matrix/mreq")
|
||||||
|
|
||||||
|
const rooms = db.prepare("select room_id from channel_room").pluck().all()
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
// Step 5: Kick users starting with @_discord_
|
||||||
|
await mreq.withAccessToken("baby", async () => {
|
||||||
|
for (const roomID of rooms) {
|
||||||
|
try {
|
||||||
|
const members = await api.getJoinedMembers(roomID)
|
||||||
|
for (const mxid of Object.keys(members.joined)) {
|
||||||
|
if (mxid.startsWith("@_discord_") && !mxid.startsWith("@_discord_bot")) {
|
||||||
|
await api.leaveRoom(roomID, mxid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0)
|
||||||
|
await api.leaveRoom(roomID)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("Appservice not in room")) {
|
||||||
|
// ok
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
13
scripts/save-channel-names-to-db.js
Normal file → Executable file
13
scripts/save-channel-names-to-db.js
Normal file → Executable file
|
@ -1,19 +1,20 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const config = require("../config")
|
const {reg} = require("../src/matrix/read-registration")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../src/passthrough")
|
||||||
const db = new sqlite("db/ooye.db")
|
const db = new sqlite("ooye.db")
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, {config, sync, db})
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
||||||
const DiscordClient = require("../d2m/discord-client")
|
const DiscordClient = require("../src/d2m/discord-client")
|
||||||
|
|
||||||
const discord = new DiscordClient(config.discordToken, "no")
|
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
||||||
passthrough.discord = discord
|
passthrough.discord = discord
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
|
7
scripts/save-event-types-to-db.js
Normal file → Executable file
7
scripts/save-event-types-to-db.js
Normal file → Executable file
|
@ -1,16 +1,17 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../src/passthrough")
|
||||||
const db = new sqlite("db/ooye.db")
|
const db = new sqlite("ooye.db")
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
||||||
const api = require("../matrix/api")
|
const api = require("../src/matrix/api")
|
||||||
|
|
||||||
/** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore
|
/** @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 message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all()
|
const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all()
|
||||||
|
|
138
scripts/seed.js
138
scripts/seed.js
|
@ -1,138 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
console.log("This could take up to 30 seconds. Please be patient.")
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
const fs = require("fs")
|
|
||||||
const sqlite = require("better-sqlite3")
|
|
||||||
const HeatSync = require("heatsync")
|
|
||||||
|
|
||||||
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
|
|
||||||
|
|
||||||
const config = require("../config")
|
|
||||||
const passthrough = require("../passthrough")
|
|
||||||
const db = new sqlite("db/ooye.db")
|
|
||||||
const migrate = require("../db/migrate")
|
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
|
||||||
|
|
||||||
Object.assign(passthrough, { sync, config, db })
|
|
||||||
|
|
||||||
const orm = sync.require("../db/orm")
|
|
||||||
passthrough.from = orm.from
|
|
||||||
passthrough.select = orm.select
|
|
||||||
|
|
||||||
const DiscordClient = require("../d2m/discord-client")
|
|
||||||
const discord = new DiscordClient(config.discordToken, "no")
|
|
||||||
passthrough.discord = discord
|
|
||||||
|
|
||||||
const api = require("../matrix/api")
|
|
||||||
const file = require("../matrix/file")
|
|
||||||
const reg = require("../matrix/read-registration")
|
|
||||||
const utils = require("../m2d/converters/utils")
|
|
||||||
|
|
||||||
function die(message) {
|
|
||||||
console.error(message)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAutoEmoji(guild, name, filename) {
|
|
||||||
let emoji = guild.emojis.find(e => e.name === name)
|
|
||||||
if (!emoji) {
|
|
||||||
console.log(` Uploading ${name}...`)
|
|
||||||
const data = fs.readFileSync(filename, null)
|
|
||||||
emoji = await discord.snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
|
|
||||||
} else {
|
|
||||||
console.log(` Reusing ${name}...`)
|
|
||||||
}
|
|
||||||
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id)
|
|
||||||
return emoji
|
|
||||||
}
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
|
||||||
|
|
||||||
// ensure registration is correctly set...
|
|
||||||
assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
|
|
||||||
assert(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls")
|
|
||||||
assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
|
||||||
assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash")
|
|
||||||
const botID = Buffer.from(config.discordToken.split(".")[0], "base64").toString()
|
|
||||||
assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format")
|
|
||||||
console.log("✅ Configuration looks good...")
|
|
||||||
|
|
||||||
// database ddl...
|
|
||||||
await migrate.migrate(db)
|
|
||||||
|
|
||||||
// add initial rows to database, like adding the bot to sim...
|
|
||||||
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
|
||||||
|
|
||||||
console.log("✅ Database is ready...")
|
|
||||||
|
|
||||||
// ensure appservice bot user is registered...
|
|
||||||
try {
|
|
||||||
await api.register(reg.sender_localpart)
|
|
||||||
} catch (e) {
|
|
||||||
if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
|
|
||||||
// "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// upload initial images...
|
|
||||||
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
|
|
||||||
|
|
||||||
console.log("✅ Matrix appservice login works...")
|
|
||||||
|
|
||||||
// upload the L1 L2 emojis to some guild
|
|
||||||
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
|
|
||||||
if (emojis.length !== 2) {
|
|
||||||
// If an argument was supplied, always use that one
|
|
||||||
let guild = null
|
|
||||||
if (args["emoji-guild"]) {
|
|
||||||
if (typeof args["emoji-guild"] === "string") {
|
|
||||||
guild = await discord.snow.guild.getGuild(args["emoji-guild"])
|
|
||||||
}
|
|
||||||
if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`)
|
|
||||||
}
|
|
||||||
// Otherwise, check if we have already registered an auto emoji guild
|
|
||||||
if (!guild) {
|
|
||||||
const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get()
|
|
||||||
if (guildID) {
|
|
||||||
guild = await discord.snow.guild.getGuild(guildID, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise, check if we should create a new guild
|
|
||||||
if (!guild) {
|
|
||||||
const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false})
|
|
||||||
if (guilds.length < 10) {
|
|
||||||
console.log(" Creating a guild for emojis...")
|
|
||||||
guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise, it's the user's problem
|
|
||||||
if (!guild) {
|
|
||||||
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run seed.js again with --emoji-guild=GUILD_ID`)
|
|
||||||
}
|
|
||||||
// Upload those emojis to the chosen location
|
|
||||||
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
|
|
||||||
await uploadAutoEmoji(guild, "L1", "docs/img/L1.png")
|
|
||||||
await uploadAutoEmoji(guild, "L2", "docs/img/L2.png")
|
|
||||||
}
|
|
||||||
console.log("✅ Emojis are ready...")
|
|
||||||
|
|
||||||
// set profile data on discord...
|
|
||||||
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
|
|
||||||
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
|
|
||||||
await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"})
|
|
||||||
console.log("✅ Discord profile updated...")
|
|
||||||
|
|
||||||
// set profile data on homeserver...
|
|
||||||
await api.profileSetDisplayname(mxid, "Out Of Your Element")
|
|
||||||
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
|
||||||
console.log("✅ Matrix profile updated...")
|
|
||||||
|
|
||||||
console.log("Good to go. I hope you enjoy Out Of Your Element.")
|
|
||||||
process.exit()
|
|
||||||
})()
|
|
356
scripts/setup.js
Normal file
356
scripts/setup.js
Normal file
|
@ -0,0 +1,356 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
|
const fs = require("fs")
|
||||||
|
const sqlite = require("better-sqlite3")
|
||||||
|
const {scheduler} = require("timers/promises")
|
||||||
|
const {isDeepStrictEqual} = require("util")
|
||||||
|
const {createServer} = require("http")
|
||||||
|
const {join} = require("path")
|
||||||
|
|
||||||
|
const {prompt} = require("enquirer")
|
||||||
|
const Input = require("enquirer/lib/prompts/input")
|
||||||
|
const fetch = require("node-fetch").default
|
||||||
|
const {magenta, bold, cyan} = require("ansi-colors")
|
||||||
|
const HeatSync = require("heatsync")
|
||||||
|
const {SnowTransfer} = require("snowtransfer")
|
||||||
|
const {createApp, defineEventHandler, toNodeListener} = require("h3")
|
||||||
|
|
||||||
|
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
|
||||||
|
|
||||||
|
// Move database file if it's still in the old location
|
||||||
|
if (fs.existsSync("db")) {
|
||||||
|
if (fs.existsSync("db/ooye.db")) {
|
||||||
|
fs.renameSync("db/ooye.db", "ooye.db")
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync("db")
|
||||||
|
if (files.length) {
|
||||||
|
console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:")
|
||||||
|
for (const file of files) {
|
||||||
|
console.error(file)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
fs.rmSync("db", {recursive: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
const passthrough = require("../src/passthrough")
|
||||||
|
const db = new sqlite("ooye.db")
|
||||||
|
const migrate = require("../src/db/migrate")
|
||||||
|
|
||||||
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
||||||
|
const orm = sync.require("../src/db/orm")
|
||||||
|
passthrough.from = orm.from
|
||||||
|
passthrough.select = orm.select
|
||||||
|
|
||||||
|
let registration = require("../src/matrix/read-registration")
|
||||||
|
let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration
|
||||||
|
|
||||||
|
function die(message) {
|
||||||
|
console.error(message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAutoEmoji(snow, guild, name, filename) {
|
||||||
|
let emoji = guild.emojis.find(e => e.name === name)
|
||||||
|
if (!emoji) {
|
||||||
|
console.log(` Uploading ${name}...`)
|
||||||
|
const data = fs.readFileSync(filename, null)
|
||||||
|
emoji = await snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
|
||||||
|
} else {
|
||||||
|
console.log(` Reusing ${name}...`)
|
||||||
|
}
|
||||||
|
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id)
|
||||||
|
return emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
|
if (!url.match(/^https?:\/\//)) return "Must be a URL"
|
||||||
|
if (url.match(/\/$/)) return "Must not end with a slash"
|
||||||
|
process.stdout.write(magenta(" checking, please wait..."))
|
||||||
|
try {
|
||||||
|
var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json())
|
||||||
|
let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "")
|
||||||
|
if (baseURL && baseURL !== url) {
|
||||||
|
serverUrlPrompt.initial = baseURL
|
||||||
|
return `Did you mean: ${bold(baseURL)}? (Enter to accept)`
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
var res = await fetch(`${url}/_matrix/client/versions`)
|
||||||
|
} catch (e) {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
if (res.status !== 200) return `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`
|
||||||
|
try {
|
||||||
|
var json = await res.json()
|
||||||
|
if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) {
|
||||||
|
return `OOYE needs Matrix version v1.11, but ${url} doesn't support this`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
// create registration file with prompts...
|
||||||
|
if (!reg) {
|
||||||
|
console.log("What is the name of your homeserver? This is the part after : in your username.")
|
||||||
|
/** @type {{server_name: string}} */
|
||||||
|
const serverNameResponse = await prompt({
|
||||||
|
type: "input",
|
||||||
|
name: "server_name",
|
||||||
|
message: "Homeserver name",
|
||||||
|
validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("What is the URL of your homeserver?")
|
||||||
|
const serverOriginPrompt = new Input({
|
||||||
|
type: "input",
|
||||||
|
name: "server_origin",
|
||||||
|
message: "Homeserver URL",
|
||||||
|
initial: () => `https://${serverNameResponse.server_name}`,
|
||||||
|
validate: url => validateHomeserverOrigin(serverOriginPrompt, url)
|
||||||
|
})
|
||||||
|
/** @type {string} */ // @ts-ignore
|
||||||
|
const serverOrigin = await serverOriginPrompt.run()
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
app.use(defineEventHandler(() => "Out Of Your Element is listening.\n"))
|
||||||
|
const server = createServer(toNodeListener(app))
|
||||||
|
await server.listen(6693)
|
||||||
|
|
||||||
|
console.log("OOYE has its own web server. It needs to be accessible on the public internet.")
|
||||||
|
console.log("You need to enter a public URL where you will be able to host this web server.")
|
||||||
|
console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.")
|
||||||
|
console.log("Now listening on port 6693. Feel free to send some test requests.")
|
||||||
|
/** @type {{bridge_origin: string}} */
|
||||||
|
const bridgeOriginResponse = await prompt({
|
||||||
|
type: "input",
|
||||||
|
name: "bridge_origin",
|
||||||
|
message: "URL to reach OOYE",
|
||||||
|
initial: () => `https://bridge.${serverNameResponse.server_name}`,
|
||||||
|
validate: async url => {
|
||||||
|
process.stdout.write(magenta(" checking, please wait..."))
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.status !== 200) return `Server returned status code ${res.status}`
|
||||||
|
const text = await res.text()
|
||||||
|
if (text !== "Out Of Your Element is listening.\n") return `Server does not point to OOYE`
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash
|
||||||
|
|
||||||
|
await server.close()
|
||||||
|
|
||||||
|
console.log("What is your Discord bot token?")
|
||||||
|
/** @type {SnowTransfer} */ // @ts-ignore
|
||||||
|
let snow = null
|
||||||
|
/** @type {{id: string, redirect_uris: string[]}} */ // @ts-ignore
|
||||||
|
let client = null
|
||||||
|
/** @type {{discord_token: string}} */
|
||||||
|
const discordTokenResponse = await prompt({
|
||||||
|
type: "input",
|
||||||
|
name: "discord_token",
|
||||||
|
message: "Bot token",
|
||||||
|
validate: async token => {
|
||||||
|
process.stdout.write(magenta(" checking, please wait..."))
|
||||||
|
try {
|
||||||
|
snow = new SnowTransfer(token)
|
||||||
|
client = await snow.requestHandler.request(`/applications/@me`, {}, "get")
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("What is your Discord client secret?")
|
||||||
|
console.log(`You can find it on the application page: https://discord.com/developers/applications/${client.id}/oauth2`)
|
||||||
|
/** @type {{discord_client_secret: string}} */
|
||||||
|
const clientSecretResponse = await prompt({
|
||||||
|
type: "input",
|
||||||
|
name: "discord_client_secret",
|
||||||
|
message: "Client secret"
|
||||||
|
})
|
||||||
|
|
||||||
|
const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth`
|
||||||
|
if (!client.redirect_uris.includes(expectedUri)) {
|
||||||
|
console.log(`On the same application page, go to the Redirects section, and add this URI: ${cyan(expectedUri)}`)
|
||||||
|
await prompt({
|
||||||
|
type: "invisible",
|
||||||
|
name: "redirect_uri",
|
||||||
|
message: "Press Enter when you've added it",
|
||||||
|
validate: async token => {
|
||||||
|
process.stdout.write(magenta("checking, please wait..."))
|
||||||
|
client = await snow.requestHandler.request(`/applications/@me`, {}, "get")
|
||||||
|
if (client.redirect_uris.includes(expectedUri)) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return "Redirect URI has not been added yet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = getTemplateRegistration(serverNameResponse.server_name)
|
||||||
|
reg = {
|
||||||
|
...template,
|
||||||
|
url: bridgeOriginResponse.bridge_origin,
|
||||||
|
ooye: {
|
||||||
|
...template.ooye,
|
||||||
|
...bridgeOriginResponse,
|
||||||
|
server_origin: serverOrigin,
|
||||||
|
...discordTokenResponse,
|
||||||
|
...clientSecretResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registration.reg = reg
|
||||||
|
checkRegistration(reg)
|
||||||
|
writeRegistration(reg)
|
||||||
|
console.log(`✅ Registration file saved as ${registrationFilePath}`)
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Valid registration file found at ${registrationFilePath}`)
|
||||||
|
}
|
||||||
|
console.log(` In ${cyan("Synapse")}, you need to add it to homeserver.yaml and ${cyan("restart Synapse")}.`)
|
||||||
|
console.log(" https://element-hq.github.io/synapse/latest/application_services.html")
|
||||||
|
console.log(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`)
|
||||||
|
console.log(" https://docs.conduit.rs/appservices.html")
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// Done with user prompts, reg is now guaranteed to be valid
|
||||||
|
const api = require("../src/matrix/api")
|
||||||
|
const file = require("../src/matrix/file")
|
||||||
|
const utils = require("../src/m2d/converters/utils")
|
||||||
|
const DiscordClient = require("../src/d2m/discord-client")
|
||||||
|
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
||||||
|
passthrough.discord = discord
|
||||||
|
|
||||||
|
const {as} = require("../src/matrix/appservice")
|
||||||
|
console.log("⏳ Waiting until homeserver registration works... (Ctrl+C to cancel)")
|
||||||
|
|
||||||
|
let itWorks = false
|
||||||
|
let lastError = null
|
||||||
|
do {
|
||||||
|
const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
|
||||||
|
// If it didn't work, log details and retry after some time
|
||||||
|
itWorks = result.ok
|
||||||
|
if (!itWorks) {
|
||||||
|
// Log the full error data if the error is different to last time
|
||||||
|
if (!isDeepStrictEqual(lastError, result.root)) {
|
||||||
|
if (typeof result.root === "string") {
|
||||||
|
console.log(`\nCannot reach homeserver: ${result.root}`)
|
||||||
|
} else if (result.root.error) {
|
||||||
|
console.log(`\nHomeserver said: [${result.status}] ${result.root.error}`)
|
||||||
|
} else {
|
||||||
|
console.log(`\nHomeserver said: [${result.status}] ${JSON.stringify(result.root)}`)
|
||||||
|
}
|
||||||
|
lastError = result.root
|
||||||
|
} else {
|
||||||
|
process.stderr.write(".")
|
||||||
|
}
|
||||||
|
await scheduler.wait(5000)
|
||||||
|
}
|
||||||
|
} while (!itWorks)
|
||||||
|
console.log("")
|
||||||
|
|
||||||
|
as.close().catch(() => {})
|
||||||
|
|
||||||
|
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||||
|
|
||||||
|
// ensure registration is correctly set...
|
||||||
|
assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
|
||||||
|
assert(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls")
|
||||||
|
assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
||||||
|
assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash")
|
||||||
|
const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString()
|
||||||
|
assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format")
|
||||||
|
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
||||||
|
|
||||||
|
console.log("✅ Configuration looks good...")
|
||||||
|
|
||||||
|
// database ddl...
|
||||||
|
await migrate.migrate(db)
|
||||||
|
|
||||||
|
// add initial rows to database, like adding the bot to sim...
|
||||||
|
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
||||||
|
|
||||||
|
console.log("✅ Database is ready...")
|
||||||
|
|
||||||
|
// ensure appservice bot user is registered...
|
||||||
|
try {
|
||||||
|
await api.register(reg.sender_localpart)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
|
||||||
|
// "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload initial images...
|
||||||
|
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
|
||||||
|
|
||||||
|
console.log("✅ Matrix appservice login works...")
|
||||||
|
|
||||||
|
// upload the L1 L2 emojis to some guild
|
||||||
|
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
|
||||||
|
if (emojis.length !== 2) {
|
||||||
|
// If an argument was supplied, always use that one
|
||||||
|
let guild = null
|
||||||
|
if (args["emoji-guild"]) {
|
||||||
|
if (typeof args["emoji-guild"] === "string") {
|
||||||
|
guild = await discord.snow.guild.getGuild(args["emoji-guild"])
|
||||||
|
}
|
||||||
|
if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`)
|
||||||
|
}
|
||||||
|
// Otherwise, check if we have already registered an auto emoji guild
|
||||||
|
if (!guild) {
|
||||||
|
const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get()
|
||||||
|
if (guildID) {
|
||||||
|
guild = await discord.snow.guild.getGuild(guildID, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, check if we should create a new guild
|
||||||
|
if (!guild) {
|
||||||
|
const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false})
|
||||||
|
if (guilds.length < 10) {
|
||||||
|
console.log(" Creating a guild for emojis...")
|
||||||
|
guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, it's the user's problem
|
||||||
|
if (!guild) {
|
||||||
|
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run setup again with --emoji-guild=GUILD_ID`)
|
||||||
|
}
|
||||||
|
// Upload those emojis to the chosen location
|
||||||
|
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
|
||||||
|
await uploadAutoEmoji(discord.snow, guild, "L1", join(__dirname, "../docs/img/L1.png"))
|
||||||
|
await uploadAutoEmoji(discord.snow, guild, "L2", join(__dirname, "../docs/img/L2.png"))
|
||||||
|
}
|
||||||
|
console.log("✅ Emojis are ready...")
|
||||||
|
|
||||||
|
// set profile data on discord...
|
||||||
|
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
|
||||||
|
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
|
||||||
|
await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"})
|
||||||
|
console.log("✅ Discord profile updated...")
|
||||||
|
|
||||||
|
// set profile data on homeserver...
|
||||||
|
console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)")
|
||||||
|
await api.profileSetDisplayname(mxid, "Out Of Your Element")
|
||||||
|
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
||||||
|
console.log("✅ Matrix profile updated...")
|
||||||
|
|
||||||
|
console.log("Good to go. I hope you enjoy Out Of Your Element.")
|
||||||
|
process.exit()
|
||||||
|
})()
|
41
scripts/start-server.js
Executable file
41
scripts/start-server.js
Executable file
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const {createServer} = require("http")
|
||||||
|
const EventEmitter = require("events")
|
||||||
|
const {createApp, createRouter, toNodeListener} = require("h3")
|
||||||
|
const sqlite = require("better-sqlite3")
|
||||||
|
const migrate = require("../src/db/migrate")
|
||||||
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
|
const {reg} = require("../src/matrix/read-registration")
|
||||||
|
const passthrough = require("../src/passthrough")
|
||||||
|
const db = new sqlite("ooye.db")
|
||||||
|
|
||||||
|
const sync = new HeatSync()
|
||||||
|
|
||||||
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
||||||
|
const DiscordClient = require("../src/d2m/discord-client")
|
||||||
|
|
||||||
|
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
||||||
|
passthrough.discord = discord
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
const router = createRouter()
|
||||||
|
app.use(router)
|
||||||
|
const server = createServer(toNodeListener(app))
|
||||||
|
server.listen(reg.socket || new URL(reg.url).port)
|
||||||
|
const as = Object.assign(new EventEmitter(), {app, router, server}) // @ts-ignore
|
||||||
|
passthrough.as = as
|
||||||
|
|
||||||
|
const orm = sync.require("../src/db/orm")
|
||||||
|
passthrough.from = orm.from
|
||||||
|
passthrough.select = orm.select
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
await migrate.migrate(db)
|
||||||
|
await discord.cloud.connect()
|
||||||
|
console.log("Discord gateway started")
|
||||||
|
sync.require("../src/web/server")
|
||||||
|
})()
|
3
scripts/wal.js
Normal file → Executable file
3
scripts/wal.js
Normal file → Executable file
|
@ -1,6 +1,7 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const db = new sqlite("db/ooye.db", {fileMustExist: true})
|
const db = new sqlite("ooye.db", {fileMustExist: true})
|
||||||
db.pragma("journal_mode = wal")
|
db.pragma("journal_mode = wal")
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const reg = require("../../matrix/read-registration")
|
const Ty = require("../../types")
|
||||||
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
|
@ -14,8 +15,6 @@ const api = sync.require("../../matrix/api")
|
||||||
const ks = sync.require("../../matrix/kstate")
|
const ks = sync.require("../../matrix/kstate")
|
||||||
/** @type {import("../../discord/utils")} */
|
/** @type {import("../../discord/utils")} */
|
||||||
const utils = sync.require("../../discord/utils")
|
const utils = sync.require("../../discord/utils")
|
||||||
/** @type {import("./create-space")}) */
|
|
||||||
const createSpace = sync.require("./create-space") // watch out for the require loop
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There are 3 levels of room privacy:
|
* There are 3 levels of room privacy:
|
||||||
|
@ -89,41 +88,36 @@ function convertNameAndTopic(channel, guild, customName) {
|
||||||
* Async because it may create the guild and/or upload the guild icon to mxc.
|
* Async because it may create the guild and/or upload the guild icon to mxc.
|
||||||
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
|
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API
|
||||||
*/
|
*/
|
||||||
async function channelToKState(channel, guild) {
|
async function channelToKState(channel, guild, di) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const parentChannel = discord.channels.get(channel.parent_id)
|
const parentChannel = discord.channels.get(channel.parent_id)
|
||||||
/** Used for membership/permission checks. */
|
const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
||||||
let guildSpaceID
|
assert(guildRow)
|
||||||
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
|
|
||||||
let parentSpaceID
|
|
||||||
let privacyLevel
|
|
||||||
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads
|
|
||||||
guildSpaceID = await createSpace.ensureSpace(guild)
|
|
||||||
parentSpaceID = await ensureRoom(channel.parent_id)
|
|
||||||
privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get()
|
|
||||||
} else { // otherwise use the guild's space like usual
|
|
||||||
parentSpaceID = await createSpace.ensureSpace(guild)
|
|
||||||
guildSpaceID = parentSpaceID
|
|
||||||
privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get()
|
|
||||||
}
|
|
||||||
assert(typeof parentSpaceID === "string")
|
|
||||||
assert(typeof guildSpaceID === "string")
|
|
||||||
assert(typeof privacyLevel === "number")
|
|
||||||
|
|
||||||
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
/** Used for membership/permission checks. */
|
||||||
const customName = row?.nick
|
let guildSpaceID = guildRow.space_id
|
||||||
const customAvatar = row?.custom_avatar
|
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
|
||||||
|
let parentSpaceID = guildSpaceID
|
||||||
|
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
|
||||||
|
parentSpaceID = await ensureRoom(channel.parent_id)
|
||||||
|
assert(typeof parentSpaceID === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
||||||
|
const customName = channelRow?.nick
|
||||||
|
const customAvatar = channelRow?.custom_avatar
|
||||||
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
|
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
|
||||||
|
|
||||||
const avatarEventContent = {}
|
const avatarEventContent = {}
|
||||||
if (customAvatar) {
|
if (customAvatar) {
|
||||||
avatarEventContent.url = customAvatar
|
avatarEventContent.url = customAvatar
|
||||||
} else if (guild.icon) {
|
} else if (guild.icon) {
|
||||||
avatarEventContent.discord_path = file.guildIcon(guild)
|
avatarEventContent.url = {$url: 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const privacyLevel = guildRow.privacy_level
|
||||||
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
||||||
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
||||||
|
|
||||||
|
@ -142,6 +136,13 @@ async function channelToKState(channel, guild) {
|
||||||
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
|
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
|
||||||
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
|
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
|
||||||
|
|
||||||
|
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
|
||||||
|
const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
|
||||||
|
|
||||||
|
/** @type {Ty.Event.M_Power_Levels} */
|
||||||
|
const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "")
|
||||||
|
const spacePower = spacePowerEvent.users
|
||||||
|
|
||||||
const channelKState = {
|
const channelKState = {
|
||||||
"m.room.name/": {name: convertedName},
|
"m.room.name/": {name: convertedName},
|
||||||
"m.room.topic/": {topic: convertedTopic},
|
"m.room.topic/": {topic: convertedTopic},
|
||||||
|
@ -155,13 +156,10 @@ async function channelToKState(channel, guild) {
|
||||||
/** @type {{join_rule: string, [x: string]: any}} */
|
/** @type {{join_rule: string, [x: string]: any}} */
|
||||||
"m.room.join_rules/": join_rules,
|
"m.room.join_rules/": join_rules,
|
||||||
"m.room.power_levels/": {
|
"m.room.power_levels/": {
|
||||||
events: {
|
|
||||||
"m.room.avatar": 0
|
|
||||||
},
|
|
||||||
notifications: {
|
notifications: {
|
||||||
room: everyoneCanMentionEveryone ? 0 : 20
|
room: everyoneCanMentionEveryone ? 0 : 20
|
||||||
},
|
},
|
||||||
users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})
|
users: {...spacePower, ...globalAdminPower}
|
||||||
},
|
},
|
||||||
"chat.schildi.hide_ui/read_receipts": {
|
"chat.schildi.hide_ui/read_receipts": {
|
||||||
hidden: true
|
hidden: true
|
||||||
|
@ -175,7 +173,7 @@ async function channelToKState(channel, guild) {
|
||||||
network: {
|
network: {
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
displayname: guild.name,
|
displayname: guild.name,
|
||||||
avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild))
|
avatar_url: {$url: file.guildIcon(guild)}
|
||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
|
@ -274,6 +272,61 @@ function channelToGuild(channel) {
|
||||||
return guild
|
return guild
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function handles whether it's allowed to bridge messages in this channel, and if so, where to.
|
||||||
|
* This has to account for whether self-service is enabled for the guild or not.
|
||||||
|
* This also has to account for different channel types, like forum channels (which need the
|
||||||
|
* parent forum to already exist, and ignore the self-service setting), or thread channels (which
|
||||||
|
* need the parent channel to already exist, and ignore the self-service setting).
|
||||||
|
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
|
||||||
|
* @param {string} guildID
|
||||||
|
* @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread
|
||||||
|
*/
|
||||||
|
function existsOrAutocreatable(channel, guildID) {
|
||||||
|
// 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee!
|
||||||
|
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get()
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
// 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and
|
||||||
|
// we'll need to create any dependent resources recursively.
|
||||||
|
const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get()
|
||||||
|
if (autocreate === 1) return autocreate
|
||||||
|
|
||||||
|
// 3. If the guild is not approved for bridging yet, we can't bridge there.
|
||||||
|
// They need to decide one way or another whether it's self-service before we can continue.
|
||||||
|
if (autocreate == null) return autocreate
|
||||||
|
|
||||||
|
// 4. If we got here, the guild is in self-service mode.
|
||||||
|
// New channels won't be able to create new rooms. But forum threads or channel threads could be fine.
|
||||||
|
if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
|
||||||
|
// In self-service mode, threads rely on the parent resource already existing.
|
||||||
|
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
|
||||||
|
const parent = discord.channels.get(channel.parent_id)
|
||||||
|
assert(parent)
|
||||||
|
const parentExisting = existsOrAutocreatable(parent, guildID)
|
||||||
|
if (parentExisting) return 1 // Autocreatable
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. If we got here, the guild is in self-service mode and the channel is truly not bridged.
|
||||||
|
return autocreate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
|
||||||
|
* @param {string} guildID
|
||||||
|
* @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable)
|
||||||
|
*/
|
||||||
|
function assertExistsOrAutocreatable(channel, guildID) {
|
||||||
|
const existing = existsOrAutocreatable(channel, guildID)
|
||||||
|
if (existing === 0) {
|
||||||
|
throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`)
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`)
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Ensure flow:
|
Ensure flow:
|
||||||
1. Get IDs
|
1. Get IDs
|
||||||
|
@ -292,6 +345,7 @@ function channelToGuild(channel) {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this.
|
||||||
* @param {string} channelID
|
* @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)
|
* @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
|
* @returns {Promise<string>} room ID
|
||||||
|
@ -306,11 +360,11 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get()
|
const existing = assertExistsOrAutocreatable(channel, guild.id)
|
||||||
|
|
||||||
if (!existing) {
|
if (existing === 1) {
|
||||||
const creation = (async () => {
|
const creation = (async () => {
|
||||||
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild)
|
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api})
|
||||||
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
|
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
|
||||||
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
|
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
|
||||||
return roomID
|
return roomID
|
||||||
|
@ -327,7 +381,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
|
|
||||||
console.log(`[room sync] to matrix: ${channel.name}`)
|
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
|
const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing
|
||||||
|
|
||||||
// sync channel state to room
|
// sync channel state to room
|
||||||
const roomKState = await roomToKState(roomID)
|
const roomKState = await roomToKState(roomID)
|
||||||
|
@ -348,12 +402,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
return roomID
|
return roomID
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */
|
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
||||||
function ensureRoom(channelID) {
|
function ensureRoom(channelID) {
|
||||||
return _syncRoom(channelID, false)
|
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. */
|
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
||||||
function syncRoom(channelID) {
|
function syncRoom(channelID) {
|
||||||
return _syncRoom(channelID, true)
|
return _syncRoom(channelID, true)
|
||||||
}
|
}
|
||||||
|
@ -362,11 +416,16 @@ async function _unbridgeRoom(channelID) {
|
||||||
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
||||||
const channel = discord.channels.get(channelID)
|
const channel = discord.channels.get(channelID)
|
||||||
assert.ok(channel)
|
assert.ok(channel)
|
||||||
return unbridgeDeletedChannel(channel.id, channel.guild_id)
|
assert.ok(channel.guild_id)
|
||||||
|
return unbridgeDeletedChannel(channel, channel.guild_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unbridgeDeletedChannel(channelID, guildID) {
|
/**
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
* @param {{id: string, topic?: string?}} channel
|
||||||
|
* @param {string} guildID
|
||||||
|
*/
|
||||||
|
async function unbridgeDeletedChannel(channel, guildID) {
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
assert.ok(roomID)
|
assert.ok(roomID)
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
|
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
|
||||||
assert.ok(spaceID)
|
assert.ok(spaceID)
|
||||||
|
@ -376,7 +435,11 @@ async function unbridgeDeletedChannel(channelID, guildID) {
|
||||||
await api.sendState(spaceID, "m.space.child", roomID, {})
|
await api.sendState(spaceID, "m.space.child", roomID, {})
|
||||||
|
|
||||||
// remove declaration that the room is bridged
|
// remove declaration that the room is bridged
|
||||||
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channelID}`, {})
|
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
|
||||||
|
if ("topic" in channel) {
|
||||||
|
// previously the Matrix topic would say the channel ID. we should remove that
|
||||||
|
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""})
|
||||||
|
}
|
||||||
|
|
||||||
// send a notification in the room
|
// send a notification in the room
|
||||||
await api.sendEvent(roomID, "m.room.message", {
|
await api.sendEvent(roomID, "m.room.message", {
|
||||||
|
@ -388,8 +451,7 @@ async function unbridgeDeletedChannel(channelID, guildID) {
|
||||||
await api.leaveRoom(roomID)
|
await api.leaveRoom(roomID)
|
||||||
|
|
||||||
// delete room from database
|
// delete room from database
|
||||||
const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID)
|
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id)
|
||||||
assert.equal(changes, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -442,3 +504,5 @@ module.exports.postApplyPowerLevels = postApplyPowerLevels
|
||||||
module.exports._convertNameAndTopic = convertNameAndTopic
|
module.exports._convertNameAndTopic = convertNameAndTopic
|
||||||
module.exports._unbridgeRoom = _unbridgeRoom
|
module.exports._unbridgeRoom = _unbridgeRoom
|
||||||
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
||||||
|
module.exports.existsOrAutocreatable = existsOrAutocreatable
|
||||||
|
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable
|
|
@ -4,50 +4,94 @@ const mixin = require("@cloudrac3r/mixin-deep")
|
||||||
const {channelToKState, _convertNameAndTopic} = require("./create-room")
|
const {channelToKState, _convertNameAndTopic} = require("./create-room")
|
||||||
const {kstateStripConditionals} = require("../../matrix/kstate")
|
const {kstateStripConditionals} = require("../../matrix/kstate")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const testData = require("../../test/data")
|
const testData = require("../../../test/data")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {db} = passthrough
|
const {db} = passthrough
|
||||||
|
|
||||||
|
|
||||||
test("channel2room: discoverable privacy room", async t => {
|
test("channel2room: discoverable privacy room", async t => {
|
||||||
|
let called = 0
|
||||||
|
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {users: {"@example:matrix.org": 50}}
|
||||||
|
}
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
|
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||||
Object.assign({}, testData.room.general, {
|
Object.assign({}, testData.room.general, {
|
||||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
"m.room.guest_access/": {guest_access: "forbidden"},
|
||||||
"m.room.join_rules/": {join_rule: "public"},
|
"m.room.join_rules/": {join_rule: "public"},
|
||||||
"m.room.history_visibility/": {history_visibility: "world_readable"}
|
"m.room.history_visibility/": {history_visibility: "world_readable"},
|
||||||
|
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("channel2room: linkable privacy room", async t => {
|
test("channel2room: linkable privacy room", async t => {
|
||||||
|
let called = 0
|
||||||
|
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {users: {"@example:matrix.org": 50}}
|
||||||
|
}
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
|
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||||
Object.assign({}, testData.room.general, {
|
Object.assign({}, testData.room.general, {
|
||||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
"m.room.guest_access/": {guest_access: "forbidden"},
|
||||||
"m.room.join_rules/": {join_rule: "public"}
|
"m.room.join_rules/": {join_rule: "public"},
|
||||||
|
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("channel2room: invite-only privacy room", async t => {
|
test("channel2room: invite-only privacy room", async t => {
|
||||||
|
let called = 0
|
||||||
|
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {users: {"@example:matrix.org": 50}}
|
||||||
|
}
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
|
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||||
testData.room.general
|
Object.assign({}, testData.room.general, {
|
||||||
|
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("channel2room: room where limited people can mention everyone", async t => {
|
test("channel2room: room where limited people can mention everyone", async t => {
|
||||||
|
let called = 0
|
||||||
|
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {users: {"@example:matrix.org": 50}}
|
||||||
|
}
|
||||||
const limitedGuild = mixin({}, testData.guild.general)
|
const limitedGuild = mixin({}, testData.guild.general)
|
||||||
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
|
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
|
||||||
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}})
|
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {
|
||||||
|
notifications: {room: 20},
|
||||||
|
users: {"@example:matrix.org": 50}
|
||||||
|
}})
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||||
limitedRoom
|
limitedRoom
|
||||||
)
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("convertNameAndTopic: custom name and topic", t => {
|
test("convertNameAndTopic: custom name and topic", t => {
|
|
@ -4,7 +4,7 @@ const assert = require("assert").strict
|
||||||
const {isDeepStrictEqual} = require("util")
|
const {isDeepStrictEqual} = require("util")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const reg = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
|
@ -31,6 +31,8 @@ async function createSpace(guild, kstate) {
|
||||||
const topic = kstate["m.room.topic/"]?.topic || undefined
|
const topic = kstate["m.room.topic/"]?.topic || undefined
|
||||||
assert(name)
|
assert(name)
|
||||||
|
|
||||||
|
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
|
||||||
|
|
||||||
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
||||||
return api.createRoom({
|
return api.createRoom({
|
||||||
name,
|
name,
|
||||||
|
@ -40,7 +42,7 @@ async function createSpace(guild, kstate) {
|
||||||
events_default: 100, // space can only be managed by bridge
|
events_default: 100, // space can only be managed by bridge
|
||||||
invite: 0 // any existing member can invite others
|
invite: 0 // any existing member can invite others
|
||||||
},
|
},
|
||||||
invite: reg.ooye.invite,
|
invite: globalAdmins,
|
||||||
topic,
|
topic,
|
||||||
creation_content: {
|
creation_content: {
|
||||||
type: "m.space"
|
type: "m.space"
|
||||||
|
@ -58,17 +60,17 @@ async function createSpace(guild, kstate) {
|
||||||
*/
|
*/
|
||||||
async function guildToKState(guild, privacyLevel) {
|
async function guildToKState(guild, privacyLevel) {
|
||||||
assert.equal(typeof privacyLevel, "number")
|
assert.equal(typeof privacyLevel, "number")
|
||||||
|
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
|
||||||
const guildKState = {
|
const guildKState = {
|
||||||
"m.room.name/": {name: guild.name},
|
"m.room.name/": {name: guild.name},
|
||||||
"m.room.avatar/": {
|
"m.room.avatar/": {
|
||||||
$if: guild.icon,
|
$if: guild.icon,
|
||||||
discord_path: file.guildIcon(guild),
|
|
||||||
url: {$url: file.guildIcon(guild)}
|
url: {$url: file.guildIcon(guild)}
|
||||||
},
|
},
|
||||||
"m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
"m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
||||||
"m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]},
|
"m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]},
|
||||||
"m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]},
|
"m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]},
|
||||||
"m.room.power_levels/": {users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})}
|
"m.room.power_levels/": {users: globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})} // used in guild initial creation postApplyPowerLevels
|
||||||
}
|
}
|
||||||
|
|
||||||
return guildKState
|
return guildKState
|
||||||
|
@ -90,6 +92,9 @@ async function _syncSpace(guild, shouldActuallySync) {
|
||||||
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
|
||||||
|
assert.equal(autocreate, 1, `refusing to implicitly create guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`)
|
||||||
|
|
||||||
const creation = (async () => {
|
const creation = (async () => {
|
||||||
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
|
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
|
||||||
const spaceID = await createSpace(guild, guildKState)
|
const spaceID = await createSpace(guild, guildKState)
|
||||||
|
@ -182,24 +187,15 @@ async function syncSpaceFully(guildID) {
|
||||||
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
||||||
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
|
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||||
|
|
||||||
/** @type {string[]} room IDs */
|
const childRooms = await api.getFullHierarchy(spaceID)
|
||||||
let childRooms = []
|
|
||||||
/** @type {string | undefined} */
|
|
||||||
let nextBatch = undefined
|
|
||||||
do {
|
|
||||||
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
|
|
||||||
const res = await api.getHierarchy(spaceID, {from: nextBatch})
|
|
||||||
childRooms.push(...res.rooms.map(room => room.room_id))
|
|
||||||
nextBatch = res.next_batch
|
|
||||||
} while (nextBatch)
|
|
||||||
|
|
||||||
for (const roomID of childRooms) {
|
for (const {room_id} of childRooms) {
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id}).pluck().get()
|
||||||
if (!channelID) continue
|
if (!channelID) continue
|
||||||
if (discord.channels.has(channelID)) {
|
if (discord.channels.has(channelID)) {
|
||||||
await createRoom.syncRoom(channelID)
|
await createRoom.syncRoom(channelID)
|
||||||
} else {
|
} else {
|
||||||
await createRoom.unbridgeDeletedChannel(channelID, guildID)
|
await createRoom.unbridgeDeletedChannel({id: channelID}, guildID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ const mixin = require("@cloudrac3r/mixin-deep")
|
||||||
const {guildToKState, ensureSpace} = require("./create-space")
|
const {guildToKState, ensureSpace} = require("./create-space")
|
||||||
const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate")
|
const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const testData = require("../../test/data")
|
const testData = require("../../../test/data")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {db} = passthrough
|
const {db} = passthrough
|
||||||
|
@ -14,7 +14,6 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as
|
||||||
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
|
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
|
||||||
{
|
{
|
||||||
"m.room.avatar/": {
|
"m.room.avatar/": {
|
||||||
discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024",
|
|
||||||
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
|
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
|
||||||
},
|
},
|
||||||
"m.room.guest_access/": {
|
"m.room.guest_access/": {
|
|
@ -53,7 +53,7 @@ async function editMessage(message, guild, row) {
|
||||||
const sendNewEventParts = new Set()
|
const sendNewEventParts = new Set()
|
||||||
for (const promotion of promotions) {
|
for (const promotion of promotions) {
|
||||||
if ("eventID" in promotion) {
|
if ("eventID" in promotion) {
|
||||||
db.prepare(`UPDATE event_message SET ${promotion.column} = 0 WHERE event_id = ?`).run(promotion.eventID)
|
db.prepare(`UPDATE event_message SET ${promotion.column} = ? WHERE event_id = ?`).run(promotion.value ?? 0, promotion.eventID)
|
||||||
} else if ("nextEvent" in promotion) {
|
} else if ("nextEvent" in promotion) {
|
||||||
sendNewEventParts.add(promotion.column)
|
sendNewEventParts.add(promotion.column)
|
||||||
}
|
}
|
|
@ -30,7 +30,7 @@ async function emojisToState(emojis) {
|
||||||
}
|
}
|
||||||
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url)
|
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url)
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
if (e.data.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
|
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
|
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
|
||||||
|
@ -66,7 +66,7 @@ async function stickersToState(stickers) {
|
||||||
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
|
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
|
||||||
shortcodes.push(shortcode)
|
shortcodes.push(shortcode)
|
||||||
|
|
||||||
result.images[shortcodes] = {
|
result.images[shortcode] = {
|
||||||
info: {
|
info: {
|
||||||
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
|
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
|
||||||
},
|
},
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
const assert = require("assert")
|
||||||
const reg = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const fetch = require("node-fetch").default
|
const fetch = require("node-fetch").default
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const reg = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const mixin = require("@cloudrac3r/mixin-deep")
|
const mixin = require("@cloudrac3r/mixin-deep")
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const {_memberToStateContent} = require("./register-user")
|
const {_memberToStateContent} = require("./register-user")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const testData = require("../../test/data")
|
const testData = require("../../../test/data")
|
||||||
|
|
||||||
test("member2state: without member nick or avatar", async t => {
|
test("member2state: without member nick or avatar", async t => {
|
||||||
t.deepEqual(
|
t.deepEqual(
|
|
@ -23,16 +23,7 @@ async function removeSomeReactions(data) {
|
||||||
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
||||||
if (!eventIDForMessage) return
|
if (!eventIDForMessage) return
|
||||||
|
|
||||||
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
|
const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation")
|
||||||
let reactions = []
|
|
||||||
/** @type {string | undefined} */
|
|
||||||
let nextBatch = undefined
|
|
||||||
do {
|
|
||||||
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
|
|
||||||
const res = await api.getRelations(roomID, eventIDForMessage, {from: nextBatch}, "m.annotation")
|
|
||||||
reactions = reactions.concat(res.chunk)
|
|
||||||
nextBatch = res.next_batch
|
|
||||||
} while (nextBatch)
|
|
||||||
|
|
||||||
// Run the proper strategy and any strategy-specific database changes
|
// Run the proper strategy and any strategy-specific database changes
|
||||||
const removals = await
|
const removals = await
|
|
@ -12,6 +12,7 @@ function debugRetrigger(message) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paused = new Set()
|
||||||
const emitter = new EventEmitter()
|
const emitter = new EventEmitter()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -25,13 +26,15 @@ const emitter = new EventEmitter()
|
||||||
* @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
|
* @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
|
||||||
*/
|
*/
|
||||||
function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
|
function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
|
||||||
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
if (!paused.has(messageID)) {
|
||||||
if (eventID) {
|
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
||||||
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
|
if (eventID) {
|
||||||
return false // event was found so don't retrigger
|
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
|
||||||
|
return false // event was found so don't retrigger
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`)
|
debugRetrigger(`[retrigger] WAIT mid = ${messageID}`)
|
||||||
emitter.once(messageID, () => {
|
emitter.once(messageID, () => {
|
||||||
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
|
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
|
||||||
fn(...rest)
|
fn(...rest)
|
||||||
|
@ -46,6 +49,25 @@ function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
|
||||||
return true // event was not found, then retrigger
|
return true // event was not found, then retrigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
|
||||||
|
* @template T
|
||||||
|
* @param {string} messageID
|
||||||
|
* @param {Promise<T>} promise
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
async function pauseChanges(messageID, promise) {
|
||||||
|
try {
|
||||||
|
debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`)
|
||||||
|
paused.add(messageID)
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
debugRetrigger(`[retrigger] RESUME mid = ${messageID}`)
|
||||||
|
paused.delete(messageID)
|
||||||
|
messageFinishedBridging(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers any pending operations that were waiting on the corresponding event ID.
|
* Triggers any pending operations that were waiting on the corresponding event ID.
|
||||||
* @param {string} messageID
|
* @param {string} messageID
|
||||||
|
@ -59,3 +81,4 @@ function messageFinishedBridging(messageID) {
|
||||||
|
|
||||||
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
|
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
|
||||||
module.exports.messageFinishedBridging = messageFinishedBridging
|
module.exports.messageFinishedBridging = messageFinishedBridging
|
||||||
|
module.exports.pauseChanges = pauseChanges
|
|
@ -24,14 +24,13 @@ function eventCanBeEdited(ev) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
* @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("discord-api-types/v10").APIGuild} guild
|
||||||
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
|
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
|
||||||
*/
|
*/
|
||||||
async function editToChanges(message, guild, api) {
|
async function editToChanges(message, guild, api) {
|
||||||
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
|
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
|
||||||
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
|
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
|
||||||
|
// Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems.
|
||||||
|
|
||||||
const isGeneratedEmbed = !("content" in message)
|
const isGeneratedEmbed = !("content" in message)
|
||||||
|
|
||||||
|
@ -123,28 +122,44 @@ async function editToChanges(message, guild, api) {
|
||||||
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
|
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
|
||||||
|
|
||||||
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
||||||
/** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */
|
// This would be disrupted if existing events that are (reaction_)part = 0 will be redacted.
|
||||||
|
// If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0.
|
||||||
|
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
|
||||||
const promotions = []
|
const promotions = []
|
||||||
for (const column of ["part", "reaction_part"]) {
|
for (const column of ["part", "reaction_part"]) {
|
||||||
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
|
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
|
||||||
// If no events with part = 0 exist (or will exist), we need to do some management.
|
// If no events with part = 0 exist (or will exist), we need to do some management.
|
||||||
if (!candidatesForParts.some(e => e.old[column] === 0)) {
|
if (!candidatesForParts.some(e => e.old[column] === 0)) {
|
||||||
|
// Try to find an existing event to promote. Bigger order is better.
|
||||||
if (candidatesForParts.length) {
|
if (candidatesForParts.length) {
|
||||||
// We can choose an existing event to promote. Bigger order is better.
|
|
||||||
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
|
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
|
||||||
candidatesForParts.sort((a, b) => order(b) - order(a))
|
candidatesForParts.sort((a, b) => order(b) - order(a))
|
||||||
if (column === "part") {
|
if (column === "part") {
|
||||||
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
|
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
|
||||||
|
} else if (eventsToSend.length) {
|
||||||
|
promotions.push({column, nextEvent: true}) // reaction_part should be the last one
|
||||||
} else {
|
} else {
|
||||||
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
|
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
|
// Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0.
|
||||||
|
else {
|
||||||
promotions.push({column, nextEvent: true})
|
promotions.push({column, nextEvent: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
|
||||||
|
if (eventsToSend.length && !promotions.length) {
|
||||||
|
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
|
||||||
|
if (!existingReaction) {
|
||||||
|
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0)
|
||||||
|
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
|
||||||
|
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
|
||||||
|
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Removing unnecessary properties before returning
|
// Removing unnecessary properties before returning
|
||||||
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
|
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
|
||||||
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
|
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
|
|
@ -1,6 +1,6 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {editToChanges} = require("./edit-to-changes")
|
const {editToChanges} = require("./edit-to-changes")
|
||||||
const data = require("../../test/data")
|
const data = require("../../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
test("edit2changes: edit by webhook", async t => {
|
test("edit2changes: edit by webhook", async t => {
|
||||||
|
@ -99,9 +99,9 @@ test("edit2changes: change file type", async t => {
|
||||||
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
|
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
|
||||||
t.deepEqual(eventsToSend, [{
|
t.deepEqual(eventsToSend, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
|
body: "📝 Uploaded file: https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "📝 Uploaded file: <a href=\"https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)",
|
formatted_body: "📝 Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text"
|
msgtype: "m.text"
|
||||||
}])
|
}])
|
||||||
|
@ -109,7 +109,7 @@ test("edit2changes: change file type", async t => {
|
||||||
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
|
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("edit2changes: add caption back to that image", async t => {
|
test("edit2changes: add caption back to that image (due to it having a reaction, the reaction_part will not be moved)", async t => {
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
|
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
|
||||||
t.deepEqual(eventsToRedact, [])
|
t.deepEqual(eventsToRedact, [])
|
||||||
t.deepEqual(eventsToSend, [{
|
t.deepEqual(eventsToSend, [{
|
||||||
|
@ -266,7 +266,62 @@ test("edit2changes: generated embed", async t => {
|
||||||
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
|
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
t.deepEqual(promotions, []) // TODO: it would be ideal to promote this to reaction_part = 0. this is OK to do because the main message won't have had any reactions yet.
|
t.deepEqual(promotions, [{
|
||||||
|
"column": "reaction_part",
|
||||||
|
"eventID": "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0",
|
||||||
|
"value": 1,
|
||||||
|
}, {
|
||||||
|
"column": "reaction_part",
|
||||||
|
"nextEvent": true,
|
||||||
|
}])
|
||||||
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
|
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
|
||||||
t.equal(called, 1)
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("edit2changes: generated embed on a reply", async t => {
|
||||||
|
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
|
||||||
|
newContent: {
|
||||||
|
$type: "m.room.message",
|
||||||
|
// Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client.
|
||||||
|
body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
|
||||||
|
+ "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">In reply to</a> a Discord user<br>[Replied-to message content wasn't provided by Discord]</blockquote></mx-reply>* <a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
|
||||||
|
"m.mentions": {},
|
||||||
|
"m.new_content": {
|
||||||
|
body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
},
|
||||||
|
"m.relates_to": {
|
||||||
|
event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
|
||||||
|
rel_type: "m.replace",
|
||||||
|
},
|
||||||
|
msgtype: "m.text",
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
t.deepEqual(eventsToSend, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| ## Matrix - Decentralised and secure communication https://matrix.to/"
|
||||||
|
+ "\n| \n| You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p><strong><a href="https://matrix.to/">Matrix - Decentralised and secure communication</a></strong>`
|
||||||
|
+ `</p><p>You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
t.deepEqual(promotions, [{
|
||||||
|
"column": "reaction_part",
|
||||||
|
"eventID": "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
|
||||||
|
"value": 1,
|
||||||
|
}, {
|
||||||
|
"column": "reaction_part",
|
||||||
|
"nextEvent": true,
|
||||||
|
}])
|
||||||
|
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
|
||||||
|
})
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {emojiToKey} = require("./emoji-to-key")
|
const {emojiToKey} = require("./emoji-to-key")
|
||||||
const data = require("../../test/data")
|
const data = require("../../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
test("emoji2key: unicode emoji works", async t => {
|
test("emoji2key: unicode emoji works", async t => {
|
|
@ -1,11 +1,18 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {messageToEvent} = require("./message-to-event")
|
const {messageToEvent} = require("./message-to-event")
|
||||||
const data = require("../../test/data")
|
const data = require("../../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
test("message2event embeds: nothing but a field", async t => {
|
test("message2event embeds: nothing but a field", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "> ↪️ @papiophidian: used `/stats`",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
|
@ -60,7 +67,7 @@ test("message2event embeds: image embed and attachment", async t => {
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
|
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
|
||||||
body: "Screenshot_20231001_034036.jpg",
|
body: "Screenshot_20231001_034036.jpg",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
|
external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg",
|
||||||
filename: "Screenshot_20231001_034036.jpg",
|
filename: "Screenshot_20231001_034036.jpg",
|
||||||
info: {
|
info: {
|
||||||
h: 1170,
|
h: 1170,
|
||||||
|
@ -143,6 +150,13 @@ test("message2event embeds: crazy html is all escaped", async t => {
|
||||||
test("message2event embeds: title without url", async t => {
|
test("message2event embeds: title without url", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
|
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "> ↪️ @papiophidian: used `/stats`",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
||||||
|
@ -155,6 +169,13 @@ test("message2event embeds: title without url", async t => {
|
||||||
test("message2event embeds: url without title", async t => {
|
test("message2event embeds: url without title", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
|
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "> ↪️ @papiophidian: used `/stats`",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| I condone pirating music!",
|
body: "| I condone pirating music!",
|
||||||
|
@ -167,6 +188,13 @@ test("message2event embeds: url without title", async t => {
|
||||||
test("message2event embeds: author without url", async t => {
|
test("message2event embeds: author without url", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
|
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "> ↪️ @papiophidian: used `/stats`",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| ## Amanda\n| \n| I condone pirating music!",
|
body: "| ## Amanda\n| \n| I condone pirating music!",
|
||||||
|
@ -179,6 +207,13 @@ test("message2event embeds: author without url", async t => {
|
||||||
test("message2event embeds: author url without name", async t => {
|
test("message2event embeds: author url without name", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
|
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "> ↪️ @papiophidian: used `/stats`",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| I condone pirating music!",
|
body: "| I condone pirating music!",
|
|
@ -18,7 +18,7 @@ const lottie = sync.require("../actions/lottie")
|
||||||
const mxUtils = sync.require("../../m2d/converters/utils")
|
const mxUtils = sync.require("../../m2d/converters/utils")
|
||||||
/** @type {import("../../discord/utils")} */
|
/** @type {import("../../discord/utils")} */
|
||||||
const dUtils = sync.require("../../discord/utils")
|
const dUtils = sync.require("../../discord/utils")
|
||||||
const reg = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||||
|
|
||||||
|
@ -32,7 +32,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
|
||||||
/** @param {{id: string, type: "discordUser"}} node */
|
/** @param {{id: string, type: "discordUser"}} node */
|
||||||
user: node => {
|
user: node => {
|
||||||
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
|
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
|
||||||
const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id
|
const interaction = message.interaction_metadata || message.interaction
|
||||||
|
const username = message.mentions.find(ment => ment.id === node.id)?.username
|
||||||
|
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|
||||||
|
|| node.id
|
||||||
if (mxid && useHTML) {
|
if (mxid && useHTML) {
|
||||||
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
|
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
|
||||||
} else {
|
} else {
|
||||||
|
@ -58,7 +61,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
|
||||||
emoji: node => {
|
emoji: node => {
|
||||||
if (useHTML) {
|
if (useHTML) {
|
||||||
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
|
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
|
||||||
assert(mxc) // All emojis should have been added ahead of time in the messageToEvent function.
|
assert(mxc, `Emoji consistency assertion failed for ${node.name}:${node.id}`) // All emojis should have been added ahead of time in the messageToEvent function.
|
||||||
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
|
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
|
||||||
} else {
|
} else {
|
||||||
return `:${node.name}:`
|
return `:${node.name}:`
|
||||||
|
@ -100,6 +103,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
|
||||||
* @param {DiscordTypes.APIAttachment} attachment
|
* @param {DiscordTypes.APIAttachment} attachment
|
||||||
*/
|
*/
|
||||||
async function attachmentToEvent(mentions, attachment) {
|
async function attachmentToEvent(mentions, attachment) {
|
||||||
|
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
|
||||||
const emoji =
|
const emoji =
|
||||||
attachment.content_type?.startsWith("image/jp") ? "📸"
|
attachment.content_type?.startsWith("image/jp") ? "📸"
|
||||||
: attachment.content_type?.startsWith("image/") ? "🖼️"
|
: attachment.content_type?.startsWith("image/") ? "🖼️"
|
||||||
|
@ -113,9 +117,9 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`,
|
body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`,
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${attachment.url}">${attachment.url}</a> (${pb(attachment.size)})</blockquote>`
|
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
||||||
|
@ -124,9 +128,9 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
|
body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`,
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `${emoji} Uploaded file: <a href="${attachment.url}">${attachment.filename}</a> (${pb(attachment.size)})`
|
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
|
||||||
}
|
}
|
||||||
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
||||||
return {
|
return {
|
||||||
|
@ -134,7 +138,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -150,7 +154,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.video",
|
msgtype: "m.video",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -166,7 +170,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.audio",
|
msgtype: "m.audio",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -181,7 +185,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.file",
|
msgtype: "m.file",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -193,11 +197,12 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").APIMessage} message
|
* @param {DiscordTypes.APIMessage} message
|
||||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
|
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean}} options default values:
|
||||||
* - includeReplyFallback: true
|
* - includeReplyFallback: true
|
||||||
* - includeEditFallbackStar: false
|
* - includeEditFallbackStar: false
|
||||||
|
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
|
||||||
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
||||||
*/
|
*/
|
||||||
async function messageToEvent(message, guild, options = {}, di) {
|
async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
@ -229,6 +234,16 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const interaction = message.interaction_metadata || message.interaction
|
||||||
|
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
|
||||||
|
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
|
||||||
|
let content = message.content
|
||||||
|
if (content) content = `\n${content}`
|
||||||
|
else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
|
||||||
|
content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
|
||||||
|
message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@type {{room?: boolean, user_ids?: string[]}}
|
@type {{room?: boolean, user_ids?: string[]}}
|
||||||
We should consider the following scenarios for mentions:
|
We should consider the following scenarios for mentions:
|
||||||
|
@ -338,8 +353,13 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
|
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
|
||||||
} else {
|
} else {
|
||||||
const ts = dUtils.snowflakeToTimestampExact(messageID)
|
const ts = dUtils.snowflakeToTimestampExact(messageID)
|
||||||
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
|
try {
|
||||||
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
|
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
|
||||||
|
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
|
||||||
|
} catch (e) {
|
||||||
|
// M_NOT_FOUND: Unable to find event from <ts> in direction Direction.FORWARDS
|
||||||
|
result = `[unknown event, timestamp resolution failed, in room: https://matrix.to/#/${roomID}?${via}]`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = `${match[0]} [event is from another server]`
|
result = `${match[0]} [event is from another server]`
|
||||||
|
@ -351,6 +371,13 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate Discord attachment links into links that go via the bridge, so they last forever.
|
||||||
|
*/
|
||||||
|
function transformAttachmentLinks(content) {
|
||||||
|
return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
|
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
|
||||||
* @param {string} content Partial or complete Discord message content
|
* @param {string} content Partial or complete Discord message content
|
||||||
|
@ -359,11 +386,12 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
* @param {any} customHtmlOutput
|
* @param {any} customHtmlOutput
|
||||||
*/
|
*/
|
||||||
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
|
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
|
||||||
|
content = transformAttachmentLinks(content)
|
||||||
content = await transformContentMessageLinks(content)
|
content = await transformContentMessageLinks(content)
|
||||||
|
|
||||||
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
|
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
|
||||||
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
|
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
|
||||||
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{2,64}):([0-9]+)>/g)]
|
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
|
||||||
await Promise.all(emojiMatches.map(match => {
|
await Promise.all(emojiMatches.map(match => {
|
||||||
const id = match[3]
|
const id = match[3]
|
||||||
const name = match[2]
|
const name = match[2]
|
||||||
|
@ -401,8 +429,13 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
return {body, html}
|
return {body, html}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: What was the scanMentions parameter supposed to activate? It's unused.
|
/**
|
||||||
async function addTextEvent(body, html, msgtype, {scanMentions}) {
|
* After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event
|
||||||
|
* @param {string} body matrix event plaintext body
|
||||||
|
* @param {string} html matrix event HTML body
|
||||||
|
* @param {string} msgtype matrix event msgtype (maybe m.text or m.notice)
|
||||||
|
*/
|
||||||
|
async function addTextEvent(body, html, msgtype) {
|
||||||
// Star * prefix for fallback edits
|
// Star * prefix for fallback edits
|
||||||
if (options.includeEditFallbackStar) {
|
if (options.includeEditFallbackStar) {
|
||||||
body = "* " + body
|
body = "* " + body
|
||||||
|
@ -410,7 +443,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const flags = message.flags || 0
|
const flags = message.flags || 0
|
||||||
if (flags & 2) {
|
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
|
||||||
body = `[🔀 ${message.author.username}]\n` + body
|
body = `[🔀 ${message.author.username}]\n` + body
|
||||||
html = `🔀 <strong>${message.author.username}</strong><br>` + html
|
html = `🔀 <strong>${message.author.username}</strong><br>` + html
|
||||||
}
|
}
|
||||||
|
@ -430,10 +463,12 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
repliedToUserHtml = repliedToDisplayName
|
repliedToUserHtml = repliedToDisplayName
|
||||||
}
|
}
|
||||||
let repliedToContent = message.referenced_message?.content
|
let repliedToContent = message.referenced_message?.content
|
||||||
if (repliedToContent?.startsWith("> <:L1:")) {
|
if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) {
|
||||||
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
|
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
|
||||||
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
|
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
|
||||||
repliedToContent = repliedToContent.split("\n").slice(2).join("\n")
|
// ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line.
|
||||||
|
// ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
|
||||||
|
repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
|
||||||
}
|
}
|
||||||
if (repliedToContent == "") repliedToContent = "[Media]"
|
if (repliedToContent == "") repliedToContent = "[Media]"
|
||||||
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
|
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
|
||||||
|
@ -462,7 +497,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
|
||||||
const isPlaintext = body === html
|
const isPlaintext = body === html
|
||||||
|
|
||||||
if (!isPlaintext) {
|
if (!isPlaintext || options.alwaysReturnFormattedBody) {
|
||||||
Object.assign(newTextMessageEvent, {
|
Object.assign(newTextMessageEvent, {
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: html
|
formatted_body: html
|
||||||
|
@ -480,7 +515,58 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
message.content = "changed the channel name to **" + message.content + "**"
|
message.content = "changed the channel name to **" + message.content + "**"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forwarded content appears first
|
||||||
|
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
|
||||||
|
// Forwarded notice
|
||||||
|
const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get()
|
||||||
|
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
|
||||||
|
const forwardedNotice = new mxUtils.MatrixStringBuilder()
|
||||||
|
if (room) {
|
||||||
|
const roomName = room && (room.nick || room.name)
|
||||||
|
const via = await getViaServersMemo(room.room_id)
|
||||||
|
if (eventID) {
|
||||||
|
forwardedNotice.addLine(
|
||||||
|
`[🔀 Forwarded from #${roomName}]`,
|
||||||
|
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}/${eventID}?${via}">${roomName}</a></em>`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
forwardedNotice.addLine(
|
||||||
|
`[🔀 Forwarded from #${roomName}]`,
|
||||||
|
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}?${via}">${roomName}</a></em>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
forwardedNotice.addLine(
|
||||||
|
`[🔀 Forwarded message]`,
|
||||||
|
tag`🔀 <em>Forwarded message</em>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwarded content
|
||||||
|
// @ts-ignore
|
||||||
|
const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true}, di)
|
||||||
|
|
||||||
|
// Indent
|
||||||
|
for (const event of forwardedEvents) {
|
||||||
|
if (["m.text", "m.notice"].includes(event.msgtype)) {
|
||||||
|
event.msgtype = "m.notice"
|
||||||
|
event.body = event.body.split("\n").map(l => "» " + l).join("\n")
|
||||||
|
event.formatted_body = `<blockquote>${event.formatted_body}</blockquote>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to merge the forwarded content with the forwarded notice
|
||||||
|
let {body, formatted_body} = forwardedNotice.get()
|
||||||
|
if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice
|
||||||
|
forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body
|
||||||
|
forwardedEvents[0].formatted_body = formatted_body + "<br>" + forwardedEvents[0].formatted_body
|
||||||
|
} else {
|
||||||
|
await addTextEvent(body, formatted_body, "m.notice")
|
||||||
|
}
|
||||||
|
events.push(...forwardedEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then text content
|
||||||
if (message.content) {
|
if (message.content) {
|
||||||
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
||||||
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
|
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
|
||||||
|
@ -499,9 +585,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text content appears first
|
|
||||||
const {body, html} = await transformContent(message.content)
|
const {body, html} = await transformContent(message.content)
|
||||||
await addTextEvent(body, html, msgtype, {scanMentions: true})
|
await addTextEvent(body, html, msgtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then attachments
|
// Then attachments
|
||||||
|
@ -573,9 +658,9 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
let chosenImage = embed.image?.url
|
let chosenImage = embed.image?.url
|
||||||
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
|
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
|
||||||
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
||||||
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`)
|
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
|
||||||
|
|
||||||
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
|
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
|
||||||
|
|
||||||
if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
|
if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
|
||||||
let {body, formatted_body: html} = rep.get()
|
let {body, formatted_body: html} = rep.get()
|
||||||
|
@ -583,7 +668,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
html = `<blockquote>${html}</blockquote>`
|
html = `<blockquote>${html}</blockquote>`
|
||||||
|
|
||||||
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
||||||
await addTextEvent(body, html, "m.notice", {scanMentions: false})
|
await addTextEvent(body, html, "m.notice")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then stickers
|
// Then stickers
|
|
@ -1,6 +1,6 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {messageToEvent} = require("./message-to-event")
|
const {messageToEvent} = require("./message-to-event")
|
||||||
const data = require("../../test/data")
|
const data = require("../../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -1,6 +1,7 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {messageToEvent} = require("./message-to-event")
|
const {messageToEvent} = require("./message-to-event")
|
||||||
const data = require("../../test/data")
|
const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
|
const data = require("../../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -64,6 +65,44 @@ test("message2event: simple user mention", async t => {
|
||||||
test("message2event: simple room mention", async t => {
|
test("message2event: simple room mention", async t => {
|
||||||
let called = 0
|
let called = 0
|
||||||
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, {
|
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {
|
||||||
|
users: {
|
||||||
|
"@_ooye_bot:cadence.moe": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getJoinedMembers(roomID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:matrix.org": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "#worm-farm",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
|
||||||
|
}])
|
||||||
|
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: nicked room mention", async t => {
|
||||||
|
let called = 0
|
||||||
|
const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, {
|
||||||
api: {
|
api: {
|
||||||
async getStateEvent(roomID, type, key) {
|
async getStateEvent(roomID, type, key) {
|
||||||
called++
|
called++
|
||||||
|
@ -229,6 +268,54 @@ test("message2event: message link that OOYE doesn't know about", async t => {
|
||||||
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
|
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("message2event: message timestamp failed to fetch", async t => {
|
||||||
|
let called = 0
|
||||||
|
const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
async getEventForTimestamp(roomID, ts) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
throw new MatrixServerError({
|
||||||
|
errcode: "M_NOT_FOUND",
|
||||||
|
error: "Unable to find event from 1726762095974 in direction Direction.FORWARDS"
|
||||||
|
}, {})
|
||||||
|
},
|
||||||
|
async getStateEvent(roomID, type, key) { // for ?via calculation
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {
|
||||||
|
users: {
|
||||||
|
"@_ooye_bot:cadence.moe": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getJoinedMembers(roomID) { // for ?via calculation
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:matrix.org": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ "
|
||||||
|
+ "[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
|
||||||
|
+ '[unknown event, timestamp resolution failed, in room: <a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org</a>]'
|
||||||
|
}])
|
||||||
|
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
|
||||||
|
})
|
||||||
|
|
||||||
test("message2event: message link from another server", async t => {
|
test("message2event: message link from another server", async t => {
|
||||||
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
|
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
@ -250,7 +337,7 @@ test("message2event: attachment with no content", async t => {
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
|
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
|
||||||
body: "image.png",
|
body: "image.png",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png",
|
external_url: "https://bridge.example.org/download/discordcdn/497161332244742154/1124628646431297546/image.png",
|
||||||
filename: "image.png",
|
filename: "image.png",
|
||||||
info: {
|
info: {
|
||||||
mimetype: "image/png",
|
mimetype: "image/png",
|
||||||
|
@ -267,9 +354,9 @@ test("message2event: spoiler attachment", async t => {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)",
|
body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>"
|
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>"
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -286,7 +373,7 @@ test("message2event: stickers", async t => {
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
|
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
|
||||||
body: "image.png",
|
body: "image.png",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
|
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1106366167486038016/image.png",
|
||||||
filename: "image.png",
|
filename: "image.png",
|
||||||
info: {
|
info: {
|
||||||
mimetype: "image/png",
|
mimetype: "image/png",
|
||||||
|
@ -340,7 +427,7 @@ test("message2event: skull webp attachment with content", async t => {
|
||||||
mimetype: "image/webp",
|
mimetype: "image/webp",
|
||||||
size: 74290
|
size: 74290
|
||||||
},
|
},
|
||||||
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084747910918195/skull.webp",
|
||||||
filename: "skull.webp",
|
filename: "skull.webp",
|
||||||
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
|
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
|
||||||
}])
|
}])
|
||||||
|
@ -374,7 +461,7 @@ test("message2event: reply to skull webp attachment with content", async t => {
|
||||||
mimetype: "image/jpeg",
|
mimetype: "image/jpeg",
|
||||||
size: 85906
|
size: 85906
|
||||||
},
|
},
|
||||||
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
|
||||||
filename: "RDT_20230704_0936184915846675925224905.jpg",
|
filename: "RDT_20230704_0936184915846675925224905.jpg",
|
||||||
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
|
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
|
||||||
}])
|
}])
|
||||||
|
@ -464,7 +551,7 @@ test("message2event: reply with a video", async t => {
|
||||||
body: "Ins_1960637570.mp4",
|
body: "Ins_1960637570.mp4",
|
||||||
filename: "Ins_1960637570.mp4",
|
filename: "Ins_1960637570.mp4",
|
||||||
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
|
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1197621094786531358/Ins_1960637570.mp4",
|
||||||
info: {
|
info: {
|
||||||
h: 854,
|
h: 854,
|
||||||
mimetype: "video/mp4",
|
mimetype: "video/mp4",
|
||||||
|
@ -485,7 +572,7 @@ test("message2event: voice message", async t => {
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "voice-message.ogg",
|
body: "voice-message.ogg",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
|
external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg",
|
||||||
filename: "voice-message.ogg",
|
filename: "voice-message.ogg",
|
||||||
info: {
|
info: {
|
||||||
duration: 3960.0000381469727,
|
duration: 3960.0000381469727,
|
||||||
|
@ -508,7 +595,7 @@ test("message2event: misc file", async t => {
|
||||||
}, {
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "the.yml",
|
body: "the.yml",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
|
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml",
|
||||||
filename: "the.yml",
|
filename: "the.yml",
|
||||||
info: {
|
info: {
|
||||||
mimetype: "text/plain; charset=utf-8",
|
mimetype: "text/plain; charset=utf-8",
|
||||||
|
@ -559,6 +646,84 @@ test("message2event: simple reply in thread to a matrix user's reply", async t =
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("message2event: infinidoge's reply to ami's matrix smalltext reply to infinidoge", async t => {
|
||||||
|
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
|
||||||
|
type: "m.room.message",
|
||||||
|
sender: "@ami:the-apothecary.club",
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$baby"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
|
||||||
|
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"m.mentions": {
|
||||||
|
user_ids: ["@ami:the-apothecary.club"]
|
||||||
|
},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: infinidoge's reply to ami's matrix smalltext singleline reply to infinidoge", async t => {
|
||||||
|
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
|
||||||
|
type: "m.room.message",
|
||||||
|
sender: "@ami:the-apothecary.club",
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$baby"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
|
||||||
|
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"m.mentions": {
|
||||||
|
user_ids: ["@ami:the-apothecary.club"]
|
||||||
|
},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
test("message2event: simple written @mention for matrix user", async t => {
|
test("message2event: simple written @mention for matrix user", async t => {
|
||||||
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
|
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
|
||||||
api: {
|
api: {
|
||||||
|
@ -672,7 +837,7 @@ test("message2event: very large attachment is linked instead of being uploaded",
|
||||||
content: "hey",
|
content: "hey",
|
||||||
attachments: [{
|
attachments: [{
|
||||||
filename: "hey.jpg",
|
filename: "hey.jpg",
|
||||||
url: "https://discord.com/404/hey.jpg",
|
url: "https://cdn.discordapp.com/attachments/123/456/789.mega",
|
||||||
content_type: "application/i-made-it-up",
|
content_type: "application/i-made-it-up",
|
||||||
size: 100e6
|
size: 100e6
|
||||||
}]
|
}]
|
||||||
|
@ -686,9 +851,9 @@ test("message2event: very large attachment is linked instead of being uploaded",
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)",
|
body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '📄 Uploaded file: <a href="https://discord.com/404/hey.jpg">hey.jpg</a> (100 MB)'
|
formatted_body: '📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)'
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -849,3 +1014,123 @@ test("message2event: @everyone within a link", async t => {
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("message2event: forwarded image", async t => {
|
||||||
|
const events = await messageToEvent(data.message.forwarded_image)
|
||||||
|
t.deepEqual(events, [
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "[🔀 Forwarded message]",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "🔀 <em>Forwarded message</em>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "100km.gif",
|
||||||
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
|
||||||
|
filename: "100km.gif",
|
||||||
|
info: {
|
||||||
|
h: 300,
|
||||||
|
mimetype: "image/gif",
|
||||||
|
size: 2965649,
|
||||||
|
w: 300,
|
||||||
|
},
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.image",
|
||||||
|
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: constructed forwarded message", async t => {
|
||||||
|
const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, {
|
||||||
|
api: {
|
||||||
|
async getJoinedMembers() {
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:matrix.org": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "[🔀 Forwarded from #wonderland]"
|
||||||
|
+ "\n» What's cooking, good looking? :hipposcope:",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&via=matrix.org">wonderland</a></em>`
|
||||||
|
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "100km.gif",
|
||||||
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
|
||||||
|
filename: "100km.gif",
|
||||||
|
info: {
|
||||||
|
h: 300,
|
||||||
|
mimetype: "image/gif",
|
||||||
|
size: 2965649,
|
||||||
|
w: 300,
|
||||||
|
},
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.image",
|
||||||
|
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "» | ## This man"
|
||||||
|
+ "\n» | "
|
||||||
|
+ "\n» | ## This man is 100 km away from your house"
|
||||||
|
+ "\n» | "
|
||||||
|
+ "\n» | ### Distance away"
|
||||||
|
+ "\n» | 99 km"
|
||||||
|
+ "\n» | "
|
||||||
|
+ "\n» | ### Distance away"
|
||||||
|
+ "\n» | 98 km",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote><blockquote><p><strong>This man</strong></p><p><strong>This man is 100 km away from your house</strong></p><p><strong>Distance away</strong><br>99 km</p><p><strong>Distance away</strong><br>98 km</p></blockquote></blockquote>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: constructed forwarded text", async t => {
|
||||||
|
const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, {
|
||||||
|
api: {
|
||||||
|
async getJoinedMembers() {
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:matrix.org": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "[🔀 Forwarded from #amanda-spam]"
|
||||||
|
+ "\n» What's cooking, good looking?",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&via=matrix.org">amanda-spam</a></em>`
|
||||||
|
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "What's cooking everybody ‼️",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
|
@ -1,5 +1,5 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const data = require("../../test/data")
|
const data = require("../../../test/data")
|
||||||
const {pinsToList} = require("./pins-to-list")
|
const {pinsToList} = require("./pins-to-list")
|
||||||
|
|
||||||
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
|
@ -10,7 +10,9 @@ function fakeSpecificReactionRemoval(userID, emoji, emojiID) {
|
||||||
channel_id: "THE_CHANNEL",
|
channel_id: "THE_CHANNEL",
|
||||||
message_id: "THE_MESSAGE",
|
message_id: "THE_MESSAGE",
|
||||||
user_id: userID,
|
user_id: userID,
|
||||||
emoji: {id: emojiID, name: emoji}
|
emoji: {id: emojiID, name: emoji},
|
||||||
|
burst: false,
|
||||||
|
type: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2635
src/d2m/converters/rlottie-wasm.js
Normal file
2635
src/d2m/converters/rlottie-wasm.js
Normal file
File diff suppressed because it is too large
Load diff
BIN
d2m/converters/rlottie-wasm.wasm → src/d2m/converters/rlottie-wasm.wasm
Executable file → Normal file
BIN
d2m/converters/rlottie-wasm.wasm → src/d2m/converters/rlottie-wasm.wasm
Executable file → Normal file
Binary file not shown.
|
@ -4,10 +4,9 @@ const assert = require("assert").strict
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
/** @type {import("../../matrix/read-registration")} */
|
|
||||||
const reg = sync.require("../../matrix/read-registration.js")
|
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
/** @type {import("../../m2d/converters/utils")} */
|
||||||
const mxUtils = sync.require("../../m2d/converters/utils")
|
const mxUtils = sync.require("../../m2d/converters/utils")
|
||||||
|
const {reg} = require("../../matrix/read-registration.js")
|
||||||
|
|
||||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {threadToAnnouncement} = require("./thread-to-announcement")
|
const {threadToAnnouncement} = require("./thread-to-announcement")
|
||||||
const data = require("../../test/data")
|
const data = require("../../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
const assert = require("assert")
|
||||||
const registration = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {select} = passthrough
|
const {select} = passthrough
|
||||||
|
@ -26,7 +26,7 @@ function downcaseUsername(user) {
|
||||||
// remove leading and trailing dashes and underscores...
|
// remove leading and trailing dashes and underscores...
|
||||||
.replace(/(?:^[_-]*|[_-]*$)/g, "")
|
.replace(/(?:^[_-]*|[_-]*$)/g, "")
|
||||||
// If requested, also make the Discord user ID part of the username
|
// If requested, also make the Discord user ID part of the username
|
||||||
if (registration.ooye.include_user_id_in_mxid) {
|
if (reg.ooye.include_user_id_in_mxid) {
|
||||||
downcased = user.id + "_" + downcased
|
downcased = user.id + "_" + downcased
|
||||||
}
|
}
|
||||||
// The new length must be at least 2 characters (in other words, it should have some content)
|
// The new length must be at least 2 characters (in other words, it should have some content)
|
|
@ -1,7 +1,7 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const tryToCatch = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const assert = require("assert")
|
const assert = require("assert")
|
||||||
const data = require("../../test/data")
|
const data = require("../../../test/data")
|
||||||
const {userToSimName} = require("./user-to-mxid")
|
const {userToSimName} = require("./user-to-mxid")
|
||||||
|
|
||||||
test("user2name: cannot create user for a webhook", async t => {
|
test("user2name: cannot create user for a webhook", async t => {
|
||||||
|
@ -46,7 +46,7 @@ test("user2name: works on special user", t => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("user2name: includes ID if requested in config", t => {
|
test("user2name: includes ID if requested in config", t => {
|
||||||
const reg = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
reg.ooye.include_user_id_in_mxid = true
|
reg.ooye.include_user_id_in_mxid = true
|
||||||
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles")
|
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles")
|
||||||
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
|
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
|
|
@ -6,7 +6,7 @@ const { Client: CloudStorm } = require("cloudstorm")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const { sync } = passthrough
|
const { sync } = passthrough
|
||||||
|
|
||||||
/** @type {typeof import("./discord-packets")} */
|
/** @type {import("./discord-packets")} */
|
||||||
const discordPackets = sync.require("./discord-packets")
|
const discordPackets = sync.require("./discord-packets")
|
||||||
|
|
||||||
class DiscordClient {
|
class DiscordClient {
|
||||||
|
@ -57,6 +57,9 @@ class DiscordClient {
|
||||||
addEventLogger("error", "Error")
|
addEventLogger("error", "Error")
|
||||||
addEventLogger("disconnected", "Disconnected")
|
addEventLogger("disconnected", "Disconnected")
|
||||||
addEventLogger("ready", "Ready")
|
addEventLogger("ready", "Ready")
|
||||||
|
this.snow.requestHandler.on("requestError", (requestID, error) => {
|
||||||
|
console.error("request error:", error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,11 @@
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const { sync } = passthrough
|
const {sync, db} = passthrough
|
||||||
|
|
||||||
|
function populateGuildID(guildID, channelID) {
|
||||||
|
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
const utils = {
|
const utils = {
|
||||||
/**
|
/**
|
||||||
|
@ -16,6 +20,8 @@ const utils = {
|
||||||
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
|
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
|
||||||
/** @type {typeof import("./event-dispatcher")} */
|
/** @type {typeof import("./event-dispatcher")} */
|
||||||
const eventDispatcher = sync.require("./event-dispatcher")
|
const eventDispatcher = sync.require("./event-dispatcher")
|
||||||
|
/** @type {import("../discord/register-interactions")} */
|
||||||
|
const interactions = sync.require("../discord/register-interactions")
|
||||||
|
|
||||||
// Client internals, keep track of the state we need
|
// Client internals, keep track of the state we need
|
||||||
if (message.t === "READY") {
|
if (message.t === "READY") {
|
||||||
|
@ -34,13 +40,16 @@ const utils = {
|
||||||
channel.guild_id = message.d.id
|
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)
|
||||||
|
populateGuildID(message.d.id, channel.id)
|
||||||
}
|
}
|
||||||
for (const thread of message.d.threads || []) {
|
for (const thread of message.d.threads || []) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
thread.guild_id = message.d.id
|
thread.guild_id = message.d.id
|
||||||
arr.push(thread.id)
|
arr.push(thread.id)
|
||||||
client.channels.set(thread.id, thread)
|
client.channels.set(thread.id, thread)
|
||||||
|
populateGuildID(message.d.id, thread.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listen === "full") {
|
if (listen === "full") {
|
||||||
eventDispatcher.checkMissedExpressions(message.d)
|
eventDispatcher.checkMissedExpressions(message.d)
|
||||||
eventDispatcher.checkMissedPins(client, message.d)
|
eventDispatcher.checkMissedPins(client, message.d)
|
||||||
|
@ -89,7 +98,11 @@ const utils = {
|
||||||
|
|
||||||
} else if (message.t === "THREAD_CREATE") {
|
} else if (message.t === "THREAD_CREATE") {
|
||||||
client.channels.set(message.d.id, message.d)
|
client.channels.set(message.d.id, message.d)
|
||||||
|
if (message.d["guild_id"]) {
|
||||||
|
populateGuildID(message.d["guild_id"], message.d.id)
|
||||||
|
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||||
|
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
||||||
|
}
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
|
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
|
||||||
client.channels.set(message.d.id, message.d)
|
client.channels.set(message.d.id, message.d)
|
||||||
|
@ -111,21 +124,21 @@ const utils = {
|
||||||
client.guildChannelMap.delete(message.d.id)
|
client.guildChannelMap.delete(message.d.id)
|
||||||
|
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
|
} else if (message.t === "CHANNEL_CREATE") {
|
||||||
if (message.t === "CHANNEL_CREATE") {
|
client.channels.set(message.d.id, message.d)
|
||||||
client.channels.set(message.d.id, message.d)
|
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
|
||||||
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
|
populateGuildID(message.d["guild_id"], message.d.id)
|
||||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||||
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
client.channels.delete(message.d.id)
|
} else if (message.t === "CHANNEL_DELETE") {
|
||||||
if (message.d["guild_id"]) {
|
client.channels.delete(message.d.id)
|
||||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
if (message.d["guild_id"]) {
|
||||||
if (channels) {
|
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||||
const previous = channels.indexOf(message.d.id)
|
if (channels) {
|
||||||
if (previous !== -1) channels.splice(previous, 1)
|
const previous = channels.indexOf(message.d.id)
|
||||||
}
|
if (previous !== -1) channels.splice(previous, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -145,6 +158,9 @@ const utils = {
|
||||||
} else if (message.t === "CHANNEL_PINS_UPDATE") {
|
} else if (message.t === "CHANNEL_PINS_UPDATE") {
|
||||||
await eventDispatcher.onChannelPinsUpdate(client, message.d)
|
await eventDispatcher.onChannelPinsUpdate(client, message.d)
|
||||||
|
|
||||||
|
} else if (message.t === "CHANNEL_DELETE") {
|
||||||
|
await eventDispatcher.onChannelDelete(client, message.d)
|
||||||
|
|
||||||
} else if (message.t === "THREAD_CREATE") {
|
} else if (message.t === "THREAD_CREATE") {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await eventDispatcher.onThreadCreate(client, message.d)
|
await eventDispatcher.onThreadCreate(client, message.d)
|
||||||
|
@ -172,10 +188,14 @@ const utils = {
|
||||||
|
|
||||||
} else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
|
} else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
|
||||||
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
|
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
|
||||||
|
|
||||||
|
} else if (message.t === "INTERACTION_CREATE") {
|
||||||
|
await interactions.dispatchInteraction(message.d)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Let OOYE try to handle errors too
|
// Let OOYE try to handle errors too
|
||||||
eventDispatcher.onError(client, e, message)
|
await eventDispatcher.onError(client, e, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -27,8 +27,6 @@ const updatePins = sync.require("./actions/update-pins")
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
/** @type {import("../discord/utils")} */
|
/** @type {import("../discord/utils")} */
|
||||||
const dUtils = sync.require("../discord/utils")
|
const dUtils = sync.require("../discord/utils")
|
||||||
/** @type {import("../discord/discord-command-handler")}) */
|
|
||||||
const discordCommandHandler = sync.require("../discord/discord-command-handler")
|
|
||||||
/** @type {import("../m2d/converters/utils")} */
|
/** @type {import("../m2d/converters/utils")} */
|
||||||
const mxUtils = require("../m2d/converters/utils")
|
const mxUtils = require("../m2d/converters/utils")
|
||||||
/** @type {import("./actions/speedbump")} */
|
/** @type {import("./actions/speedbump")} */
|
||||||
|
@ -50,7 +48,7 @@ module.exports = {
|
||||||
* @param {Error} e
|
* @param {Error} e
|
||||||
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage
|
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage
|
||||||
*/
|
*/
|
||||||
onError(client, e, gatewayMessage) {
|
async onError(client, e, gatewayMessage) {
|
||||||
console.error("hit event-dispatcher's error handler with this exception:")
|
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(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.error(`while handling this ${gatewayMessage.t} gateway event:`)
|
||||||
|
@ -83,7 +81,7 @@ module.exports = {
|
||||||
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
|
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
|
||||||
}
|
}
|
||||||
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
|
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
|
||||||
api.sendEvent(roomID, "m.room.message", {
|
await api.sendEvent(roomID, "m.room.message", {
|
||||||
...builder.get(),
|
...builder.get(),
|
||||||
"moe.cadence.ooye.error": {
|
"moe.cadence.ooye.error": {
|
||||||
source: "discord",
|
source: "discord",
|
||||||
|
@ -193,7 +191,7 @@ module.exports = {
|
||||||
async onThreadCreate(client, thread) {
|
async onThreadCreate(client, thread) {
|
||||||
const channelID = thread.parent_id || undefined
|
const channelID = thread.parent_id || undefined
|
||||||
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel
|
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
|
||||||
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
|
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)
|
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
|
||||||
},
|
},
|
||||||
|
@ -230,6 +228,19 @@ module.exports = {
|
||||||
await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp)
|
await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("./discord-client")} client
|
||||||
|
* @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel
|
||||||
|
*/
|
||||||
|
async onChannelDelete(client, channel) {
|
||||||
|
const guildID = channel["guild_id"]
|
||||||
|
if (!guildID) return // channel must have been a DM channel or something
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
|
if (!roomID) return // channel wasn't being bridged in the first place
|
||||||
|
// @ts-ignore
|
||||||
|
await createRoom.unbridgeDeletedChannel(channel, guildID)
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||||
|
@ -238,6 +249,7 @@ module.exports = {
|
||||||
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
|
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
|
||||||
const channel = client.channels.get(message.channel_id)
|
const channel = client.channels.get(message.channel_id)
|
||||||
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
||||||
|
|
||||||
const guild = client.guilds.get(channel.guild_id)
|
const guild = client.guilds.get(channel.guild_id)
|
||||||
assert(guild)
|
assert(guild)
|
||||||
|
|
||||||
|
@ -248,12 +260,13 @@ module.exports = {
|
||||||
|
|
||||||
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
|
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
|
||||||
|
|
||||||
|
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
|
||||||
|
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
||||||
if (affected) return
|
if (affected) return
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await sendMessage.sendMessage(message, channel, guild, row),
|
await sendMessage.sendMessage(message, channel, guild, row)
|
||||||
await discordCommandHandler.execute(message, channel, guild)
|
|
||||||
|
|
||||||
retrigger.messageFinishedBridging(message.id)
|
retrigger.messageFinishedBridging(message.id)
|
||||||
},
|
},
|
||||||
|
@ -268,9 +281,6 @@ module.exports = {
|
||||||
// Otherwise, if there are embeds, then the system generated URL preview embeds.
|
// Otherwise, if there are embeds, then the system generated URL preview embeds.
|
||||||
if (!(typeof data.content === "string" || "embeds" in data)) return
|
if (!(typeof data.content === "string" || "embeds" in data)) return
|
||||||
|
|
||||||
// Deal with Eventual Consistency(TM)
|
|
||||||
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
|
|
||||||
|
|
||||||
if (data.webhook_id) {
|
if (data.webhook_id) {
|
||||||
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
|
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
|
||||||
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
||||||
|
@ -282,16 +292,19 @@ module.exports = {
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
||||||
if (affected) return
|
if (affected) return
|
||||||
|
|
||||||
|
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
|
||||||
|
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
|
||||||
|
|
||||||
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const message = data
|
const message = data
|
||||||
|
|
||||||
const channel = client.channels.get(message.channel_id)
|
const channel = client.channels.get(message.channel_id)
|
||||||
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
||||||
const guild = client.guilds.get(channel.guild_id)
|
const guild = client.guilds.get(channel.guild_id)
|
||||||
assert(guild)
|
assert(guild)
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await editMessage.editMessage(message, guild, row)
|
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -300,7 +313,6 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
async onReactionAdd(client, data) {
|
async onReactionAdd(client, data) {
|
||||||
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
|
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
|
||||||
discordCommandHandler.onReactionAdd(data)
|
|
||||||
await addReaction.addReaction(data)
|
await addReaction.addReaction(data)
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,6 +3,7 @@ module.exports = async function(db) {
|
||||||
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
|
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
|
||||||
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
|
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
/* c8 ignore next 6 */
|
||||||
for (let s of contents) {
|
for (let s of contents) {
|
||||||
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
|
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
|
||||||
const unsignedHash = hasher.h64Raw(b)
|
const unsignedHash = hasher.h64Raw(b)
|
|
@ -10,7 +10,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
module.exports = async function(db) {
|
module.exports = async function(db) {
|
||||||
const config = require("../../config")
|
const id = require("../../../addbot").id
|
||||||
const id = Buffer.from(config.discordToken.split(".")[0], "base64").toString()
|
|
||||||
db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id)
|
db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id)
|
||||||
}
|
}
|
14
src/db/migrations/0012-add-member-power.sql
Normal file
14
src/db/migrations/0012-add-member-power.sql
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- the power we want them to have
|
||||||
|
CREATE TABLE IF NOT EXISTS member_power (
|
||||||
|
mxid TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
power_level INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(mxid, room_id)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
-- the power they have
|
||||||
|
ALTER TABLE member_cache ADD COLUMN power_level INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMIT;
|
8
src/db/migrations/0013-media-proxy.sql
Normal file
8
src/db/migrations/0013-media-proxy.sql
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
CREATE TABLE "media_proxy" (
|
||||||
|
"permitted_hash" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY("permitted_hash")
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
COMMIT;
|
11
src/db/migrations/0014-add-guild-active.sql
Normal file
11
src/db/migrations/0014-add-guild-active.sql
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
CREATE TABLE "guild_active" (
|
||||||
|
"guild_id" TEXT NOT NULL,
|
||||||
|
"autocreate" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY("guild_id")
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
INSERT INTO guild_active (guild_id, autocreate) SELECT guild_id, 1 FROM guild_space;
|
||||||
|
|
||||||
|
COMMIT;
|
5
src/db/migrations/0015-add-guild-id-to-channel-room.sql
Normal file
5
src/db/migrations/0015-add-guild-id-to-channel-room.sql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
ALTER TABLE channel_room ADD COLUMN guild_id TEXT;
|
||||||
|
|
||||||
|
COMMIT;
|
20
db/orm-defs.d.ts → src/db/orm-defs.d.ts
vendored
20
db/orm-defs.d.ts → src/db/orm-defs.d.ts
vendored
|
@ -33,6 +33,11 @@ export type Models = {
|
||||||
privacy_level: number
|
privacy_level: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guild_active: {
|
||||||
|
guild_id: string
|
||||||
|
autocreate: 0 | 1
|
||||||
|
}
|
||||||
|
|
||||||
lottie: {
|
lottie: {
|
||||||
sticker_id: string
|
sticker_id: string
|
||||||
mxc_url: string
|
mxc_url: string
|
||||||
|
@ -42,7 +47,14 @@ export type Models = {
|
||||||
room_id: string
|
room_id: string
|
||||||
mxid: string
|
mxid: string
|
||||||
displayname: string | null
|
displayname: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null,
|
||||||
|
power_level: number
|
||||||
|
}
|
||||||
|
|
||||||
|
member_power: {
|
||||||
|
mxid: string
|
||||||
|
room_id: string
|
||||||
|
power_level: number
|
||||||
}
|
}
|
||||||
|
|
||||||
message_channel: {
|
message_channel: {
|
||||||
|
@ -93,6 +105,10 @@ export type Models = {
|
||||||
emoji_id: string
|
emoji_id: string
|
||||||
guild_id: string
|
guild_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
media_proxy: {
|
||||||
|
permitted_hash: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Prepared<Row> = {
|
export type Prepared<Row> = {
|
||||||
|
@ -107,3 +123,5 @@ export type AllKeys<U> = U extends any ? keyof U : never
|
||||||
export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never
|
export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never
|
||||||
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
|
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
|
||||||
export type Nullable<T> = {[k in keyof T]: T[k] | null}
|
export type Nullable<T> = {[k in keyof T]: T[k] | null}
|
||||||
|
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
|
||||||
|
export type ValueOrArray<T> = {[k in keyof T]: T[k][] | T[k]}
|
|
@ -8,15 +8,20 @@ const U = require("./orm-defs")
|
||||||
* @template {keyof U.Models[Table]} Col
|
* @template {keyof U.Models[Table]} Col
|
||||||
* @param {Table} table
|
* @param {Table} table
|
||||||
* @param {Col[] | Col} cols
|
* @param {Col[] | Col} cols
|
||||||
* @param {Partial<U.Models[Table]>} where
|
* @param {Partial<U.ValueOrArray<U.Numberish<U.Models[Table]>>>} where
|
||||||
* @param {string} [e]
|
* @param {string} [e]
|
||||||
*/
|
*/
|
||||||
function select(table, cols, where = {}, e = "") {
|
function select(table, cols, where = {}, e = "") {
|
||||||
if (!Array.isArray(cols)) cols = [cols]
|
if (!Array.isArray(cols)) cols = [cols]
|
||||||
const parameters = []
|
const parameters = []
|
||||||
const wheres = Object.entries(where).map(([col, value]) => {
|
const wheres = Object.entries(where).map(([col, value]) => {
|
||||||
parameters.push(value)
|
if (Array.isArray(value)) {
|
||||||
return `"${col}" = ?`
|
parameters.push(...value)
|
||||||
|
return `"${col}" IN (` + Array(value.length).fill("?").join(", ") + ")"
|
||||||
|
} else {
|
||||||
|
parameters.push(value)
|
||||||
|
return `"${col}" = ?`
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : ""
|
const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : ""
|
||||||
/** @type {U.Prepared<Pick<U.Models[Table], Col>>} */
|
/** @type {U.Prepared<Pick<U.Models[Table], Col>>} */
|
||||||
|
@ -44,6 +49,8 @@ class From {
|
||||||
/** @private */
|
/** @private */
|
||||||
this.cols = []
|
this.cols = []
|
||||||
/** @private */
|
/** @private */
|
||||||
|
this.makeColsSafe = true
|
||||||
|
/** @private */
|
||||||
this.using = []
|
this.using = []
|
||||||
/** @private */
|
/** @private */
|
||||||
this.isPluck = false
|
this.isPluck = false
|
||||||
|
@ -78,6 +85,12 @@ class From {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectUnsafe(...cols) {
|
||||||
|
this.cols = cols
|
||||||
|
this.makeColsSafe = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @template {Col} Select
|
* @template {Col} Select
|
||||||
* @param {Select} col
|
* @param {Select} col
|
||||||
|
@ -100,7 +113,7 @@ class From {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Partial<U.Models[Table]>} conditions
|
* @param {Partial<U.Numberish<U.Models[Table]>>} conditions
|
||||||
*/
|
*/
|
||||||
where(conditions) {
|
where(conditions) {
|
||||||
const wheres = Object.entries(conditions).map(([col, value]) => {
|
const wheres = Object.entries(conditions).map(([col, value]) => {
|
||||||
|
@ -112,7 +125,8 @@ class From {
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
let sql = `SELECT ${this.cols.map(k => `"${k}"`).join(", ")} FROM ${this.tables[0]} `
|
if (this.makeColsSafe) this.cols = this.cols.map(k => `"${k}"`)
|
||||||
|
let sql = `SELECT ${this.cols.join(", ")} FROM ${this.tables[0]} `
|
||||||
for (let i = 1; i < this.tables.length; i++) {
|
for (let i = 1; i < this.tables.length; i++) {
|
||||||
const table = this.tables[i]
|
const table = this.tables[i]
|
||||||
const col = this.using[i-1]
|
const col = this.using[i-1]
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const data = require("../test/data")
|
const data = require("../../test/data")
|
||||||
|
|
||||||
const {db, select, from} = require("../passthrough")
|
const {db, select, from} = require("../passthrough")
|
||||||
|
|
||||||
|
@ -30,6 +30,11 @@ test("orm: select: all, where and pluck works on multiple columns", t => {
|
||||||
t.deepEqual(names, ["cadence [they]"])
|
t.deepEqual(names, ["cadence [they]"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("orm: select: in array works", t => {
|
||||||
|
const ids = select("emoji", "emoji_id", {name: ["online", "upstinky"]}).pluck().all()
|
||||||
|
t.deepEqual(ids, ["288858540888686602", "606664341298872324"])
|
||||||
|
})
|
||||||
|
|
||||||
test("orm: from: get pluck works", t => {
|
test("orm: from: get pluck works", t => {
|
||||||
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||||
t.equal(guildID, data.guild.general.id)
|
t.equal(guildID, data.guild.general.id)
|
||||||
|
@ -53,3 +58,13 @@ test("orm: from: join direction works", t => {
|
||||||
const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
|
const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
|
||||||
t.deepEqual(hasNoOwnerInner, undefined)
|
t.deepEqual(hasNoOwnerInner, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("orm: select unsafe works (to select complex column names that can't be type verified)", t => {
|
||||||
|
const results = from("member_cache")
|
||||||
|
.join("member_power", "mxid")
|
||||||
|
.join("channel_room", "room_id") // only include rooms that are bridged
|
||||||
|
.and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level")
|
||||||
|
.selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level")
|
||||||
|
.all()
|
||||||
|
t.equal(results[0].power_level, 100)
|
||||||
|
})
|
156
src/discord/interactions/invite.js
Normal file
156
src/discord/interactions/invite.js
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const assert = require("assert/strict")
|
||||||
|
const {InteractionMethods} = require("snowtransfer")
|
||||||
|
const {id: botID} = require("../../../addbot")
|
||||||
|
const {discord, sync, db, select} = require("../../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../../d2m/actions/create-room")} */
|
||||||
|
const createRoom = sync.require("../../d2m/actions/create-room")
|
||||||
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
/** @type {import("../../matrix/read-registration")} */
|
||||||
|
const {reg} = sync.require("../../matrix/read-registration")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
|
*/
|
||||||
|
async function* _interact({data, channel, guild_id}, {api}) {
|
||||||
|
// Check guild exists - it might not exist if the application was added with applications.commands scope and not bot scope
|
||||||
|
const guild = discord.guilds.get(guild_id)
|
||||||
|
if (!guild) return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `I can't perform actions in this server because there is no bot presence in the server. You should try re-adding this bot to the server, making sure that it has bot scope (not just commands).\nIf you add the bot from ${reg.ooye.bridge_origin} this should work automatically.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Get named MXID
|
||||||
|
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
|
||||||
|
const options = data.options
|
||||||
|
const input = options?.[0]?.value || ""
|
||||||
|
const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
|
||||||
|
if (!mxid) return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Ensure guild and room are bridged
|
||||||
|
db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, 1)").run(guild_id)
|
||||||
|
const existing = createRoom.existsOrAutocreatable(channel, guild_id)
|
||||||
|
if (existing === 0) return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
assert(existing) // can't be null or undefined as we just inserted the guild_active row
|
||||||
|
|
||||||
|
yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
const spaceID = await createSpace.ensureSpace(guild)
|
||||||
|
const roomID = await createRoom.ensureRoom(channel.id)
|
||||||
|
|
||||||
|
// Check for existing invite to the space
|
||||||
|
let spaceMember
|
||||||
|
try {
|
||||||
|
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
|
||||||
|
} catch (e) {}
|
||||||
|
if (spaceMember && spaceMember.membership === "invite") {
|
||||||
|
return yield {editOriginalInteractionResponse: {
|
||||||
|
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite Matrix user if not in space
|
||||||
|
if (!spaceMember || spaceMember.membership !== "join") {
|
||||||
|
await api.inviteToRoom(spaceID, mxid)
|
||||||
|
return yield {editOriginalInteractionResponse: {
|
||||||
|
content: `You invited \`${mxid}\` to the server.`
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
|
||||||
|
let roomMember
|
||||||
|
try {
|
||||||
|
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
|
||||||
|
} catch (e) {}
|
||||||
|
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
|
||||||
|
return yield {editOriginalInteractionResponse: {
|
||||||
|
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
|
||||||
|
components: [{
|
||||||
|
type: DiscordTypes.ComponentType.ActionRow,
|
||||||
|
components: [{
|
||||||
|
type: DiscordTypes.ComponentType.Button,
|
||||||
|
custom_id: "invite_channel",
|
||||||
|
style: DiscordTypes.ButtonStyle.Primary,
|
||||||
|
label: "Sure",
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Matrix user *is* in the space and in the channel.
|
||||||
|
return yield {editOriginalInteractionResponse: {
|
||||||
|
content: `\`${mxid}\` is already in this server and this channel.`,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interactButton({channel, message}, {api}) {
|
||||||
|
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
|
||||||
|
assert(mxid)
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
|
await api.inviteToRoom(roomID, mxid)
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.UpdateMessage,
|
||||||
|
data: {
|
||||||
|
content: `You invited \`${mxid}\` to the channel.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral,
|
||||||
|
components: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
for await (const response of _interact(interaction, {api})) {
|
||||||
|
if (response.createInteractionResponse) {
|
||||||
|
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */
|
||||||
|
async function interactButton(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction, {api}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
||||||
|
module.exports.interactButton = interactButton
|
||||||
|
module.exports._interact = _interact
|
||||||
|
module.exports._interactButton = _interactButton
|
256
src/discord/interactions/invite.test.js
Normal file
256
src/discord/interactions/invite.test.js
Normal file
|
@ -0,0 +1,256 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {db, discord} = require("../../passthrough")
|
||||||
|
const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
|
const {_interact, _interactButton} = require("./invite")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {AsyncIterable<T>} ai
|
||||||
|
* @returns {Promise<T[]>}
|
||||||
|
*/
|
||||||
|
async function fromAsync(ai) {
|
||||||
|
const result = []
|
||||||
|
for await (const value of ai) {
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test("invite: checks for missing matrix ID", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: []
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("0"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks for invalid matrix ID", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("0"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks if guild exists", async t => { // it might not exist if the application was added with applications.commands scope and not bot scope
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("0"),
|
||||||
|
guild_id: "0"
|
||||||
|
}, {}))
|
||||||
|
t.match(msgs[0].createInteractionResponse.data.content, /there is no bot presence in the server/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks if channel exists or is autocreatable", async t => {
|
||||||
|
db.prepare("UPDATE guild_active SET autocreate = 0").run()
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("498323546729086986"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.")
|
||||||
|
db.prepare("UPDATE guild_active SET autocreate = 1").run()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks if user is already invited to space", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "invite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` already has an invite, which they haven't accepted yet.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: invites if user is not in space", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
throw new MatrixServerError("State event doesn't exist or something")
|
||||||
|
},
|
||||||
|
inviteToRoom: async (roomID, mxid) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(mxid, "@cadence:cadence.moe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "You invited `@cadence:cadence.moe` to the server.")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: prompts to invite to room (if never joined)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "join"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new MatrixServerError("State event doesn't exist or something")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: prompts to invite to room (if left)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "join"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "leave"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite button: invites to room when button clicked", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msg = await _interactButton({
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
message: {
|
||||||
|
content: "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
inviteToRoom: async (roomID, mxid) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(mxid, "@cadence:cadence.moe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(msg.data.content, "You invited `@cadence:cadence.moe` to the channel.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: no-op if in room and space", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "join"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server and this channel.")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
63
src/discord/interactions/matrix-info.js
Normal file
63
src/discord/interactions/matrix-info.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {discord, sync, from} = require("../../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interact({guild_id, data}, {api}) {
|
||||||
|
const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
||||||
|
.select("name", "nick", "source", "channel_id", "room_id", "event_id").where({message_id: data.target_id, part: 0}).get()
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "This message hasn't been bridged to Matrix.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``
|
||||||
|
const roomName = message.nick || message.name
|
||||||
|
|
||||||
|
if (message.source === 1) { // from Discord
|
||||||
|
const userID = data.resolved.messages[data.target_id].author.id
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord to [${roomName}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix.`
|
||||||
|
+ idInfo,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// from Matrix
|
||||||
|
const event = await api.getEvent(message.room_id, message.event_id)
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `Bridged [${event.sender}](<https://matrix.to/#/${event.sender}>)'s message in [${roomName}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix to https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord.`
|
||||||
|
+ idInfo,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
||||||
|
module.exports._interact = _interact
|
73
src/discord/interactions/matrix-info.test.js
Normal file
73
src/discord/interactions/matrix-info.test.js
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const data = require("../../../test/data")
|
||||||
|
const {_interact} = require("./matrix-info")
|
||||||
|
|
||||||
|
test("matrix info: checks if message is bridged", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "0"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {})
|
||||||
|
t.equal(msg.data.content, "This message hasn't been bridged to Matrix.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matrix info: shows info for discord source message", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1141619794500649020",
|
||||||
|
resolved: {
|
||||||
|
messages: {
|
||||||
|
"1141619794500649020": data.message_update.edit_by_webhook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
guild_id: "497159726455455754"
|
||||||
|
}, {})
|
||||||
|
t.equal(
|
||||||
|
msg.data.content,
|
||||||
|
"Bridged <@700285844094845050> https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020 on Discord to [amanda-spam](<https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe/$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10>) on Matrix."
|
||||||
|
+ "\n-# Room ID: `!CzvdIdUQXgUjDVKxeU:cadence.moe`"
|
||||||
|
+ "\n-# Event ID: `$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10`"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matrix info: shows info for matrix source message", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666",
|
||||||
|
resolved: {
|
||||||
|
messages: {
|
||||||
|
"1141501302736695316": data.message.simple_reply_to_matrix_user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
event_id: eventID,
|
||||||
|
room_id: roomID,
|
||||||
|
type: "m.room.message",
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "so can you reply to my webhook uwu"
|
||||||
|
},
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(
|
||||||
|
msg.data.content,
|
||||||
|
"Bridged [@cadence:cadence.moe](<https://matrix.to/#/@cadence:cadence.moe>)'s message in [main](<https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4>) on Matrix to https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 on Discord."
|
||||||
|
+ "\n-# Room ID: `!kLRqKKUQXcibIMtOpl:cadence.moe`"
|
||||||
|
+ "\n-# Event ID: `$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4`"
|
||||||
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
160
src/discord/interactions/permissions.js
Normal file
160
src/discord/interactions/permissions.js
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
const {discord, sync, select, from} = require("../../passthrough")
|
||||||
|
const assert = require("assert/strict")
|
||||||
|
const {id: botID} = require("../../../addbot")
|
||||||
|
const {InteractionMethods} = require("snowtransfer")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
|
*/
|
||||||
|
async function* _interact({data, guild_id}, {api}) {
|
||||||
|
// Get message info
|
||||||
|
const row = from("event_message")
|
||||||
|
.join("message_channel", "message_id")
|
||||||
|
.select("event_id", "source", "channel_id")
|
||||||
|
.where({message_id: data.target_id})
|
||||||
|
.get()
|
||||||
|
|
||||||
|
// Can't operate on Discord users
|
||||||
|
if (!row || row.source === 1) { // not bridged or sent by a discord user
|
||||||
|
return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `The permissions command can only be used on Matrix users.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the message sender, the person that will be inspected/edited
|
||||||
|
const eventID = row.event_id
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: row.channel_id}).pluck().get()
|
||||||
|
assert(roomID)
|
||||||
|
const event = await api.getEvent(roomID, eventID)
|
||||||
|
const sender = event.sender
|
||||||
|
|
||||||
|
// Get the space, where the power levels will be inspected/edited
|
||||||
|
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
||||||
|
assert(spaceID)
|
||||||
|
|
||||||
|
// Get the power level
|
||||||
|
/** @type {Ty.Event.M_Power_Levels} */
|
||||||
|
const powerLevelsContent = await api.getStateEvent(spaceID, "m.room.power_levels", "")
|
||||||
|
const userPower = powerLevelsContent.users?.[event.sender] || 0
|
||||||
|
|
||||||
|
// Administrators equal to the bot cannot be demoted
|
||||||
|
if (userPower >= 100) {
|
||||||
|
return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `\`${sender}\` has administrator permissions. This cannot be edited.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `Showing permissions for \`${sender}\`. Click to edit.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: DiscordTypes.ComponentType.ActionRow,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: DiscordTypes.ComponentType.StringSelect,
|
||||||
|
custom_id: "permissions_edit",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "Default",
|
||||||
|
value: "default",
|
||||||
|
default: userPower < 50
|
||||||
|
}, {
|
||||||
|
label: "Moderator",
|
||||||
|
value: "moderator",
|
||||||
|
default: userPower >= 50 && userPower < 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
|
*/
|
||||||
|
async function* _interactEdit({data, guild_id, message}, {api}) {
|
||||||
|
// Get the person that will be inspected/edited
|
||||||
|
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
|
||||||
|
assert(mxid)
|
||||||
|
|
||||||
|
const permission = data.values[0]
|
||||||
|
const power = permission === "moderator" ? 50 : 0
|
||||||
|
|
||||||
|
yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.UpdateMessage,
|
||||||
|
data: {
|
||||||
|
content: `Updating \`${mxid}\` to **${permission}**, please wait...`,
|
||||||
|
components: []
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Get the space, where the power levels will be inspected/edited
|
||||||
|
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
||||||
|
assert(spaceID)
|
||||||
|
|
||||||
|
// Do it
|
||||||
|
await api.setUserPowerCascade(spaceID, mxid, power)
|
||||||
|
|
||||||
|
// ACK
|
||||||
|
yield {editOriginalInteractionResponse: {
|
||||||
|
content: `Updated \`${mxid}\` to **${permission}**.`,
|
||||||
|
components: []
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
for await (const response of _interact(interaction, {api})) {
|
||||||
|
if (response.createInteractionResponse) {
|
||||||
|
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction */
|
||||||
|
async function interactEdit(interaction) {
|
||||||
|
for await (const response of _interactEdit(interaction, {api})) {
|
||||||
|
if (response.createInteractionResponse) {
|
||||||
|
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
||||||
|
module.exports.interactEdit = interactEdit
|
||||||
|
module.exports._interact = _interact
|
||||||
|
module.exports._interactEdit = _interactEdit
|
199
src/discord/interactions/permissions.test.js
Normal file
199
src/discord/interactions/permissions.test.js
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {select, db} = require("../../passthrough")
|
||||||
|
const {_interact, _interactEdit} = require("./permissions")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {AsyncIterable<T>} ai
|
||||||
|
* @returns {Promise<T[]>}
|
||||||
|
*/
|
||||||
|
async function fromAsync(ai) {
|
||||||
|
const result = []
|
||||||
|
for await (const value of ai) {
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test("permissions: checks if message is bridged", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "0"
|
||||||
|
},
|
||||||
|
guild_id: "0"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: checks if message is sent by a matrix user", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1126786462646550579"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: reports permissions of selected matrix user (implicit default)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {
|
||||||
|
users: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.")
|
||||||
|
t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[0], {label: "Default", value: "default", default: true})
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: reports permissions of selected matrix user (moderator)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {
|
||||||
|
users: {
|
||||||
|
"@cadence:cadence.moe": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.")
|
||||||
|
t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[1], {label: "Moderator", value: "moderator", default: true})
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: reports permissions of selected matrix user (admin)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {
|
||||||
|
users: {
|
||||||
|
"@cadence:cadence.moe": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "`@cadence:cadence.moe` has administrator permissions. This cannot be edited.")
|
||||||
|
t.notOk(msgs[0].createInteractionResponse.data.components)
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: can update user to moderator", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interactEdit({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666",
|
||||||
|
values: ["moderator"]
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
content: "Showing permissions for `@cadence:cadence.moe`. Click to edit."
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async setUserPowerCascade(roomID, mxid, power) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(mxid, "@cadence:cadence.moe")
|
||||||
|
t.equal(power, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs.length, 2)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **moderator**, please wait...")
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **moderator**.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: can update user to default", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interactEdit({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666",
|
||||||
|
values: ["default"]
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
content: "Showing permissions for `@cadence:cadence.moe`. Click to edit."
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async setUserPowerCascade(roomID, mxid, power) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(mxid, "@cadence:cadence.moe")
|
||||||
|
t.equal(power, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs.length, 2)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **default**, please wait...")
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **default**.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
79
src/discord/interactions/privacy.js
Normal file
79
src/discord/interactions/privacy.js
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {discord, sync, db, select} = require("../../passthrough")
|
||||||
|
const {id: botID} = require("../../../addbot")
|
||||||
|
const {InteractionMethods} = require("snowtransfer")
|
||||||
|
|
||||||
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
|
||||||
|
* @param {{createSpace: typeof createSpace}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
|
*/
|
||||||
|
async function* _interact({data, guild_id}, {createSpace}) {
|
||||||
|
// Check guild is bridged
|
||||||
|
const current = select("guild_space", "privacy_level", {guild_id}).pluck().get()
|
||||||
|
if (current == null) {
|
||||||
|
return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get input level
|
||||||
|
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
|
||||||
|
const options = data.options
|
||||||
|
const input = options?.[0]?.value || ""
|
||||||
|
const levels = ["invite", "link", "directory"]
|
||||||
|
const level = levels.findIndex(x => input === x)
|
||||||
|
if (level === -1) {
|
||||||
|
return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "**Usage: `/privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
|
||||||
|
+ "\n`invite`: Can only join with a direct in-app invite from another user. No shareable invite links."
|
||||||
|
+ "\n`link`: Matrix links can be created and shared like Discord's invite links. In-app invites still work."
|
||||||
|
+ "\n`directory`: Publicly visible in the Matrix space directory, like Server Discovery. Invites and links still work."
|
||||||
|
+ `\n**Current privacy level: \`${levels[current]}\`**`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id)
|
||||||
|
await createSpace.syncSpaceFully(guild_id) // this is inefficient but OK to call infrequently on user request
|
||||||
|
|
||||||
|
yield {editOriginalInteractionResponse: {
|
||||||
|
content: `Privacy level updated to \`${levels[level]}\`.`
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
for await (const response of _interact(interaction, {createSpace})) {
|
||||||
|
if (response.createInteractionResponse) {
|
||||||
|
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
||||||
|
module.exports._interact = _interact
|
86
src/discord/interactions/privacy.test.js
Normal file
86
src/discord/interactions/privacy.test.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {select, db} = require("../../passthrough")
|
||||||
|
const {_interact} = require("./privacy")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {AsyncIterable<T>} ai
|
||||||
|
* @returns {Promise<T[]>}
|
||||||
|
*/
|
||||||
|
async function fromAsync(ai) {
|
||||||
|
const result = []
|
||||||
|
for await (const value of ai) {
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test("privacy: checks if guild is bridged", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: []
|
||||||
|
},
|
||||||
|
guild_id: "0"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("privacy: reports usage if there is no parameter", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: []
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("privacy: reports usage for invalid parameter", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "level",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("privacy: updates setting and calls syncSpace for valid parameter", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "level",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "directory"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
createSpace: {
|
||||||
|
async syncSpaceFully(guildID) {
|
||||||
|
called++
|
||||||
|
t.equal(guildID, "112760669178241024")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs.length, 2)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.type, DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource)
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "Privacy level updated to `directory`.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2)
|
||||||
|
// Undo database changes
|
||||||
|
db.prepare("UPDATE guild_space SET privacy_level = 0 WHERE guild_id = ?").run("112760669178241024")
|
||||||
|
})
|
69
src/discord/interactions/reactions.js
Normal file
69
src/discord/interactions/reactions.js
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {discord, sync, select, from} = require("../../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
/** @type {import("../../m2d/converters/utils")} */
|
||||||
|
const utils = sync.require("../../m2d/converters/utils")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interact({data}, {api}) {
|
||||||
|
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
||||||
|
.select("event_id", "room_id").where({message_id: data.target_id}).get()
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "This message hasn't been bridged to Matrix.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
|
||||||
|
|
||||||
|
/** @type {Map<string, string[]>} */
|
||||||
|
const inverted = new Map()
|
||||||
|
for (const reaction of reactions) {
|
||||||
|
if (utils.eventSenderIsFromDiscord(reaction.sender)) continue
|
||||||
|
const key = reaction.content["m.relates_to"].key
|
||||||
|
const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: row.room_id}).pluck().get() || reaction.sender
|
||||||
|
if (!inverted.has(key)) inverted.set(key, [])
|
||||||
|
// @ts-ignore
|
||||||
|
inverted.get(key).push(displayname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inverted.size === 0) {
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "Nobody from Matrix reacted to this message.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"),
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
||||||
|
module.exports._interact = _interact
|
83
src/discord/interactions/reactions.test.js
Normal file
83
src/discord/interactions/reactions.test.js
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const {_interact} = require("./reactions")
|
||||||
|
|
||||||
|
test("reactions: checks if message is bridged", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "0"
|
||||||
|
}
|
||||||
|
}, {})
|
||||||
|
t.equal(msg.data.content, "This message hasn't been bridged to Matrix.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reactions: different response if nobody reacted", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1126786462646550579"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getFullRelations(roomID, eventID) {
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(msg.data.content, "Nobody from Matrix reacted to this message.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reactions: shows reactions if there are some, ignoring discord users", async t => {
|
||||||
|
let called = 1
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1126786462646550579"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getFullRelations(roomID, eventID) {
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
|
||||||
|
return [{
|
||||||
|
sender: "@cadence:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sender: "@rnl:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sender: "@cadence:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈⬛",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sender: "@_ooye_rnl:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(
|
||||||
|
msg.data.content,
|
||||||
|
"🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe"
|
||||||
|
+ "\n🐈⬛ ⮞ cadence [they]"
|
||||||
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
107
src/discord/register-interactions.js
Normal file
107
src/discord/register-interactions.js
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {discord, sync, db, select} = require("../passthrough")
|
||||||
|
const {id} = require("../../addbot")
|
||||||
|
|
||||||
|
const matrixInfo = sync.require("./interactions/matrix-info.js")
|
||||||
|
const invite = sync.require("./interactions/invite.js")
|
||||||
|
const permissions = sync.require("./interactions/permissions.js")
|
||||||
|
const reactions = sync.require("./interactions/reactions.js")
|
||||||
|
const privacy = sync.require("./interactions/privacy.js")
|
||||||
|
|
||||||
|
// User must have EVERY permission in default_member_permissions to be able to use the command
|
||||||
|
|
||||||
|
discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
|
||||||
|
name: "Matrix info",
|
||||||
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
|
type: DiscordTypes.ApplicationCommandType.Message,
|
||||||
|
}, {
|
||||||
|
name: "Permissions",
|
||||||
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
|
type: DiscordTypes.ApplicationCommandType.Message,
|
||||||
|
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles)
|
||||||
|
}, {
|
||||||
|
name: "Reactions",
|
||||||
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
|
type: DiscordTypes.ApplicationCommandType.Message
|
||||||
|
}, {
|
||||||
|
name: "invite",
|
||||||
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
|
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||||
|
description: "Invite a Matrix user to this Discord server",
|
||||||
|
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
description: "The Matrix user to invite, e.g. @username:example.org",
|
||||||
|
name: "user"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, {
|
||||||
|
name: "privacy",
|
||||||
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
|
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||||
|
description: "Change whether Matrix users can join through direct invites, links, or the public directory.",
|
||||||
|
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageGuild),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
description: "Check or set the new privacy level",
|
||||||
|
name: "level",
|
||||||
|
choices: [{
|
||||||
|
name: "❓️ Check the current privacy level and get more information.",
|
||||||
|
value: "info"
|
||||||
|
}, {
|
||||||
|
name: "🤝 Only allow joining with a direct in-app invite from another user. No shareable invite links.",
|
||||||
|
value: "invite"
|
||||||
|
}, {
|
||||||
|
name: "🔗 Matrix links can be created and shared like Discord's invite links. In-app invites still work.",
|
||||||
|
value: "link",
|
||||||
|
}, {
|
||||||
|
name: "🌏️ Publicly visible in the Matrix directory, like Server Discovery. Invites and links still work.",
|
||||||
|
value: "directory"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}])
|
||||||
|
|
||||||
|
async function dispatchInteraction(interaction) {
|
||||||
|
const interactionId = interaction.data.custom_id || interaction.data.name
|
||||||
|
try {
|
||||||
|
if (interactionId === "Matrix info") {
|
||||||
|
await matrixInfo.interact(interaction)
|
||||||
|
} else if (interactionId === "invite") {
|
||||||
|
await invite.interact(interaction)
|
||||||
|
} else if (interactionId === "invite_channel") {
|
||||||
|
await invite.interactButton(interaction)
|
||||||
|
} else if (interactionId === "Permissions") {
|
||||||
|
await permissions.interact(interaction)
|
||||||
|
} else if (interactionId === "permissions_edit") {
|
||||||
|
await permissions.interactEdit(interaction)
|
||||||
|
} else if (interactionId === "Reactions") {
|
||||||
|
await reactions.interact(interaction)
|
||||||
|
} else if (interactionId === "privacy") {
|
||||||
|
await privacy.interact(interaction)
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown interaction ${interactionId}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
let stackLines = null
|
||||||
|
if (e.stack) {
|
||||||
|
stackLines = e.stack.split("\n")
|
||||||
|
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
|
||||||
|
if (cloudstormLine !== -1) {
|
||||||
|
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await discord.snow.interaction.createFollowupMessage(id, interaction.token, {
|
||||||
|
content: `Interaction failed: **${interactionId}**`
|
||||||
|
+ `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\``
|
||||||
|
+ `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.dispatchInteraction = dispatchInteraction
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue