forked from cadence/out-of-your-element
Compare commits
14 commits
023e1fb55f
...
d53e9efe9e
| Author | SHA1 | Date | |
|---|---|---|---|
| d53e9efe9e | |||
| b6717b046d | |||
| 711e024caa | |||
| f1b111a8a4 | |||
| d3afa728ed | |||
| 6716b432ba | |||
| 3365023fe3 | |||
| e6c3013993 | |||
| cb4e8df91e | |||
| f90cdfdbb5 | |||
| ff022e8793 | |||
| 99f4c52beb | |||
| 5f768fee01 | |||
| 6ca1b836e1 |
34 changed files with 1226 additions and 591 deletions
|
|
@ -89,15 +89,14 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
|
|||
|
||||
# Dependency justification
|
||||
|
||||
Total transitive production dependencies: 134
|
||||
Total transitive production dependencies: 144
|
||||
|
||||
### <font size="+2">🦕</font>
|
||||
|
||||
* (31) better-sqlite3: SQLite 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.
|
||||
* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more.
|
||||
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
|
||||
* (35) better-sqlite3: SQLite is the best database, and this is the best library for it.
|
||||
* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
|
||||
* (26) @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.)
|
||||
* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
|
||||
|
||||
### <font size="-1">🪱</font>
|
||||
|
||||
|
|
@ -108,6 +107,7 @@ Total transitive production dependencies: 134
|
|||
* (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/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this.
|
||||
* (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.
|
||||
|
|
@ -115,12 +115,12 @@ Total transitive production dependencies: 134
|
|||
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
||||
* (0) discord-api-types: Bitfields needed at runtime and types needed for development.
|
||||
* (0) domino: DOM implementation that's already pulled in by turndown.
|
||||
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
|
||||
* (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
|
||||
* (0) entities: Looks fine. No dependencies.
|
||||
* (0) get-relative-path: Looks fine. No dependencies.
|
||||
* (1) heatsync: Module hot-reloader that I trust.
|
||||
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
||||
* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type.
|
||||
* (1) mime-types: List of mime type mappings. Needed to serve static files.
|
||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
||||
|
|
|
|||
867
package-lock.json
generated
867
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -19,13 +19,14 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@chriscdn/promise-semaphore": "^3.0.1",
|
||||
"@cloudrac3r/discord-markdown": "^2.6.10",
|
||||
"@cloudrac3r/discord-markdown": "^2.7.0",
|
||||
"@cloudrac3r/giframe": "^0.4.3",
|
||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||
"@cloudrac3r/in-your-element": "^1.1.1",
|
||||
"@cloudrac3r/mixin-deep": "^3.0.1",
|
||||
"@cloudrac3r/pngjs": "^7.0.3",
|
||||
"@cloudrac3r/pug": "^4.0.4",
|
||||
"@cloudrac3r/stream-type": "^1.0.0",
|
||||
"@cloudrac3r/turndown": "^7.1.4",
|
||||
"@stackoverflow/stacks": "^2.5.4",
|
||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||
|
|
@ -46,7 +47,6 @@
|
|||
"prettier-bytes": "^1.0.4",
|
||||
"sharp": "^0.34.5",
|
||||
"snowtransfer": "^0.17.5",
|
||||
"stream-mime-type": "^1.0.2",
|
||||
"try-to-catch": "^4.0.5",
|
||||
"uqr": "^0.1.2",
|
||||
"xxhash-wasm": "^1.0.2",
|
||||
|
|
|
|||
|
|
@ -51,10 +51,24 @@ const preparedInsert = backfill.prepare("INSERT INTO backfill (channel_id, messa
|
|||
|
||||
async function event(event) {
|
||||
if (event.t !== "GUILD_CREATE") return
|
||||
const channel = event.d.channels.find(c => c.id === channelID)
|
||||
if (!channel) return
|
||||
let channel = event.d.channels.find(c => c.id === channelID) || (event.d.threads || []).find(c => c.id === channelID)
|
||||
const guild_id = event.d.id
|
||||
|
||||
if (!channel) {
|
||||
// May be an archived thread not present in GUILD_CREATE data - try fetching via API
|
||||
try {
|
||||
const fetched = await discord.snow.channel.getChannel(channelID)
|
||||
if (!fetched.guild_id || fetched.guild_id !== guild_id) return
|
||||
fetched.guild_id = guild_id
|
||||
discord.channels.set(fetched.id, fetched)
|
||||
channel = fetched
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel) return
|
||||
|
||||
try {
|
||||
await createRoom.syncRoom(channelID)
|
||||
let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ async function channelToKState(channel, guild, di) {
|
|||
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
||||
}
|
||||
|
||||
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||
const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
|
||||
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
||||
|
||||
|
|
@ -442,8 +442,9 @@ function syncRoom(channelID) {
|
|||
/**
|
||||
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
|
||||
* @param {string} guildID
|
||||
* @param {string} messageBeforeLeave
|
||||
*/
|
||||
async function unbridgeChannel(channel, guildID) {
|
||||
async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room was removed from the bridge.") {
|
||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||
assert.ok(roomID)
|
||||
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
|
||||
|
|
@ -493,7 +494,7 @@ async function unbridgeChannel(channel, guildID) {
|
|||
// send a notification in the room
|
||||
await api.sendEvent(roomID, "m.room.message", {
|
||||
msgtype: "m.notice",
|
||||
body: "⚠️ This room was removed from the bridge."
|
||||
body: `⚠️ ${messageBeforeLeave}`
|
||||
})
|
||||
|
||||
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ function memberToPowerLevel(user, member, guild, channel) {
|
|||
if (!member) return 0
|
||||
|
||||
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
|
||||
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||
const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
|
||||
/*
|
||||
* PL 100 = Administrator = People who can brick the room. RATIONALE:
|
||||
* - Administrator.
|
||||
|
|
|
|||
124
src/d2m/actions/remove-member.js
Normal file
124
src/d2m/actions/remove-member.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, select, from} = passthrough
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
|
||||
/**
|
||||
* Make a specific sim leave all rooms in a guild and clean up the database.
|
||||
* @param {string} mxid
|
||||
* @param {string} guildID
|
||||
* @returns {Promise<number>} number of rooms left
|
||||
*/
|
||||
async function removeSimFromGuild(mxid, guildID) {
|
||||
const rooms = from("sim_member")
|
||||
.join("channel_room", "room_id")
|
||||
.where({mxid, guild_id: guildID})
|
||||
.pluck("room_id")
|
||||
.all()
|
||||
|
||||
for (const roomID of rooms) {
|
||||
try {
|
||||
await api.leaveRoom(roomID, mxid)
|
||||
} catch (e) {
|
||||
// Room may no longer exist or sim may already have left
|
||||
console.log(`[remove member] failed to leave room ${roomID}: ${e}`)
|
||||
}
|
||||
db.prepare("DELETE FROM sim_member WHERE mxid = ? AND room_id = ?").run(mxid, roomID)
|
||||
}
|
||||
|
||||
return rooms.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user's sim and their PluralKit proxy sims from all rooms in a guild.
|
||||
* Called when a Discord user leaves a guild.
|
||||
* @param {string} userID Discord user ID
|
||||
* @param {string} guildID Discord guild ID
|
||||
*/
|
||||
async function removeMember(userID, guildID) {
|
||||
// Remove the user's own sim
|
||||
const mxid = select("sim", "mxid", {user_id: userID}).pluck().get()
|
||||
if (mxid) {
|
||||
const count = await removeSimFromGuild(mxid, guildID)
|
||||
if (count) console.log(`[remove member] removed sim for ${userID} from ${count} rooms in guild ${guildID}`)
|
||||
}
|
||||
|
||||
// Remove PluralKit proxy sims owned by this user
|
||||
const pkUserIDs = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().all()
|
||||
for (const pkUserID of pkUserIDs) {
|
||||
const pkMxid = select("sim", "mxid", {user_id: pkUserID}).pluck().get()
|
||||
if (!pkMxid) continue
|
||||
const count = await removeSimFromGuild(pkMxid, guildID)
|
||||
if (count) console.log(`[remove member] removed pk sim for ${pkUserID} (owner: ${userID}) from ${count} rooms in guild ${guildID}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill: check all sims in guild rooms and remove those whose Discord users have left the guild.
|
||||
* Called on GUILD_CREATE to catch removals that happened while the bridge was offline.
|
||||
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
|
||||
*/
|
||||
async function checkMissedMembers(guild) {
|
||||
if (guild.unavailable) return
|
||||
|
||||
// Find all distinct regular Discord user IDs with sims in this guild's rooms
|
||||
// Exclude PK sims (UUIDs with dashes) and webhook sims (with underscores)
|
||||
const rows = from("sim_member")
|
||||
.join("channel_room", "room_id")
|
||||
.join("sim", "mxid")
|
||||
.where({guild_id: guild.id})
|
||||
.and("AND user_id NOT LIKE '%-%' AND user_id NOT LIKE '%\\_%' ESCAPE '\\'")
|
||||
.pluck("user_id")
|
||||
.all()
|
||||
|
||||
const userIDs = [...new Set(rows)]
|
||||
|
||||
for (const userID of userIDs) {
|
||||
try {
|
||||
await discord.snow.guild.getGuildMember(guild.id, userID)
|
||||
} catch (e) {
|
||||
if (String(e).includes("10007") || String(e).includes("Unknown Member")) {
|
||||
console.log(`[remove member] backfill: user ${userID} is no longer in guild ${guild.id}`)
|
||||
await removeMember(userID, guild.id)
|
||||
}
|
||||
// Other errors (rate limits, network issues) - skip this user
|
||||
}
|
||||
}
|
||||
|
||||
// Also check PK proxy owners who may not have a regular sim but whose PK sims are in guild rooms
|
||||
const pkRows = from("sim_member")
|
||||
.join("channel_room", "room_id")
|
||||
.join("sim", "mxid")
|
||||
.join("sim_proxy", "user_id")
|
||||
.where({guild_id: guild.id})
|
||||
.pluck("proxy_owner_id")
|
||||
.all()
|
||||
|
||||
const pkOwnerIDs = [...new Set(pkRows)].filter(id => !userIDs.includes(id))
|
||||
|
||||
for (const ownerID of pkOwnerIDs) {
|
||||
try {
|
||||
await discord.snow.guild.getGuildMember(guild.id, ownerID)
|
||||
} catch (e) {
|
||||
if (String(e).includes("10007") || String(e).includes("Unknown Member")) {
|
||||
console.log(`[remove member] backfill: pk owner ${ownerID} is no longer in guild ${guild.id}`)
|
||||
// Only remove PK sims for this owner, not their own sim (they don't have one in this guild)
|
||||
const pkUserIDs = select("sim_proxy", "user_id", {proxy_owner_id: ownerID}).pluck().all()
|
||||
for (const pkUserID of pkUserIDs) {
|
||||
const pkMxid = select("sim", "mxid", {user_id: pkUserID}).pluck().get()
|
||||
if (!pkMxid) continue
|
||||
const count = await removeSimFromGuild(pkMxid, guild.id)
|
||||
if (count) console.log(`[remove member] backfill: removed pk sim for ${pkUserID} (owner: ${ownerID}) from ${count} rooms in guild ${guild.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.removeSimFromGuild = removeSimFromGuild
|
||||
module.exports.removeMember = removeMember
|
||||
module.exports.checkMissedMembers = checkMissedMembers
|
||||
202
src/d2m/actions/remove-member.test.js
Normal file
202
src/d2m/actions/remove-member.test.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const {removeSimFromGuild, removeMember, checkMissedMembers} = require("./remove-member")
|
||||
const passthrough = require("../../passthrough")
|
||||
const {db, sync, discord} = passthrough
|
||||
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
|
||||
function setupTestSim(userID, simName, mxid) {
|
||||
db.prepare("INSERT OR IGNORE INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(userID, simName, simName, mxid)
|
||||
}
|
||||
|
||||
function setupTestSimMember(mxid, roomID) {
|
||||
db.prepare("INSERT OR IGNORE INTO sim_member (mxid, room_id, hashed_profile_content) VALUES (?, ?, NULL)").run(mxid, roomID)
|
||||
}
|
||||
|
||||
function cleanupTestSim(userID) {
|
||||
const mxid = db.prepare("SELECT mxid FROM sim WHERE user_id = ?").pluck().get(userID)
|
||||
if (mxid) {
|
||||
db.prepare("DELETE FROM sim_member WHERE mxid = ?").run(mxid)
|
||||
}
|
||||
db.prepare("DELETE FROM sim WHERE user_id = ?").run(userID)
|
||||
}
|
||||
|
||||
test("remove-member: removeSimFromGuild removes sim from all guild rooms", async t => {
|
||||
const mxid = "@_ooye_testrm1:cadence.moe"
|
||||
const guildID = "112760669178241024"
|
||||
const roomID1 = "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
||||
const roomID2 = "!fGgIymcYWOqjbSRUdV:cadence.moe"
|
||||
|
||||
setupTestSim("999999990", "testrm1", mxid)
|
||||
setupTestSimMember(mxid, roomID1)
|
||||
setupTestSimMember(mxid, roomID2)
|
||||
|
||||
const leftRooms = []
|
||||
const originalLeaveRoom = api.leaveRoom
|
||||
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
|
||||
|
||||
try {
|
||||
const count = await removeSimFromGuild(mxid, guildID)
|
||||
|
||||
t.equal(count, 2)
|
||||
t.equal(leftRooms.length, 2)
|
||||
t.ok(leftRooms.some(r => r.roomID === roomID1))
|
||||
t.ok(leftRooms.some(r => r.roomID === roomID2))
|
||||
|
||||
const remaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid)
|
||||
t.equal(remaining, 0)
|
||||
} finally {
|
||||
api.leaveRoom = originalLeaveRoom
|
||||
cleanupTestSim("999999990")
|
||||
}
|
||||
})
|
||||
|
||||
test("remove-member: removeSimFromGuild only affects rooms in the specified guild", async t => {
|
||||
const mxid = "@_ooye_testrm2:cadence.moe"
|
||||
const guildID = "112760669178241024"
|
||||
const guildRoom = "!kLRqKKUQXcibIMtOpl:cadence.moe" // guild 112760669178241024
|
||||
const otherGuildRoom = "!BnKuBPCvyfOkhcUjEu:cadence.moe" // guild 66192955777486848
|
||||
|
||||
setupTestSim("999999991", "testrm2", mxid)
|
||||
setupTestSimMember(mxid, guildRoom)
|
||||
setupTestSimMember(mxid, otherGuildRoom)
|
||||
|
||||
const leftRooms = []
|
||||
const originalLeaveRoom = api.leaveRoom
|
||||
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
|
||||
|
||||
try {
|
||||
const count = await removeSimFromGuild(mxid, guildID)
|
||||
|
||||
t.equal(count, 1)
|
||||
t.equal(leftRooms[0].roomID, guildRoom)
|
||||
|
||||
const otherRemaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ? AND room_id = ?").pluck().get(mxid, otherGuildRoom)
|
||||
t.equal(otherRemaining, 1, "other guild's room should be untouched")
|
||||
} finally {
|
||||
api.leaveRoom = originalLeaveRoom
|
||||
cleanupTestSim("999999991")
|
||||
}
|
||||
})
|
||||
|
||||
test("remove-member: removeSimFromGuild handles leaveRoom errors gracefully", async t => {
|
||||
const mxid = "@_ooye_testrm3:cadence.moe"
|
||||
const guildID = "112760669178241024"
|
||||
const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
||||
|
||||
setupTestSim("999999992", "testrm3", mxid)
|
||||
setupTestSimMember(mxid, roomID)
|
||||
|
||||
const originalLeaveRoom = api.leaveRoom
|
||||
api.leaveRoom = async () => { throw new Error("not in room") }
|
||||
|
||||
try {
|
||||
const count = await removeSimFromGuild(mxid, guildID)
|
||||
t.equal(count, 1)
|
||||
|
||||
const remaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid)
|
||||
t.equal(remaining, 0, "sim_member should be deleted even if leaveRoom fails")
|
||||
} finally {
|
||||
api.leaveRoom = originalLeaveRoom
|
||||
cleanupTestSim("999999992")
|
||||
}
|
||||
})
|
||||
|
||||
test("remove-member: removeMember removes user sim and their PK proxy sims", async t => {
|
||||
const userID = "999999993"
|
||||
const mxid = "@_ooye_testrm4:cadence.moe"
|
||||
const pkUserID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
const pkMxid = "@_ooye__pk_testrmpk:cadence.moe"
|
||||
const guildID = "112760669178241024"
|
||||
const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
||||
|
||||
setupTestSim(userID, "testrm4", mxid)
|
||||
setupTestSimMember(mxid, roomID)
|
||||
setupTestSim(pkUserID, "_pk_testrmpk", pkMxid)
|
||||
setupTestSimMember(pkMxid, roomID)
|
||||
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkUserID, userID, "Test PK")
|
||||
|
||||
const leftRooms = []
|
||||
const originalLeaveRoom = api.leaveRoom
|
||||
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
|
||||
|
||||
try {
|
||||
await removeMember(userID, guildID)
|
||||
|
||||
t.equal(leftRooms.length, 2)
|
||||
t.ok(leftRooms.some(r => r.mxid === mxid), "user sim should leave")
|
||||
t.ok(leftRooms.some(r => r.mxid === pkMxid), "pk sim should leave")
|
||||
|
||||
t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid), 0)
|
||||
t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(pkMxid), 0)
|
||||
} finally {
|
||||
api.leaveRoom = originalLeaveRoom
|
||||
db.prepare("DELETE FROM sim_proxy WHERE user_id = ?").run(pkUserID)
|
||||
cleanupTestSim(userID)
|
||||
cleanupTestSim(pkUserID)
|
||||
}
|
||||
})
|
||||
|
||||
test("remove-member: removeMember does nothing for unknown user", async t => {
|
||||
const leftRooms = []
|
||||
const originalLeaveRoom = api.leaveRoom
|
||||
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
|
||||
|
||||
try {
|
||||
await removeMember("000000000000000000", "112760669178241024")
|
||||
t.equal(leftRooms.length, 0)
|
||||
} finally {
|
||||
api.leaveRoom = originalLeaveRoom
|
||||
}
|
||||
})
|
||||
|
||||
test("remove-member: checkMissedMembers removes sims for departed users", async t => {
|
||||
const userID = "999999994"
|
||||
const mxid = "@_ooye_testrm5:cadence.moe"
|
||||
const guildID = "112760669178241024"
|
||||
const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
||||
|
||||
setupTestSim(userID, "testrm5", mxid)
|
||||
setupTestSimMember(mxid, roomID)
|
||||
|
||||
const leftRooms = []
|
||||
const originalLeaveRoom = api.leaveRoom
|
||||
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
|
||||
|
||||
const originalSnow = discord.snow
|
||||
discord.snow = {
|
||||
guild: {
|
||||
getGuildMember: async (gid, uid) => {
|
||||
if (uid === userID) throw new Error('{"message": "Unknown Member", "code": 10007}')
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await checkMissedMembers({id: guildID})
|
||||
|
||||
t.ok(leftRooms.some(r => r.mxid === mxid), "departed user's sim should be removed")
|
||||
t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid), 0)
|
||||
} finally {
|
||||
api.leaveRoom = originalLeaveRoom
|
||||
discord.snow = originalSnow
|
||||
cleanupTestSim(userID)
|
||||
}
|
||||
})
|
||||
|
||||
test("remove-member: checkMissedMembers skips unavailable guilds", async t => {
|
||||
const leftRooms = []
|
||||
const originalLeaveRoom = api.leaveRoom
|
||||
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
|
||||
|
||||
try {
|
||||
await checkMissedMembers({id: "112760669178241024", unavailable: true})
|
||||
t.equal(leftRooms.length, 0, "should not process unavailable guilds")
|
||||
} finally {
|
||||
api.leaveRoom = originalLeaveRoom
|
||||
}
|
||||
})
|
||||
|
|
@ -151,9 +151,11 @@ async function editToChanges(message, guild, api) {
|
|||
const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
|
||||
// Don't post new generated embeds for messages if the setting was disabled.
|
||||
const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
|
||||
// Bots may rely on embeds to send new content, so the rules may be more lax for them.
|
||||
const botEmbedsApproved = message.author?.bot && !originallyFromMatrix
|
||||
if (messageReallyOld) {
|
||||
eventsToSend = [] // Only allow edits to change and delete, but not send new.
|
||||
} else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) {
|
||||
} else if ((messageQuiteOld || !embedsEnabled) && !botEmbedsApproved) {
|
||||
eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => {
|
|||
newContent: {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||
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:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> 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": {
|
||||
|
|
@ -87,7 +87,7 @@ test("edit2changes: bot response", async t => {
|
|||
// *** Replaced With: ***
|
||||
"m.new_content": {
|
||||
msgtype: "m.text",
|
||||
body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||
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:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> 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": {
|
||||
|
|
|
|||
|
|
@ -146,10 +146,18 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
|
|||
// Highlight the relevant part of the message
|
||||
const start = baseOffset + best.scored.matchedInputTokens[0].index
|
||||
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
|
||||
const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end)
|
||||
const newNodes = [{
|
||||
type: "text", content: content.slice(0, start)
|
||||
}, {
|
||||
type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [
|
||||
{type: "text", content: content.slice(start, end)}
|
||||
]
|
||||
}, {
|
||||
type: "text", content: content.slice(end)
|
||||
}]
|
||||
return {
|
||||
mxid: best.mxid,
|
||||
newContent
|
||||
newNodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,29 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} newEvents merge into events
|
||||
* @param {any} events will be modified
|
||||
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
|
||||
*/
|
||||
function mergeTextEvents(newEvents, events, forceSameMsgtype) {
|
||||
let prev = events.at(-1)
|
||||
for (const ne of newEvents) {
|
||||
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
|
||||
const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
|
||||
if (isAllText && typesPermitted) {
|
||||
const rep = new mxUtils.MatrixStringBuilder()
|
||||
rep.body = prev.body
|
||||
rep.formattedBody = prev.formatted_body
|
||||
rep.addLine(ne.body, ne.formatted_body)
|
||||
prev.body = rep.body
|
||||
prev.formatted_body = rep.formattedBody
|
||||
} else {
|
||||
events.push(ne)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessage} message
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
|
|
@ -519,29 +542,60 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
|
||||
}))
|
||||
|
||||
async function transformParsedVia(parsed) {
|
||||
for (const node of parsed) {
|
||||
async function transformParsedVia(parsed, scanTextForMentions) {
|
||||
for (let n = 0; n < parsed.length; n++) {
|
||||
const node = parsed[n]
|
||||
if (node.type === "discordChannel" || node.type === "discordChannelLink") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
else if (node.type === "text" && typeof node.content === "string") {
|
||||
// Merge adjacent text nodes into this one
|
||||
while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") {
|
||||
node.content += parsed[n+1].content
|
||||
parsed.splice(n+1, 1)
|
||||
}
|
||||
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
||||
if (scanTextForMentions) {
|
||||
let content = node.content
|
||||
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
|
||||
for (let i = matches.length; i--;) {
|
||||
const m = matches[i]
|
||||
const prefix = m[1]
|
||||
const maximumWrittenSection = m[2].toLowerCase()
|
||||
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
|
||||
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
|
||||
|
||||
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||
assert(roomID)
|
||||
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
|
||||
|
||||
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
|
||||
if (found) {
|
||||
addMention(found.mxid)
|
||||
parsed.splice(n, 1, ...found.newNodes)
|
||||
content = found.newNodes[0].content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const maybeChildNodesArray of [node, node.content, node.items]) {
|
||||
if (Array.isArray(maybeChildNodesArray)) {
|
||||
await transformParsedVia(maybeChildNodesArray)
|
||||
await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type))
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
||||
let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), {
|
||||
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
|
||||
...customOptions
|
||||
}, customParser, customHtmlOutput)
|
||||
|
||||
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
||||
let body = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, false), { // not scanning plaintext body for mentions as we don't parse whether they're in code
|
||||
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
||||
discordOnly: true,
|
||||
escapeHTML: false,
|
||||
|
|
@ -735,35 +789,12 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
|
||||
// Then text content
|
||||
if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
|
||||
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
||||
let content = message.content
|
||||
if (options.scanTextForMentions !== false) {
|
||||
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
|
||||
for (let i = matches.length; i--;) {
|
||||
const m = matches[i]
|
||||
const prefix = m[1]
|
||||
const maximumWrittenSection = m[2].toLowerCase()
|
||||
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
|
||||
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
|
||||
|
||||
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||
assert(roomID)
|
||||
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
|
||||
|
||||
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
|
||||
if (found) {
|
||||
addMention(found.mxid)
|
||||
content = found.newContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan the content for emojihax and replace them with real emojis
|
||||
content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
|
||||
let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
|
||||
return `<:${name}:${id}>`
|
||||
})
|
||||
|
||||
const {body, html} = await transformContent(content)
|
||||
const {body, html} = await transformContent(content, {isTheMessageContent: true})
|
||||
await addTextEvent(body, html, msgtype)
|
||||
}
|
||||
|
||||
|
|
@ -827,15 +858,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
|
||||
// Try to merge attachment events with the previous event
|
||||
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
|
||||
let prev = events.at(-1)
|
||||
for (const atch of attachmentEvents) {
|
||||
if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) {
|
||||
prev.body = prev.body + "\n" + atch.body
|
||||
prev.formatted_body = prev.formatted_body + "<br>" + atch.formatted_body
|
||||
} else {
|
||||
events.push(atch)
|
||||
}
|
||||
}
|
||||
mergeTextEvents(attachmentEvents, events, false)
|
||||
}
|
||||
|
||||
// Then components
|
||||
|
|
@ -973,6 +996,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
|
||||
// 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()
|
||||
let isAdditionalImage = false
|
||||
|
||||
if (isKlipyGIF) {
|
||||
assert(embed.video?.url)
|
||||
|
|
@ -1039,7 +1063,11 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
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(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
|
||||
|
||||
if (chosenImage) {
|
||||
isAdditionalImage = !rep.body && !!events.length
|
||||
rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
|
||||
}
|
||||
|
||||
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
|
||||
|
||||
|
|
@ -1048,6 +1076,11 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
body = body.split("\n").map(l => "| " + l).join("\n")
|
||||
html = `<blockquote>${html}</blockquote>`
|
||||
|
||||
if (isAdditionalImage) {
|
||||
mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
||||
await addTextEvent(body, html, "m.notice")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,6 +204,44 @@ test("message2event embeds: author url without name", async t => {
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event embeds: 4 images", async t => {
|
||||
const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>",
|
||||
"m.mentions": {}
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "» | ## ⏺️ AUTOMATON WEST (@AUTOMATON_ENG) https://x.com/AUTOMATON_ENG/status/2032003668787020046"
|
||||
+ "\n» | "
|
||||
+ "\n» | 4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non\\-AI made social network”"
|
||||
+ "\n» | ︀︀"
|
||||
+ "\n» | ︀︀[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)"
|
||||
+ "\n» | "
|
||||
+ "\n» | **[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36 [🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212 [❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K **"
|
||||
+ "\n» | "
|
||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig"
|
||||
+ "\n» | — FixupX"
|
||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig"
|
||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig"
|
||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<blockquote><blockquote><p><strong><a href=\"https://x.com/AUTOMATON_ENG/status/2032003668787020046\">⏺️ AUTOMATON WEST (@AUTOMATON_ENG)</a></strong></p>"
|
||||
+ "<p>4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non-AI made social network”"
|
||||
+ "<br>︀︀<br>︀︀<a href=\"https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/\">automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/</a>"
|
||||
+ "<br><br><strong><a href=\"https://x.com/intent/tweet?in_reply_to=2032003668787020046\">💬</a> 36 <a href=\"https://x.com/intent/retweet?tweet_id=2032003668787020046\">🔁</a> 212 <a href=\"https://x.com/intent/like?tweet_id=2032003668787020046\">❤</a> 3.0K 👁 131.7K </strong></p>"
|
||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig</p>— FixupX</blockquote>"
|
||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig</p>"
|
||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig</p>"
|
||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig</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, [{
|
||||
|
|
|
|||
|
|
@ -789,7 +789,7 @@ test("message2event: simple written @mention for matrix user", async t => {
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym",
|
||||
body: "@ash do you need anything from the store btw as I'm heading there after gym",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym`
|
||||
}])
|
||||
|
|
@ -838,7 +838,7 @@ test("message2event: many written @mentions for matrix users", async t => {
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)",
|
||||
body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>`
|
||||
}])
|
||||
|
|
@ -890,7 +890,7 @@ test("message2event: written @mentions may match part of the name", async t => {
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?",
|
||||
body: "I wonder if @cadence saw this?",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
|
||||
}])
|
||||
|
|
@ -941,7 +941,7 @@ test("message2event: written @mentions may match part of the mxid", async t => {
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?",
|
||||
body: "I wonder if @huck saw this?",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
|
||||
}])
|
||||
|
|
@ -962,6 +962,36 @@ test("message2event: written @mentions do not match in URLs", async t => {
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event: written @mentions do not match in inline code", async t => {
|
||||
const events = await messageToEvent({
|
||||
...data.message.advanced_written_at_mention_for_matrix,
|
||||
content: "`public @Nullable EntityType<?>`"
|
||||
}, data.guild.general, {}, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "`public @Nullable EntityType<?>`",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<code>public @Nullable EntityType<?></code>`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: written @mentions do not match in code block", async t => {
|
||||
const events = await messageToEvent({
|
||||
...data.message.advanced_written_at_mention_for_matrix,
|
||||
content: "```java\npublic @Nullable EntityType<?>\n```"
|
||||
}, data.guild.general, {}, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "```java\npublic @Nullable EntityType<?>\n```",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<pre><code class="language-java">public @Nullable EntityType<?></code></pre>`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: entire message may match elaborate display name", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent({
|
||||
|
|
@ -1007,7 +1037,7 @@ test("message2event: entire message may match elaborate display name", async t =
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)",
|
||||
body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
|
||||
}])
|
||||
|
|
@ -1084,7 +1114,7 @@ test("message2event: multiple attachments are combined into the same event where
|
|||
formatted_body: "hey"
|
||||
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
||||
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
|
||||
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
||||
+ `📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class DiscordClient {
|
|||
/** @type {import("cloudstorm").IClientOptions["intents"]} */
|
||||
const intents = [
|
||||
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MEMBERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
|
||||
"MESSAGE_CONTENT"
|
||||
]
|
||||
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
|
||||
|
|
@ -52,7 +52,11 @@ class DiscordClient {
|
|||
/** @type {Map<string, Array<string>>} */
|
||||
this.guildChannelMap = new Map()
|
||||
if (listen !== "no") {
|
||||
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
|
||||
this.cloud.on("event", message => {
|
||||
process.nextTick(() => {
|
||||
discordPackets.onPacket(this, message, listen)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const addEventLogger = (eventName, logName) => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const utils = {
|
|||
client.user = message.d.user
|
||||
client.application = message.d.application
|
||||
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
|
||||
interactions.registerInteractions()
|
||||
|
||||
} else if (message.t === "GUILD_CREATE") {
|
||||
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
|
||||
|
|
@ -47,10 +48,10 @@ const utils = {
|
|||
|
||||
if (listen === "full") {
|
||||
try {
|
||||
interactions.registerInteractions()
|
||||
await eventDispatcher.checkMissedExpressions(message.d)
|
||||
await eventDispatcher.checkMissedPins(client, message.d)
|
||||
await eventDispatcher.checkMissedMessages(client, message.d)
|
||||
await eventDispatcher.checkMissedMembers(message.d)
|
||||
} catch (e) {
|
||||
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
||||
console.error(e)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ const vote = sync.require("./actions/poll-vote")
|
|||
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
|
||||
/** @type {import("../discord/interactions/matrix-info")} */
|
||||
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
|
||||
/** @type {import("./actions/remove-member")} */
|
||||
const removeMember = sync.require("./actions/remove-member")
|
||||
|
||||
const {Semaphore} = require("@chriscdn/promise-semaphore")
|
||||
const checkMissedPinsSema = new Semaphore()
|
||||
|
|
@ -413,5 +415,24 @@ module.exports = {
|
|||
const status = data.status
|
||||
if (!status) return
|
||||
setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status)
|
||||
},
|
||||
|
||||
/**
|
||||
* When a Discord user leaves (or is kicked/banned from) the guild, make their sims leave the bridged Matrix rooms.
|
||||
* Also removes PluralKit proxy sims owned by the departing user.
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data
|
||||
*/
|
||||
async GUILD_MEMBER_REMOVE(client, data) {
|
||||
if (data.user.id === client.user.id) return // Don't process if the bot itself is removed
|
||||
await removeMember.removeMember(data.user.id, data.guild_id)
|
||||
},
|
||||
|
||||
/**
|
||||
* When logging back in, check for sims whose Discord users have left the guild while the bridge was offline.
|
||||
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
|
||||
*/
|
||||
async checkMissedMembers(guild) {
|
||||
await removeMember.checkMissedMembers(guild)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const assert = require("assert").strict
|
|||
|
||||
const {reg} = require("../matrix/read-registration")
|
||||
|
||||
const {db} = require("../passthrough")
|
||||
const {db, select} = require("../passthrough")
|
||||
|
||||
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
||||
let hasher = null
|
||||
|
|
@ -58,6 +58,15 @@ function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrite
|
|||
return allowed
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{id: string, roles: DiscordTypes.APIGuild["roles"]}} guild
|
||||
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channel]
|
||||
*/
|
||||
function getDefaultPermissions(guild, channel) {
|
||||
const defaultRoles = select("role_default", "role_id", {guild_id: guild.id}).pluck().all()
|
||||
return getPermissions(guild.id, defaultRoles, guild.roles, undefined, channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
@ -174,6 +183,7 @@ function filterTo(xs, fn) {
|
|||
}
|
||||
|
||||
module.exports.getPermissions = getPermissions
|
||||
module.exports.getDefaultPermissions = getDefaultPermissions
|
||||
module.exports.hasPermission = hasPermission
|
||||
module.exports.hasSomePermissions = hasSomePermissions
|
||||
module.exports.hasAllPermissions = hasAllPermissions
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const sharp = require("sharp")
|
|||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/mreq")} */
|
||||
const mreq = sync.require("../../matrix/mreq")
|
||||
const streamMimeType = require("stream-mime-type")
|
||||
const {streamType} = require("@cloudrac3r/stream-type")
|
||||
|
||||
const WIDTH = 160
|
||||
const HEIGHT = 160
|
||||
|
|
@ -26,13 +26,13 @@ async function getAndResizeSticker(mxc) {
|
|||
}
|
||||
|
||||
const streamIn = Readable.fromWeb(res.body)
|
||||
const { stream, mime } = await streamMimeType.getMimeType(streamIn)
|
||||
const animated = ["image/gif", "image/webp"].includes(mime)
|
||||
const {streamThrough, type} = await streamType(streamIn)
|
||||
const animated = ["image/gif", "image/webp"].includes(type)
|
||||
|
||||
const transformer = sharp({animated: animated})
|
||||
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||
.webp()
|
||||
stream.pipe(transformer)
|
||||
streamThrough.pipe(transformer)
|
||||
return Readable.toWeb(transformer)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const {pipeline} = require("stream").promises
|
|||
const sharp = require("sharp")
|
||||
const {GIFrame} = require("@cloudrac3r/giframe")
|
||||
const {PNG} = require("@cloudrac3r/pngjs")
|
||||
const streamMimeType = require("stream-mime-type")
|
||||
const {streamType} = require("@cloudrac3r/stream-type")
|
||||
|
||||
const SIZE = 48
|
||||
const RESULT_WIDTH = 400
|
||||
|
|
@ -54,11 +54,11 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
|
|||
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image
|
||||
*/
|
||||
async function convertImageStream(streamIn, stopStream) {
|
||||
const {stream, mime} = await streamMimeType.getMimeType(streamIn)
|
||||
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`)
|
||||
const {streamThrough, type} = await streamType(streamIn)
|
||||
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(type), `Mime type ${type} is impossible for emojis`)
|
||||
|
||||
try {
|
||||
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
|
||||
if (type === "image/png" || type === "image/jpeg" || type === "image/webp") {
|
||||
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const transformer = sharp()
|
||||
|
|
@ -70,15 +70,15 @@ async function convertImageStream(streamIn, stopStream) {
|
|||
resolve({info, buffer})
|
||||
})
|
||||
pipeline(
|
||||
stream,
|
||||
streamThrough,
|
||||
transformer
|
||||
)
|
||||
})
|
||||
return result.buffer
|
||||
|
||||
} else if (mime === "image/gif") {
|
||||
} else if (type === "image/gif") {
|
||||
const giframe = new GIFrame(0)
|
||||
stream.on("data", chunk => {
|
||||
streamThrough.on("data", chunk => {
|
||||
giframe.feed(chunk)
|
||||
})
|
||||
const frame = await giframe.getFrame()
|
||||
|
|
@ -91,10 +91,10 @@ async function convertImageStream(streamIn, stopStream) {
|
|||
.toBuffer({resolveWithObject: true})
|
||||
return buffer.data
|
||||
|
||||
} else if (mime === "image/apng") {
|
||||
} else if (type === "image/apng") {
|
||||
const png = new PNG({maxFrames: 1})
|
||||
// @ts-ignore
|
||||
stream.pipe(png)
|
||||
streamThrough.pipe(png)
|
||||
/** @type {Buffer} */ // @ts-ignore
|
||||
const frame = await new Promise(resolve => png.on("parsed", resolve))
|
||||
stopStream()
|
||||
|
|
|
|||
|
|
@ -898,7 +898,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
|
||||
if (!shouldSuppress && guild?.roles) {
|
||||
// Suppress if regular users don't have permission
|
||||
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||
const permissions = dUtils.getDefaultPermissions(guild, channel?.permission_overwrites)
|
||||
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
||||
shouldSuppress = !canEmbedLinks
|
||||
}
|
||||
|
|
@ -961,7 +961,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
|
||||
// Suppress if regular users don't have permission
|
||||
if (!shouldSuppress && guild?.roles) {
|
||||
const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||
const permissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
|
||||
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
||||
shouldSuppress = !canEmbedLinks
|
||||
}
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ async event => {
|
|||
console.error(e)
|
||||
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
|
||||
}
|
||||
if (inviteRoomState?.encryption) return await api.leaveRoomWithReason(event.room_id, "Encrypted rooms are not supported for bridging. Please use an unencrypted room.")
|
||||
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
|
||||
await api.joinRoom(event.room_id)
|
||||
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
|
||||
|
|
@ -483,6 +484,20 @@ async event => {
|
|||
await roomUpgrade.onTombstone(event, api)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.encryption", guard("m.room.encryption",
|
||||
/**
|
||||
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Encryption>} event
|
||||
*/
|
||||
async event => {
|
||||
// Dramatically unbridge rooms if they become encrypted
|
||||
if (event.state_key !== "") return
|
||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||
if (!channelID) return
|
||||
const channel = discord.channels.get(channelID)
|
||||
if (!channel) return
|
||||
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
|
||||
}))
|
||||
|
||||
module.exports.stringifyErrorStack = stringifyErrorStack
|
||||
module.exports.sendError = sendError
|
||||
module.exports.printError = printError
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ function getStateEventOuter(roomID, type, key) {
|
|||
/**
|
||||
* @param {string} roomID
|
||||
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
|
||||
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>}
|
||||
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?, encryption: string?}>}
|
||||
*/
|
||||
async function getInviteState(roomID, event) {
|
||||
function getFromInviteRoomState(strippedState, nskey, key) {
|
||||
|
|
@ -191,7 +191,8 @@ async function getInviteState(roomID, event) {
|
|||
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
|
||||
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
|
||||
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
|
||||
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type")
|
||||
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type"),
|
||||
encryption: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.encryption", "algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +228,8 @@ async function getInviteState(roomID, event) {
|
|||
name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
|
||||
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
|
||||
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
|
||||
type: getFromInviteRoomState(strippedState, "m.room.create", "type")
|
||||
type: getFromInviteRoomState(strippedState, "m.room.create", "type"),
|
||||
encryption: getFromInviteRoomState(strippedState, "m.room.encryption", "algorithm")
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
|
@ -240,7 +242,8 @@ async function getInviteState(roomID, event) {
|
|||
name: room.name ?? null,
|
||||
topic: room.topic ?? null,
|
||||
avatar: room.avatar_url ?? null,
|
||||
type: room.room_type ?? null
|
||||
type: room.room_type ?? null,
|
||||
encryption: (room.encryption || room["im.nheko.summary.encryption"]) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ async function _actuallyUploadDiscordFileToMxc(url) {
|
|||
writeRegistration(reg)
|
||||
return root
|
||||
}
|
||||
e.uploadURL = url
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ const commands = [{
|
|||
// Guard
|
||||
/** @type {string} */ // @ts-ignore
|
||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||
const guildID = discord.channels.get(channelID)?.["guild_id"]
|
||||
const channel = discord.channels.get(channelID)
|
||||
const guildID = channel?.["guild_id"]
|
||||
let matrixOnlyReason = null
|
||||
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
|
||||
// Check if we can/should upload to Discord, for various causes
|
||||
|
|
@ -115,7 +116,7 @@ const commands = [{
|
|||
const guild = discord.guilds.get(guildID)
|
||||
assert(guild)
|
||||
const slots = getSlotCount(guild.premium_tier)
|
||||
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
|
||||
if (guild.emojis.length >= slots) {
|
||||
matrixOnlyReason = "CAPACITY"
|
||||
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
|
||||
|
|
@ -240,7 +241,8 @@ const commands = [{
|
|||
// Guard
|
||||
/** @type {string} */ // @ts-ignore
|
||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||
const guildID = discord.channels.get(channelID)?.["guild_id"]
|
||||
const channel = discord.channels.get(channelID)
|
||||
const guildID = channel?.["guild_id"]
|
||||
if (!guildID) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
|
|
@ -251,7 +253,7 @@ const commands = [{
|
|||
|
||||
const guild = discord.guilds.get(guildID)
|
||||
assert(guild)
|
||||
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
|
||||
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
|
|
@ -270,7 +272,8 @@ const commands = [{
|
|||
// Guard
|
||||
/** @type {string} */ // @ts-ignore
|
||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||
const guildID = discord.channels.get(channelID)?.["guild_id"]
|
||||
const channel = discord.channels.get(channelID)
|
||||
const guildID = channel?.["guild_id"]
|
||||
if (!guildID) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
|
|
@ -281,7 +284,7 @@ const commands = [{
|
|||
|
||||
const guild = discord.guilds.get(guildID)
|
||||
assert(guild)
|
||||
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
|
||||
if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
|
|
@ -290,7 +293,19 @@ const commands = [{
|
|||
})
|
||||
}
|
||||
|
||||
const invite = await discord.snow.channel.createChannelInvite(channelID)
|
||||
try {
|
||||
var invite = await discord.snow.channel.createChannelInvite(channelID)
|
||||
} catch (e) {
|
||||
if (e.message === `{"message": "Missing Permissions", "code": 50013}`) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "I don't have permission to create invites to the Discord channel/server."
|
||||
})
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
const validHours = Math.ceil(invite.max_age / (60 * 60))
|
||||
const validUses =
|
||||
( invite.max_uses === 0 ? "unlimited uses"
|
||||
|
|
|
|||
|
|
@ -54,17 +54,17 @@ async function onBotMembership(event, api, createRoom) {
|
|||
assert.equal(event.type, "m.room.member")
|
||||
assert.equal(event.state_key, utils.bot)
|
||||
|
||||
// Check if an upgrade is pending for this room
|
||||
const newRoomID = event.room_id
|
||||
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
|
||||
if (!oldRoomID) return false
|
||||
const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
|
||||
assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining
|
||||
|
||||
// Check if is join/invite
|
||||
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
|
||||
|
||||
return await roomUpgradeSema.request(async () => {
|
||||
// Check if an upgrade is pending for this room
|
||||
const newRoomID = event.room_id
|
||||
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
|
||||
if (!oldRoomID) return false
|
||||
const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
|
||||
assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining
|
||||
|
||||
// Check if is join/invite
|
||||
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
|
||||
|
||||
// If invited, join
|
||||
if (event.content.membership === "invite") {
|
||||
await api.joinRoom(newRoomID)
|
||||
|
|
|
|||
9
src/types.d.ts
vendored
9
src/types.d.ts
vendored
|
|
@ -157,7 +157,7 @@ export namespace Event {
|
|||
type: string
|
||||
state_key: string
|
||||
sender: string
|
||||
content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias
|
||||
content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias | Event.M_Room_Encryption
|
||||
}
|
||||
|
||||
export type M_Room_Create = {
|
||||
|
|
@ -390,6 +390,12 @@ export namespace Event {
|
|||
body: string
|
||||
replacement_room: string
|
||||
}
|
||||
|
||||
export type M_Room_Encryption = {
|
||||
algorithm: string
|
||||
rotation_period_ms?: number
|
||||
rotation_period_msgs?: number
|
||||
}
|
||||
}
|
||||
|
||||
export namespace R {
|
||||
|
|
@ -437,6 +443,7 @@ export namespace R {
|
|||
num_joined_members: number
|
||||
room_id: string
|
||||
room_type?: string
|
||||
encryption?: string
|
||||
}
|
||||
|
||||
export type ResolvedRoom = {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ block body
|
|||
|
||||
h3.mt32.fs-category Default roles
|
||||
.s-card
|
||||
form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles
|
||||
form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles
|
||||
input(type="hidden" name="guild_id" value=guild_id)
|
||||
.d-flex.fw-wrap.g4
|
||||
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
|
||||
|
|
@ -249,6 +249,11 @@ block body
|
|||
ul.my8.ml24
|
||||
each row in removedLinkedRooms
|
||||
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
|
||||
h3.mt24 Unavailable rooms: Encryption not supported
|
||||
.s-card.p0
|
||||
ul.my8.ml24
|
||||
each row in removedEncryptedRooms
|
||||
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
|
||||
h3.mt24 Unavailable rooms: Wrong type
|
||||
.s-card.p0
|
||||
ul.my8.ml24
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ as.router.post("/api/default-roles", defineEventHandler(async event => {
|
|||
db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID)
|
||||
}
|
||||
|
||||
const createSpace = getCreateSpace(event)
|
||||
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
|
||||
|
||||
if (getRequestHeader(event, "HX-Request")) {
|
||||
return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -123,13 +123,14 @@ function getChannelRoomsLinks(guild, rooms, roles) {
|
|||
let unlinkedRooms = [...rooms]
|
||||
let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
|
||||
let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
|
||||
let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"])
|
||||
// https://discord.com/developers/docs/topics/threads#active-archived-threads
|
||||
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
|
||||
let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
|
||||
|
||||
return {
|
||||
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
|
||||
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms
|
||||
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,12 @@ as.router.post("/api/link", defineEventHandler(async event => {
|
|||
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
|
||||
}
|
||||
|
||||
// Check room is not encrypted
|
||||
const encryption = await api.getStateEvent(parsedBody.matrix, "m.room.encryption", "").catch(() => null)
|
||||
if (encryption) {
|
||||
throw createError({status: 400, message: "Bad Request", data: "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room."})
|
||||
}
|
||||
|
||||
// Check bridge has PL 100
|
||||
const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api)
|
||||
if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
|
||||
|
|
|
|||
|
|
@ -435,6 +435,47 @@ test("web link room: check that bridge can join room (uses via for join attempt)
|
|||
t.equal(called, 2)
|
||||
})
|
||||
|
||||
test("web link room: check that room is not encrypted", async t => {
|
||||
let called = 0
|
||||
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
|
||||
sessionData: {
|
||||
managedGuilds: ["665289423482519565"]
|
||||
},
|
||||
body: {
|
||||
discord: "665310973967597573",
|
||||
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
|
||||
guild_id: "665289423482519565"
|
||||
},
|
||||
api: {
|
||||
async joinRoom(roomID) {
|
||||
called++
|
||||
return roomID
|
||||
},
|
||||
async *generateFullHierarchy(spaceID) {
|
||||
called++
|
||||
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
|
||||
yield {
|
||||
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
|
||||
children_state: [],
|
||||
guest_can_join: false,
|
||||
num_joined_members: 2
|
||||
}
|
||||
/* c8 ignore next */
|
||||
},
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
|
||||
if (type === "m.room.encryption" && key === "") {
|
||||
return {algorithm: "m.megolm.v1.aes-sha2"}
|
||||
}
|
||||
throw new Error("Unknown state event")
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.equal(error.data, "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room.")
|
||||
t.equal(called, 3)
|
||||
})
|
||||
|
||||
test("web link room: check that bridge has PL 100 in target room", async t => {
|
||||
let called = 0
|
||||
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
|
||||
|
|
@ -465,9 +506,10 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
|
|||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {users_default: 50}
|
||||
if (type === "m.room.power_levels" && key === "") {
|
||||
return {users_default: 50}
|
||||
}
|
||||
throw new Error("Unknown state event")
|
||||
},
|
||||
async getStateEventOuter(roomID, type, key) {
|
||||
called++
|
||||
|
|
@ -489,7 +531,7 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
|
|||
}
|
||||
}))
|
||||
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
|
||||
t.equal(called, 4)
|
||||
t.equal(called, 5)
|
||||
})
|
||||
|
||||
test("web link room: successfully calls createRoom", async t => {
|
||||
|
|
|
|||
135
test/data.js
135
test/data.js
|
|
@ -5067,6 +5067,141 @@ module.exports = {
|
|||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false
|
||||
},
|
||||
four_images: {
|
||||
type: 0,
|
||||
content: "",
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
timestamp: "2026-03-12T18:00:50.737000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 16384,
|
||||
components: [],
|
||||
id: "1481713598278533241",
|
||||
channel_id: "687028734322147344",
|
||||
author: {
|
||||
id: "112760500130975744",
|
||||
username: "minimus",
|
||||
avatar: "a_a354b9eaff512485b49c82b13691b941",
|
||||
discriminator: "0",
|
||||
public_flags: 512,
|
||||
flags: 512,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "minimus",
|
||||
avatar_decoration_data: null,
|
||||
collectibles: null,
|
||||
display_name_styles: { font_id: 11, effect_id: 5, colors: [ 6106655 ] },
|
||||
banner_color: null,
|
||||
clan: null,
|
||||
primary_guild: null
|
||||
},
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false,
|
||||
message_reference: {
|
||||
type: 1,
|
||||
channel_id: "637339857118822430",
|
||||
message_id: "1481696763483258891",
|
||||
guild_id: "408573045540651009"
|
||||
},
|
||||
message_snapshots: [
|
||||
{
|
||||
message: {
|
||||
type: 0,
|
||||
content: "https://fixupx.com/i/status/2032003668787020046",
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [
|
||||
{
|
||||
type: "rich",
|
||||
url: "https://fixupx.com/i/status/2032003668787020046",
|
||||
description: "4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non\\-AI made social network”\n" +
|
||||
"︀︀\n" +
|
||||
"︀︀[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)\n" +
|
||||
"\n" +
|
||||
"**[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36 [🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212 [❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K **",
|
||||
color: 6513919,
|
||||
timestamp: "2026-03-12T08:00:02+00:00",
|
||||
author: {
|
||||
name: "AUTOMATON WEST (@AUTOMATON_ENG)",
|
||||
url: "https://x.com/AUTOMATON_ENG/status/2032003668787020046",
|
||||
icon_url: "https://pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg",
|
||||
proxy_icon_url: "https://images-ext-1.discordapp.net/external/1OzGhjvZTRstTxM38_7pqHXlmdbMddqh1F8R0-WrKqw/https/pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg"
|
||||
},
|
||||
image: {
|
||||
url: "https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig",
|
||||
proxy_url: "https://images-ext-1.discordapp.net/external/NkNgp2SyY1OCH9IdS8hqsUqbnbrp3A9oLNwYusVVCVQ/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg",
|
||||
width: 872,
|
||||
height: 886,
|
||||
content_type: "image/jpeg",
|
||||
placeholder: "6vcFFwL6R3lye2V3l1mIl5l3WPN5FZ8H",
|
||||
placeholder_version: 1,
|
||||
flags: 0
|
||||
},
|
||||
footer: {
|
||||
text: "FixupX",
|
||||
icon_url: "https://assets.fxembed.com/logos/fixupx64.png",
|
||||
proxy_icon_url: "https://images-ext-1.discordapp.net/external/LwQ70Uiqfu0OCN4ZbA4f482TGCgQa-xGsnUFYfhIgYA/https/assets.fxembed.com/logos/fixupx64.png"
|
||||
},
|
||||
content_scan_version: 4
|
||||
},
|
||||
{
|
||||
type: "rich",
|
||||
url: "https://fixupx.com/i/status/2032003668787020046",
|
||||
image: {
|
||||
url: "https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig",
|
||||
proxy_url: "https://images-ext-1.discordapp.net/external/Rquh1ec-tG9hMqdHqIVSphO7zf5B5Fg_7yTWhCjlsek/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg",
|
||||
width: 1114,
|
||||
height: 991,
|
||||
content_type: "image/jpeg",
|
||||
placeholder: "JQgKDoL3epZ8ZIdnlmmHZ4d4CIGmUEc=",
|
||||
placeholder_version: 1,
|
||||
flags: 0
|
||||
},
|
||||
content_scan_version: 4
|
||||
},
|
||||
{
|
||||
type: "rich",
|
||||
url: "https://fixupx.com/i/status/2032003668787020046",
|
||||
image: {
|
||||
url: "https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig",
|
||||
proxy_url: "https://images-ext-1.discordapp.net/external/XrkhHNH3CvlZYvjkdykVnf-_xdz6HWX8uwesoAwwSfY/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUrPobgAAeb90.jpg",
|
||||
width: 944,
|
||||
height: 954,
|
||||
content_type: "image/jpeg",
|
||||
placeholder: "m/cJDwCbV0mfaoZzlihqeXdqCVN9A6oD",
|
||||
placeholder_version: 1,
|
||||
flags: 0
|
||||
},
|
||||
content_scan_version: 4
|
||||
},
|
||||
{
|
||||
type: "rich",
|
||||
url: "https://fixupx.com/i/status/2032003668787020046",
|
||||
image: {
|
||||
url: "https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
|
||||
proxy_url: "https://images-ext-1.discordapp.net/external/lO-5hBMU9bGH13Ax9xum2T2Mg0ATdv0b6BEx_VeVi80/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
content_type: "image/jpeg",
|
||||
placeholder: "tfcJDIK3mIl1eIiPdY23dX9b9w==",
|
||||
placeholder_version: 1,
|
||||
flags: 0
|
||||
},
|
||||
content_scan_version: 4
|
||||
}
|
||||
],
|
||||
timestamp: "2026-03-12T16:53:57.009000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
message_with_components: {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
|||
require("../src/d2m/actions/create-room.test")
|
||||
require("../src/d2m/actions/create-space.test")
|
||||
require("../src/d2m/actions/register-user.test")
|
||||
require("../src/d2m/actions/remove-member.test")
|
||||
require("../src/d2m/converters/edit-to-changes.test")
|
||||
require("../src/d2m/converters/emoji-to-key.test")
|
||||
require("../src/d2m/converters/find-mentions.test")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue