Compare commits
177 commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
541c5e1eb5 | |||
eb98af0ac3 | |||
5a65e6f6da | |||
a2f0bb79db | |||
149cee845a | |||
9e51482b60 | |||
8c506ed270 | |||
49598c7af7 | |||
c9aa5b4c64 | |||
24a3b9b0f4 | |||
07a133eba9 | |||
1f5865b0d8 | |||
b6b65992f7 | |||
5f0e765934 | |||
642be26313 | |||
ff7af39802 | |||
7a00b95883 | |||
566b2a9d9e | |||
0deb415511 | |||
bce3d0f2c9 | |||
c615ea1e61 | |||
23d85547f3 | |||
d01c888d02 | |||
955310b759 | |||
08c01e8664 | |||
f5ffc09fab | |||
25cd8cb289 | |||
cc9e1de49e | |||
a190e690b1 | |||
12d85c982e | |||
0f1cf7a20c | |||
043f178d1e | |||
bf3d219716 | |||
2fb68900c7 | |||
e2d0ea41d5 | |||
1e8066ca0a | |||
15e5ad88af | |||
47ac49a855 | |||
c5d6c5e4c7 | |||
18ef337aef | |||
8d037ff559 | |||
6738290d99 | |||
e236a25da2 | |||
6e41f85996 | |||
77b7772062 | |||
16f9c81097 | |||
19a34a6385 | |||
141ae835df | |||
|
7a9e1168fe | ||
e0d96cde19 | |||
553a9441d7 | |||
310bf2282c | |||
67939860b2 | |||
369e0862e5 | |||
ae4e299cf3 | |||
4f37aaf6a6 | |||
56f959e9f3 | |||
e999fcf819 | |||
|
355ebfe2af | ||
7756a34a5f | |||
f79833c444 | |||
fd11e3fd10 | |||
13d1f86d31 | |||
d673296619 | |||
789a90a893 | |||
a9f57fc252 | |||
4286829b42 | |||
30afaa1e17 | |||
3fb2c983e0 | |||
cfc89c40f9 | |||
0e701b2d54 | |||
0f25e73d67 | |||
c7fb6fd52e | |||
69922c4a14 | |||
98477dc0f6 | |||
64671519bd | |||
6c3164edd6 | |||
f48c1f3f31 | |||
3d87bd9da5 | |||
c084aa0156 | |||
4591b5ae03 | |||
a71c9515ec | |||
6a06dc14ce | |||
11864f80cf | |||
988cb9408d | |||
0e75c23aee | |||
8591ea5c1f | |||
67dc31f747 | |||
706b37669b | |||
845f93e5d0 | |||
e49dc18e67 | |||
5ef5dbb2e8 | |||
0ab81d3d88 | |||
c3dc3c89b5 | |||
f3cacc89fd | |||
aa9c2cc0c7 | |||
235aee3fef | |||
fbf51dab64 | |||
0237e6d8fd | |||
f3b7fcd1a3 | |||
d02f86b342 | |||
011889216b | |||
64beb6c996 |
143 changed files with 13263 additions and 3537 deletions
8
.c8rc.json
Normal file
8
.c8rc.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"watermarks": {
|
||||||
|
"statements": [60, 100],
|
||||||
|
"lines": [60, 100],
|
||||||
|
"functions": [60, 100],
|
||||||
|
"branches": [60, 100]
|
||||||
|
}
|
||||||
|
}
|
15
.gitignore
vendored
15
.gitignore
vendored
|
@ -1,5 +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/lottie*
|
||||||
|
icon.svg
|
||||||
|
*~
|
||||||
|
.#*
|
||||||
|
\#*#
|
||||||
|
launch.json
|
||||||
|
|
8
addbot.js
Normal file → Executable file
8
addbot.js
Normal file → Executable file
|
@ -1,10 +1,11 @@
|
||||||
|
#!/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 `
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,4 +13,5 @@ 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"
|
|
||||||
}
|
|
|
@ -1,177 +0,0 @@
|
||||||
const {test} = require("supertape")
|
|
||||||
const {editToChanges} = require("./edit-to-changes")
|
|
||||||
const data = require("../../test/data")
|
|
||||||
const Ty = require("../../types")
|
|
||||||
|
|
||||||
test("edit2changes: edit by webhook", async t => {
|
|
||||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToSend, [])
|
|
||||||
t.deepEqual(eventsToReplace, [{
|
|
||||||
oldID: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10",
|
|
||||||
newContent: {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "* test 2",
|
|
||||||
"m.mentions": {},
|
|
||||||
"m.new_content": {
|
|
||||||
// *** Replaced With: ***
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "test 2",
|
|
||||||
"m.mentions": {}
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.replace",
|
|
||||||
event_id: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
t.equal(senderMxid, null)
|
|
||||||
t.deepEqual(promotions, [])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: bot response", async t => {
|
|
||||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
|
|
||||||
return new Promise(resolve => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve({
|
|
||||||
joined: {
|
|
||||||
"@cadence:cadence.moe": {
|
|
||||||
displayname: "cadence [they]",
|
|
||||||
avatar_url: "whatever"
|
|
||||||
},
|
|
||||||
"@_ooye_botrac4r:cadence.moe": {
|
|
||||||
displayname: "botrac4r",
|
|
||||||
avatar_url: "whatever"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToSend, [])
|
|
||||||
t.deepEqual(eventsToReplace, [{
|
|
||||||
oldID: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY",
|
|
||||||
newContent: {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "* :ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
|
||||||
"m.mentions": {
|
|
||||||
// Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred*
|
|
||||||
},
|
|
||||||
// *** Replaced With: ***
|
|
||||||
"m.new_content": {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: ":ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
|
||||||
"m.mentions": {
|
|
||||||
// Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event
|
|
||||||
"user_ids": ["@cadence:cadence.moe"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.replace",
|
|
||||||
event_id: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe")
|
|
||||||
t.deepEqual(promotions, [])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: remove caption from image", async t => {
|
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"])
|
|
||||||
t.deepEqual(eventsToSend, [])
|
|
||||||
t.deepEqual(eventsToReplace, [])
|
|
||||||
t.deepEqual(promotions, [{column: "part", eventID: "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI"}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: change file type", async t => {
|
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
|
|
||||||
t.deepEqual(eventsToSend, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
|
|
||||||
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)",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text"
|
|
||||||
}])
|
|
||||||
t.deepEqual(eventsToReplace, [])
|
|
||||||
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: add caption back to that image", async t => {
|
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToSend, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "some text",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
t.deepEqual(eventsToReplace, [])
|
|
||||||
t.deepEqual(promotions, [])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
|
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToSend, [])
|
|
||||||
t.deepEqual(eventsToReplace, [{
|
|
||||||
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
|
|
||||||
newContent: {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "* only the content can be edited",
|
|
||||||
"m.mentions": {},
|
|
||||||
// *** Replaced With: ***
|
|
||||||
"m.new_content": {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "only the content can be edited",
|
|
||||||
"m.mentions": {}
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.replace",
|
|
||||||
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: edit of reply to skull webp attachment with content", async t => {
|
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToSend, [])
|
|
||||||
t.deepEqual(eventsToReplace, [{
|
|
||||||
oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M",
|
|
||||||
newContent: {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "> Extremity: Image\n\n* Edit",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body:
|
|
||||||
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
|
|
||||||
+ '<br>Image</blockquote></mx-reply>'
|
|
||||||
+ '* Edit',
|
|
||||||
"m.mentions": {},
|
|
||||||
"m.new_content": {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "Edit",
|
|
||||||
"m.mentions": {}
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.replace",
|
|
||||||
event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
|
@ -1,117 +0,0 @@
|
||||||
const {test} = require("supertape")
|
|
||||||
const {messageToEvent} = require("./message-to-event")
|
|
||||||
const data = require("../../test/data")
|
|
||||||
const Ty = require("../../types")
|
|
||||||
|
|
||||||
test("message2event embeds: nothing but a field", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "> ### Amanda 🎵#2192 :online:"
|
|
||||||
+ "\n> willow tree, branch 0"
|
|
||||||
+ "\n> **❯ Uptime:**\n> 3m 55s\n> **❯ Memory:**\n> 64.45MB",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
|
||||||
+ '<br>willow tree, branch 0</strong>'
|
|
||||||
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
|
|
||||||
+ '<br><strong>❯ Memory:</strong><br>64.45MB</p></blockquote>'
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: reply with just an embed", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
"m.mentions": {},
|
|
||||||
body: "> ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
|
|
||||||
+ "\n> \n> ## https://twitter.com/i/status/1707484191963648161"
|
|
||||||
+ "\n> \n> does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
|
|
||||||
+ "\n> \n> ### Retweets"
|
|
||||||
+ "\n> 119"
|
|
||||||
+ "\n> \n> ### Likes"
|
|
||||||
+ "\n> 5581"
|
|
||||||
+ "\n> — Twitter",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong></p>'
|
|
||||||
+ '<p><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
|
|
||||||
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
|
|
||||||
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: image embed and attachment", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
return {joined: []}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<a href="https://tootsuite.net/Warp-Gate2.gif">https://tootsuite.net/Warp-Gate2.gif</a><br>tanget: @ monster spawner',
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.image",
|
|
||||||
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
|
|
||||||
body: "Screenshot_20231001_034036.jpg",
|
|
||||||
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
|
|
||||||
filename: "Screenshot_20231001_034036.jpg",
|
|
||||||
info: {
|
|
||||||
h: 1170,
|
|
||||||
w: 1080,
|
|
||||||
size: 51981,
|
|
||||||
mimetype: "image/jpeg"
|
|
||||||
},
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: blockquote in embed", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: ":emoji: **4 |** #wonderland",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO\" title=\":emoji:\" alt=\":emoji:\"> <strong>4 |</strong> <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe\">#wonderland</a>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "> ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo\n> \n> reply draft\n> > The following is a message composed via consensus of the Stinker Council.\n> > \n> > For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n> > \n> > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n> > \n> > There will be no further communication.\n> \n> [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo)",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo\">Go to Message</a></p></blockquote>",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: crazy html is all escaped", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "> ## ⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&) https://a.co/&<script>"
|
|
||||||
+ "\n> \n> ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&) https://a.co/&<script>"
|
|
||||||
+ "\n> \n> <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
|
||||||
+ "\n> \n> ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
|
||||||
+ "\n> <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
|
||||||
+ "\n> — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote>`
|
|
||||||
+ `<p><strong><a href="https://a.co/&amp;<script>">⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)</a></strong></p>`
|
|
||||||
+ `<p><strong><a href=\"https://a.co/&amp;<script>"><strong>[<span data-mx-color='#123456'>Hey<script>](<a href="https://a.co/&amp">https://a.co/&amp</a>;)</a></strong></p>`
|
|
||||||
+ `<p><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
|
||||||
+ `<p><strong><strong>[<span data-mx-color='#123456'>Hey<script>](<a href=\"https://a.co/&amp\">https://a.co/&amp</a>;)</strong>`
|
|
||||||
+ `<br><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
|
||||||
+ `— <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)</blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
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 & BigInt(1))) {
|
|
||||||
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
|
|
|
@ -1,74 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
|
|
||||||
const EPOCH = 1420070400000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string[]} userRoles
|
|
||||||
* @param {DiscordTypes.APIGuild["roles"]} guildRoles
|
|
||||||
* @param {string} [userID]
|
|
||||||
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites]
|
|
||||||
*/
|
|
||||||
function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
|
|
||||||
let allowed = BigInt(0)
|
|
||||||
let everyoneID
|
|
||||||
// Guild allows
|
|
||||||
for (const role of guildRoles) {
|
|
||||||
if (role.name === "@everyone") {
|
|
||||||
allowed |= BigInt(role.permissions)
|
|
||||||
everyoneID = role.id
|
|
||||||
}
|
|
||||||
if (userRoles.includes(role.id)) {
|
|
||||||
allowed |= BigInt(role.permissions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channelOverwrites) {
|
|
||||||
/** @type {((overwrite: Required<DiscordTypes.APIGuildChannel>["permission_overwrites"][0]) => any)[]} */
|
|
||||||
const actions = [
|
|
||||||
// Channel @everyone deny
|
|
||||||
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
|
|
||||||
// Channel @everyone allow
|
|
||||||
overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)),
|
|
||||||
// Role deny
|
|
||||||
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
|
|
||||||
// Role allow
|
|
||||||
overwrite => userRoles.includes(overwrite.id) && (allowed |= BigInt(overwrite.allow)),
|
|
||||||
// User deny
|
|
||||||
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
|
|
||||||
// User allow
|
|
||||||
overwrite => overwrite.id === userID && (allowed |= BigInt(overwrite.allow))
|
|
||||||
]
|
|
||||||
for (let i = 0; i < actions.length; i++) {
|
|
||||||
for (const overwrite of channelOverwrites) {
|
|
||||||
actions[i](overwrite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return allowed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server.
|
|
||||||
* @param {DiscordTypes.APIMessage} message
|
|
||||||
*/
|
|
||||||
function isWebhookMessage(message) {
|
|
||||||
const isInteractionResponse = message.type === 20
|
|
||||||
return message.webhook_id && !isInteractionResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} snowflake */
|
|
||||||
function snowflakeToTimestampExact(snowflake) {
|
|
||||||
return Number(BigInt(snowflake) >> 22n) + EPOCH
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {number} timestamp */
|
|
||||||
function timestampToSnowflakeInexact(timestamp) {
|
|
||||||
return String((timestamp - EPOCH) * 2**22)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.getPermissions = getPermissions
|
|
||||||
module.exports.isWebhookMessage = isWebhookMessage
|
|
||||||
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
|
|
||||||
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
|
|
|
@ -32,13 +32,13 @@ What does it look like on Discord-side?
|
||||||
|
|
||||||
This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list.
|
This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list.
|
||||||
|
|
||||||
## What will the converter look like?
|
## What will the converter do?
|
||||||
|
|
||||||
The converter will be very different in both directions.
|
The converter will be very different in both directions.
|
||||||
|
|
||||||
For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.
|
**For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.**
|
||||||
|
|
||||||
For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its state.
|
**For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its st**ate.
|
||||||
|
|
||||||
## Missing messages
|
## Missing messages
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ In this situation we need to stop and think about the possible paths forward we
|
||||||
|
|
||||||
The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room.
|
The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room.
|
||||||
|
|
||||||
To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, those partial pins Discord would be lost from Discord side.
|
To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, then pins Matrix doesn't know about would be lost from Discord side.
|
||||||
|
|
||||||
In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list.
|
In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list.
|
||||||
|
|
||||||
|
@ -61,7 +61,9 @@ If you were implementing this for real, you might have made different decisions
|
||||||
|
|
||||||
## Test data for the d2m converter
|
## Test data for the d2m converter
|
||||||
|
|
||||||
Let's start writing the d2m converter. It's helpful to write unit tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or play around with the interface.
|
Let's start writing the d2m converter. It's helpful to write automated tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or mess around with the interface.
|
||||||
|
|
||||||
|
To test the Discord-to-Matrix pin converter, we'll need some samples of Discord message objects. Then we can put these sample message objects through the converter and check what comes out the other side.
|
||||||
|
|
||||||
Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is:
|
Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is:
|
||||||
|
|
||||||
|
@ -74,7 +76,7 @@ Normally for getting test data, I would `curl` the Discord API to grab some real
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so I'm just making a list of IDs to save time.
|
"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so to save time, I'm just making a list of IDs.
|
||||||
|
|
||||||
These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section.
|
These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section.
|
||||||
|
|
||||||
|
@ -104,7 +106,7 @@ index c36f252..4919beb 100644
|
||||||
|
|
||||||
## Writing the d2m converter
|
## Writing the d2m converter
|
||||||
|
|
||||||
We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ it won't _do_ anything by itself. So it goes in the converters folder. The actual function is pretty simple since I've already planned what to do:
|
We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ It won't _do_ anything by itself. So it goes in the converters folder. I've already planned (in the "What will the converter do?" section) what to do, so writing the function is pretty simple:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
||||||
|
@ -133,9 +135,36 @@ index 0000000..e4107be
|
||||||
+module.exports.pinsToList = pinsToList
|
+module.exports.pinsToList = pinsToList
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Explaining the code
|
||||||
|
|
||||||
|
All converters have a `function` which does the work, and the function is added to `module.exports` so that other files can use it.
|
||||||
|
|
||||||
|
Importing `select` from `passthrough` lets us do database access. Calling the `select` function can select from OOYE's own SQLite database. If you want to see what's in the database, look at `ooye-test-data.sql` for test data, or open `ooye.db` for real data from your own bridge.
|
||||||
|
|
||||||
|
The comments `// @ts-check`, `/** @type ... */`, and `/** @param ... */` provide type-based autosuggestions when editing in Visual Studio Code.
|
||||||
|
|
||||||
|
Here's the code I haven't yet discussed:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function pinsToList(pins) {
|
||||||
|
const result = []
|
||||||
|
for (const message of pins) {
|
||||||
|
const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get()
|
||||||
|
if (eventID) result.push(eventID)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It will go through each `message` in `pins`. For each message, it will look up the corresponding Matrix event in the database, and if found, it will add it to `result`.
|
||||||
|
|
||||||
|
The `select` line will run this SQL: `SELECT event_id FROM event_message WHERE message_id = {the message ID}` and will return the event ID as a string or null.
|
||||||
|
|
||||||
|
For any database experts worried about an SQL query inside a loop, the N+1 problem does not apply to SQLite because the queries are executed in the same process rather than crossing a process (and network) boundary. https://www.sqlite.org/np1queryprob.html
|
||||||
|
|
||||||
## Test case for the d2m converter
|
## Test case for the d2m converter
|
||||||
|
|
||||||
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. We'll take the data we just prepared and process it through the function we just wrote:
|
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. The testing code will take the data we just prepared and process it through the `pinsToList` function we just wrote. Then, it will check the result is what we expected.
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
|
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
|
||||||
|
@ -177,6 +206,18 @@ index 5cc851e..280503d 100644
|
||||||
|
|
||||||
Good to go.
|
Good to go.
|
||||||
|
|
||||||
|
### Explaining the code
|
||||||
|
|
||||||
|
`require("supertape")` is a library that helps with testing and printing test results. `data = require("../../test/data")` is the file we edited earlier in the "Test data for the d2m converter" section. `require("./pins-to-list")` is the function we want to test.
|
||||||
|
|
||||||
|
Here is how you declare a test: `test("pins2list: converts known IDs, ignores unknown IDs", t => {` The string describes what you are trying to test and it will be displayed if the test fails.
|
||||||
|
|
||||||
|
`result = pinsToList(data.pins.faked)` is calling the implementation function we wrote.
|
||||||
|
|
||||||
|
`t.deepEqual(actual, expected)` will check whether the `actual` result value is the same as our `expected` result value. If it's not, it'll mark that as a failed test.
|
||||||
|
|
||||||
|
### Run the test!
|
||||||
|
|
||||||
```
|
```
|
||||||
><> $ npm t
|
><> $ npm t
|
||||||
|
|
||||||
|
@ -209,7 +250,11 @@ Oh no! (I promise I didn't make it fail for demonstration purposes, this was act
|
||||||
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
|
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
|
||||||
```
|
```
|
||||||
|
|
||||||
Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events. We should consider the text to be the primary part, and pin that, and consider the image to be the secondary part, and not pin that.
|
Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events.
|
||||||
|
|
||||||
|
In the current code, `pinsToList` is picking ALL the associated event IDs, and then `.get` is forcing it to limit that list to 1. It doesn't care which, so it's essentially random which event it wants to pin.
|
||||||
|
|
||||||
|
We should make a decision on which event is more important. You can make whatever decision you want - you could even make it pin every event associated with a message - but I've decided that the text should be the primary part and be pinned, and the image should be considered a secondary part and left unpinned.
|
||||||
|
|
||||||
We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column:
|
We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column:
|
||||||
|
|
||||||
|
@ -229,6 +274,8 @@ index e4107be..f401de2 100644
|
||||||
return result
|
return result
|
||||||
```
|
```
|
||||||
|
|
||||||
|
As long as the database is consistent, this new `select` will return at most 1 event, always choosing the primary part.
|
||||||
|
|
||||||
```
|
```
|
||||||
><> $ npm t
|
><> $ npm t
|
||||||
|
|
||||||
|
@ -341,6 +388,8 @@ I try to keep as much logic as possible out of the actions and in the converters
|
||||||
|
|
||||||
## See if it works
|
## See if it works
|
||||||
|
|
||||||
|
Since the automated tests pass, let's start up the bridge and run our nice new code:
|
||||||
|
|
||||||
```
|
```
|
||||||
node start.js
|
node start.js
|
||||||
```
|
```
|
||||||
|
@ -359,7 +408,7 @@ I expected that to be the end of the guide, but after some time, I noticed a new
|
||||||
|
|
||||||
[After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order.
|
[After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order.
|
||||||
|
|
||||||
We'll fix this by reversing the order of the list of pins before we store it. I'll do this in the converter.
|
We can fix this by reversing the order of the list of pins before we store it. The converter can do this:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
||||||
|
@ -405,7 +454,7 @@ index c2e3774..92e5678 100644
|
||||||
Pass!
|
Pass!
|
||||||
```
|
```
|
||||||
|
|
||||||
Next time a message is pinned or unpinned on Discord, the order should be updated on Matrix.
|
Next time a message is pinned or unpinned on Discord, OOYE should update the order of all the pins on Matrix.
|
||||||
|
|
||||||
## Notes on missed events
|
## Notes on missed events
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,9 @@ OOYE's speedbump will prevent the edit command appearing at all on Matrix-side,
|
||||||
|
|
||||||
## Database schema
|
## Database schema
|
||||||
|
|
||||||
TBD
|
* channel_room
|
||||||
|
+ speedbump_id - the ID of the webhook that may be proxying in this channel
|
||||||
|
+ speedbump_checked - time in unix seconds when the webhooks were last queried
|
||||||
|
|
||||||
## Unsolved problems
|
## Unsolved problems
|
||||||
|
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
const {pipeline} = require("stream").promises
|
|
||||||
const sharp = require("sharp")
|
|
||||||
const {GIFrame} = require("giframe")
|
|
||||||
const utils = require("./utils")
|
|
||||||
const fetch = require("node-fetch").default
|
|
||||||
const streamMimeType = require("stream-mime-type")
|
|
||||||
|
|
||||||
const SIZE = 48
|
|
||||||
const RESULT_WIDTH = 400
|
|
||||||
const IMAGES_ACROSS = Math.floor(RESULT_WIDTH / SIZE)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord.
|
|
||||||
* @param {string[]} mxcs mxc URLs, in order
|
|
||||||
* @returns {Promise<Buffer>} PNG image
|
|
||||||
*/
|
|
||||||
async function compositeMatrixEmojis(mxcs) {
|
|
||||||
let buffers = await Promise.all(mxcs.map(async mxc => {
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = utils.getPublicUrlForMxc(mxc)
|
|
||||||
assert(url)
|
|
||||||
|
|
||||||
/** @type {import("node-fetch").Response} res */
|
|
||||||
// If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing.
|
|
||||||
// If we were using connection pooling, we would be forced to download the entire GIF.
|
|
||||||
// So we set no agent to ensure we are not connection pooling.
|
|
||||||
// @ts-ignore the signal is slightly different from the type it wants (still works fine)
|
|
||||||
const res = await fetch(url, {agent: false, signal: abortController.signal})
|
|
||||||
const {stream, mime} = await streamMimeType.getMimeType(res.body)
|
|
||||||
|
|
||||||
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
|
|
||||||
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
|
|
||||||
const result = await new Promise((resolve, reject) => {
|
|
||||||
const transformer = sharp()
|
|
||||||
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
|
||||||
.png({compressionLevel: 0})
|
|
||||||
.toBuffer((err, buffer, info) => {
|
|
||||||
/* c8 ignore next */
|
|
||||||
if (err) return reject(err)
|
|
||||||
resolve({info, buffer})
|
|
||||||
})
|
|
||||||
pipeline(
|
|
||||||
stream,
|
|
||||||
transformer
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return result.buffer
|
|
||||||
|
|
||||||
} else if (mime === "image/gif") {
|
|
||||||
const giframe = new GIFrame(0)
|
|
||||||
stream.on("data", chunk => {
|
|
||||||
giframe.feed(chunk)
|
|
||||||
})
|
|
||||||
const frame = await giframe.getFrame()
|
|
||||||
|
|
||||||
const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}})
|
|
||||||
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
|
||||||
.png({compressionLevel: 0})
|
|
||||||
.toBuffer({resolveWithObject: true})
|
|
||||||
return buffer.data
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// unsupported mime type
|
|
||||||
console.error(`I don't know what a ${mime} emoji is.`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
abortController.abort()
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Calculate the size of the final composited image
|
|
||||||
const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE
|
|
||||||
const imagesDown = Math.ceil(buffers.length / IMAGES_ACROSS)
|
|
||||||
const totalHeight = imagesDown * SIZE
|
|
||||||
const comp = []
|
|
||||||
let left = 0, top = 0
|
|
||||||
for (const buffer of buffers) {
|
|
||||||
if (Buffer.isBuffer(buffer)) {
|
|
||||||
// Composite the current buffer into the sprite sheet
|
|
||||||
comp.push({left, top, input: buffer})
|
|
||||||
// The next buffer should be placed one slot to the right
|
|
||||||
left += SIZE
|
|
||||||
// If we're out of space to fit the entire next buffer there, wrap to the next line
|
|
||||||
if (left + SIZE > RESULT_WIDTH) {
|
|
||||||
left = 0
|
|
||||||
top += SIZE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = await sharp({create: {width: totalWidth, height: totalHeight, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}}})
|
|
||||||
.composite(comp)
|
|
||||||
.png()
|
|
||||||
.toBuffer({resolveWithObject: true})
|
|
||||||
return output.data
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.compositeMatrixEmojis = compositeMatrixEmojis
|
|
|
@ -1,134 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const reg = require("../../matrix/read-registration")
|
|
||||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
|
||||||
const assert = require("assert").strict
|
|
||||||
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
|
||||||
let hasher = null
|
|
||||||
// @ts-ignore
|
|
||||||
require("xxhash-wasm")().then(h => hasher = h)
|
|
||||||
|
|
||||||
const BLOCK_ELEMENTS = [
|
|
||||||
"ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
|
|
||||||
"CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
|
|
||||||
"FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
|
|
||||||
"HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
|
|
||||||
"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
|
|
||||||
"TFOOT", "TH", "THEAD", "TR", "UL"
|
|
||||||
]
|
|
||||||
const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether an event is the bridged representation of a discord message.
|
|
||||||
* Such messages shouldn't be bridged again.
|
|
||||||
* @param {string} sender
|
|
||||||
*/
|
|
||||||
function eventSenderIsFromDiscord(sender) {
|
|
||||||
// If it's from a user in the bridge's namespace, then it originated from discord
|
|
||||||
// This includes messages sent by the appservice's bot user, because that is what's used for webhooks
|
|
||||||
// TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
|
|
||||||
// TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
|
|
||||||
if (userRegex.some(x => sender.match(x))) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} mxc
|
|
||||||
* @returns {string?}
|
|
||||||
*/
|
|
||||||
function getPublicUrlForMxc(mxc) {
|
|
||||||
const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
|
||||||
if (avatarURLParts) return `${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
|
|
||||||
else return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event IDs are really big and have more entropy than we need.
|
|
||||||
* If we want to store the event ID in the database, we can store a more compact version by hashing it with this.
|
|
||||||
* I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics
|
|
||||||
* xxhash outputs an unsigned 64-bit integer.
|
|
||||||
* Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format
|
|
||||||
* This should give very efficient storage with sufficient entropy.
|
|
||||||
* @param {string} eventID
|
|
||||||
*/
|
|
||||||
function getEventIDHash(eventID) {
|
|
||||||
assert(hasher, "xxhash is not ready yet")
|
|
||||||
if (eventID[0] === "$" && eventID.length >= 13) {
|
|
||||||
eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash
|
|
||||||
}
|
|
||||||
const unsignedHash = hasher.h64(eventID)
|
|
||||||
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
|
||||||
return signedHash
|
|
||||||
}
|
|
||||||
|
|
||||||
class MatrixStringBuilder {
|
|
||||||
constructor() {
|
|
||||||
this.body = ""
|
|
||||||
this.formattedBody = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} body
|
|
||||||
* @param {string} formattedBody
|
|
||||||
* @param {any} [condition]
|
|
||||||
*/
|
|
||||||
add(body, formattedBody, condition = true) {
|
|
||||||
if (condition) {
|
|
||||||
if (formattedBody == undefined) formattedBody = body
|
|
||||||
this.body += body
|
|
||||||
this.formattedBody += formattedBody
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} body
|
|
||||||
* @param {string} [formattedBody]
|
|
||||||
* @param {any} [condition]
|
|
||||||
*/
|
|
||||||
addLine(body, formattedBody, condition = true) {
|
|
||||||
if (condition) {
|
|
||||||
if (formattedBody == undefined) formattedBody = body
|
|
||||||
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
|
|
||||||
this.body += body
|
|
||||||
const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
|
|
||||||
if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>"
|
|
||||||
this.formattedBody += formattedBody
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} body
|
|
||||||
* @param {string} [formattedBody]
|
|
||||||
* @param {any} [condition]
|
|
||||||
*/
|
|
||||||
addParagraph(body, formattedBody, condition = true) {
|
|
||||||
if (condition) {
|
|
||||||
if (formattedBody == undefined) formattedBody = body
|
|
||||||
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n"
|
|
||||||
this.body += body
|
|
||||||
formattedBody = `<p>${formattedBody}</p>`
|
|
||||||
this.formattedBody += formattedBody
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
return {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: this.body,
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: this.formattedBody
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
|
|
||||||
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
|
||||||
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
|
|
||||||
module.exports.getEventIDHash = getEventIDHash
|
|
||||||
module.exports.MatrixStringBuilder = MatrixStringBuilder
|
|
|
@ -1,25 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {test} = require("supertape")
|
|
||||||
const {eventSenderIsFromDiscord, getEventIDHash} = require("./utils")
|
|
||||||
|
|
||||||
test("sender type: matrix user", t => {
|
|
||||||
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sender type: ooye bot", t => {
|
|
||||||
t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sender type: ooye puppet", t => {
|
|
||||||
t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("event hash: hash is the same each time", t => {
|
|
||||||
const eventID = "$example"
|
|
||||||
t.equal(getEventIDHash(eventID), getEventIDHash(eventID))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("event hash: hash is different for different inputs", t => {
|
|
||||||
t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
|
|
||||||
})
|
|
|
@ -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
|
|
3143
package-lock.json
generated
3143
package-lock.json
generated
File diff suppressed because it is too large
Load diff
53
package.json
53
package.json
|
@ -14,44 +14,55 @@
|
||||||
],
|
],
|
||||||
"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",
|
||||||
"better-sqlite3": "^9.0.0",
|
"@cloudrac3r/discord-markdown": "^2.6.3",
|
||||||
|
"@cloudrac3r/giframe": "^0.4.3",
|
||||||
|
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||||
|
"@cloudrac3r/in-your-element": "^1.0.0",
|
||||||
|
"@cloudrac3r/mixin-deep": "^3.0.0",
|
||||||
|
"@cloudrac3r/pngjs": "^7.0.3",
|
||||||
|
"@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.9.5",
|
"cloudstorm": "^0.10.10",
|
||||||
"deep-equal": "^2.2.3",
|
"domino": "^2.1.6",
|
||||||
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#de519353668c87ecc8c543e9749093481bc72ff8",
|
"enquirer": "^2.4.1",
|
||||||
"entities": "^4.5.0",
|
"entities": "^5.0.0",
|
||||||
"giframe": "github:cloudrac3r/giframe#v0.4.1",
|
"get-stream": "^6.0.1",
|
||||||
"heatsync": "^2.4.1",
|
"h3": "^1.12.0",
|
||||||
"html-template-tag": "github:cloudrac3r/html-template-tag#v5.0",
|
"heatsync": "^2.5.3",
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"matrix-appservice": "^2.0.0",
|
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
|
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
"pngjs": "^7.0.0",
|
|
||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.33.4",
|
||||||
"snowtransfer": "^0.9.0",
|
"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",
|
"xxhash-wasm": "^1.0.2",
|
||||||
"xxhash-wasm": "^1.0.2"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@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",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"discord-api-types": "^0.37.60",
|
"discord-api-types": "^0.37.60",
|
||||||
"supertape": "^8.3.0",
|
"supertape": "^10.4.0"
|
||||||
"tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "node start.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 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 -r html -r text supertape --no-check-assertions-count --format fail test/test.js -- --slow"
|
"cover": "c8 -o test/coverage --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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
108
readme.md
108
readme.md
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
<img src="docs/img/icon.png" height="128" width="128">
|
<img src="docs/img/icon.png" height="128" width="128">
|
||||||
|
|
||||||
Modern Matrix-to-Discord appservice bridge.
|
Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe)
|
||||||
|
|
||||||
Created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) // Discuss in [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe)
|
[![Releases](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=plastic&color=green)](https://gitdab.com/cadence/out-of-your-element/releases) [![Discuss on Matrix](https://img.shields.io/badge/discuss-%23out--of--your--element-white?style=plastic)](https://matrix.to/#/#out-of-your-element:cadence.moe)
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
|
@ -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,47 +74,50 @@ 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. Stabler 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).
|
||||||
|
|
||||||
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 `node scripts/seed.js` 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 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
* You are here! :)
|
||||||
|
├── readme.md
|
||||||
* The bridge's SQLite database is stored here:
|
* The bridge's SQLite database is stored here:
|
||||||
|
├── ooye.db*
|
||||||
|
* Source code
|
||||||
|
└── src
|
||||||
|
* Database schema:
|
||||||
├── db
|
├── db
|
||||||
│ ├── *.sql, *.db
|
│ ├── orm.js, orm-defs.d.ts
|
||||||
│ * Migrations change the database schema when you update to a newer version of OOYE:
|
│ * Migrations change the database schema when you update to a newer version of OOYE:
|
||||||
|
│ ├── migrate.js
|
||||||
│ └── migrations
|
│ └── migrations
|
||||||
│ └── *.sql, *.js
|
│ └── *.sql, *.js
|
||||||
* Discord-to-Matrix bridging:
|
* Discord-to-Matrix bridging:
|
||||||
|
@ -129,7 +132,10 @@ Follow these steps:
|
||||||
│ ├── discord-*.js
|
│ ├── discord-*.js
|
||||||
│ * Listening to events from Discord and dispatching them to the correct `action`:
|
│ * Listening to events from Discord and dispatching them to the correct `action`:
|
||||||
│ └── event-dispatcher.js
|
│ └── event-dispatcher.js
|
||||||
|
* Discord bot commands and menus:
|
||||||
├── discord
|
├── discord
|
||||||
|
│ ├── interactions
|
||||||
|
│ │ └── *.js
|
||||||
│ └── discord-command-handler.js
|
│ └── discord-command-handler.js
|
||||||
* Matrix-to-Discord bridging:
|
* Matrix-to-Discord bridging:
|
||||||
├── m2d
|
├── m2d
|
||||||
|
@ -145,36 +151,48 @@ Follow these steps:
|
||||||
├── matrix
|
├── matrix
|
||||||
│ └── *.js
|
│ └── *.js
|
||||||
* Various files you can run once if you need them.
|
* Various files you can run once if you need them.
|
||||||
├── scripts
|
└── scripts
|
||||||
│ * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge:
|
* First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge:
|
||||||
│ ├── seed.js
|
├── seed.js
|
||||||
│ * Hopefully you won't need the rest of these. Code quality varies wildly.
|
* Hopefully you won't need the rest of these. Code quality varies wildly.
|
||||||
│ └── *.js
|
└── *.js
|
||||||
* You are here! :)
|
|
||||||
└── readme.md
|
|
||||||
|
|
||||||
## 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.
|
||||||
* (0) deep-equal: It's already pulled in by supertape.
|
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
|
||||||
* (1) discord-markdown: This is my fork!
|
|
||||||
* (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) 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) 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.
|
||||||
|
|
|
@ -18,6 +18,8 @@ ooye:
|
||||||
max_file_size: 5000000
|
max_file_size: 5000000
|
||||||
server_name: [the part after the colon in your matrix id, like cadence.moe]
|
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]
|
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:
|
invite:
|
||||||
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
|
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
|
||||||
# - @cadence:cadence.moe
|
# - '@cadence:cadence.moe'
|
||||||
|
|
13
scripts/capture-message-update-events.js
Normal file → Executable file
13
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 () => {
|
||||||
|
@ -37,7 +38,7 @@ passthrough.discord = discord
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const events = new sqlite("scripts/events.db")
|
const events = new sqlite("scripts/events.db")
|
||||||
const sql = "INSERT INTO \"update\" (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")"
|
const sql = "INSERT INTO update_event (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")"
|
||||||
console.log(sql)
|
console.log(sql)
|
||||||
const prepared = events.prepare(sql)
|
const prepared = events.prepare(sql)
|
||||||
|
|
||||||
|
|
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()
|
||||||
|
|
230
scripts/seed.js
Normal file → Executable file
230
scripts/seed.js
Normal file → Executable file
|
@ -1,47 +1,65 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
console.log("This could take up to 30 seconds. Please be patient.")
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
|
const {scheduler} = require("timers/promises")
|
||||||
|
const {isDeepStrictEqual} = require("util")
|
||||||
|
const {createServer} = require("http")
|
||||||
|
|
||||||
|
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 HeatSync = require("heatsync")
|
||||||
|
const {SnowTransfer} = require("snowtransfer")
|
||||||
|
const {createApp, defineEventHandler, toNodeListener} = require("h3")
|
||||||
|
|
||||||
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
|
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
|
||||||
|
|
||||||
const config = require("../config")
|
// Move database file if it's still in the old location
|
||||||
const passthrough = require("../passthrough")
|
if (fs.existsSync("db")) {
|
||||||
const db = new sqlite("db/ooye.db")
|
if (fs.existsSync("db/ooye.db")) {
|
||||||
const migrate = require("../db/migrate")
|
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})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, { sync, config, db })
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
||||||
const orm = sync.require("../db/orm")
|
const orm = sync.require("../src/db/orm")
|
||||||
passthrough.from = orm.from
|
passthrough.from = orm.from
|
||||||
passthrough.select = orm.select
|
passthrough.select = orm.select
|
||||||
|
|
||||||
const DiscordClient = require("../d2m/discord-client")
|
let registration = require("../src/matrix/read-registration")
|
||||||
const discord = new DiscordClient(config.discordToken, "no")
|
let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration
|
||||||
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) {
|
function die(message) {
|
||||||
console.error(message)
|
console.error(message)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadAutoEmoji(guild, name, filename) {
|
async function uploadAutoEmoji(snow, guild, name, filename) {
|
||||||
let emoji = guild.emojis.find(e => e.name === name)
|
let emoji = guild.emojis.find(e => e.name === name)
|
||||||
if (!emoji) {
|
if (!emoji) {
|
||||||
console.log(` Uploading ${name}...`)
|
console.log(` Uploading ${name}...`)
|
||||||
const data = fs.readFileSync(filename, null)
|
const data = fs.readFileSync(filename, null)
|
||||||
emoji = await discord.snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
|
emoji = await snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
|
||||||
} else {
|
} else {
|
||||||
console.log(` Reusing ${name}...`)
|
console.log(` Reusing ${name}...`)
|
||||||
}
|
}
|
||||||
|
@ -49,21 +67,176 @@ async function uploadAutoEmoji(guild, name, filename) {
|
||||||
return emoji
|
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 () => {
|
;(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"
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.close()
|
||||||
|
|
||||||
|
console.log("What is your Discord bot token?")
|
||||||
|
/** @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 {
|
||||||
|
const snow = new SnowTransfer(token)
|
||||||
|
await snow.user.getSelf()
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const template = getTemplateRegistration()
|
||||||
|
reg = {...template, url: bridgeOriginResponse.bridge_origin, ooye: {...template.ooye, ...serverNameResponse, ...bridgeOriginResponse, server_origin: serverOrigin, ...discordTokenResponse}}
|
||||||
|
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}`
|
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||||
|
|
||||||
// ensure registration is correctly set...
|
// 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(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(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls")
|
||||||
assert(reg.ooye.server_origin.match(/^https?:\/\//)) // must start with http or https
|
assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
||||||
assert.notEqual(reg.ooye.server_origin.slice(-1), "/") // must not end in slash
|
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...")
|
console.log("✅ Configuration looks good...")
|
||||||
|
|
||||||
// database ddl...
|
// database ddl...
|
||||||
await migrate.migrate(db)
|
await migrate.migrate(db)
|
||||||
|
|
||||||
// add initial rows to database, like adding the bot to sim...
|
// 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("0", reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
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...")
|
console.log("✅ Database is ready...")
|
||||||
|
|
||||||
|
@ -71,7 +244,11 @@ async function uploadAutoEmoji(guild, name, filename) {
|
||||||
try {
|
try {
|
||||||
await api.register(reg.sender_localpart)
|
await api.register(reg.sender_localpart)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.errcode === "M_USER_IN_USE" || e.data?.error !== "Internal server error") throw e // "Internal server error" is the only OK error because Synapse says this if you try to register the same username twice.
|
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...
|
// upload initial images...
|
||||||
|
@ -111,8 +288,8 @@ async function uploadAutoEmoji(guild, name, filename) {
|
||||||
}
|
}
|
||||||
// Upload those emojis to the chosen location
|
// Upload those emojis to the chosen location
|
||||||
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
|
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(discord.snow, guild, "L1", "docs/img/L1.png")
|
||||||
await uploadAutoEmoji(guild, "L2", "docs/img/L2.png")
|
await uploadAutoEmoji(discord.snow, guild, "L2", "docs/img/L2.png")
|
||||||
}
|
}
|
||||||
console.log("✅ Emojis are ready...")
|
console.log("✅ Emojis are ready...")
|
||||||
|
|
||||||
|
@ -123,6 +300,7 @@ async function uploadAutoEmoji(guild, name, filename) {
|
||||||
console.log("✅ Discord profile updated...")
|
console.log("✅ Discord profile updated...")
|
||||||
|
|
||||||
// set profile data on homeserver...
|
// 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.profileSetDisplayname(mxid, "Out Of Your Element")
|
||||||
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
||||||
console.log("✅ Matrix profile updated...")
|
console.log("✅ Matrix profile updated...")
|
||||||
|
|
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()
|
||||||
|
|
|
@ -8,6 +8,8 @@ const {discord, sync, db, select} = passthrough
|
||||||
const threadToAnnouncement = sync.require("../converters/thread-to-announcement")
|
const threadToAnnouncement = sync.require("../converters/thread-to-announcement")
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
|
/** @type {import("./register-user")} */
|
||||||
|
const registerUser = sync.require("./register-user")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} parentRoomID
|
* @param {string} parentRoomID
|
||||||
|
@ -15,10 +17,10 @@ const api = sync.require("../../matrix/api")
|
||||||
* @param {import("discord-api-types/v10").APIThreadChannel} thread
|
* @param {import("discord-api-types/v10").APIThreadChannel} thread
|
||||||
*/
|
*/
|
||||||
async function announceThread(parentRoomID, threadRoomID, thread) {
|
async function announceThread(parentRoomID, threadRoomID, thread) {
|
||||||
const creatorMxid = select("sim", "mxid", {user_id: thread.owner_id}).pluck().get()
|
assert(thread.owner_id)
|
||||||
|
// @ts-ignore
|
||||||
|
const creatorMxid = await registerUser.ensureSimJoined({id: thread.owner_id}, parentRoomID)
|
||||||
const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api})
|
const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api})
|
||||||
|
|
||||||
await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid)
|
await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
@ -12,6 +13,8 @@ const file = sync.require("../../matrix/file")
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../matrix/kstate")} */
|
/** @type {import("../../matrix/kstate")} */
|
||||||
const ks = sync.require("../../matrix/kstate")
|
const ks = sync.require("../../matrix/kstate")
|
||||||
|
/** @type {import("../../discord/utils")} */
|
||||||
|
const utils = sync.require("../../discord/utils")
|
||||||
/** @type {import("./create-space")}) */
|
/** @type {import("./create-space")}) */
|
||||||
const createSpace = sync.require("./create-space") // watch out for the require loop
|
const createSpace = sync.require("./create-space") // watch out for the require loop
|
||||||
|
|
||||||
|
@ -49,21 +52,24 @@ async function roomToKState(roomID) {
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {any} kstate
|
* @param {any} kstate
|
||||||
*/
|
*/
|
||||||
function applyKStateDiffToRoom(roomID, kstate) {
|
async function applyKStateDiffToRoom(roomID, kstate) {
|
||||||
const events = ks.kstateToState(kstate)
|
const events = await ks.kstateToState(kstate)
|
||||||
return Promise.all(events.map(({type, state_key, content}) =>
|
return Promise.all(events.map(({type, state_key, content}) =>
|
||||||
api.sendState(roomID, type, state_key, content)
|
api.sendState(roomID, type, state_key, content)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{id: string, name: string, topic?: string?, type: number}} channel
|
* @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel
|
||||||
* @param {{id: string}} guild
|
* @param {{id: string}} guild
|
||||||
* @param {string | null | undefined} customName
|
* @param {string | null | undefined} customName
|
||||||
*/
|
*/
|
||||||
function convertNameAndTopic(channel, guild, customName) {
|
function convertNameAndTopic(channel, guild, customName) {
|
||||||
|
// @ts-ignore
|
||||||
|
const parentChannel = discord.channels.get(channel.parent_id)
|
||||||
let channelPrefix =
|
let channelPrefix =
|
||||||
( channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
|
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? ""
|
||||||
|
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
|
||||||
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
|
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
|
||||||
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
|
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
|
||||||
: "")
|
: "")
|
||||||
|
@ -84,11 +90,27 @@ 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) {
|
||||||
const spaceID = await createSpace.ensureSpace(guild)
|
// @ts-ignore
|
||||||
assert(typeof spaceID === "string")
|
const parentChannel = discord.channels.get(channel.parent_id)
|
||||||
const privacyLevel = select("guild_space", "privacy_level", {space_id: spaceID}).pluck().get()
|
/** Used for membership/permission checks. */
|
||||||
|
let guildSpaceID
|
||||||
|
/** 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")
|
assert(typeof privacyLevel === "number")
|
||||||
|
|
||||||
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
||||||
|
@ -100,8 +122,7 @@ async function channelToKState(channel, guild) {
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
||||||
|
@ -112,30 +133,40 @@ async function channelToKState(channel, guild) {
|
||||||
join_rule: "restricted",
|
join_rule: "restricted",
|
||||||
allow: [{
|
allow: [{
|
||||||
type: "m.room_membership",
|
type: "m.room_membership",
|
||||||
room_id: spaceID
|
room_id: guildSpaceID
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
|
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
|
||||||
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
|
||||||
|
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},
|
||||||
"m.room.avatar/": avatarEventContent,
|
"m.room.avatar/": avatarEventContent,
|
||||||
"m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
"m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
||||||
"m.room.history_visibility/": {history_visibility},
|
"m.room.history_visibility/": {history_visibility},
|
||||||
[`m.space.parent/${spaceID}`]: {
|
[`m.space.parent/${parentSpaceID}`]: {
|
||||||
via: [reg.ooye.server_name],
|
via: [reg.ooye.server_name],
|
||||||
canonical: true
|
canonical: true
|
||||||
},
|
},
|
||||||
/** @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: {
|
notifications: {
|
||||||
"m.room.avatar": 0
|
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
|
||||||
|
@ -159,7 +190,7 @@ async function channelToKState(channel, guild) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {spaceID, privacyLevel, channelKState}
|
return {spaceID: parentSpaceID, privacyLevel, channelKState}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -175,6 +206,9 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
||||||
let threadParent = null
|
let threadParent = null
|
||||||
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
|
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
|
||||||
|
|
||||||
|
let spaceCreationContent = {}
|
||||||
|
if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}}
|
||||||
|
|
||||||
// Name and topic can be done earlier in room creation rather than in initial_state
|
// Name and topic can be done earlier in room creation rather than in initial_state
|
||||||
// https://spec.matrix.org/latest/client-server-api/#creation
|
// https://spec.matrix.org/latest/client-server-api/#creation
|
||||||
const name = kstate["m.room.name/"].name
|
const name = kstate["m.room.name/"].name
|
||||||
|
@ -191,7 +225,8 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
||||||
preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway
|
preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway
|
||||||
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
|
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
|
||||||
invite: [],
|
invite: [],
|
||||||
initial_state: ks.kstateToState(kstate)
|
initial_state: await ks.kstateToState(kstate),
|
||||||
|
...spaceCreationContent
|
||||||
})
|
})
|
||||||
|
|
||||||
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
|
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
|
||||||
|
@ -280,7 +315,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
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
|
||||||
|
@ -297,7 +332,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)
|
||||||
|
@ -332,11 +367,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)
|
||||||
|
@ -346,7 +386,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", {
|
||||||
|
@ -358,8 +402,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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -1,42 +1,97 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
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)
|
||||||
|
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
|
||||||
|
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {
|
||||||
|
notifications: {room: 20},
|
||||||
|
users: {"@example:matrix.org": 50}
|
||||||
|
}})
|
||||||
|
t.deepEqual(
|
||||||
|
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||||
|
limitedRoom
|
||||||
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("convertNameAndTopic: custom name and topic", t => {
|
test("convertNameAndTopic: custom name and topic", t => {
|
|
@ -1,9 +1,10 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
const {isDeepStrictEqual} = require("util")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const deepEqual = require("deep-equal")
|
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
|
||||||
|
@ -13,8 +14,8 @@ const api = sync.require("../../matrix/api")
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
/** @type {import("./create-room")} */
|
/** @type {import("./create-room")} */
|
||||||
const createRoom = sync.require("./create-room")
|
const createRoom = sync.require("./create-room")
|
||||||
/** @type {import("../converters/expression")} */
|
/** @type {import("./expression")} */
|
||||||
const expression = sync.require("../converters/expression")
|
const expression = sync.require("./expression")
|
||||||
/** @type {import("../../matrix/kstate")} */
|
/** @type {import("../../matrix/kstate")} */
|
||||||
const ks = sync.require("../../matrix/kstate")
|
const ks = sync.require("../../matrix/kstate")
|
||||||
|
|
||||||
|
@ -30,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,
|
||||||
|
@ -39,12 +42,12 @@ 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"
|
||||||
},
|
},
|
||||||
initial_state: ks.kstateToState(kstate)
|
initial_state: await ks.kstateToState(kstate)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
|
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
|
||||||
|
@ -56,19 +59,18 @@ async function createSpace(guild, kstate) {
|
||||||
* @param {number} privacyLevel
|
* @param {number} privacyLevel
|
||||||
*/
|
*/
|
||||||
async function guildToKState(guild, privacyLevel) {
|
async function guildToKState(guild, privacyLevel) {
|
||||||
const avatarEventContent = {}
|
assert.equal(typeof privacyLevel, "number")
|
||||||
if (guild.icon) {
|
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
|
||||||
avatarEventContent.discord_path = file.guildIcon(guild)
|
|
||||||
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
|
|
||||||
}
|
|
||||||
|
|
||||||
const guildKState = {
|
const guildKState = {
|
||||||
"m.room.name/": {name: guild.name},
|
"m.room.name/": {name: guild.name},
|
||||||
"m.room.avatar/": avatarEventContent,
|
"m.room.avatar/": {
|
||||||
|
$if: guild.icon,
|
||||||
|
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
|
||||||
|
@ -122,7 +124,8 @@ async function _syncSpace(guild, shouldActuallySync) {
|
||||||
// don't try to update rooms with custom avatars though
|
// don't try to update rooms with custom avatars though
|
||||||
const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all()
|
const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all()
|
||||||
|
|
||||||
const childRooms = ks.kstateToState(spaceKState).filter(({type, state_key, content}) => {
|
const state = await ks.kstateToState(spaceKState)
|
||||||
|
const childRooms = state.filter(({type, state_key, content}) => {
|
||||||
return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key)
|
return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key)
|
||||||
}).map(({state_key}) => state_key)
|
}).map(({state_key}) => state_key)
|
||||||
|
|
||||||
|
@ -181,17 +184,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)
|
||||||
|
|
||||||
const childRooms = ks.kstateToState(spaceKState).filter(({type, content}) => {
|
const childRooms = await api.getFullHierarchy(spaceID)
|
||||||
return type === "m.space.child" && "via" in content
|
|
||||||
}).map(({state_key}) => state_key)
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -226,7 +227,7 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
|
||||||
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
|
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
|
||||||
existing = fn([])
|
existing = fn([])
|
||||||
}
|
}
|
||||||
if (deepEqual(existing, content, {strict: true})) return
|
if (isDeepStrictEqual(existing, content)) return
|
||||||
}
|
}
|
||||||
api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
|
api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
|
||||||
}
|
}
|
38
src/d2m/actions/create-space.test.js
Normal file
38
src/d2m/actions/create-space.test.js
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const mixin = require("@cloudrac3r/mixin-deep")
|
||||||
|
const {guildToKState, ensureSpace} = require("./create-space")
|
||||||
|
const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate")
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const testData = require("../../../test/data")
|
||||||
|
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {db} = passthrough
|
||||||
|
|
||||||
|
test("guild2space: can generate kstate for a guild, passing privacy level 0", async t => {
|
||||||
|
t.deepEqual(
|
||||||
|
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
|
||||||
|
{
|
||||||
|
"m.room.avatar/": {
|
||||||
|
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
|
||||||
|
},
|
||||||
|
"m.room.guest_access/": {
|
||||||
|
guest_access: "can_join"
|
||||||
|
},
|
||||||
|
"m.room.history_visibility/": {
|
||||||
|
history_visibility: "invited"
|
||||||
|
},
|
||||||
|
"m.room.join_rules/": {
|
||||||
|
join_rule: "invite"
|
||||||
|
},
|
||||||
|
"m.room.name/": {
|
||||||
|
name: "Psychonauts 3"
|
||||||
|
},
|
||||||
|
"m.room.power_levels/": {
|
||||||
|
users: {
|
||||||
|
"@test_auto_invite:example.org": 100
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
|
@ -4,21 +4,25 @@ const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select, from} = passthrough
|
const {sync, db, select, from} = passthrough
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
|
/** @type {import("./speedbump")} */
|
||||||
|
const speedbump = sync.require("./speedbump")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
|
||||||
*/
|
*/
|
||||||
async function deleteMessage(data) {
|
async function deleteMessage(data) {
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
const row = select("channel_room", ["room_id", "speedbump_checked", "thread_parent"], {channel_id: data.channel_id}).get()
|
||||||
if (!roomID) return
|
if (!row) return
|
||||||
|
|
||||||
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
|
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
|
||||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id)
|
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id)
|
||||||
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(data.id)
|
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(data.id)
|
||||||
for (const eventID of eventsToRedact) {
|
for (const eventID of eventsToRedact) {
|
||||||
// Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
|
// Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
|
||||||
await api.redactEvent(roomID, eventID)
|
await api.redactEvent(row.room_id, eventID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await speedbump.updateCache(row.thread_parent || data.channel_id, row.speedbump_checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -29,9 +33,9 @@ async function deleteMessageBulk(data) {
|
||||||
if (!roomID) return
|
if (!roomID) return
|
||||||
|
|
||||||
const sids = JSON.stringify(data.ids)
|
const sids = JSON.stringify(data.ids)
|
||||||
const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?)").all(sids)
|
const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids)
|
||||||
db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?)").run(sids)
|
db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
|
||||||
db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?)").run(sids)
|
db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
|
||||||
for (const eventID of eventsToRedact) {
|
for (const eventID of eventsToRedact) {
|
||||||
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
|
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
|
||||||
await api.redactEvent(roomID, eventID)
|
await api.redactEvent(roomID, eventID)
|
|
@ -1,18 +1,32 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select} = passthrough
|
const {sync, db, select} = passthrough
|
||||||
/** @type {import("../converters/edit-to-changes")} */
|
/** @type {import("../converters/edit-to-changes")} */
|
||||||
const editToChanges = sync.require("../converters/edit-to-changes")
|
const editToChanges = sync.require("../converters/edit-to-changes")
|
||||||
|
/** @type {import("./register-pk-user")} */
|
||||||
|
const registerPkUser = sync.require("./register-pk-user")
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
||||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
* @param {import("discord-api-types/v10").APIGuild} guild
|
||||||
|
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
|
||||||
*/
|
*/
|
||||||
async function editMessage(message, guild) {
|
async function editMessage(message, guild, row) {
|
||||||
const {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
|
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
|
||||||
|
|
||||||
|
if (row && row.speedbump_webhook_id === message.webhook_id) {
|
||||||
|
// Handle the PluralKit public instance
|
||||||
|
if (row.speedbump_id === "466378653216014359") {
|
||||||
|
const root = await registerPkUser.fetchMessage(message.id)
|
||||||
|
assert(root.member)
|
||||||
|
senderMxid = await registerPkUser.ensureSimJoined(root, roomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Replace all the things.
|
// 1. Replace all the things.
|
||||||
for (const {oldID, newContent} of eventsToReplace) {
|
for (const {oldID, newContent} of eventsToReplace) {
|
||||||
|
@ -39,7 +53,7 @@ async function editMessage(message, guild) {
|
||||||
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)
|
||||||
}
|
}
|
|
@ -1,10 +1,9 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {sync, db} = passthrough
|
||||||
/** @type {import("../../matrix/file")} */
|
/** @type {import("../../matrix/file")} */
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
|
|
||||||
|
@ -67,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"
|
||||||
},
|
},
|
|
@ -3,63 +3,40 @@
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const {PNG} = require("pngjs")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, discord, select} = passthrough
|
const {sync, db, select} = passthrough
|
||||||
/** @type {import("../../matrix/file")} */
|
/** @type {import("../../matrix/file")} */
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
//** @type {import("../../matrix/mreq")} */
|
/** @type {import("../../matrix/mreq")} */
|
||||||
const mreq = sync.require("../../matrix/mreq")
|
const mreq = sync.require("../../matrix/mreq")
|
||||||
|
/** @type {import("../converters/lottie")} */
|
||||||
const SIZE = 160 // Discord's display size on 1x displays is 160
|
const convertLottie = sync.require("../converters/lottie")
|
||||||
|
|
||||||
const INFO = {
|
const INFO = {
|
||||||
mimetype: "image/png",
|
mimetype: "image/png",
|
||||||
w: SIZE,
|
w: convertLottie.SIZE,
|
||||||
h: SIZE
|
h: convertLottie.SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef RlottieWasm
|
|
||||||
* @prop {(string) => boolean} load load lottie data from string of json
|
|
||||||
* @prop {() => number} frames get number of frames
|
|
||||||
* @prop {(frameCount: number, width: number, height: number) => Uint8Array} render render lottie data to bitmap
|
|
||||||
*/
|
|
||||||
|
|
||||||
const Rlottie = (async () => {
|
|
||||||
const Rlottie = require("./rlottie-wasm.js")
|
|
||||||
await new Promise(resolve => Rlottie.onRuntimeInitialized = resolve)
|
|
||||||
return Rlottie
|
|
||||||
})()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIStickerItem} stickerItem
|
* @param {DiscordTypes.APIStickerItem} stickerItem
|
||||||
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
|
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
|
||||||
*/
|
*/
|
||||||
async function convert(stickerItem) {
|
async function convert(stickerItem) {
|
||||||
|
// Reuse sticker if already converted and uploaded
|
||||||
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
|
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
|
||||||
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
|
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
|
||||||
const r = await Rlottie
|
|
||||||
|
// Fetch sticker data from Discord
|
||||||
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
|
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
|
||||||
if (res.status !== 200) throw new Error("Sticker data file not found.")
|
if (res.status !== 200) throw new Error("Sticker data file not found.")
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
/** @type RlottieWasm */
|
|
||||||
const rh = new r.RlottieWasm()
|
// Convert to PNG (readable stream)
|
||||||
const status = rh.load(text)
|
const readablePng = await convertLottie.convert(text)
|
||||||
if (!status) throw new Error(`Rlottie unable to load ${text.length} byte data file.`)
|
|
||||||
const rendered = rh.render(0, SIZE, SIZE)
|
// Upload to MXC
|
||||||
let png = new PNG({
|
|
||||||
width: SIZE,
|
|
||||||
height: SIZE,
|
|
||||||
bitDepth: 8, // 8 red + 8 green + 8 blue + 8 alpha
|
|
||||||
colorType: 6, // RGBA
|
|
||||||
inputColorType: 6, // RGBA
|
|
||||||
inputHasAlpha: true,
|
|
||||||
})
|
|
||||||
png.data = Buffer.from(rendered)
|
|
||||||
// @ts-ignore wrong type from pngjs
|
|
||||||
const readablePng = png.pack()
|
|
||||||
/** @type {Ty.R.FileUploaded} */
|
/** @type {Ty.R.FileUploaded} */
|
||||||
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
|
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -67,6 +44,8 @@ async function convert(stickerItem) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
assert(root.content_uri)
|
assert(root.content_uri)
|
||||||
|
|
||||||
|
// Save the link for next time
|
||||||
db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri)
|
db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri)
|
||||||
return {mxc_url: root.content_uri, info: INFO}
|
return {mxc_url: root.content_uri, info: INFO}
|
||||||
}
|
}
|
164
src/d2m/actions/register-pk-user.js
Normal file
164
src/d2m/actions/register-pk-user.js
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert")
|
||||||
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
const fetch = require("node-fetch").default
|
||||||
|
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {discord, sync, db, select} = passthrough
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
/** @type {import("../../matrix/file")} */
|
||||||
|
const file = sync.require("../../matrix/file")
|
||||||
|
/** @type {import("./register-user")} */
|
||||||
|
const registerUser = sync.require("./register-user")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef WebhookAuthor Discord API message->author. A webhook as an author.
|
||||||
|
* @prop {string} username
|
||||||
|
* @prop {string?} avatar
|
||||||
|
* @prop {string} id
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
||||||
|
* @param {Ty.PkMessage} pkMessage
|
||||||
|
* @returns mxid
|
||||||
|
*/
|
||||||
|
async function createSim(pkMessage) {
|
||||||
|
// Choose sim name
|
||||||
|
const simName = "_pk_" + pkMessage.member.id
|
||||||
|
const localpart = reg.ooye.namespace_prefix + simName
|
||||||
|
const mxid = `@${localpart}:${reg.ooye.server_name}`
|
||||||
|
|
||||||
|
// Save chosen name in the database forever
|
||||||
|
db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid)
|
||||||
|
|
||||||
|
// Register matrix user with that name
|
||||||
|
try {
|
||||||
|
await api.register(localpart)
|
||||||
|
} catch (e) {
|
||||||
|
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
|
||||||
|
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
|
||||||
|
db.prepare("DELETE FROM sim WHERE user_id = ?").run(pkMessage.member.uuid)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return mxid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a sim is registered for the user.
|
||||||
|
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
|
||||||
|
* @param {Ty.PkMessage} pkMessage
|
||||||
|
* @returns {Promise<string>} mxid
|
||||||
|
*/
|
||||||
|
async function ensureSim(pkMessage) {
|
||||||
|
let mxid = null
|
||||||
|
const existing = select("sim", "mxid", {user_id: pkMessage.member.uuid}).pluck().get()
|
||||||
|
if (existing) {
|
||||||
|
mxid = existing
|
||||||
|
} else {
|
||||||
|
mxid = await createSim(pkMessage)
|
||||||
|
}
|
||||||
|
return mxid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a sim is registered for the user and is joined to the room.
|
||||||
|
* @param {Ty.PkMessage} pkMessage
|
||||||
|
* @param {string} roomID
|
||||||
|
* @returns {Promise<string>} mxid
|
||||||
|
*/
|
||||||
|
async function ensureSimJoined(pkMessage, roomID) {
|
||||||
|
// Ensure room ID is really an ID, not an alias
|
||||||
|
assert.ok(roomID[0] === "!")
|
||||||
|
|
||||||
|
// Ensure user
|
||||||
|
const mxid = await ensureSim(pkMessage)
|
||||||
|
|
||||||
|
// Ensure joined
|
||||||
|
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
|
||||||
|
if (!existing) {
|
||||||
|
try {
|
||||||
|
await api.inviteToRoom(roomID, mxid)
|
||||||
|
await api.joinRoom(roomID, mxid)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("is already in the room.")) {
|
||||||
|
// Sweet!
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
|
||||||
|
}
|
||||||
|
return mxid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Ty.PkMessage} pkMessage
|
||||||
|
* @param {WebhookAuthor} author
|
||||||
|
*/
|
||||||
|
async function memberToStateContent(pkMessage, author) {
|
||||||
|
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
|
||||||
|
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
|
||||||
|
const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author)
|
||||||
|
|
||||||
|
const content = {
|
||||||
|
displayname: author.username,
|
||||||
|
membership: "join",
|
||||||
|
"moe.cadence.ooye.pk_member": pkMessage.member
|
||||||
|
}
|
||||||
|
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync profile data for a sim user. This function follows the following process:
|
||||||
|
* 1. Join the sim to the room if needed
|
||||||
|
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
|
||||||
|
* 3. Compare against the previously known state content, which is helpfully stored in the database
|
||||||
|
* 4. If the state content has changed, send it to Matrix and update it in the database for next time
|
||||||
|
* @param {WebhookAuthor} author
|
||||||
|
* @param {Ty.PkMessage} pkMessage
|
||||||
|
* @param {string} roomID
|
||||||
|
* @returns {Promise<string>} mxid of the updated sim
|
||||||
|
*/
|
||||||
|
async function syncUser(author, pkMessage, roomID) {
|
||||||
|
const mxid = await ensureSimJoined(pkMessage, roomID)
|
||||||
|
// Update the sim_proxy table, so mentions can look up the original sender later
|
||||||
|
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
|
||||||
|
// Sync the member state
|
||||||
|
const content = await memberToStateContent(pkMessage, author)
|
||||||
|
const currentHash = registerUser._hashProfileContent(content, 0)
|
||||||
|
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
||||||
|
// only do the actual sync if the hash has changed since we last looked
|
||||||
|
if (existingHash !== currentHash) {
|
||||||
|
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
||||||
|
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||||
|
}
|
||||||
|
return mxid
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<Ty.PkMessage>} */
|
||||||
|
async function fetchMessage(messageID) {
|
||||||
|
// Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts.
|
||||||
|
let attempts = 0
|
||||||
|
do {
|
||||||
|
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
|
||||||
|
if (res.ok) return res.json()
|
||||||
|
|
||||||
|
// I think the backend needs some time to update.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
} while (++attempts < 3)
|
||||||
|
|
||||||
|
const errorMessage = await res.json()
|
||||||
|
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports._memberToStateContent = memberToStateContent
|
||||||
|
module.exports.ensureSim = ensureSim
|
||||||
|
module.exports.ensureSimJoined = ensureSimJoined
|
||||||
|
module.exports.syncUser = syncUser
|
||||||
|
module.exports.fetchMessage = fetchMessage
|
|
@ -1,7 +1,9 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
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 mixin = require("@cloudrac3r/mixin-deep")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
|
@ -9,6 +11,8 @@ const {discord, sync, db, select} = passthrough
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../matrix/file")} */
|
/** @type {import("../../matrix/file")} */
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
|
/** @type {import("../../discord/utils")} */
|
||||||
|
const utils = sync.require("../../discord/utils")
|
||||||
/** @type {import("../converters/user-to-mxid")} */
|
/** @type {import("../converters/user-to-mxid")} */
|
||||||
const userToMxid = sync.require("../converters/user-to-mxid")
|
const userToMxid = sync.require("../converters/user-to-mxid")
|
||||||
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
||||||
|
@ -18,7 +22,7 @@ require("xxhash-wasm")().then(h => hasher = h)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
||||||
* @param {import("discord-api-types/v10").APIUser} user
|
* @param {DiscordTypes.APIUser} user
|
||||||
* @returns mxid
|
* @returns mxid
|
||||||
*/
|
*/
|
||||||
async function createSim(user) {
|
async function createSim(user) {
|
||||||
|
@ -46,7 +50,7 @@ async function createSim(user) {
|
||||||
/**
|
/**
|
||||||
* Ensure a sim is registered for the user.
|
* Ensure a sim is registered for the user.
|
||||||
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
|
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
|
||||||
* @param {import("discord-api-types/v10").APIUser} user
|
* @param {DiscordTypes.APIUser} user
|
||||||
* @returns {Promise<string>} mxid
|
* @returns {Promise<string>} mxid
|
||||||
*/
|
*/
|
||||||
async function ensureSim(user) {
|
async function ensureSim(user) {
|
||||||
|
@ -62,7 +66,7 @@ async function ensureSim(user) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a sim is registered for the user and is joined to the room.
|
* Ensure a sim is registered for the user and is joined to the room.
|
||||||
* @param {import("discord-api-types/v10").APIUser} user
|
* @param {DiscordTypes.APIUser} user
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @returns {Promise<string>} mxid
|
* @returns {Promise<string>} mxid
|
||||||
*/
|
*/
|
||||||
|
@ -92,8 +96,8 @@ async function ensureSimJoined(user, roomID) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").APIUser} user
|
* @param {DiscordTypes.APIUser} user
|
||||||
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
|
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
|
||||||
*/
|
*/
|
||||||
async function memberToStateContent(user, member, guildID) {
|
async function memberToStateContent(user, member, guildID) {
|
||||||
let displayname = user.username
|
let displayname = user.username
|
||||||
|
@ -123,8 +127,46 @@ async function memberToStateContent(user, member, guildID) {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashProfileContent(content) {
|
/**
|
||||||
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`)
|
* https://gitdab.com/cadence/out-of-your-element/issues/9
|
||||||
|
* @param {DiscordTypes.APIUser} user
|
||||||
|
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
|
||||||
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @param {DiscordTypes.APIGuildChannel} channel
|
||||||
|
* @returns {number} 0 to 100
|
||||||
|
*/
|
||||||
|
function memberToPowerLevel(user, member, guild, channel) {
|
||||||
|
const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites)
|
||||||
|
/*
|
||||||
|
* PL 100 = Administrator = People who can brick the room. RATIONALE:
|
||||||
|
* - Administrator.
|
||||||
|
* - Manage Webhooks: People who remove the webhook can break the room.
|
||||||
|
* - Manage Guild: People who can manage guild can add bots.
|
||||||
|
* - Manage Channels: People who can manage the channel can delete it.
|
||||||
|
* (Setting sim users to PL 100 is safe because even though we can't demote the sims we can use code to make the sims demote themselves.)
|
||||||
|
*/
|
||||||
|
if (guild.owner_id === user.id || utils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
|
||||||
|
/*
|
||||||
|
* PL 50 = Moderator = People who can manage people and messages in many ways. RATIONALE:
|
||||||
|
* - Manage Messages: Can moderate by pinning or deleting the conversation.
|
||||||
|
* - Manage Nicknames: Can moderate by removing inappropriate nicknames.
|
||||||
|
* - Manage Threads: Can moderate by deleting conversations.
|
||||||
|
* - Kick Members & Ban Members: Can moderate by removing disruptive people.
|
||||||
|
* - Mute Members & Deafen Members: Can moderate by silencing disruptive people in ways they can't undo.
|
||||||
|
* - Moderate Members.
|
||||||
|
*/
|
||||||
|
if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
|
||||||
|
/* PL 20 = Mention Everyone for technical reasons. */
|
||||||
|
if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} content
|
||||||
|
* @param {number} powerLevel
|
||||||
|
*/
|
||||||
|
function _hashProfileContent(content, powerLevel) {
|
||||||
|
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`)
|
||||||
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
||||||
return signedHash
|
return signedHash
|
||||||
}
|
}
|
||||||
|
@ -133,52 +175,72 @@ function hashProfileContent(content) {
|
||||||
* Sync profile data for a sim user. This function follows the following process:
|
* Sync profile data for a sim user. This function follows the following process:
|
||||||
* 1. Join the sim to the room if needed
|
* 1. Join the sim to the room if needed
|
||||||
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
|
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
|
||||||
* 3. Compare against the previously known state content, which is helpfully stored in the database
|
* 3. Calculate the power level the user should get based on their Discord permissions
|
||||||
* 4. If the state content has changed, send it to Matrix and update it in the database for next time
|
* 4. Compare against the previously known state content, which is helpfully stored in the database
|
||||||
* @param {import("discord-api-types/v10").APIUser} user
|
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
|
||||||
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
|
* @param {DiscordTypes.APIUser} user
|
||||||
|
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
|
||||||
|
* @param {DiscordTypes.APIGuildChannel} channel
|
||||||
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @param {string} roomID
|
||||||
* @returns {Promise<string>} mxid of the updated sim
|
* @returns {Promise<string>} mxid of the updated sim
|
||||||
*/
|
*/
|
||||||
async function syncUser(user, member, guildID, roomID) {
|
async function syncUser(user, member, channel, guild, roomID) {
|
||||||
const mxid = await ensureSimJoined(user, roomID)
|
const mxid = await ensureSimJoined(user, roomID)
|
||||||
const content = await memberToStateContent(user, member, guildID)
|
const content = await memberToStateContent(user, member, guild.id)
|
||||||
const currentHash = hashProfileContent(content)
|
const powerLevel = memberToPowerLevel(user, member, guild, channel)
|
||||||
|
const currentHash = _hashProfileContent(content, powerLevel)
|
||||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
||||||
// only do the actual sync if the hash has changed since we last looked
|
// only do the actual sync if the hash has changed since we last looked
|
||||||
if (existingHash !== currentHash) {
|
if (existingHash !== currentHash) {
|
||||||
|
// Update room member state
|
||||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
||||||
|
// Update power levels
|
||||||
|
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||||
|
const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || 0
|
||||||
|
mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}})
|
||||||
|
if (powerLevel === 0) delete powerLevelsStateContent.users[mxid] // keep the event compact
|
||||||
|
const sendPowerLevelAs = powerLevel < oldPowerLevel ? mxid : undefined // bridge bot won't not have permission to demote equal power users, so do this action as themselves
|
||||||
|
await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent, sendPowerLevelAs)
|
||||||
|
// Update cached hash
|
||||||
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||||
}
|
}
|
||||||
return mxid
|
return mxid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} roomID
|
||||||
|
*/
|
||||||
async function syncAllUsersInRoom(roomID) {
|
async function syncAllUsersInRoom(roomID) {
|
||||||
const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
|
const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
|
||||||
|
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||||
assert.ok(typeof channelID === "string")
|
assert.ok(typeof channelID === "string")
|
||||||
|
|
||||||
/** @ts-ignore @type {import("discord-api-types/v10").APIGuildChannel} */
|
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
||||||
const channel = discord.channels.get(channelID)
|
const channel = discord.channels.get(channelID)
|
||||||
const guildID = channel.guild_id
|
const guildID = channel.guild_id
|
||||||
assert.ok(typeof guildID === "string")
|
assert.ok(typeof guildID === "string")
|
||||||
|
/** @ts-ignore @type {DiscordTypes.APIGuild} */
|
||||||
|
const guild = discord.guilds.get(guildID)
|
||||||
|
|
||||||
for (const mxid of mxids) {
|
for (const mxid of mxids) {
|
||||||
const userID = select("sim", "user_id", {mxid}).pluck().get()
|
const userID = select("sim", "user_id", {mxid}).pluck().get()
|
||||||
assert.ok(typeof userID === "string")
|
assert.ok(typeof userID === "string")
|
||||||
|
|
||||||
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIGuildMember>} */
|
/** @ts-ignore @type {Required<DiscordTypes.APIGuildMember>} */
|
||||||
const member = await discord.snow.guild.getGuildMember(guildID, userID)
|
const member = await discord.snow.guild.getGuildMember(guildID, userID)
|
||||||
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIUser>} user */
|
/** @ts-ignore @type {Required<DiscordTypes.APIUser>} user */
|
||||||
const user = member.user
|
const user = member.user
|
||||||
assert.ok(user)
|
assert.ok(user)
|
||||||
|
|
||||||
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
|
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
|
||||||
await syncUser(user, member, guildID, roomID)
|
await syncUser(user, member, channel, guild, roomID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports._memberToStateContent = memberToStateContent
|
module.exports._memberToStateContent = memberToStateContent
|
||||||
|
module.exports._hashProfileContent = _hashProfileContent
|
||||||
module.exports.ensureSim = ensureSim
|
module.exports.ensureSim = ensureSim
|
||||||
module.exports.ensureSimJoined = ensureSimJoined
|
module.exports.ensureSimJoined = ensureSimJoined
|
||||||
module.exports.syncUser = syncUser
|
module.exports.syncUser = syncUser
|
|
@ -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
|
61
src/d2m/actions/retrigger.js
Normal file
61
src/d2m/actions/retrigger.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const {EventEmitter} = require("events")
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {select} = passthrough
|
||||||
|
|
||||||
|
const DEBUG_RETRIGGER = false
|
||||||
|
|
||||||
|
function debugRetrigger(message) {
|
||||||
|
if (DEBUG_RETRIGGER) {
|
||||||
|
console.log(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitter = new EventEmitter()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
|
||||||
|
* (or before the it has finished being bridged to an event).
|
||||||
|
* In this case, wait until the original message has finished bridging, then retrigger the passed function.
|
||||||
|
* @template {(...args: any[]) => Promise<any>} T
|
||||||
|
* @param {string} messageID
|
||||||
|
* @param {T} fn
|
||||||
|
* @param {Parameters<T>} rest
|
||||||
|
* @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) {
|
||||||
|
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
||||||
|
if (eventID) {
|
||||||
|
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
|
||||||
|
return false // event was found so don't retrigger
|
||||||
|
}
|
||||||
|
|
||||||
|
debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`)
|
||||||
|
emitter.once(messageID, () => {
|
||||||
|
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
|
||||||
|
fn(...rest)
|
||||||
|
})
|
||||||
|
// if the event never arrives, don't trigger the callback, just clean up
|
||||||
|
setTimeout(() => {
|
||||||
|
if (emitter.listeners(messageID).length) {
|
||||||
|
debugRetrigger(`[retrigger] EXPIRE mid = ${messageID}`)
|
||||||
|
}
|
||||||
|
emitter.removeAllListeners(messageID)
|
||||||
|
}, 60 * 1000) // 1 minute
|
||||||
|
return true // event was not found, then retrigger
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers any pending operations that were waiting on the corresponding event ID.
|
||||||
|
* @param {string} messageID
|
||||||
|
*/
|
||||||
|
function messageFinishedBridging(messageID) {
|
||||||
|
if (emitter.listeners(messageID).length) {
|
||||||
|
debugRetrigger(`[retrigger] EMIT mid = ${messageID}`)
|
||||||
|
}
|
||||||
|
emitter.emit(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
|
||||||
|
module.exports.messageFinishedBridging = messageFinishedBridging
|
|
@ -1,6 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
const assert = require("assert").strict
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const { discord, sync, db } = passthrough
|
const { discord, sync, db } = passthrough
|
||||||
|
@ -10,25 +11,37 @@ const messageToEvent = sync.require("../converters/message-to-event")
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("./register-user")} */
|
/** @type {import("./register-user")} */
|
||||||
const registerUser = sync.require("./register-user")
|
const registerUser = sync.require("./register-user")
|
||||||
|
/** @type {import("./register-pk-user")} */
|
||||||
|
const registerPkUser = sync.require("./register-pk-user")
|
||||||
/** @type {import("../actions/create-room")} */
|
/** @type {import("../actions/create-room")} */
|
||||||
const createRoom = sync.require("../actions/create-room")
|
const createRoom = sync.require("../actions/create-room")
|
||||||
/** @type {import("../../discord/utils")} */
|
/** @type {import("../../discord/utils")} */
|
||||||
const dUtils = sync.require("../../discord/utils")
|
const dUtils = sync.require("../../discord/utils")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
* @param {DiscordTypes.APIGuildChannel} channel
|
||||||
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
|
||||||
*/
|
*/
|
||||||
async function sendMessage(message, guild) {
|
async function sendMessage(message, channel, guild, row) {
|
||||||
const roomID = await createRoom.ensureRoom(message.channel_id)
|
const roomID = await createRoom.ensureRoom(message.channel_id)
|
||||||
|
|
||||||
let senderMxid = null
|
let senderMxid = null
|
||||||
if (!dUtils.isWebhookMessage(message)) {
|
if (!dUtils.isWebhookMessage(message)) {
|
||||||
if (message.member) { // available on a gateway message create event
|
if (message.author.id === discord.application.id) {
|
||||||
senderMxid = await registerUser.syncUser(message.author, message.member, message.guild_id, roomID)
|
// no need to sync the bot's own user
|
||||||
|
} else if (message.member) { // available on a gateway message create event
|
||||||
|
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
|
||||||
} else { // well, good enough...
|
} else { // well, good enough...
|
||||||
senderMxid = await registerUser.ensureSimJoined(message.author, roomID)
|
senderMxid = await registerUser.ensureSimJoined(message.author, roomID)
|
||||||
}
|
}
|
||||||
|
} else if (row && row.speedbump_webhook_id === message.webhook_id) {
|
||||||
|
// Handle the PluralKit public instance
|
||||||
|
if (row.speedbump_id === "466378653216014359") {
|
||||||
|
const pkMessage = await registerPkUser.fetchMessage(message.id)
|
||||||
|
senderMxid = await registerPkUser.syncUser(message.author, pkMessage, roomID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
|
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
|
68
src/d2m/actions/speedbump.js
Normal file
68
src/d2m/actions/speedbump.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {discord, select, db} = passthrough
|
||||||
|
|
||||||
|
const SPEEDBUMP_SPEED = 4000 // 4 seconds delay
|
||||||
|
const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
|
||||||
|
|
||||||
|
/** @type {Set<any>} */
|
||||||
|
const KNOWN_BOTS = new Set([
|
||||||
|
"466378653216014359" // PluralKit
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch new speedbump data for the channel and put it in the database as cache
|
||||||
|
* @param {string} channelID
|
||||||
|
* @param {number?} lastChecked
|
||||||
|
*/
|
||||||
|
async function updateCache(channelID, lastChecked) {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
|
||||||
|
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
|
||||||
|
const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
|
||||||
|
const foundApplication = found?.application_id
|
||||||
|
const foundWebhook = found?.id
|
||||||
|
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Set<string>} set of messageID */
|
||||||
|
const bumping = new Set()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
||||||
|
* @param {string} messageID
|
||||||
|
* @returns whether it was deleted
|
||||||
|
*/
|
||||||
|
async function doSpeedbump(messageID) {
|
||||||
|
bumping.add(messageID)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
|
||||||
|
return !bumping.delete(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
||||||
|
* @param {string} channelID
|
||||||
|
* @param {string} messageID
|
||||||
|
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
|
||||||
|
*/
|
||||||
|
async function maybeDoSpeedbump(channelID, messageID) {
|
||||||
|
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
|
||||||
|
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
|
||||||
|
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
|
||||||
|
const affected = await doSpeedbump(messageID)
|
||||||
|
return {affected, row} // maybe affected, and there is a speedbump
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} messageID
|
||||||
|
*/
|
||||||
|
function onMessageDelete(messageID) {
|
||||||
|
bumping.delete(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.updateCache = updateCache
|
||||||
|
module.exports.doSpeedbump = doSpeedbump
|
||||||
|
module.exports.maybeDoSpeedbump = maybeDoSpeedbump
|
||||||
|
module.exports.onMessageDelete = onMessageDelete
|
|
@ -3,31 +3,56 @@
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select, from} = passthrough
|
const {sync, select, from} = passthrough
|
||||||
/** @type {import("./message-to-event")} */
|
/** @type {import("./message-to-event")} */
|
||||||
const messageToEvent = sync.require("../converters/message-to-event")
|
const messageToEvent = sync.require("../converters/message-to-event")
|
||||||
/** @type {import("../actions/register-user")} */
|
/** @type {import("../../m2d/converters/utils")} */
|
||||||
const registerUser = sync.require("../actions/register-user")
|
const utils = sync.require("../../m2d/converters/utils")
|
||||||
/** @type {import("../actions/create-room")} */
|
|
||||||
const createRoom = sync.require("../actions/create-room")
|
function eventCanBeEdited(ev) {
|
||||||
|
// Discord does not allow files, images, attachments, or videos to be edited.
|
||||||
|
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Discord does not allow stickers to be edited.
|
||||||
|
if (ev.old.event_type === "m.sticker") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Anything else is fair game.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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 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)
|
||||||
|
|
||||||
// Figure out what events we will be replacing
|
// Figure out what events we will be replacing
|
||||||
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||||
assert(roomID)
|
assert(roomID)
|
||||||
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
|
|
||||||
const senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
|
|
||||||
|
|
||||||
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
|
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
|
||||||
|
|
||||||
|
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
|
||||||
|
let senderMxid = null
|
||||||
|
if (message.author) {
|
||||||
|
senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
|
||||||
|
} else {
|
||||||
|
// Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients.
|
||||||
|
const eventID = oldEventRows[0].event_id // a calling function should have already checked that there is at least one message to edit
|
||||||
|
const event = await api.getEvent(roomID, eventID)
|
||||||
|
if (utils.eventSenderIsFromDiscord(event.sender)) {
|
||||||
|
senderMxid = event.sender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Figure out what we will be replacing them with
|
// Figure out what we will be replacing them with
|
||||||
|
|
||||||
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
|
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
|
||||||
|
@ -48,7 +73,8 @@ async function editToChanges(message, guild, api) {
|
||||||
let eventsToRedact = []
|
let eventsToRedact = []
|
||||||
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
|
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
|
||||||
let eventsToSend = []
|
let eventsToSend = []
|
||||||
// 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing.
|
/** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
|
||||||
|
let unchangedEvents = []
|
||||||
|
|
||||||
function shift() {
|
function shift() {
|
||||||
newFallbackContent.shift()
|
newFallbackContent.shift()
|
||||||
|
@ -81,44 +107,55 @@ async function editToChanges(message, guild, api) {
|
||||||
shift()
|
shift()
|
||||||
}
|
}
|
||||||
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
|
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
|
||||||
eventsToRedact = oldEventRows
|
eventsToRedact = oldEventRows.map(e => ({old: e}))
|
||||||
|
|
||||||
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
// If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things.
|
||||||
/** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */
|
if (isGeneratedEmbed) {
|
||||||
const promotions = []
|
unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
|
||||||
for (const column of ["part", "reaction_part"]) {
|
eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
|
||||||
// If no events with part = 0 exist (or will exist), we need to do some management.
|
|
||||||
if (!eventsToReplace.some(e => e.old[column] === 0)) {
|
|
||||||
if (eventsToReplace.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.event_subtype === "m.text")
|
|
||||||
eventsToReplace.sort((a, b) => order(b) - order(a))
|
|
||||||
promotions.push({column, eventID: eventsToReplace[0].old.event_id})
|
|
||||||
} else {
|
|
||||||
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
|
|
||||||
promotions.push({column, nextEvent: true})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
|
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
|
||||||
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
|
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
|
||||||
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
|
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
|
||||||
eventsToReplace = eventsToReplace.filter(ev => {
|
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
|
||||||
// Discord does not allow files, images, attachments, or videos to be edited.
|
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
|
||||||
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
|
|
||||||
return false
|
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
||||||
|
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
|
||||||
|
const promotions = []
|
||||||
|
for (const column of ["part", "reaction_part"]) {
|
||||||
|
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
|
||||||
|
// 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.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")
|
||||||
|
candidatesForParts.sort((a, b) => order(b) - order(a))
|
||||||
|
if (column === "part") {
|
||||||
|
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
|
||||||
|
} else {
|
||||||
|
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.
|
||||||
|
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 = candidatesForParts.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Discord does not allow stickers to be edited.
|
|
||||||
if (ev.old.event_type === "m.sticker") {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
// Anything else is fair game.
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Removing unnecessary properties before returning
|
// Removing unnecessary properties before returning
|
||||||
eventsToRedact = eventsToRedact.map(e => e.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)}))
|
||||||
|
|
||||||
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}
|
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}
|
327
src/d2m/converters/edit-to-changes.test.js
Normal file
327
src/d2m/converters/edit-to-changes.test.js
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const {editToChanges} = require("./edit-to-changes")
|
||||||
|
const data = require("../../../test/data")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
|
||||||
|
test("edit2changes: edit by webhook", async t => {
|
||||||
|
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToSend, [])
|
||||||
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
oldID: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10",
|
||||||
|
newContent: {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "* test 2",
|
||||||
|
"m.mentions": {},
|
||||||
|
"m.new_content": {
|
||||||
|
// *** Replaced With: ***
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "test 2",
|
||||||
|
"m.mentions": {}
|
||||||
|
},
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.replace",
|
||||||
|
event_id: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
t.equal(senderMxid, null)
|
||||||
|
t.deepEqual(promotions, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: bot response", async t => {
|
||||||
|
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
|
||||||
|
async getJoinedMembers(roomID) {
|
||||||
|
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
joined: {
|
||||||
|
"@cadence:cadence.moe": {
|
||||||
|
displayname: "cadence [they]",
|
||||||
|
avatar_url: "whatever"
|
||||||
|
},
|
||||||
|
"@_ooye_botrac4r:cadence.moe": {
|
||||||
|
displayname: "botrac4r",
|
||||||
|
avatar_url: "whatever"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToSend, [])
|
||||||
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
oldID: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY",
|
||||||
|
newContent: {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "* :ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||||
|
"m.mentions": {
|
||||||
|
// Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred*
|
||||||
|
},
|
||||||
|
// *** Replaced With: ***
|
||||||
|
"m.new_content": {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: ":ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||||
|
"m.mentions": {
|
||||||
|
// Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event
|
||||||
|
"user_ids": ["@cadence:cadence.moe"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.replace",
|
||||||
|
event_id: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe")
|
||||||
|
t.deepEqual(promotions, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: remove caption from image", async t => {
|
||||||
|
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"])
|
||||||
|
t.deepEqual(eventsToSend, [])
|
||||||
|
t.deepEqual(eventsToReplace, [])
|
||||||
|
t.deepEqual(promotions, [{column: "part", eventID: "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI"}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: change file type", async t => {
|
||||||
|
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
|
||||||
|
t.deepEqual(eventsToSend, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "📝 Uploaded file: https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
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": {},
|
||||||
|
msgtype: "m.text"
|
||||||
|
}])
|
||||||
|
t.deepEqual(eventsToReplace, [])
|
||||||
|
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
|
||||||
|
})
|
||||||
|
|
||||||
|
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, {})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToSend, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "some text",
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
t.deepEqual(eventsToReplace, [])
|
||||||
|
t.deepEqual(promotions, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
|
||||||
|
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToSend, [])
|
||||||
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
|
||||||
|
newContent: {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "* only the content can be edited",
|
||||||
|
"m.mentions": {},
|
||||||
|
// *** Replaced With: ***
|
||||||
|
"m.new_content": {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "only the content can be edited",
|
||||||
|
"m.mentions": {}
|
||||||
|
},
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.replace",
|
||||||
|
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: edit of reply to skull webp attachment with content", async t => {
|
||||||
|
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToSend, [])
|
||||||
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M",
|
||||||
|
newContent: {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "> Extremity: Image\n\n* Edit",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body:
|
||||||
|
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
|
||||||
|
+ '<br>Image</blockquote></mx-reply>'
|
||||||
|
+ '* Edit',
|
||||||
|
"m.mentions": {},
|
||||||
|
"m.new_content": {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Edit",
|
||||||
|
"m.mentions": {}
|
||||||
|
},
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.replace",
|
||||||
|
event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => {
|
||||||
|
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToSend, [])
|
||||||
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999",
|
||||||
|
newContent: {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "* only the content can be edited",
|
||||||
|
"m.mentions": {},
|
||||||
|
// *** Replaced With: ***
|
||||||
|
"m.new_content": {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "only the content can be edited",
|
||||||
|
"m.mentions": {}
|
||||||
|
},
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.replace",
|
||||||
|
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => {
|
||||||
|
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToSend, [])
|
||||||
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111",
|
||||||
|
newContent: {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "* only the content can be edited",
|
||||||
|
"m.mentions": {},
|
||||||
|
// *** Replaced With: ***
|
||||||
|
"m.new_content": {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "only the content can be edited",
|
||||||
|
"m.mentions": {}
|
||||||
|
},
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.replace",
|
||||||
|
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
t.deepEqual(promotions, [
|
||||||
|
{
|
||||||
|
column: "part",
|
||||||
|
eventID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: "reaction_part",
|
||||||
|
eventID: "$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("edit2changes: generated embed", async t => {
|
||||||
|
let called = 0
|
||||||
|
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(eventID, "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0")
|
||||||
|
return {sender: "@_ooye_cadence:cadence.moe"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(eventsToRedact, [])
|
||||||
|
t.deepEqual(eventsToReplace, [])
|
||||||
|
t.deepEqual(eventsToSend, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| via hthrflwrs on cohost"
|
||||||
|
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
|
||||||
|
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
|
||||||
|
+ "\n| \n| * Both players draw eight cards"
|
||||||
|
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
|
||||||
|
+ "\n| * Both players present their best five-or-less-card pok...",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
|
||||||
|
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
|
||||||
|
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
|
||||||
|
+ `<br><br><ul><li>Both players draw eight cards`
|
||||||
|
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
|
||||||
|
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
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(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 => {
|
48
src/d2m/converters/lottie.js
Normal file
48
src/d2m/converters/lottie.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert")
|
||||||
|
const stream = require("stream")
|
||||||
|
const {PNG} = require("@cloudrac3r/pngjs")
|
||||||
|
|
||||||
|
const SIZE = 160 // Discord's display size on 1x displays is 160
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef RlottieWasm
|
||||||
|
* @prop {(string) => boolean} load load lottie data from string of json
|
||||||
|
* @prop {() => number} frames get number of frames
|
||||||
|
* @prop {(frameCount: number, width: number, height: number) => Uint8Array} render render lottie data to bitmap
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Rlottie = (async () => {
|
||||||
|
const Rlottie = require("./rlottie-wasm.js")
|
||||||
|
await new Promise(resolve => Rlottie.onRuntimeInitialized = resolve)
|
||||||
|
return Rlottie
|
||||||
|
})()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {Promise<import("stream").Readable>}
|
||||||
|
*/
|
||||||
|
async function convert(text) {
|
||||||
|
const r = await Rlottie
|
||||||
|
/** @type RlottieWasm */
|
||||||
|
const rh = new r.RlottieWasm()
|
||||||
|
const status = rh.load(text)
|
||||||
|
assert(status, `Rlottie unable to load ${text.length} byte data file.`)
|
||||||
|
const rendered = rh.render(0, SIZE, SIZE)
|
||||||
|
let png = new PNG({
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
bitDepth: 8, // 8 red + 8 green + 8 blue + 8 alpha
|
||||||
|
colorType: 6, // RGBA
|
||||||
|
inputColorType: 6, // RGBA
|
||||||
|
inputHasAlpha: true,
|
||||||
|
})
|
||||||
|
png.data = Buffer.from(rendered)
|
||||||
|
// png.pack() is a bad stream and will throw away any data it sends if it's not connected to a destination straight away.
|
||||||
|
// We use Duplex.from to convert it into a good stream.
|
||||||
|
return stream.Duplex.from(png.pack())
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.convert = convert
|
||||||
|
module.exports.SIZE = SIZE
|
34
src/d2m/converters/lottie.test.js
Normal file
34
src/d2m/converters/lottie.test.js
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
|
const stream = require("stream")
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const {convert} = require("./lottie")
|
||||||
|
|
||||||
|
const WRITE_PNG = false
|
||||||
|
|
||||||
|
test("lottie: can convert and save PNG", async t => {
|
||||||
|
const input = await fs.promises.readFile("test/res/lottie-bee.json", "utf8")
|
||||||
|
const resultStream = await convert(input)
|
||||||
|
/* c8 ignore next 3 */
|
||||||
|
if (WRITE_PNG) {
|
||||||
|
resultStream.pipe(fs.createWriteStream("test/res/lottie-bee.png"))
|
||||||
|
t.fail("PNG written to /test/res/lottie-bee.png, please manually check it")
|
||||||
|
} else {
|
||||||
|
const expected = await fs.promises.readFile("test/res/lottie-bee.png")
|
||||||
|
const actual = Buffer.alloc(expected.length)
|
||||||
|
let i = 0
|
||||||
|
await stream.promises.pipeline(
|
||||||
|
resultStream,
|
||||||
|
async function* (source) {
|
||||||
|
for await (const chunk of source) {
|
||||||
|
chunk.copy(actual, i)
|
||||||
|
i += chunk.length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new stream.PassThrough()
|
||||||
|
)
|
||||||
|
t.equal(i, actual.length, `allocated ${actual.length} bytes, but wrote ${i}`)
|
||||||
|
t.deepEqual(actual, expected)
|
||||||
|
}
|
||||||
|
})
|
353
src/d2m/converters/message-to-event.embeds.test.js
Normal file
353
src/d2m/converters/message-to-event.embeds.test.js
Normal file
|
@ -0,0 +1,353 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const {messageToEvent} = require("./message-to-event")
|
||||||
|
const data = require("../../../test/data")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
|
||||||
|
test("message2event embeds: nothing but a field", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
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",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| ### Amanda 🎵#2192 :online:"
|
||||||
|
+ "\n| willow tree, branch 0"
|
||||||
|
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
||||||
|
+ '<br>willow tree, branch 0</strong>'
|
||||||
|
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
|
||||||
|
+ '<br><strong>❯ Memory:</strong><br>64.45MB</p></blockquote>'
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: reply with just an embed", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
"m.mentions": {},
|
||||||
|
body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
|
||||||
|
+ "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
|
||||||
|
+ "\n| \n| ### Retweets"
|
||||||
|
+ "\n| 119"
|
||||||
|
+ "\n| \n| ### Likes"
|
||||||
|
+ "\n| 5581"
|
||||||
|
+ "\n| — Twitter",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>'
|
||||||
|
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
|
||||||
|
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: image embed and attachment", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
async getJoinedMembers(roomID) {
|
||||||
|
return {joined: []}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<a href="https://tootsuite.net/Warp-Gate2.gif">https://tootsuite.net/Warp-Gate2.gif</a><br>tanget: @ monster spawner',
|
||||||
|
"m.mentions": {}
|
||||||
|
}, {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.image",
|
||||||
|
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
|
||||||
|
body: "Screenshot_20231001_034036.jpg",
|
||||||
|
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
|
||||||
|
filename: "Screenshot_20231001_034036.jpg",
|
||||||
|
info: {
|
||||||
|
h: 1170,
|
||||||
|
w: 1080,
|
||||||
|
size: 51981,
|
||||||
|
mimetype: "image/jpeg"
|
||||||
|
},
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: blockquote in embed", async t => {
|
||||||
|
let called = 0
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!qzDBLKlildpzrrOnFZ: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, "!qzDBLKlildpzrrOnFZ:cadence.moe")
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:example.invalid": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: ":emoji: **4 |** #wonderland",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO\" title=\":emoji:\" alt=\":emoji:\"> <strong>4 |</strong> <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe?via=cadence.moe&via=example.invalid\">#wonderland</a>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}, {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\n| \n| reply draft\n| > The following is a message composed via consensus of the Stinker Council.\n| > \n| > For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n| > \n| > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n| > \n| > There will be no further communication.\n| \n| [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid)",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\">Go to Message</a></p></blockquote>",
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: crazy html is all escaped", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| ## ⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&) https://a.co/&<script>"
|
||||||
|
+ "\n| \n| ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&) https://a.co/&<script>"
|
||||||
|
+ "\n| \n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
||||||
|
+ "\n| \n| ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
||||||
|
+ "\n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
||||||
|
+ "\n| — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote>`
|
||||||
|
+ `<p><strong><a href="https://a.co/&amp;<script>">⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)</a></strong></p>`
|
||||||
|
+ `<p><strong><a href=\"https://a.co/&amp;<script>"><strong>[<span data-mx-color='#123456'>Hey<script>](<a href="https://a.co/&amp">https://a.co/&amp</a>;)</a></strong></p>`
|
||||||
|
+ `<p><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
||||||
|
+ `<p><strong><strong>[<span data-mx-color='#123456'>Hey<script>](<a href=\"https://a.co/&amp\">https://a.co/&amp</a>;)</strong>`
|
||||||
|
+ `<br><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
||||||
|
+ `— <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)</blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: title without url", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
|
||||||
|
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",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: url without title", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
|
||||||
|
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",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| I condone pirating music!",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: author without url", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
|
||||||
|
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",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| ## Amanda\n| \n| I condone pirating music!",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: author url without name", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
|
||||||
|
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",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| I condone pirating music!",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: vx image", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "https://vxtwitter.com/TomorrowCorp/status/1760330671074287875 we got a release date!!!",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">https://vxtwitter.com/TomorrowCorp/status/1760330671074287875</a> we got a release date!!!',
|
||||||
|
"m.mentions": {}
|
||||||
|
}, {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
|
||||||
|
+ "\n| "
|
||||||
|
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
|
||||||
|
+ "\n| "
|
||||||
|
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
|
||||||
|
+ "\n| "
|
||||||
|
+ "\n| Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux)."
|
||||||
|
+ "\n| "
|
||||||
|
+ "\n| https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms"
|
||||||
|
+ "\n| "
|
||||||
|
+ "\n| 💖 123 🔁 36"
|
||||||
|
+ "\n| "
|
||||||
|
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
|
||||||
|
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
|
||||||
|
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
|
||||||
|
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
|
||||||
|
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
|
||||||
|
+ `<br><br>💖 123 🔁 36`
|
||||||
|
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: vx video", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.vx_video, data.guild.general)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "https://vxtwitter.com/McDonalds/status/1759971752254341417",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">https://vxtwitter.com/McDonalds/status/1759971752254341417</a>',
|
||||||
|
"m.mentions": {}
|
||||||
|
}, {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
|
||||||
|
+ "\n| \n| ## McDonald’s🤝@studiopierrot"
|
||||||
|
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
|
||||||
|
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
|
||||||
|
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
|
||||||
|
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonald’s🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
|
||||||
|
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
|
||||||
|
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: youtube video", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}, {
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "| via YouTube https://www.youtube.com"
|
||||||
|
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
|
||||||
|
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
|
||||||
|
+ "\n| \n| twitter"
|
||||||
|
+ "\n| https://twitter.com/matsuevogelpark"
|
||||||
|
+ "\n| \n| The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill"
|
||||||
|
+ "\n| some people also called them the living dinosaur~~"
|
||||||
|
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
|
||||||
|
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>`
|
||||||
|
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
|
||||||
|
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
|
||||||
|
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
|
||||||
|
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
|
||||||
|
+ `</p></blockquote>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe")
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {
|
||||||
|
users: {
|
||||||
|
"@_ooye_bot:cadence.moe": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getJoinedMembers(roomID) {
|
||||||
|
t.equal(roomID, "!TqlyQmifxGUggEmdBN: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",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "(test https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org)",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `(test <a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org">https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org</a>)`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
|
@ -1,10 +1,10 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const markdown = require("discord-markdown")
|
const markdown = require("@cloudrac3r/discord-markdown")
|
||||||
const pb = require("prettier-bytes")
|
const pb = require("prettier-bytes")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {tag} = require("html-template-tag")
|
const {tag} = require("@cloudrac3r/html-template-tag")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, discord, select, from} = passthrough
|
const {sync, db, discord, select, from} = passthrough
|
||||||
|
@ -12,13 +12,13 @@ const {sync, db, discord, select, from} = passthrough
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
/** @type {import("./emoji-to-key")} */
|
/** @type {import("./emoji-to-key")} */
|
||||||
const emojiToKey = sync.require("./emoji-to-key")
|
const emojiToKey = sync.require("./emoji-to-key")
|
||||||
/** @type {import("./lottie")} */
|
/** @type {import("../actions/lottie")} */
|
||||||
const lottie = sync.require("./lottie")
|
const lottie = sync.require("../actions/lottie")
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
/** @type {import("../../m2d/converters/utils")} */
|
||||||
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,29 +32,36 @@ 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 {
|
||||||
return `@${username}:`
|
return `@${username}:`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** @param {{id: string, type: "discordChannel"}} node */
|
/** @param {{id: string, type: "discordChannel", row: {room_id: string, name: string, nick: string?}?, via: string}} node */
|
||||||
channel: node => {
|
channel: node => {
|
||||||
const row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
|
if (!node.row) { // fallback for when this channel is not bridged
|
||||||
if (!row) {
|
const channel = discord.channels.get(node.id)
|
||||||
return `<#${node.id}>` // fallback for when this channel is not bridged
|
if (channel) {
|
||||||
} else if (useHTML) {
|
return `#${channel.name} [channel not bridged]`
|
||||||
return `<a href="https://matrix.to/#/${row.room_id}">#${row.nick || row.name}</a>`
|
|
||||||
} else {
|
} else {
|
||||||
return `#${row.nick || row.name}`
|
return `#unknown-channel [channel from an unbridged server]`
|
||||||
|
}
|
||||||
|
} else if (useHTML) {
|
||||||
|
return `<a href="https://matrix.to/#/${node.row.room_id}?${node.via}">#${node.row.nick || node.row.name}</a>`
|
||||||
|
} else {
|
||||||
|
return `#${node.row.nick || node.row.name}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
|
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
|
||||||
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}:`
|
||||||
|
@ -74,10 +81,14 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
|
||||||
return `@${role.name}:`
|
return `@${role.name}:`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
everyone: node =>
|
everyone: () => {
|
||||||
"@room",
|
if (message.mention_everyone) return "@room"
|
||||||
here: node =>
|
return "@everyone"
|
||||||
"@here"
|
},
|
||||||
|
here: () => {
|
||||||
|
if (message.mention_everyone) return "@room"
|
||||||
|
return "@here"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,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 publicURL = 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/") ? "🖼️"
|
||||||
|
@ -105,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: ${publicURL} (${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="${publicURL}">${publicURL}</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
|
||||||
|
@ -116,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: ${publicURL} (${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="${publicURL}">${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 {
|
||||||
|
@ -195,6 +207,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
async function messageToEvent(message, guild, options = {}, di) {
|
async function messageToEvent(message, guild, options = {}, di) {
|
||||||
const events = []
|
const events = []
|
||||||
|
|
||||||
|
/* c8 ignore next 7 */
|
||||||
if (message.type === DiscordTypes.MessageType.ThreadCreated) {
|
if (message.type === DiscordTypes.MessageType.ThreadCreated) {
|
||||||
// This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off.
|
// This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off.
|
||||||
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
|
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
|
||||||
|
@ -220,6 +233,13 @@ 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.
|
||||||
|
if (message.content) message.content = `\n${message.content}`
|
||||||
|
message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@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:
|
||||||
|
@ -240,6 +260,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
let repliedToEventRow = null
|
let repliedToEventRow = null
|
||||||
let repliedToEventSenderMxid = null
|
let repliedToEventSenderMxid = null
|
||||||
|
|
||||||
|
if (message.mention_everyone) mentions.room = true
|
||||||
|
|
||||||
function addMention(mxid) {
|
function addMention(mxid) {
|
||||||
if (!mentions.user_ids) mentions.user_ids = []
|
if (!mentions.user_ids) mentions.user_ids = []
|
||||||
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
|
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
|
||||||
|
@ -252,6 +274,37 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
if (row) {
|
if (row) {
|
||||||
repliedToEventRow = row
|
repliedToEventRow = row
|
||||||
}
|
}
|
||||||
|
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
|
||||||
|
// It could be a PluralKit emulated reply, let's see if it has a message link
|
||||||
|
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
|
||||||
|
const isEmulatedReplyToAttachment = message.embeds[0].description?.startsWith("*[(click to see attachment")
|
||||||
|
if (isEmulatedReplyToText || isEmulatedReplyToAttachment) {
|
||||||
|
assert(message.embeds[0].description)
|
||||||
|
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
|
||||||
|
if (match) {
|
||||||
|
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
|
||||||
|
if (row) {
|
||||||
|
/*
|
||||||
|
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
|
||||||
|
the following properties are necessary:
|
||||||
|
- content: used for generating the reply fallback
|
||||||
|
- author: used for the top of the reply fallback (only used for discord authors. for matrix authors, repliedToEventSenderMxid is set.)
|
||||||
|
*/
|
||||||
|
const emulatedMessageContent =
|
||||||
|
( isEmulatedReplyToAttachment ? "[Media]"
|
||||||
|
: message.embeds[0].description.replace(/^.*?\)\*\*\s*/, ""))
|
||||||
|
message.referenced_message = {
|
||||||
|
content: emulatedMessageContent,
|
||||||
|
// @ts-ignore
|
||||||
|
author: {
|
||||||
|
username: message.embeds[0].author.name.replace(/\s*↩️\s*$/, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.embeds.shift()
|
||||||
|
repliedToEventRow = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix
|
if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix
|
||||||
// Need to figure out who sent that event...
|
// Need to figure out who sent that event...
|
||||||
|
@ -261,6 +314,20 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
addMention(repliedToEventSenderMxid)
|
addMention(repliedToEventSenderMxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {Map<string, Promise<string>>} */
|
||||||
|
const viaMemo = new Map()
|
||||||
|
/**
|
||||||
|
* @param {string} roomID
|
||||||
|
* @returns {Promise<string>} string encoded URLSearchParams
|
||||||
|
*/
|
||||||
|
function getViaServersMemo(roomID) {
|
||||||
|
// @ts-ignore
|
||||||
|
if (viaMemo.has(roomID)) return viaMemo.get(roomID)
|
||||||
|
const promise = mxUtils.getViaServersQuery(roomID, di.api).then(p => p.toString())
|
||||||
|
viaMemo.set(roomID, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate Discord message links to Matrix event links.
|
* Translate Discord message links to Matrix event links.
|
||||||
* If OOYE has handled this message in the past, this is an instant database lookup.
|
* If OOYE has handled this message in the past, this is an instant database lookup.
|
||||||
|
@ -277,12 +344,18 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
if (roomID) {
|
if (roomID) {
|
||||||
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
||||||
|
const via = await getViaServersMemo(roomID)
|
||||||
if (eventID && roomID) {
|
if (eventID && roomID) {
|
||||||
result = `https://matrix.to/#/${roomID}/${eventID}`
|
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
|
||||||
} else {
|
} else {
|
||||||
const ts = dUtils.snowflakeToTimestampExact(messageID)
|
const ts = dUtils.snowflakeToTimestampExact(messageID)
|
||||||
|
try {
|
||||||
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
|
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
|
||||||
result = `https://matrix.to/#/${roomID}/${event_id}`
|
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]`
|
||||||
|
@ -294,6 +367,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
|
||||||
|
@ -302,11 +382,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]
|
||||||
|
@ -314,21 +395,37 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed
|
return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed
|
||||||
}))
|
}))
|
||||||
|
|
||||||
let html = markdown.toHTML(content, {
|
async function transformParsedVia(parsed) {
|
||||||
|
for (const node of parsed) {
|
||||||
|
if (node.type === "discordChannel") {
|
||||||
|
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
|
||||||
|
if (node.row?.room_id) {
|
||||||
|
node.via = await getViaServersMemo(node.row.room_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
await transformParsedVia(node.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, true),
|
discordCallback: getDiscordParseCallbacks(message, guild, true),
|
||||||
...customOptions
|
...customOptions
|
||||||
}, customParser, customHtmlOutput)
|
}, customParser, customHtmlOutput)
|
||||||
|
|
||||||
let body = markdown.toHTML(content, {
|
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
||||||
discordOnly: true,
|
discordOnly: true,
|
||||||
escapeHTML: false,
|
escapeHTML: false,
|
||||||
...customOptions
|
...customOptions
|
||||||
}, null, null)
|
})
|
||||||
|
|
||||||
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}) {
|
async function addTextEvent(body, html, msgtype, {scanMentions}) {
|
||||||
// Star * prefix for fallback edits
|
// Star * prefix for fallback edits
|
||||||
if (options.includeEditFallbackStar) {
|
if (options.includeEditFallbackStar) {
|
||||||
|
@ -350,28 +447,30 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
|
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
|
||||||
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
||||||
assert(match)
|
assert(match)
|
||||||
repliedToDisplayName = match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
repliedToDisplayName = message.referenced_message?.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
||||||
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
||||||
} else {
|
} else {
|
||||||
repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
|
repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
|
||||||
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]"
|
||||||
const repliedToHtml = markdown.toHTML(repliedToContent, {
|
const repliedToHtml = markdown.toHTML(repliedToContent, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, true)
|
discordCallback: getDiscordParseCallbacks(message, guild, true)
|
||||||
}, null, null)
|
})
|
||||||
const repliedToBody = markdown.toHTML(repliedToContent, {
|
const repliedToBody = markdown.toHTML(repliedToContent, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
||||||
discordOnly: true,
|
discordOnly: true,
|
||||||
escapeHTML: false,
|
escapeHTML: false,
|
||||||
}, null, null)
|
})
|
||||||
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
|
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
|
||||||
+ `<br>${repliedToHtml}</blockquote></mx-reply>`
|
+ `<br>${repliedToHtml}</blockquote></mx-reply>`
|
||||||
+ html
|
+ html
|
||||||
|
@ -407,9 +506,11 @@ 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 + "**"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
|
if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
|
||||||
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
|
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||||
assert(roomID)
|
assert(roomID)
|
||||||
|
@ -425,14 +526,15 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text content appears first
|
// Text content appears first
|
||||||
if (message.content) {
|
|
||||||
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, {scanMentions: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then attachments
|
// Then attachments
|
||||||
|
if (message.attachments) {
|
||||||
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
|
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
|
||||||
events.push(...attachmentEvents)
|
events.push(...attachmentEvents)
|
||||||
|
}
|
||||||
|
|
||||||
// Then embeds
|
// Then embeds
|
||||||
for (const embed of message.embeds || []) {
|
for (const embed of message.embeds || []) {
|
||||||
|
@ -440,13 +542,26 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
continue // Matrix's own URL previews are fine for images.
|
continue // Matrix's own URL previews are fine for images.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (embed.url?.startsWith("https://discord.com/")) {
|
||||||
|
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
|
||||||
|
}
|
||||||
|
|
||||||
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
||||||
const rep = new mxUtils.MatrixStringBuilder()
|
const rep = new mxUtils.MatrixStringBuilder()
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
if (embed.provider?.name) {
|
||||||
|
if (embed.provider.url) {
|
||||||
|
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
|
||||||
|
} else {
|
||||||
|
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Author and URL into a paragraph
|
// Author and URL into a paragraph
|
||||||
let authorNameText = embed.author?.name || ""
|
let authorNameText = embed.author?.name || ""
|
||||||
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
|
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
|
||||||
if (authorNameText || embed.author?.url) {
|
if (authorNameText) {
|
||||||
if (embed.author?.url) {
|
if (embed.author?.url) {
|
||||||
const authorURL = await transformContentMessageLinks(embed.author.url)
|
const authorURL = await transformContentMessageLinks(embed.author.url)
|
||||||
rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`)
|
rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`)
|
||||||
|
@ -463,11 +578,11 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
} else {
|
} else {
|
||||||
rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`)
|
rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`)
|
||||||
}
|
}
|
||||||
} else if (embed.url) {
|
|
||||||
rep.addParagraph(`## ${embed.url}`, tag`<strong><a href="${embed.url}">${embed.url}</a></strong>`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (embed.description) {
|
let embedTypeShouldShowDescription = embed.type !== "video" // Discord doesn't display descriptions for videos
|
||||||
|
if (embed.provider?.name === "YouTube") embedTypeShouldShowDescription = true // But I personally like showing the descriptions for YouTube videos specifically
|
||||||
|
if (embed.description && embedTypeShouldShowDescription) {
|
||||||
const {body, html} = await transformContent(embed.description)
|
const {body, html} = await transformContent(embed.description)
|
||||||
rep.addParagraph(body, html)
|
rep.addParagraph(body, html)
|
||||||
}
|
}
|
||||||
|
@ -481,12 +596,16 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body)
|
rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (embed.image?.url) rep.addParagraph(`📸 ${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
|
||||||
|
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
||||||
|
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`)
|
||||||
|
|
||||||
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
|
if (embed.video?.url) rep.addParagraph(`🎞️ ${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()
|
||||||
body = body.split("\n").map(l => "> " + l).join("\n")
|
body = body.split("\n").map(l => "| " + l).join("\n")
|
||||||
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
|
||||||
|
@ -497,8 +616,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
if (message.sticker_items) {
|
if (message.sticker_items) {
|
||||||
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
|
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
|
||||||
const format = file.stickerFormat.get(stickerItem.format_type)
|
const format = file.stickerFormat.get(stickerItem.format_type)
|
||||||
|
assert(format?.mime)
|
||||||
if (format?.mime === "lottie") {
|
if (format?.mime === "lottie") {
|
||||||
try {
|
|
||||||
const {mxc_url, info} = await lottie.convert(stickerItem)
|
const {mxc_url, info} = await lottie.convert(stickerItem)
|
||||||
return {
|
return {
|
||||||
$type: "m.sticker",
|
$type: "m.sticker",
|
||||||
|
@ -507,15 +626,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
info,
|
info,
|
||||||
url: mxc_url
|
url: mxc_url
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else {
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: `Failed to convert Lottie sticker:\n${e.toString()}\n${e.stack}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (format?.mime) {
|
|
||||||
let body = stickerItem.name
|
let body = stickerItem.name
|
||||||
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
|
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
|
||||||
if (sticker && sticker.description) body += ` - ${sticker.description}`
|
if (sticker && sticker.description) body += ` - ${sticker.description}`
|
||||||
|
@ -529,12 +640,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
|
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: `Unsupported sticker format ${format?.mime}. Name: ${stickerItem.name}`
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
events.push(...stickerEvents)
|
events.push(...stickerEvents)
|
||||||
}
|
}
|
134
src/d2m/converters/message-to-event.pk.test.js
Normal file
134
src/d2m/converters/message-to-event.pk.test.js
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const {messageToEvent} = require("./message-to-event")
|
||||||
|
const data = require("../../../test/data")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} roomID
|
||||||
|
* @param {string} eventID
|
||||||
|
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
|
||||||
|
*/
|
||||||
|
function mockGetEvent(t, roomID_in, eventID_in, outer) {
|
||||||
|
return async function(roomID, eventID) {
|
||||||
|
t.equal(roomID, roomID_in)
|
||||||
|
t.equal(eventID, eventID_in)
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
event_id: eventID_in,
|
||||||
|
room_id: roomID_in,
|
||||||
|
origin_server_ts: 1680000000000,
|
||||||
|
unsigned: {
|
||||||
|
age: 2245,
|
||||||
|
transaction_id: "$local.whatever"
|
||||||
|
},
|
||||||
|
...outer
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("message2event: pk reply to matrix is converted to native matrix reply", async t => {
|
||||||
|
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix, {}, {}, {
|
||||||
|
api: {
|
||||||
|
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
|
||||||
|
type: "m.room.message",
|
||||||
|
sender: "@cadence:cadence.moe",
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "now for my next experiment:"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {
|
||||||
|
user_ids: [
|
||||||
|
"@cadence:cadence.moe"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "> cadence [they]: now for my next experiment:\n\nthis is a reply",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence [they]</a><br>'
|
||||||
|
+ "now for my next experiment:</blockquote></mx-reply>"
|
||||||
|
+ "this is a reply",
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: pk reply to discord is converted to native matrix reply", async t => {
|
||||||
|
const events = await messageToEvent(data.pk_message.pk_reply_to_discord, {}, {}, {
|
||||||
|
api: {
|
||||||
|
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
|
||||||
|
type: "m.room.message",
|
||||||
|
sender: "@_ooye_.wing.:cadence.moe",
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "some text"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
"m.mentions": {},
|
||||||
|
body: "> wing: some text\n\nthis is a reply",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA">In reply to</a> wing<br>'
|
||||||
|
+ "some text</blockquote></mx-reply>"
|
||||||
|
+ "this is a reply",
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: pk reply to matrix attachment is converted to native matrix reply", async t => {
|
||||||
|
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix_attachment, {}, {}, {
|
||||||
|
api: {
|
||||||
|
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y", {
|
||||||
|
sender: "@ampflower:matrix.org",
|
||||||
|
type: "m.room.message",
|
||||||
|
content: {
|
||||||
|
body: "catnod.gif",
|
||||||
|
filename: "catnod.gif",
|
||||||
|
info: {
|
||||||
|
h: 128,
|
||||||
|
mimetype: "image/gif",
|
||||||
|
size: 20816,
|
||||||
|
w: 128
|
||||||
|
},
|
||||||
|
msgtype: "m.image",
|
||||||
|
url: "mxc://matrix.org/jtzXIawXCkFIHSsMUNsKkUJX"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
"m.mentions": {
|
||||||
|
user_ids: ["@ampflower:matrix.org"]
|
||||||
|
},
|
||||||
|
body: "> Ampflower 🌺: [Media]\n\nCat nod",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y">In reply to</a> <a href="https://matrix.to/#/@ampflower:matrix.org">Ampflower 🌺</a><br>'
|
||||||
|
+ "[Media]</blockquote></mx-reply>"
|
||||||
|
+ "Cat nod",
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
|
@ -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")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -62,14 +63,98 @@ test("message2event: simple user mention", async t => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: simple room mention", async t => {
|
test("message2event: simple room mention", async t => {
|
||||||
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {})
|
let called = 0
|
||||||
|
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: {
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
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) {
|
||||||
|
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, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "#main",
|
body: "#main",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe">#main</a>'
|
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">#main</a>'
|
||||||
|
}])
|
||||||
|
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: unknown room mention", async t => {
|
||||||
|
const events = await messageToEvent(data.message.unknown_room_mention, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "#unknown-channel [channel from an unbridged server]"
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: unbridged room mention", async t => {
|
||||||
|
const events = await messageToEvent(data.message.unbridged_room_mention, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "#bad-boots-prison [channel not bridged]"
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -98,15 +183,41 @@ test("message2event: manually constructed unknown roles should use fallback", as
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: simple message link", async t => {
|
test("message2event: simple message link", async t => {
|
||||||
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {})
|
let called = 0
|
||||||
|
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
|
||||||
|
api: {
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
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) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:super.invalid": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg</a>'
|
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid</a>'
|
||||||
}])
|
}])
|
||||||
|
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: message link that OOYE doesn't know about", async t => {
|
test("message2event: message link that OOYE doesn't know about", async t => {
|
||||||
|
@ -120,6 +231,27 @@ test("message2event: message link that OOYE doesn't know about", async t => {
|
||||||
event_id: "$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U",
|
event_id: "$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U",
|
||||||
origin_server_ts: 1613287812754
|
origin_server_ts: 1613287812754
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -128,12 +260,60 @@ test("message2event: message link that OOYE doesn't know about", async t => {
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
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:_ "
|
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:_ "
|
||||||
+ "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U",
|
+ "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org",
|
||||||
format: "org.matrix.custom.html",
|
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> "
|
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> "
|
||||||
+ '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U</a>'
|
+ '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org</a>'
|
||||||
}])
|
}])
|
||||||
t.equal(called, 1, "getEventForTimestamp should be called once")
|
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 => {
|
||||||
|
@ -174,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>"
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -214,6 +394,21 @@ test("message2event: stickers", async t => {
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("message2event: lottie sticker", async t => {
|
||||||
|
const events = await messageToEvent(data.message.lottie_sticker, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.sticker",
|
||||||
|
"m.mentions": {},
|
||||||
|
body: "8",
|
||||||
|
info: {
|
||||||
|
mimetype: "image/png",
|
||||||
|
w: 160,
|
||||||
|
h: 160
|
||||||
|
},
|
||||||
|
url: "mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR"
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
test("message2event: skull webp attachment with content", async t => {
|
test("message2event: skull webp attachment with content", async t => {
|
||||||
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
|
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
@ -337,6 +532,81 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("message2event: reply with a video", async t => {
|
||||||
|
const events = await messageToEvent(data.message.reply_with_video, data.guild.general, {
|
||||||
|
api: {
|
||||||
|
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw", {
|
||||||
|
type: "m.room.message",
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: 'deadpicord "extremity you woke up at 4 am"'
|
||||||
|
},
|
||||||
|
sender: "@_ooye_extremity:cadence.moe"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.video",
|
||||||
|
body: "Ins_1960637570.mp4",
|
||||||
|
filename: "Ins_1960637570.mp4",
|
||||||
|
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
|
||||||
|
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
|
||||||
|
info: {
|
||||||
|
h: 854,
|
||||||
|
mimetype: "video/mp4",
|
||||||
|
size: 860559,
|
||||||
|
w: 480,
|
||||||
|
},
|
||||||
|
"m.mentions": {},
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: voice message", async t => {
|
||||||
|
const events = await messageToEvent(data.message.voice_message)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "voice-message.ogg",
|
||||||
|
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
|
||||||
|
filename: "voice-message.ogg",
|
||||||
|
info: {
|
||||||
|
duration: 3960.0000381469727,
|
||||||
|
mimetype: "audio/ogg",
|
||||||
|
size: 10584,
|
||||||
|
},
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.audio",
|
||||||
|
url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB"
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: misc file", async t => {
|
||||||
|
const events = await messageToEvent(data.message.misc_file)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "final final final revised draft",
|
||||||
|
"m.mentions": {}
|
||||||
|
}, {
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "the.yml",
|
||||||
|
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
|
||||||
|
filename: "the.yml",
|
||||||
|
info: {
|
||||||
|
mimetype: "text/plain; charset=utf-8",
|
||||||
|
size: 2274
|
||||||
|
},
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.file",
|
||||||
|
url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP"
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
test("message2event: simple reply in thread to a matrix user's reply", async t => {
|
test("message2event: simple reply in thread to a matrix user's reply", async t => {
|
||||||
const events = await messageToEvent(data.message.simple_reply_to_reply_in_thread, data.guild.general, {}, {
|
const events = await messageToEvent(data.message.simple_reply_to_reply_in_thread, data.guild.general, {}, {
|
||||||
api: {
|
api: {
|
||||||
|
@ -370,9 +640,87 @@ test("message2event: simple reply in thread to a matrix user's reply", async t =
|
||||||
user_ids: ["@cadence:cadence.moe"]
|
user_ids: ["@cadence:cadence.moe"]
|
||||||
},
|
},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "> cadence: What about them?\n\nWell, they don't seem to...",
|
body: "> cadence [they]: What about them?\n\nWell, they don't seem to...",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">cadence</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
|
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">cadence [they]</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
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`,
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -489,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
|
||||||
}]
|
}]
|
||||||
|
@ -503,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)'
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -606,3 +954,63 @@ test("message2event: crossposted announcements say where they are crossposted fr
|
||||||
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
|
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("message2event: @everyone", async t => {
|
||||||
|
const events = await messageToEvent(data.message_mention_everyone.at_everyone)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "@room",
|
||||||
|
"m.mentions": {
|
||||||
|
room: true
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: @here", async t => {
|
||||||
|
const events = await messageToEvent(data.message_mention_everyone.at_here)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "@room",
|
||||||
|
"m.mentions": {
|
||||||
|
room: true
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: @everyone without permission", async t => {
|
||||||
|
const events = await messageToEvent(data.message_mention_everyone.at_everyone_without_permission)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: @here without permission", async t => {
|
||||||
|
const events = await messageToEvent(data.message_mention_everyone.at_here_without_permission)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: @everyone within a link", async t => {
|
||||||
|
const events = await messageToEvent(data.message_mention_everyone.at_everyone_within_link)
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "https://github.com/@everyone",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<a href="https://github.com/@everyone">https://github.com/@everyone</a>`,
|
||||||
|
"m.mentions": {}
|
||||||
|
}])
|
||||||
|
})
|
|
@ -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 => {
|
|
@ -12,7 +12,7 @@ const utils = sync.require("../../m2d/converters/utils")
|
||||||
* @typedef ReactionRemoveRequest
|
* @typedef ReactionRemoveRequest
|
||||||
* @prop {string} eventID
|
* @prop {string} eventID
|
||||||
* @prop {string | null} mxid
|
* @prop {string | null} mxid
|
||||||
* @prop {BigInt} [hash]
|
* @prop {bigint} [hash]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -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,8 +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")} */
|
/** @type {import("../../m2d/converters/utils")} */
|
||||||
const reg = sync.require("../../matrix/read-registration.js")
|
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))
|
||||||
|
|
||||||
|
@ -29,8 +30,9 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr
|
||||||
|
|
||||||
const msgtype = creatorMxid ? "m.emote" : "m.text"
|
const msgtype = creatorMxid ? "m.emote" : "m.text"
|
||||||
const template = creatorMxid ? "started a thread:" : "Thread started:"
|
const template = creatorMxid ? "started a thread:" : "Thread started:"
|
||||||
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}`
|
const via = await mxUtils.getViaServersQuery(threadRoomID, di.api)
|
||||||
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}">${thread.name}</a>`
|
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}`
|
||||||
|
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}?${via.toString()}">${thread.name}</a>`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
msgtype,
|
msgtype,
|
|
@ -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")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -29,16 +29,34 @@ function mockGetEvent(t, roomID_in, eventID_in, outer) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viaApi = {
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
return {
|
||||||
|
users: {
|
||||||
|
"@_ooye_bot:cadence.moe": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getJoinedMembers(roomID) {
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:matrix.org": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("thread2announcement: no known creator, no branched from event", async t => {
|
test("thread2announcement: no known creator, no branched from event", async t => {
|
||||||
const content = await threadToAnnouncement("!parent", "!thread", null, {
|
const content = await threadToAnnouncement("!parent", "!thread", null, {
|
||||||
name: "test thread",
|
name: "test thread",
|
||||||
id: "-1"
|
id: "-1"
|
||||||
})
|
}, {api: viaApi})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "Thread started: test thread https://matrix.to/#/!thread",
|
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -47,12 +65,12 @@ test("thread2announcement: known creator, no branched from event", async t => {
|
||||||
const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", {
|
const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", {
|
||||||
name: "test thread",
|
name: "test thread",
|
||||||
id: "-1"
|
id: "-1"
|
||||||
})
|
}, {api: viaApi})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.emote",
|
msgtype: "m.emote",
|
||||||
body: "started a thread: test thread https://matrix.to/#/!thread",
|
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -70,14 +88,15 @@ test("thread2announcement: no known creator, branched from discord event", async
|
||||||
msgtype: 'm.text',
|
msgtype: 'm.text',
|
||||||
body: 'testing testing testing'
|
body: 'testing testing testing'
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
...viaApi
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "Thread started: test thread https://matrix.to/#/!thread",
|
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
|
@ -100,14 +119,15 @@ test("thread2announcement: known creator, branched from discord event", async t
|
||||||
msgtype: 'm.text',
|
msgtype: 'm.text',
|
||||||
body: 'testing testing testing'
|
body: 'testing testing testing'
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
...viaApi
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.emote",
|
msgtype: "m.emote",
|
||||||
body: "started a thread: test thread https://matrix.to/#/!thread",
|
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
|
@ -130,14 +150,15 @@ test("thread2announcement: no known creator, branched from matrix event", async
|
||||||
body: "so can you reply to my webhook uwu"
|
body: "so can you reply to my webhook uwu"
|
||||||
},
|
},
|
||||||
sender: "@cadence:cadence.moe"
|
sender: "@cadence:cadence.moe"
|
||||||
})
|
}),
|
||||||
|
...viaApi
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "Thread started: test thread https://matrix.to/#/!thread",
|
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
||||||
"m.mentions": {
|
"m.mentions": {
|
||||||
user_ids: ["@cadence:cadence.moe"]
|
user_ids: ["@cadence:cadence.moe"]
|
||||||
},
|
},
|
|
@ -1,6 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
const assert = require("assert")
|
||||||
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {select} = passthrough
|
const {select} = passthrough
|
||||||
|
@ -24,6 +25,10 @@ function downcaseUsername(user) {
|
||||||
.replace(/[^a-z0-9._=/-]*/g, "")
|
.replace(/[^a-z0-9._=/-]*/g, "")
|
||||||
// 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 (reg.ooye.include_user_id_in_mxid) {
|
||||||
|
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)
|
||||||
if (downcased.length < 2) {
|
if (downcased.length < 2) {
|
||||||
downcased = user.id
|
downcased = user.id
|
||||||
|
@ -60,7 +65,7 @@ function userToSimName(user) {
|
||||||
|
|
||||||
// 1. Is sim user already registered?
|
// 1. Is sim user already registered?
|
||||||
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get()
|
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get()
|
||||||
if (existing) return existing
|
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
||||||
|
|
||||||
// 2. Register based on username (could be new or old format)
|
// 2. Register based on username (could be new or old format)
|
||||||
// (Unless it's a special user, in which case copy their provided mappings.)
|
// (Unless it's a special user, in which case copy their provided mappings.)
|
|
@ -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 => {
|
||||||
|
@ -44,3 +44,11 @@ test("user2name: uses ID when name has only disallowed characters", t => {
|
||||||
test("user2name: works on special user", t => {
|
test("user2name: works on special user", t => {
|
||||||
t.equal(userToSimName(data.user.clyde_ai), "clyde_ai")
|
t.equal(userToSimName(data.user.clyde_ai), "clyde_ai")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("user2name: includes ID if requested in config", t => {
|
||||||
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
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: "f***", discriminator: "0001", id: "123456"}), "123456_f")
|
||||||
|
reg.ooye.include_user_id_in_mxid = false
|
||||||
|
})
|
|
@ -47,7 +47,19 @@ class DiscordClient {
|
||||||
if (listen !== "no") {
|
if (listen !== "no") {
|
||||||
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
|
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
|
||||||
}
|
}
|
||||||
this.cloud.on("error", console.error)
|
|
||||||
|
const addEventLogger = (eventName, logName) => {
|
||||||
|
this.cloud.on(eventName, (...args) => {
|
||||||
|
const d = new Date().toISOString().slice(0, 19)
|
||||||
|
console.error(`[${d} Client ${logName}]`, ...args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
addEventLogger("error", "Error")
|
||||||
|
addEventLogger("disconnected", "Disconnected")
|
||||||
|
addEventLogger("ready", "Ready")
|
||||||
|
this.snow.requestHandler.on("requestError", (requestID, error) => {
|
||||||
|
console.error("request error:", error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,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") {
|
||||||
|
@ -145,6 +147,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 +177,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -26,9 +26,13 @@ const updatePins = sync.require("./actions/update-pins")
|
||||||
/** @type {import("../matrix/api")}) */
|
/** @type {import("../matrix/api")}) */
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
/** @type {import("../discord/utils")} */
|
/** @type {import("../discord/utils")} */
|
||||||
const utils = sync.require("../discord/utils")
|
const dUtils = sync.require("../discord/utils")
|
||||||
/** @type {import("../discord/discord-command-handler")}) */
|
/** @type {import("../m2d/converters/utils")} */
|
||||||
const discordCommandHandler = sync.require("../discord/discord-command-handler")
|
const mxUtils = require("../m2d/converters/utils")
|
||||||
|
/** @type {import("./actions/speedbump")} */
|
||||||
|
const speedbump = sync.require("./actions/speedbump")
|
||||||
|
/** @type {import("./actions/retrigger")} */
|
||||||
|
const retrigger = sync.require("./actions/retrigger")
|
||||||
|
|
||||||
/** @type {any} */ // @ts-ignore bad types from semaphore
|
/** @type {any} */ // @ts-ignore bad types from semaphore
|
||||||
const Semaphore = require("@chriscdn/promise-semaphore")
|
const Semaphore = require("@chriscdn/promise-semaphore")
|
||||||
|
@ -44,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:`)
|
||||||
|
@ -68,20 +72,17 @@ module.exports = {
|
||||||
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let formattedBody = "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
|
|
||||||
+ `<br>Gateway event: ${gatewayMessage.t}`
|
const builder = new mxUtils.MatrixStringBuilder()
|
||||||
+ `<br>${e.toString()}`
|
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
|
||||||
|
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
|
||||||
|
builder.addLine(e.toString())
|
||||||
if (stackLines) {
|
if (stackLines) {
|
||||||
formattedBody += `<br><details><summary>Error trace</summary>`
|
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
|
||||||
+ `<pre>${stackLines.join("\n")}</pre></details>`
|
|
||||||
}
|
}
|
||||||
formattedBody += `<details><summary>Original payload</summary>`
|
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
|
||||||
+ `<pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`,
|
await api.sendEvent(roomID, "m.room.message", {
|
||||||
api.sendEvent(roomID, "m.room.message", {
|
...builder.get(),
|
||||||
msgtype: "m.text",
|
|
||||||
body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: formattedBody,
|
|
||||||
"moe.cadence.ooye.error": {
|
"moe.cadence.ooye.error": {
|
||||||
source: "discord",
|
source: "discord",
|
||||||
payload: gatewayMessage
|
payload: gatewayMessage
|
||||||
|
@ -113,9 +114,8 @@ module.exports = {
|
||||||
const member = guild.members.find(m => m.user?.id === client.user.id)
|
const member = guild.members.find(m => m.user?.id === client.user.id)
|
||||||
if (!member) return
|
if (!member) return
|
||||||
if (!("permission_overwrites" in channel)) continue
|
if (!("permission_overwrites" in channel)) continue
|
||||||
const permissions = utils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
||||||
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
|
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look back in this channel
|
||||||
if ((permissions & wants) !== wants) continue // We don't have permission to look back in this channel
|
|
||||||
|
|
||||||
/** More recent messages come first. */
|
/** More recent messages come first. */
|
||||||
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
|
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
|
||||||
|
@ -162,9 +162,8 @@ module.exports = {
|
||||||
const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp)
|
const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp)
|
||||||
|
|
||||||
// Permissions check
|
// Permissions check
|
||||||
const permissions = utils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
||||||
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
|
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look up the pins in this channel
|
||||||
if ((permissions & wants) !== wants) continue // We don't have permission to look up the pins in this channel
|
|
||||||
|
|
||||||
const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get()
|
const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get()
|
||||||
if (!row) continue // Only care about already bridged channels
|
if (!row) continue // Only care about already bridged channels
|
||||||
|
@ -229,26 +228,44 @@ 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
|
||||||
*/
|
*/
|
||||||
async onMessageCreate(client, message) {
|
async onMessageCreate(client, message) {
|
||||||
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.
|
||||||
if (message.webhook_id) {
|
|
||||||
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
|
|
||||||
if (row) {
|
|
||||||
// The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
|
||||||
await sendMessage.sendMessage(message, guild),
|
if (message.webhook_id) {
|
||||||
await discordCommandHandler.execute(message, channel, guild)
|
const row = select("webhook", "webhook_id", {webhook_id: message.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 (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
|
||||||
|
|
||||||
|
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
||||||
|
if (affected) return
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
await sendMessage.sendMessage(message, channel, guild, row),
|
||||||
|
|
||||||
|
retrigger.messageFinishedBridging(message.id)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -256,25 +273,35 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
|
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onMessageUpdate(client, data) {
|
async onMessageUpdate(client, data) {
|
||||||
if (data.webhook_id) {
|
|
||||||
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
|
|
||||||
if (row) {
|
|
||||||
// The update was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
|
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
|
||||||
// If the message content is a string then it includes all interesting fields and is meaningful.
|
// If the message content is a string then it includes all interesting fields and is meaningful.
|
||||||
if (typeof data.content === "string") {
|
// Otherwise, if there are embeds, then the system generated URL preview embeds.
|
||||||
|
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) {
|
||||||
|
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 (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
|
||||||
|
|
||||||
|
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
|
||||||
|
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
||||||
|
if (affected) 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)
|
||||||
await editMessage.editMessage(message, guild)
|
// @ts-ignore
|
||||||
}
|
await editMessage.editMessage(message, guild, row)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -283,7 +310,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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -300,6 +326,8 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
|
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onMessageDelete(client, data) {
|
async onMessageDelete(client, data) {
|
||||||
|
speedbump.onMessageDelete(data.id)
|
||||||
|
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return
|
||||||
await deleteMessage.deleteMessage(data)
|
await deleteMessage.deleteMessage(data)
|
||||||
},
|
},
|
||||||
|
|
0
src/db/migrations/.baby
Normal file
0
src/db/migrations/.baby
Normal file
7
src/db/migrations/0009-add-speedbump-id.sql
Normal file
7
src/db/migrations/0009-add-speedbump-id.sql
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
ALTER TABLE channel_room ADD COLUMN speedbump_id TEXT;
|
||||||
|
ALTER TABLE channel_room ADD COLUMN speedbump_webhook_id TEXT;
|
||||||
|
ALTER TABLE channel_room ADD COLUMN speedbump_checked INTEGER;
|
||||||
|
|
||||||
|
COMMIT;
|
6
src/db/migrations/0010-add-sim-proxy.sql
Normal file
6
src/db/migrations/0010-add-sim-proxy.sql
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS sim_proxy (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
proxy_owner_id TEXT NOT NULL,
|
||||||
|
displayname TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id)
|
||||||
|
) WITHOUT ROWID;
|
15
src/db/migrations/0011-move-bridge-bot-to-real-id.up.js
Normal file
15
src/db/migrations/0011-move-bridge-bot-to-real-id.up.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
a. If the bridge bot sim already has the correct ID:
|
||||||
|
- No rows updated.
|
||||||
|
|
||||||
|
b. If the bridge bot sim has the wrong ID but there's no duplicate:
|
||||||
|
- One row updated.
|
||||||
|
|
||||||
|
c. If the bridge bot sim has the wrong ID and there's a duplicate:
|
||||||
|
- One row updated (replaces an existing row).
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = async function(db) {
|
||||||
|
const id = require("../../../addbot").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;
|
26
db/orm-defs.d.ts → src/db/orm-defs.d.ts
vendored
26
db/orm-defs.d.ts → src/db/orm-defs.d.ts
vendored
|
@ -7,6 +7,9 @@ export type Models = {
|
||||||
thread_parent: string | null
|
thread_parent: string | null
|
||||||
custom_avatar: string | null
|
custom_avatar: string | null
|
||||||
last_bridged_pin_timestamp: number | null
|
last_bridged_pin_timestamp: number | null
|
||||||
|
speedbump_id: string | null
|
||||||
|
speedbump_webhook_id: string | null
|
||||||
|
speedbump_checked: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
event_message: {
|
event_message: {
|
||||||
|
@ -39,7 +42,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: {
|
||||||
|
@ -60,6 +70,12 @@ export type Models = {
|
||||||
hashed_profile_content: number
|
hashed_profile_content: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sim_proxy: {
|
||||||
|
user_id: string
|
||||||
|
proxy_owner_id: string
|
||||||
|
displayname: string
|
||||||
|
}
|
||||||
|
|
||||||
webhook: {
|
webhook: {
|
||||||
channel_id: string
|
channel_id: string
|
||||||
webhook_id: string
|
webhook_id: string
|
||||||
|
@ -84,6 +100,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> = {
|
||||||
|
@ -91,9 +111,11 @@ export type Prepared<Row> = {
|
||||||
safeIntegers: () => Prepared<{[K in keyof Row]: Row[K] extends number ? BigInt : Row[K]}>
|
safeIntegers: () => Prepared<{[K in keyof Row]: Row[K] extends number ? BigInt : Row[K]}>
|
||||||
raw: () => Prepared<Row[keyof Row][]>
|
raw: () => Prepared<Row[keyof Row][]>
|
||||||
all: (..._: any[]) => Row[]
|
all: (..._: any[]) => Row[]
|
||||||
get: (..._: any[]) => Row | null
|
get: (..._: any[]) => Row | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllKeys<U> = U extends any ? keyof U : never
|
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 Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
|
|
@ -8,7 +8,7 @@ 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.Numberish<U.Models[Table]>>} where
|
||||||
* @param {string} [e]
|
* @param {string} [e]
|
||||||
*/
|
*/
|
||||||
function select(table, cols, where = {}, e = "") {
|
function select(table, cols, where = {}, e = "") {
|
||||||
|
@ -38,10 +38,14 @@ class From {
|
||||||
/** @private @type {Table[]} */
|
/** @private @type {Table[]} */
|
||||||
this.tables = [table]
|
this.tables = [table]
|
||||||
/** @private */
|
/** @private */
|
||||||
|
this.directions = []
|
||||||
|
/** @private */
|
||||||
this.sql = ""
|
this.sql = ""
|
||||||
/** @private */
|
/** @private */
|
||||||
this.cols = []
|
this.cols = []
|
||||||
/** @private */
|
/** @private */
|
||||||
|
this.makeColsSafe = true
|
||||||
|
/** @private */
|
||||||
this.using = []
|
this.using = []
|
||||||
/** @private */
|
/** @private */
|
||||||
this.isPluck = false
|
this.isPluck = false
|
||||||
|
@ -53,12 +57,14 @@ class From {
|
||||||
* @template {keyof U.Models} Table2
|
* @template {keyof U.Models} Table2
|
||||||
* @param {Table2} table
|
* @param {Table2} table
|
||||||
* @param {Col & (keyof U.Models[Table2])} col
|
* @param {Col & (keyof U.Models[Table2])} col
|
||||||
|
* @param {"inner" | "left"} [direction]
|
||||||
*/
|
*/
|
||||||
join(table, col) {
|
join(table, col, direction = "inner") {
|
||||||
/** @type {From<Table | Table2, keyof U.Merge<U.Models[Table | Table2]>>} */
|
/** @type {From<Table | Table2, keyof U.Merge<U.Models[Table | Table2]>>} */
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const r = this
|
const r = this
|
||||||
r.tables.push(table)
|
r.tables.push(table)
|
||||||
|
r.directions.push(direction.toUpperCase())
|
||||||
r.using.push(col)
|
r.using.push(col)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
@ -74,6 +80,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
|
||||||
|
@ -96,7 +108,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]) => {
|
||||||
|
@ -108,11 +120,13 @@ 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]
|
||||||
sql += `INNER JOIN ${table} USING (${col}) `
|
const direction = this.directions[i-1]
|
||||||
|
sql += `${direction} JOIN ${table} USING (${col}) `
|
||||||
}
|
}
|
||||||
sql += this.sql
|
sql += this.sql
|
||||||
/** @type {U.Prepared<Pick<U.Merge<U.Models[Table]>, Col>>} */
|
/** @type {U.Prepared<Pick<U.Merge<U.Models[Table]>, Col>>} */
|
|
@ -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")
|
||||||
|
|
||||||
|
@ -44,3 +44,12 @@ test("orm: from: where and pluck works", t => {
|
||||||
const subtypes = from("event_message").where({message_id: "1141501302736695316"}).pluck("event_subtype").all()
|
const subtypes = from("event_message").where({message_id: "1141501302736695316"}).pluck("event_subtype").all()
|
||||||
t.deepEqual(subtypes.sort(), ["m.image", "m.text"])
|
t.deepEqual(subtypes.sort(), ["m.image", "m.text"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("orm: from: join direction works", t => {
|
||||||
|
const hasOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "_pk_zoego"}).get()
|
||||||
|
t.deepEqual(hasOwner, {user_id: "43d378d5-1183-47dc-ab3c-d14e21c3fe58", proxy_owner_id: "196188877885538304"})
|
||||||
|
const hasNoOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
|
||||||
|
t.deepEqual(hasNoOwner, {user_id: "820865262526005258", proxy_owner_id: null})
|
||||||
|
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)
|
||||||
|
})
|
115
src/discord/interactions/bridge.js
Normal file
115
src/discord/interactions/bridge.js
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
const {discord, sync, db, select, from, as} = require("../../passthrough")
|
||||||
|
const assert = require("assert/strict")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/** @type {Map<string, Promise<{name: string, value: string}[]>>} spaceID -> list of rooms */
|
||||||
|
const cache = new Map()
|
||||||
|
/** @type {Map<string, string>} roomID -> spaceID */
|
||||||
|
const reverseCache = new Map()
|
||||||
|
|
||||||
|
// Manage clearing the cache
|
||||||
|
sync.addTemporaryListener(as, "type:m.room.name", /** @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event */ async event => {
|
||||||
|
if (event.state_key !== "") return
|
||||||
|
const roomID = event.room_id
|
||||||
|
const spaceID = reverseCache.get(roomID)
|
||||||
|
if (!spaceID) return
|
||||||
|
const childRooms = await cache.get(spaceID)
|
||||||
|
if (!childRooms) return
|
||||||
|
if (event.content.name) {
|
||||||
|
const found = childRooms.find(r => r.value === roomID)
|
||||||
|
if (!found) return
|
||||||
|
found.name = event.content.name
|
||||||
|
} else {
|
||||||
|
cache.set(spaceID, Promise.resolve(childRooms.filter(r => r.value !== roomID)))
|
||||||
|
reverseCache.delete(roomID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Manage adding to the cache
|
||||||
|
async function getCachedHierarchy(spaceID) {
|
||||||
|
return cache.get(spaceID) || (() => {
|
||||||
|
const entry = (async () => {
|
||||||
|
const result = await api.getFullHierarchy(spaceID)
|
||||||
|
/** @type {{name: string, value: string}[]} */
|
||||||
|
const childRooms = []
|
||||||
|
for (const room of result) {
|
||||||
|
if (room.name && !room.name.match(/^\[[⛓️🔊]\]/) && room.room_type !== "m.space") {
|
||||||
|
childRooms.push({name: room.name, value: room.room_id})
|
||||||
|
reverseCache.set(room.room_id, spaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return childRooms
|
||||||
|
})()
|
||||||
|
cache.set(spaceID, entry)
|
||||||
|
return entry
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */
|
||||||
|
async function interactAutocomplete({id, token, data, guild_id}) {
|
||||||
|
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
||||||
|
if (!spaceID) {
|
||||||
|
return discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
|
||||||
|
data: {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
name: `Error: This server needs to be bridged somewhere first...`,
|
||||||
|
value: "baby"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let rooms = await getCachedHierarchy(spaceID)
|
||||||
|
// @ts-ignore
|
||||||
|
rooms = rooms.filter(r => r.name.includes(data.options[0].value))
|
||||||
|
|
||||||
|
await discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
|
||||||
|
data: {
|
||||||
|
choices: rooms
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interactSubmit({id, token, data, guild_id}) {
|
||||||
|
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
||||||
|
if (!spaceID) {
|
||||||
|
return discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "Error: This server needs to be bridged somewhere first...",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "Valid input. This would do something but it isn't implemented yet.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) {
|
||||||
|
return interactAutocomplete(interaction)
|
||||||
|
} else if (interaction.type === DiscordTypes.InteractionType.ApplicationCommand) {
|
||||||
|
// @ts-ignore
|
||||||
|
return interactSubmit(interaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
131
src/discord/interactions/invite.js
Normal file
131
src/discord/interactions/invite.js
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const assert = require("assert/strict")
|
||||||
|
const {discord, sync, db, select, from} = require("../../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interact({data, channel, guild_id}) {
|
||||||
|
// Check guild is bridged
|
||||||
|
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
|
if (!spaceID || !roomID) return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "This server isn't bridged to Matrix, so you can't invite Matrix users.",
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite Matrix user if not in space
|
||||||
|
if (!spaceMember || spaceMember.membership !== "join") {
|
||||||
|
await api.inviteToRoom(spaceID, mxid)
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
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 {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral,
|
||||||
|
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 {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `\`${mxid}\` is already in this server and this channel.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interactButton({channel, message}) {
|
||||||
|
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: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */
|
||||||
|
async function interactButton(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
||||||
|
module.exports.interactButton = interactButton
|
||||||
|
module.exports._interact = _interact
|
||||||
|
module.exports._interactButton = _interactButton
|
51
src/discord/interactions/matrix-info.js
Normal file
51
src/discord/interactions/matrix-info.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {discord, sync, db, select, from} = require("../../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
||||||
|
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact({id, token, guild_id, channel, data}) {
|
||||||
|
const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
||||||
|
.select("name", "nick", "source", "room_id", "event_id").where({message_id: data.target_id}).get()
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
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}\``
|
||||||
|
|
||||||
|
if (message.source === 1) { // from Discord
|
||||||
|
const userID = data.resolved.messages[data.target_id].author.id
|
||||||
|
return discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord to [${message.nick || message.name}](<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 discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `Bridged [${event.sender}](<https://matrix.to/#/${event.sender}>)'s message in [${message.nick || message.name}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix to https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord.`
|
||||||
|
+ idInfo,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
128
src/discord/interactions/permissions.js
Normal file
128
src/discord/interactions/permissions.js
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
const {discord, sync, db, select, from} = require("../../passthrough")
|
||||||
|
const assert = require("assert/strict")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interact({data, channel, guild_id}) {
|
||||||
|
const row = select("event_message", ["event_id", "source"], {message_id: data.target_id}).get()
|
||||||
|
assert(row)
|
||||||
|
|
||||||
|
// Can't operate on Discord users
|
||||||
|
if (row.source === 1) { // discord
|
||||||
|
return {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `This command is only meaningful for 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: 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 {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: `\`${sender}\` has administrator permissions. This cannot be edited.`,
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async function interactEdit({data, id, token, guild_id, message}) {
|
||||||
|
// 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
|
||||||
|
|
||||||
|
await discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
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
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(discord.application.id, token, {
|
||||||
|
content: `Updated \`${mxid}\` to **${permission}**.`,
|
||||||
|
components: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
||||||
|
module.exports.interactEdit = interactEdit
|
||||||
|
module.exports._interact = _interact
|
59
src/discord/interactions/privacy.js
Normal file
59
src/discord/interactions/privacy.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {discord, sync, db, select} = require("../../passthrough")
|
||||||
|
const {id: botID} = require("../../../addbot")
|
||||||
|
|
||||||
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
|
||||||
|
*/
|
||||||
|
async function interact({id, token, data, guild_id}) {
|
||||||
|
// Check guild is bridged
|
||||||
|
const current = select("guild_space", "privacy_level", {guild_id}).pluck().get()
|
||||||
|
if (current == null) return {
|
||||||
|
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 discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
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
|
||||||
|
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, token, {
|
||||||
|
content: `Privacy level updated to \`${levels[level]}\`.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
58
src/discord/interactions/reactions.js
Normal file
58
src/discord/interactions/reactions.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {discord, sync, db, 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.APIContextMenuGuildInteraction} interaction */
|
||||||
|
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact({id, token, data}) {
|
||||||
|
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 discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
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 discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "Nobody from Matrix reacted to this message.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return discord.snow.interaction.createInteractionResponse(id, token, {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"),
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.interact = interact
|
124
src/discord/register-interactions.js
Normal file
124
src/discord/register-interactions.js
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
// @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 bridge = sync.require("./interactions/bridge.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: "bridge",
|
||||||
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
|
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||||
|
description: "Start bridging this channel to a Matrix room",
|
||||||
|
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageChannels),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
description: "Destination room to bridge to",
|
||||||
|
name: "room",
|
||||||
|
autocomplete: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, {
|
||||||
|
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 === "bridge") {
|
||||||
|
await bridge.interact(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
|
137
src/discord/utils.js
Normal file
137
src/discord/utils.js
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const assert = require("assert").strict
|
||||||
|
|
||||||
|
const {reg} = require("../matrix/read-registration")
|
||||||
|
|
||||||
|
const EPOCH = 1420070400000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} userRoles
|
||||||
|
* @param {DiscordTypes.APIGuild["roles"]} guildRoles
|
||||||
|
* @param {string} [userID]
|
||||||
|
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites]
|
||||||
|
*/
|
||||||
|
function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
|
||||||
|
let allowed = BigInt(0)
|
||||||
|
let everyoneID
|
||||||
|
// Guild allows
|
||||||
|
for (const role of guildRoles) {
|
||||||
|
if (role.name === "@everyone") {
|
||||||
|
allowed |= BigInt(role.permissions)
|
||||||
|
everyoneID = role.id
|
||||||
|
}
|
||||||
|
if (userRoles.includes(role.id)) {
|
||||||
|
allowed |= BigInt(role.permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelOverwrites) {
|
||||||
|
/** @type {((overwrite: Required<DiscordTypes.APIOverwrite>) => any)[]} */
|
||||||
|
const actions = [
|
||||||
|
// Channel @everyone deny
|
||||||
|
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
|
// Channel @everyone allow
|
||||||
|
overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)),
|
||||||
|
// Role deny
|
||||||
|
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
|
// Role allow
|
||||||
|
overwrite => userRoles.includes(overwrite.id) && (allowed |= BigInt(overwrite.allow)),
|
||||||
|
// User deny
|
||||||
|
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
|
// User allow
|
||||||
|
overwrite => overwrite.id === userID && (allowed |= BigInt(overwrite.allow))
|
||||||
|
]
|
||||||
|
for (let i = 0; i < actions.length; i++) {
|
||||||
|
for (const overwrite of channelOverwrites) {
|
||||||
|
actions[i](overwrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
|
||||||
|
* It is designed like this to avoid developer error with bit manipulations.
|
||||||
|
*
|
||||||
|
* @param {bigint} resolvedPermissions
|
||||||
|
* @param {bigint} permissionToCheckFor
|
||||||
|
* @returns {boolean} whether the user has the requested permission
|
||||||
|
* @example
|
||||||
|
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
|
||||||
|
* hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
|
||||||
|
*/
|
||||||
|
function hasPermission(resolvedPermissions, permissionToCheckFor) {
|
||||||
|
// Make sure permissionToCheckFor has exactly one permission in it
|
||||||
|
assert.equal(permissionToCheckFor.toString(2).match(/1/g)?.length, 1)
|
||||||
|
// Do the actual calculation
|
||||||
|
return (resolvedPermissions & permissionToCheckFor) === permissionToCheckFor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {bigint} resolvedPermissions
|
||||||
|
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
|
||||||
|
* @returns {boolean} whether the user has any of the requested permissions
|
||||||
|
* @example
|
||||||
|
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
|
||||||
|
* hasSomePermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
|
||||||
|
*/
|
||||||
|
function hasSomePermissions(resolvedPermissions, permissionsToCheckFor) {
|
||||||
|
return permissionsToCheckFor.some(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {bigint} resolvedPermissions
|
||||||
|
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
|
||||||
|
* @returns {boolean} whether the user has all of the requested permissions
|
||||||
|
* @example
|
||||||
|
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
|
||||||
|
* hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
|
||||||
|
*/
|
||||||
|
function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
|
||||||
|
return permissionsToCheckFor.every(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server.
|
||||||
|
* @param {DiscordTypes.APIMessage} message
|
||||||
|
*/
|
||||||
|
function isWebhookMessage(message) {
|
||||||
|
return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Pick<DiscordTypes.APIMessage, "flags">} message
|
||||||
|
*/
|
||||||
|
function isEphemeralMessage(message) {
|
||||||
|
return message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} snowflake */
|
||||||
|
function snowflakeToTimestampExact(snowflake) {
|
||||||
|
return Number(BigInt(snowflake) >> 22n) + EPOCH
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} timestamp */
|
||||||
|
function timestampToSnowflakeInexact(timestamp) {
|
||||||
|
return String((timestamp - EPOCH) * 2**22)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} url */
|
||||||
|
function getPublicUrlForCdn(url) {
|
||||||
|
const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/)
|
||||||
|
if (!match) return url
|
||||||
|
return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.getPermissions = getPermissions
|
||||||
|
module.exports.hasPermission = hasPermission
|
||||||
|
module.exports.hasSomePermissions = hasSomePermissions
|
||||||
|
module.exports.hasAllPermissions = hasAllPermissions
|
||||||
|
module.exports.isWebhookMessage = isWebhookMessage
|
||||||
|
module.exports.isEphemeralMessage = isEphemeralMessage
|
||||||
|
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
|
||||||
|
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
|
||||||
|
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue