mirror of
https://gitdab.com/cadence/out-of-your-element.git
synced 2025-09-10 12:22:50 +02:00
Move everything to src folder... it had to happen
This commit is contained in:
parent
decc32f7e6
commit
4247a3114a
103 changed files with 1 additions and 1 deletions
338
src/matrix/api.js
Normal file
338
src/matrix/api.js
Normal file
|
@ -0,0 +1,338 @@
|
|||
// @ts-check
|
||||
|
||||
const Ty = require("../types")
|
||||
const assert = require("assert").strict
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const { discord, sync, db } = passthrough
|
||||
/** @type {import("./mreq")} */
|
||||
const mreq = sync.require("./mreq")
|
||||
/** @type {import("./file")} */
|
||||
const file = sync.require("./file")
|
||||
/** @type {import("./txnid")} */
|
||||
const makeTxnId = sync.require("./txnid")
|
||||
const {reg} = require("./read-registration.js")
|
||||
|
||||
/**
|
||||
* @param {string} p endpoint to access
|
||||
* @param {string?} [mxid] optional: user to act as, for the ?user_id parameter
|
||||
* @param {{[x: string]: any}} [otherParams] optional: any other query parameters to add
|
||||
* @returns {string} the new endpoint
|
||||
*/
|
||||
function path(p, mxid, otherParams = {}) {
|
||||
const u = new URL(p, "http://localhost")
|
||||
if (mxid) u.searchParams.set("user_id", mxid)
|
||||
for (const entry of Object.entries(otherParams)) {
|
||||
if (entry[1] != undefined) {
|
||||
u.searchParams.set(entry[0], entry[1])
|
||||
}
|
||||
}
|
||||
let result = u.pathname
|
||||
const str = u.searchParams.toString()
|
||||
if (str) result += "?" + str
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} username
|
||||
* @returns {Promise<Ty.R.Registered>}
|
||||
*/
|
||||
function register(username) {
|
||||
console.log(`[api] register: ${username}`)
|
||||
return mreq.mreq("POST", "/client/v3/register", {
|
||||
type: "m.login.application_service",
|
||||
username
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>} room ID
|
||||
*/
|
||||
async function createRoom(content) {
|
||||
console.log(`[api] create room:`, content)
|
||||
/** @type {Ty.R.RoomCreated} */
|
||||
const root = await mreq.mreq("POST", "/client/v3/createRoom", content)
|
||||
return root.room_id
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>} room ID
|
||||
*/
|
||||
async function joinRoom(roomIDOrAlias, mxid) {
|
||||
/** @type {Ty.R.RoomJoined} */
|
||||
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
|
||||
return root.room_id
|
||||
}
|
||||
|
||||
async function inviteToRoom(roomID, mxidToInvite, mxid) {
|
||||
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), {
|
||||
user_id: mxidToInvite
|
||||
})
|
||||
}
|
||||
|
||||
async function leaveRoom(roomID, mxid) {
|
||||
console.log(`[api] leave: ${roomID}: ${mxid}`)
|
||||
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} eventID
|
||||
* @template T
|
||||
*/
|
||||
async function getEvent(roomID, eventID) {
|
||||
/** @type {Ty.Event.Outer<T>} */
|
||||
const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${eventID}`)
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {number} ts unix silliseconds
|
||||
*/
|
||||
async function getEventForTimestamp(roomID, ts) {
|
||||
/** @type {{event_id: string, origin_server_ts: number}} */
|
||||
const root = await mreq.mreq("GET", path(`/client/v1/rooms/${roomID}/timestamp_to_event`, null, {ts}))
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @returns {Promise<Ty.Event.BaseStateEvent[]>}
|
||||
*/
|
||||
function getAllState(roomID) {
|
||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} type
|
||||
* @param {string} key
|
||||
* @returns the *content* of the state event
|
||||
*/
|
||||
function getStateEvent(roomID, type, key) {
|
||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
|
||||
* @param {string} roomID
|
||||
* @returns {Promise<{joined: {[mxid: string]: {avatar_url: string?, display_name: string?}}}>}
|
||||
*/
|
||||
function getJoinedMembers(roomID) {
|
||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {{from?: string, limit?: any}} pagination
|
||||
* @returns {Promise<Ty.HierarchyPagination<Ty.R.Hierarchy>>}
|
||||
*/
|
||||
function getHierarchy(roomID, pagination) {
|
||||
let path = `/client/v1/rooms/${roomID}/hierarchy`
|
||||
if (!pagination.from) delete pagination.from
|
||||
if (!pagination.limit) pagination.limit = 50
|
||||
path += `?${new URLSearchParams(pagination)}`
|
||||
return mreq.mreq("GET", path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `getHierarchy` but collects all pages for you.
|
||||
* @param {string} roomID
|
||||
*/
|
||||
async function getFullHierarchy(roomID) {
|
||||
/** @type {Ty.R.Hierarchy[]} */
|
||||
let rooms = []
|
||||
/** @type {string | undefined} */
|
||||
let nextBatch = undefined
|
||||
do {
|
||||
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
|
||||
const res = await getHierarchy(roomID, {from: nextBatch})
|
||||
rooms.push(...res.rooms)
|
||||
nextBatch = res.next_batch
|
||||
} while (nextBatch)
|
||||
return rooms
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} eventID
|
||||
* @param {{from?: string, limit?: any}} pagination
|
||||
* @param {string?} [relType]
|
||||
* @returns {Promise<Ty.Pagination<Ty.Event.Outer<any>>>}
|
||||
*/
|
||||
function getRelations(roomID, eventID, pagination, relType) {
|
||||
let path = `/client/v1/rooms/${roomID}/relations/${eventID}`
|
||||
if (relType) path += `/${relType}`
|
||||
if (!pagination.from) delete pagination.from
|
||||
if (!pagination.limit) pagination.limit = 50 // get a little more consistency between homeservers
|
||||
path += `?${new URLSearchParams(pagination)}`
|
||||
return mreq.mreq("GET", path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `getRelations` but collects and filters all pages for you.
|
||||
* @param {string} roomID
|
||||
* @param {string} eventID
|
||||
* @param {string?} [relType] type of relations to filter, e.g. "m.annotation" for reactions
|
||||
*/
|
||||
async function getFullRelations(roomID, eventID, relType) {
|
||||
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
|
||||
let reactions = []
|
||||
/** @type {string | undefined} */
|
||||
let nextBatch = undefined
|
||||
do {
|
||||
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
|
||||
const res = await getRelations(roomID, eventID, {from: nextBatch}, relType)
|
||||
reactions = reactions.concat(res.chunk)
|
||||
nextBatch = res.next_batch
|
||||
} while (nextBatch)
|
||||
return reactions
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} type
|
||||
* @param {string} stateKey
|
||||
* @param {string} [mxid]
|
||||
* @returns {Promise<string>} event ID
|
||||
*/
|
||||
async function sendState(roomID, type, stateKey, content, mxid) {
|
||||
console.log(`[api] state: ${roomID}: ${type}/${stateKey}`)
|
||||
assert.ok(type)
|
||||
assert.ok(typeof stateKey === "string")
|
||||
/** @type {Ty.R.EventSent} */
|
||||
// encodeURIComponent is necessary because state key can contain some special characters like / but you must encode them so they fit in a single component of the URI
|
||||
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${encodeURIComponent(stateKey)}`, mxid), content)
|
||||
return root.event_id
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} type
|
||||
* @param {any} content
|
||||
* @param {string?} [mxid]
|
||||
* @param {number} [timestamp] timestamp of the newly created event, in unix milliseconds
|
||||
*/
|
||||
async function sendEvent(roomID, type, content, mxid, timestamp) {
|
||||
if (!["m.room.message", "m.reaction", "m.sticker"].includes(type)) {
|
||||
console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`)
|
||||
}
|
||||
/** @type {Ty.R.EventSent} */
|
||||
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content)
|
||||
return root.event_id
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} eventID
|
||||
* @param {string?} [mxid]
|
||||
* @returns {Promise<string>} event ID
|
||||
*/
|
||||
async function redactEvent(roomID, eventID, mxid) {
|
||||
/** @type {Ty.R.EventRedacted} */
|
||||
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {})
|
||||
return root.event_id
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {boolean} isTyping
|
||||
* @param {string} mxid
|
||||
* @param {number} [duration] milliseconds
|
||||
*/
|
||||
async function sendTyping(roomID, isTyping, mxid, duration) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/typing/${mxid}`, mxid), {
|
||||
typing: isTyping,
|
||||
timeout: duration
|
||||
})
|
||||
}
|
||||
|
||||
async function profileSetDisplayname(mxid, displayname) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), {
|
||||
displayname
|
||||
})
|
||||
}
|
||||
|
||||
async function profileSetAvatarUrl(mxid, avatar_url) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), {
|
||||
avatar_url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a user's power level within a room.
|
||||
* @param {string} roomID
|
||||
* @param {string} mxid
|
||||
* @param {number} power
|
||||
*/
|
||||
async function setUserPower(roomID, mxid, power) {
|
||||
assert(roomID[0] === "!")
|
||||
assert(mxid[0] === "@")
|
||||
// Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352
|
||||
const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "")
|
||||
powerLevels.users = powerLevels.users || {}
|
||||
if (power != null) {
|
||||
powerLevels.users[mxid] = power
|
||||
} else {
|
||||
delete powerLevels.users[mxid]
|
||||
}
|
||||
await sendState(roomID, "m.room.power_levels", "", powerLevels)
|
||||
return powerLevels
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a user's power level for a whole room hierarchy.
|
||||
* @param {string} roomID
|
||||
* @param {string} mxid
|
||||
* @param {number} power
|
||||
*/
|
||||
async function setUserPowerCascade(roomID, mxid, power) {
|
||||
assert(roomID[0] === "!")
|
||||
assert(mxid[0] === "@")
|
||||
const rooms = await getFullHierarchy(roomID)
|
||||
for (const room of rooms) {
|
||||
await setUserPower(room.room_id, mxid, power)
|
||||
}
|
||||
}
|
||||
|
||||
async function ping() {
|
||||
const res = await fetch(`${mreq.baseUrl}/client/v1/appservice/${reg.id}/ping`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${reg.as_token}`
|
||||
},
|
||||
body: "{}"
|
||||
})
|
||||
const root = await res.json()
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.path = path
|
||||
module.exports.register = register
|
||||
module.exports.createRoom = createRoom
|
||||
module.exports.joinRoom = joinRoom
|
||||
module.exports.inviteToRoom = inviteToRoom
|
||||
module.exports.leaveRoom = leaveRoom
|
||||
module.exports.getEvent = getEvent
|
||||
module.exports.getEventForTimestamp = getEventForTimestamp
|
||||
module.exports.getAllState = getAllState
|
||||
module.exports.getStateEvent = getStateEvent
|
||||
module.exports.getJoinedMembers = getJoinedMembers
|
||||
module.exports.getHierarchy = getHierarchy
|
||||
module.exports.getFullHierarchy = getFullHierarchy
|
||||
module.exports.getRelations = getRelations
|
||||
module.exports.getFullRelations = getFullRelations
|
||||
module.exports.sendState = sendState
|
||||
module.exports.sendEvent = sendEvent
|
||||
module.exports.redactEvent = redactEvent
|
||||
module.exports.sendTyping = sendTyping
|
||||
module.exports.profileSetDisplayname = profileSetDisplayname
|
||||
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
|
||||
module.exports.setUserPower = setUserPower
|
||||
module.exports.setUserPowerCascade = setUserPowerCascade
|
||||
module.exports.ping = ping
|
26
src/matrix/api.test.js
Normal file
26
src/matrix/api.test.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
const {test} = require("supertape")
|
||||
const {path} = require("./api")
|
||||
|
||||
test("api path: no change for plain path", t => {
|
||||
t.equal(path("/hello/world"), "/hello/world")
|
||||
})
|
||||
|
||||
test("api path: add mxid to the URL", t => {
|
||||
t.equal(path("/hello/world", "12345"), "/hello/world?user_id=12345")
|
||||
})
|
||||
|
||||
test("api path: empty path with mxid", t => {
|
||||
t.equal(path("", "12345"), "/?user_id=12345")
|
||||
})
|
||||
|
||||
test("api path: existing query parameters with mxid", t => {
|
||||
t.equal(path("/hello/world?foo=bar&baz=qux", "12345"), "/hello/world?foo=bar&baz=qux&user_id=12345")
|
||||
})
|
||||
|
||||
test("api path: real world mxid", t => {
|
||||
t.equal(path("/hello/world", "@cookie_monster:cadence.moe"), "/hello/world?user_id=%40cookie_monster%3Acadence.moe")
|
||||
})
|
||||
|
||||
test("api path: extras number works", t => {
|
||||
t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120")
|
||||
})
|
8
src/matrix/appservice.js
Normal file
8
src/matrix/appservice.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
// @ts-check
|
||||
|
||||
const {reg} = require("../matrix/read-registration")
|
||||
const {AppService} = require("@cloudrac3r/in-your-element")
|
||||
const as = new AppService(reg)
|
||||
as.listen()
|
||||
|
||||
module.exports.as = as
|
119
src/matrix/file.js
Normal file
119
src/matrix/file.js
Normal file
|
@ -0,0 +1,119 @@
|
|||
// @ts-check
|
||||
|
||||
const fetch = require("node-fetch").default
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync, db, select} = passthrough
|
||||
/** @type {import("./mreq")} */
|
||||
const mreq = sync.require("./mreq")
|
||||
|
||||
const DISCORD_IMAGES_BASE = "https://cdn.discordapp.com"
|
||||
const IMAGE_SIZE = 1024
|
||||
|
||||
/** @type {Map<string, Promise<string>>} */
|
||||
const inflight = new Map()
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
function _removeExpiryParams(url) {
|
||||
return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path or full URL if it's not a Discord CDN file
|
||||
*/
|
||||
async function uploadDiscordFileToMxc(path) {
|
||||
let url
|
||||
if (path.startsWith("http")) {
|
||||
url = path
|
||||
} else {
|
||||
url = DISCORD_IMAGES_BASE + path
|
||||
}
|
||||
|
||||
// Discord attachment content is always the same no matter what their ?ex parameter is.
|
||||
const urlNoExpiry = _removeExpiryParams(url)
|
||||
|
||||
// Are we uploading this file RIGHT NOW? Return the same inflight promise with the same resolution
|
||||
const existingInflight = inflight.get(urlNoExpiry)
|
||||
if (existingInflight) {
|
||||
return existingInflight
|
||||
}
|
||||
|
||||
// Has this file already been uploaded in the past? Grab the existing copy from the database.
|
||||
const existingFromDb = select("file", "mxc_url", {discord_url: urlNoExpiry}).pluck().get()
|
||||
if (typeof existingFromDb === "string") {
|
||||
return existingFromDb
|
||||
}
|
||||
|
||||
// Download from Discord
|
||||
const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => {
|
||||
// Upload to Matrix
|
||||
const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res)
|
||||
|
||||
// Store relationship in database
|
||||
db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri)
|
||||
inflight.delete(urlNoExpiry)
|
||||
|
||||
return root.content_uri
|
||||
})
|
||||
inflight.set(urlNoExpiry, promise)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
async function _actuallyUploadDiscordFileToMxc(url, res) {
|
||||
const body = res.body
|
||||
/** @type {import("../types").R.FileUploaded} */
|
||||
const root = await mreq.mreq("POST", "/media/v3/upload", body, {
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type")
|
||||
}
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
function guildIcon(guild) {
|
||||
return `/icons/${guild.id}/${guild.icon}.png?size=${IMAGE_SIZE}`
|
||||
}
|
||||
|
||||
function userAvatar(user) {
|
||||
return `/avatars/${user.id}/${user.avatar}.png?size=${IMAGE_SIZE}`
|
||||
}
|
||||
|
||||
function memberAvatar(guildID, user, member) {
|
||||
if (!member.avatar) return userAvatar(user)
|
||||
return `/guilds/${guildID}/users/${user.id}/avatars/${member.avatar}.png?size=${IMAGE_SIZE}`
|
||||
}
|
||||
|
||||
function emoji(emojiID, animated) {
|
||||
const base = `/emojis/${emojiID}`
|
||||
if (animated) return base + ".gif"
|
||||
else return base + ".png"
|
||||
}
|
||||
|
||||
const stickerFormat = new Map([
|
||||
[1, {label: "PNG", ext: "png", mime: "image/png"}],
|
||||
[2, {label: "APNG", ext: "png", mime: "image/apng"}],
|
||||
[3, {label: "LOTTIE", ext: "json", mime: "lottie"}],
|
||||
[4, {label: "GIF", ext: "gif", mime: "image/gif"}]
|
||||
])
|
||||
|
||||
/** @param {{id: string, format_type: number}} sticker */
|
||||
function sticker(sticker) {
|
||||
const format = stickerFormat.get(sticker.format_type)
|
||||
if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`)
|
||||
const ext = format.ext
|
||||
return `/stickers/${sticker.id}.${ext}`
|
||||
}
|
||||
|
||||
module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE
|
||||
module.exports.guildIcon = guildIcon
|
||||
module.exports.userAvatar = userAvatar
|
||||
module.exports.memberAvatar = memberAvatar
|
||||
module.exports.emoji = emoji
|
||||
module.exports.stickerFormat = stickerFormat
|
||||
module.exports.sticker = sticker
|
||||
module.exports.uploadDiscordFileToMxc = uploadDiscordFileToMxc
|
||||
module.exports._actuallyUploadDiscordFileToMxc = _actuallyUploadDiscordFileToMxc
|
||||
module.exports._removeExpiryParams = _removeExpiryParams
|
22
src/matrix/file.test.js
Normal file
22
src/matrix/file.test.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const file = require("./file")
|
||||
|
||||
test("removeExpiryParams: url without params is unchanged", t => {
|
||||
const url = "https://cdn.discordapp.com/attachments/1154455830591176734/1157034603496882267/59ce542f-bf66-4d9a-83b7-ad6d05a69bac.jpg"
|
||||
const result = file._removeExpiryParams(url)
|
||||
t.equal(result, url)
|
||||
})
|
||||
|
||||
test("removeExpiryParams: params are removed", t => {
|
||||
const url = "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png?ex=651856ae&is=6517052e&hm=88353defb15cbd833e6977817e8f72f4ff28f4edfd26b8ad5f267a4f2b946e69&"
|
||||
const result = file._removeExpiryParams(url)
|
||||
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
|
||||
})
|
||||
|
||||
test("removeExpiryParams: rearranged params are removed", t => {
|
||||
const url = "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png?hm=88353defb15cbd833e6977817e8f72f4ff28f4edfd26b8ad5f267a4f2b946e69&ex=651856ae&is=6517052e"
|
||||
const result = file._removeExpiryParams(url)
|
||||
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
|
||||
})
|
109
src/matrix/kstate.js
Normal file
109
src/matrix/kstate.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const mixin = require("@cloudrac3r/mixin-deep")
|
||||
const {isDeepStrictEqual} = require("util")
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync} = passthrough
|
||||
/** @type {import("./file")} */
|
||||
const file = sync.require("./file")
|
||||
|
||||
/** Mutates the input. Not recursive - can only include or exclude entire state events. */
|
||||
function kstateStripConditionals(kstate) {
|
||||
for (const [k, content] of Object.entries(kstate)) {
|
||||
// conditional for whether a key is even part of the kstate (doing this declaratively on json is hard, so represent it as a property instead.)
|
||||
if ("$if" in content) {
|
||||
if (content.$if) delete content.$if
|
||||
else delete kstate[k]
|
||||
}
|
||||
}
|
||||
return kstate
|
||||
}
|
||||
|
||||
/** Mutates the input. Works recursively through object tree. */
|
||||
async function kstateUploadMxc(obj) {
|
||||
const promises = []
|
||||
function inner(obj) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v == null || typeof v !== "object") continue
|
||||
|
||||
if (v.$url) {
|
||||
promises.push(
|
||||
file.uploadDiscordFileToMxc(v.$url)
|
||||
.then(mxc => obj[k] = mxc)
|
||||
)
|
||||
}
|
||||
|
||||
inner(v)
|
||||
}
|
||||
}
|
||||
inner(obj)
|
||||
await Promise.all(promises)
|
||||
return obj
|
||||
}
|
||||
|
||||
/** Automatically strips conditionals and uploads URLs to mxc. */
|
||||
async function kstateToState(kstate) {
|
||||
const events = []
|
||||
kstateStripConditionals(kstate)
|
||||
await kstateUploadMxc(kstate)
|
||||
for (const [k, content] of Object.entries(kstate)) {
|
||||
const slashIndex = k.indexOf("/")
|
||||
assert(slashIndex > 0)
|
||||
const type = k.slice(0, slashIndex)
|
||||
const state_key = k.slice(slashIndex + 1)
|
||||
events.push({type, state_key, content})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../types").Event.BaseStateEvent[]} events
|
||||
* @returns {any}
|
||||
*/
|
||||
function stateToKState(events) {
|
||||
const kstate = {}
|
||||
for (const event of events) {
|
||||
kstate[event.type + "/" + event.state_key] = event.content
|
||||
}
|
||||
return kstate
|
||||
}
|
||||
|
||||
function diffKState(actual, target) {
|
||||
const diff = {}
|
||||
// go through each key that it should have
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!key.includes("/")) throw new Error(`target kstate's key "${key}" does not contain a slash separator; if a blank state_key was intended, add a trailing slash to the kstate key.\ncontext: ${JSON.stringify(target)}`)
|
||||
|
||||
if (key === "m.room.power_levels/") {
|
||||
// Special handling for power levels, we want to deep merge the actual and target into the final state.
|
||||
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
|
||||
const temp = mixin({}, actual[key], target[key])
|
||||
if (!isDeepStrictEqual(actual[key], temp)) {
|
||||
// they differ. use the newly prepared object as the diff.
|
||||
diff[key] = temp
|
||||
}
|
||||
|
||||
} else if (key in actual) {
|
||||
// diff
|
||||
if (!isDeepStrictEqual(actual[key], target[key])) {
|
||||
// they differ. use the target as the diff.
|
||||
diff[key] = target[key]
|
||||
}
|
||||
|
||||
} else {
|
||||
// not present, needs to be added
|
||||
diff[key] = target[key]
|
||||
}
|
||||
|
||||
// keys that are missing in "actual" will not be deleted on "target" (no action)
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
module.exports.kstateStripConditionals = kstateStripConditionals
|
||||
module.exports.kstateUploadMxc = kstateUploadMxc
|
||||
module.exports.kstateToState = kstateToState
|
||||
module.exports.stateToKState = stateToKState
|
||||
module.exports.diffKState = diffKState
|
236
src/matrix/kstate.test.js
Normal file
236
src/matrix/kstate.test.js
Normal file
|
@ -0,0 +1,236 @@
|
|||
const assert = require("assert")
|
||||
const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc} = require("./kstate")
|
||||
const {test} = require("supertape")
|
||||
|
||||
test("kstate strip: strips false conditions", t => {
|
||||
t.deepEqual(kstateStripConditionals({
|
||||
a: {$if: false, value: 2},
|
||||
b: {value: 4}
|
||||
}), {
|
||||
b: {value: 4}
|
||||
})
|
||||
})
|
||||
|
||||
test("kstate strip: keeps true conditions while removing $if", t => {
|
||||
t.deepEqual(kstateStripConditionals({
|
||||
a: {$if: true, value: 2},
|
||||
b: {value: 4}
|
||||
}), {
|
||||
a: {value: 2},
|
||||
b: {value: 4}
|
||||
})
|
||||
})
|
||||
|
||||
test("kstateUploadMxc: sets the mxc", async t => {
|
||||
const input = {
|
||||
"m.room.avatar/": {
|
||||
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"},
|
||||
test1: {
|
||||
test2: {
|
||||
test3: {$url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await kstateUploadMxc(input)
|
||||
t.deepEqual(input, {
|
||||
"m.room.avatar/": {
|
||||
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl",
|
||||
test1: {
|
||||
test2: {
|
||||
test3: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("kstateUploadMxc and strip: work together", async t => {
|
||||
const input = {
|
||||
"m.room.avatar/yes": {
|
||||
$if: true,
|
||||
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"}
|
||||
},
|
||||
"m.room.avatar/no": {
|
||||
$if: false,
|
||||
url: {$url: "https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024"}
|
||||
},
|
||||
}
|
||||
kstateStripConditionals(input)
|
||||
await kstateUploadMxc(input)
|
||||
t.deepEqual(input, {
|
||||
"m.room.avatar/yes": {
|
||||
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl"
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
test("kstate2state: general", async t => {
|
||||
t.deepEqual(await kstateToState({
|
||||
"m.room.name/": {name: "test name"},
|
||||
"m.room.member/@cadence:cadence.moe": {membership: "join"},
|
||||
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
|
||||
}), [
|
||||
{
|
||||
type: "m.room.name",
|
||||
state_key: "",
|
||||
content: {
|
||||
name: "test name"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: "@cadence:cadence.moe",
|
||||
content: {
|
||||
membership: "join"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "uk.half-shot.bridge",
|
||||
state_key: "org.matrix.appservice-irc://irc/epicord.net/#general",
|
||||
content: {
|
||||
creator: "@cadence:cadence.moe"
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test("state2kstate: general", t => {
|
||||
t.deepEqual(stateToKState([
|
||||
{
|
||||
type: "m.room.name",
|
||||
state_key: "",
|
||||
content: {
|
||||
name: "test name"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: "@cadence:cadence.moe",
|
||||
content: {
|
||||
membership: "join"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "uk.half-shot.bridge",
|
||||
state_key: "org.matrix.appservice-irc://irc/epicord.net/#general",
|
||||
content: {
|
||||
creator: "@cadence:cadence.moe"
|
||||
}
|
||||
}
|
||||
]), {
|
||||
"m.room.name/": {name: "test name"},
|
||||
"m.room.member/@cadence:cadence.moe": {membership: "join"},
|
||||
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
|
||||
})
|
||||
})
|
||||
|
||||
test("diffKState: detects edits", t => {
|
||||
t.deepEqual(
|
||||
diffKState({
|
||||
"m.room.name/": {name: "test name"},
|
||||
"same/": {a: 2}
|
||||
}, {
|
||||
"m.room.name/": {name: "edited name"},
|
||||
"same/": {a: 2}
|
||||
}),
|
||||
{
|
||||
"m.room.name/": {name: "edited name"}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test("diffKState: detects new properties", t => {
|
||||
t.deepEqual(
|
||||
diffKState({
|
||||
"m.room.name/": {name: "test name"},
|
||||
}, {
|
||||
"m.room.name/": {name: "test name"},
|
||||
"new/": {a: 2}
|
||||
}),
|
||||
{
|
||||
"new/": {a: 2}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test("diffKState: power levels are mixed together", t => {
|
||||
const original = {
|
||||
"m.room.power_levels/": {
|
||||
"ban": 50,
|
||||
"events": {
|
||||
"m.room.name": 100,
|
||||
"m.room.power_levels": 100
|
||||
},
|
||||
"events_default": 0,
|
||||
"invite": 50,
|
||||
"kick": 50,
|
||||
"notifications": {
|
||||
"room": 20
|
||||
},
|
||||
"redact": 50,
|
||||
"state_default": 50,
|
||||
"users": {
|
||||
"@example:localhost": 100
|
||||
},
|
||||
"users_default": 0
|
||||
}
|
||||
}
|
||||
const result = diffKState(original, {
|
||||
"m.room.power_levels/": {
|
||||
"events": {
|
||||
"m.room.avatar": 0
|
||||
}
|
||||
}
|
||||
})
|
||||
t.deepEqual(result, {
|
||||
"m.room.power_levels/": {
|
||||
"ban": 50,
|
||||
"events": {
|
||||
"m.room.name": 100,
|
||||
"m.room.power_levels": 100,
|
||||
"m.room.avatar": 0
|
||||
},
|
||||
"events_default": 0,
|
||||
"invite": 50,
|
||||
"kick": 50,
|
||||
"notifications": {
|
||||
"room": 20
|
||||
},
|
||||
"redact": 50,
|
||||
"state_default": 50,
|
||||
"users": {
|
||||
"@example:localhost": 100
|
||||
},
|
||||
"users_default": 0
|
||||
}
|
||||
})
|
||||
t.notDeepEqual(original, result)
|
||||
})
|
||||
|
||||
test("diffKState: cannot merge power levels if original power levels are missing", t => {
|
||||
const original = {}
|
||||
assert.throws(() =>
|
||||
diffKState(original, {
|
||||
"m.room.power_levels/": {
|
||||
"events": {
|
||||
"m.room.avatar": 0
|
||||
}
|
||||
}
|
||||
})
|
||||
, /original power level data is missing/)
|
||||
t.pass()
|
||||
})
|
||||
|
||||
test("diffKState: kstate keys must contain a slash separator", t => {
|
||||
assert.throws(() =>
|
||||
diffKState({
|
||||
"m.room.name/": {name: "test name"},
|
||||
}, {
|
||||
"m.room.name/": {name: "test name"},
|
||||
"new": {a: 2}
|
||||
})
|
||||
, /does not contain a slash separator/)
|
||||
t.pass()
|
||||
})
|
297
src/matrix/matrix-command-handler.js
Normal file
297
src/matrix/matrix-command-handler.js
Normal file
|
@ -0,0 +1,297 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const Ty = require("../types")
|
||||
const {pipeline} = require("stream").promises
|
||||
const sharp = require("sharp")
|
||||
|
||||
const {discord, sync, db, select} = require("../passthrough")
|
||||
/** @type {import("./api")}) */
|
||||
const api = sync.require("./api")
|
||||
/** @type {import("../m2d/converters/utils")} */
|
||||
const mxUtils = sync.require("../m2d/converters/utils")
|
||||
/** @type {import("../discord/utils")} */
|
||||
const dUtils = sync.require("../discord/utils")
|
||||
/** @type {import("./kstate")} */
|
||||
const ks = sync.require("./kstate")
|
||||
const {reg} = require("./read-registration")
|
||||
|
||||
const PREFIXES = ["//", "/"]
|
||||
|
||||
const EMOJI_SIZE = 128
|
||||
|
||||
/** This many normal emojis + this many animated emojis. The total number is doubled. */
|
||||
const TIER_EMOJI_SLOTS = new Map([
|
||||
[1, 100],
|
||||
[2, 150],
|
||||
[3, 250]
|
||||
])
|
||||
|
||||
/** @param {number} tier */
|
||||
function getSlotCount(tier) {
|
||||
return TIER_EMOJI_SLOTS.get(tier) || 50
|
||||
}
|
||||
|
||||
let buttons = []
|
||||
|
||||
/**
|
||||
* @param {string} roomID where to add the button
|
||||
* @param {string} eventID where to add the button
|
||||
* @param {string} key emoji to add as a button
|
||||
* @param {string} mxid only listen for responses from this user
|
||||
* @returns {Promise<import("discord-api-types/v10").GatewayMessageReactionAddDispatchData>}
|
||||
*/
|
||||
async function addButton(roomID, eventID, key, mxid) {
|
||||
await api.sendEvent(roomID, "m.reaction", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: eventID,
|
||||
key
|
||||
}
|
||||
})
|
||||
return new Promise(resolve => {
|
||||
buttons.push({roomID, eventID, mxid, key, 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 {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
|
||||
function onReactionAdd(event) {
|
||||
const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
|
||||
if (button) {
|
||||
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
|
||||
button.resolve(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @callback CommandExecute
|
||||
* @param {Ty.Event.Outer_M_Room_Message} event
|
||||
* @param {string} realBody
|
||||
* @param {string[]} words
|
||||
* @param {any} [ctx]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef Command
|
||||
* @property {string[]} aliases
|
||||
* @property {CommandExecute} execute
|
||||
*/
|
||||
|
||||
/** @param {CommandExecute} execute */
|
||||
function replyctx(execute) {
|
||||
/** @type {CommandExecute} */
|
||||
return function(event, realBody, words, ctx = {}) {
|
||||
ctx["m.relates_to"] = {
|
||||
"m.in_reply_to": {
|
||||
event_id: event.event_id
|
||||
}
|
||||
}
|
||||
return execute(event, realBody, words, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Command[]} */
|
||||
const commands = [{
|
||||
aliases: ["emoji"],
|
||||
execute: replyctx(
|
||||
async (event, realBody, words, ctx) => {
|
||||
// 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"]
|
||||
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
|
||||
if (!guildID) {
|
||||
matrixOnlyReason = "NOT_BRIDGED"
|
||||
} else {
|
||||
const guild = discord.guilds.get(guildID)
|
||||
assert(guild)
|
||||
const slots = getSlotCount(guild.premium_tier)
|
||||
const permissions = dUtils.getPermissions([], guild.roles)
|
||||
if (guild.emojis.length >= slots) {
|
||||
matrixOnlyReason = "CAPACITY"
|
||||
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
|
||||
matrixOnlyReason = "USER_PERMISSIONS"
|
||||
}
|
||||
}
|
||||
if (matrixOnlyReason) {
|
||||
// If uploading to Matrix, check if we have permission
|
||||
const state = await api.getAllState(event.room_id)
|
||||
const kstate = ks.stateToKState(state)
|
||||
const powerLevels = kstate["m.room.power_levels/"]
|
||||
const required = powerLevels.events["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50
|
||||
const have = powerLevels.users[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? powerLevels.users_default ?? 0
|
||||
if (have < required) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "I don't have sufficient permissions in this Matrix room to edit emojis."
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{url: string, name: string}[]} */
|
||||
const toUpload = []
|
||||
const nameMatch = realBody.match(/:([a-zA-Z0-9_]{2,}):/)
|
||||
const mxcMatch = realBody.match(/(mxc:\/\/.*?)\b/)
|
||||
if (event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id) {
|
||||
const repliedToEventID = event.content["m.relates_to"]["m.in_reply_to"].event_id
|
||||
const repliedToEvent = await api.getEvent(event.room_id, repliedToEventID)
|
||||
if (nameMatch && repliedToEvent.type === "m.room.message" && repliedToEvent.content.msgtype === "m.image" && repliedToEvent.content.url) {
|
||||
toUpload.push({url: repliedToEvent.content.url, name: nameMatch[1]})
|
||||
} else if (repliedToEvent.type === "m.room.message" && repliedToEvent.content.msgtype === "m.text" && "formatted_body" in repliedToEvent.content) {
|
||||
const namePrefixMatch = realBody.match(/:([a-zA-Z0-9_]{2,})(?:\b|:)/)
|
||||
const imgMatches = [...repliedToEvent.content.formatted_body.matchAll(/<img [^>]*>/g)]
|
||||
for (const match of imgMatches) {
|
||||
const e = match[0]
|
||||
const url = e.match(/src="([^"]*)"/)?.[1]
|
||||
let name = e.match(/title=":?([^":]*):?"/)?.[1]
|
||||
if (!url || !name) continue
|
||||
if (namePrefixMatch) name = namePrefixMatch[1] + name
|
||||
toUpload.push({url, name})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!toUpload.length && mxcMatch && nameMatch) {
|
||||
toUpload.push({url: mxcMatch[1], name: nameMatch[1]})
|
||||
}
|
||||
if (!toUpload.length) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "Not sure what image you wanted to add. Try replying to an uploaded image when you use the command, or write an mxc:// URL in your message. You should specify the new name :like_this:."
|
||||
})
|
||||
}
|
||||
|
||||
const b = new mxUtils.MatrixStringBuilder()
|
||||
.addLine("## Emoji preview", "<h2>Emoji preview</h2>")
|
||||
.addLine(`Ⓜ️ This room isn't bridged to Discord. ${matrixOnlyConclusion}`, `Ⓜ️ <em>This room isn't bridged to Discord. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "NOT_BRIDGED")
|
||||
.addLine(`Ⓜ️ *Discord ran out of space for emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>Discord ran out of space for emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
|
||||
.addLine(`Ⓜ️ *If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
|
||||
.addLine("[Preview not available in plain text.]", "Preview:")
|
||||
for (const e of toUpload) {
|
||||
b.add("", `<img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`)
|
||||
}
|
||||
b.addLine("Hit ✅ to add it.")
|
||||
const sent = await api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
...b.get()
|
||||
})
|
||||
addButton(event.room_id, sent, "✅", event.sender).then(async () => {
|
||||
if (matrixOnlyReason) {
|
||||
// Edit some state
|
||||
const type = "im.ponies.room_emotes"
|
||||
const key = "moe.cadence.ooye.pack.matrix"
|
||||
let pack
|
||||
try {
|
||||
pack = await api.getStateEvent(event.room_id, type, key)
|
||||
} catch (e) {
|
||||
pack = {
|
||||
pack: {
|
||||
display_name: "Non-Discord Emojis",
|
||||
usage: ["emoticon", "sticker"]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!("images" in pack)) pack.images = {}
|
||||
const b = new mxUtils.MatrixStringBuilder()
|
||||
.addLine(`Created ${toUpload.length} emojis`, "")
|
||||
for (const e of toUpload) {
|
||||
pack.images[e.name] = {
|
||||
url: e.url // Directly use the same file that the Matrix user uploaded. Don't need to worry about dimensions/filesize because clients already request their preferred resized version from the homeserver.
|
||||
}
|
||||
b.add("", `<img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`)
|
||||
}
|
||||
await api.sendState(event.room_id, type, key, pack)
|
||||
api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
...b.get()
|
||||
})
|
||||
} else {
|
||||
// Upload it to Discord and have the bridge sync it back to Matrix again
|
||||
for (const e of toUpload) {
|
||||
const publicUrl = mxUtils.getPublicUrlForMxc(e.url)
|
||||
// @ts-ignore
|
||||
const resizeInput = await fetch(publicUrl, {agent: false}).then(res => res.arrayBuffer())
|
||||
const resizeOutput = await sharp(resizeInput)
|
||||
.resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||
.png()
|
||||
.toBuffer({resolveWithObject: true})
|
||||
console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`)
|
||||
const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")})
|
||||
}
|
||||
api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: `Created ${toUpload.length} emojis`
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}, {
|
||||
aliases: ["thread"],
|
||||
execute: replyctx(
|
||||
async (event, realBody, words, ctx) => {
|
||||
// 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"]
|
||||
if (!guildID) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "This room isn't bridged to the other side."
|
||||
})
|
||||
}
|
||||
|
||||
const guild = discord.guilds.get(guildID)
|
||||
assert(guild)
|
||||
const permissions = dUtils.getPermissions([], guild.roles)
|
||||
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission."
|
||||
})
|
||||
}
|
||||
|
||||
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
|
||||
}
|
||||
)
|
||||
}]
|
||||
|
||||
|
||||
/** @type {CommandExecute} */
|
||||
async function execute(event) {
|
||||
let realBody = event.content.body
|
||||
while (realBody.startsWith("> ")) {
|
||||
const i = realBody.indexOf("\n")
|
||||
if (i === -1) return
|
||||
realBody = realBody.slice(i + 1)
|
||||
}
|
||||
realBody = realBody.replace(/^\s*/, "")
|
||||
let words
|
||||
for (const prefix of PREFIXES) {
|
||||
if (realBody.startsWith(prefix)) {
|
||||
words = realBody.slice(prefix.length).split(" ")
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!words) return
|
||||
const commandName = words[0]
|
||||
const command = commands.find(c => c.aliases.includes(commandName))
|
||||
if (!command) return
|
||||
|
||||
await command.execute(event, realBody, words)
|
||||
}
|
||||
|
||||
module.exports.execute = execute
|
||||
module.exports.onReactionAdd = onReactionAdd
|
83
src/matrix/mreq.js
Normal file
83
src/matrix/mreq.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
// @ts-check
|
||||
|
||||
const fetch = require("node-fetch").default
|
||||
const mixin = require("@cloudrac3r/mixin-deep")
|
||||
const stream = require("stream")
|
||||
const getStream = require("get-stream")
|
||||
|
||||
const {reg} = require("./read-registration.js")
|
||||
|
||||
const baseUrl = `${reg.ooye.server_origin}/_matrix`
|
||||
|
||||
class MatrixServerError extends Error {
|
||||
constructor(data, opts) {
|
||||
super(data.error || data.errcode)
|
||||
this.data = data
|
||||
/** @type {string} */
|
||||
this.errcode = data.errcode
|
||||
this.opts = opts
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {string} url
|
||||
* @param {any} [body]
|
||||
* @param {any} [extra]
|
||||
*/
|
||||
async function mreq(method, url, body, extra = {}) {
|
||||
if (body == undefined || Object.is(body.constructor, Object)) {
|
||||
body = JSON.stringify(body)
|
||||
} else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) {
|
||||
body = await getStream.buffer(body)
|
||||
}
|
||||
|
||||
const opts = mixin({
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
Authorization: `Bearer ${reg.as_token}`
|
||||
}
|
||||
}, extra)
|
||||
|
||||
// console.log(baseUrl + url, opts)
|
||||
const res = await fetch(baseUrl + url, opts)
|
||||
const root = await res.json()
|
||||
|
||||
if (!res.ok || root.errcode) {
|
||||
if (root.error?.includes("Content-Length")) {
|
||||
console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:`
|
||||
+ `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's`
|
||||
+ `\n \`server_origin\` to nginx`
|
||||
+ `\n * Set \`content_length_workaround: true\` in registration.yaml (this will`
|
||||
+ `\n halve the speed of bridging d->m files)`)
|
||||
throw new Error("Synapse is not accepting stream uploads, see the message above.")
|
||||
}
|
||||
delete opts.headers.Authorization
|
||||
throw new MatrixServerError(root, {baseUrl, url, ...opts})
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript doesn't have Racket-like parameters with dynamic scoping, so
|
||||
* do NOT do anything else at the same time as this.
|
||||
* @template T
|
||||
* @param {string} token
|
||||
* @param {(...arg: any[]) => Promise<T>} callback
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
async function withAccessToken(token, callback) {
|
||||
const prevToken = reg.as_token
|
||||
reg.as_token = token
|
||||
try {
|
||||
return await callback()
|
||||
} finally {
|
||||
reg.as_token = prevToken
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.MatrixServerError = MatrixServerError
|
||||
module.exports.baseUrl = baseUrl
|
||||
module.exports.mreq = mreq
|
||||
module.exports.withAccessToken = withAccessToken
|
36
src/matrix/power.js
Normal file
36
src/matrix/power.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
// @ts-check
|
||||
|
||||
const {db, from} = require("../passthrough")
|
||||
const {reg} = require("./read-registration")
|
||||
const ks = require("./kstate")
|
||||
const {applyKStateDiffToRoom, roomToKState} = require("../d2m/actions/create-room")
|
||||
|
||||
/** Apply global power level requests across ALL rooms where the member cache entry exists but the power level has not been applied yet. */
|
||||
function _getAffectedRooms() {
|
||||
return from("member_cache")
|
||||
.join("member_power", "mxid")
|
||||
.join("channel_room", "room_id") // only include rooms that are bridged
|
||||
.and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level")
|
||||
.selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level")
|
||||
.all()
|
||||
}
|
||||
|
||||
async function applyPower() {
|
||||
// Migrate reg.ooye.invite setting to database
|
||||
for (const mxid of reg.ooye.invite) {
|
||||
db.prepare("INSERT OR IGNORE INTO member_power (mxid, room_id, power_level) VALUES (?, ?, 100)").run(mxid, "*")
|
||||
}
|
||||
|
||||
const rows = _getAffectedRooms()
|
||||
for (const row of rows) {
|
||||
const kstate = await roomToKState(row.room_id)
|
||||
const diff = ks.diffKState(kstate, {"m.room.power_levels/": {users: {[row.mxid]: row.power_level}}})
|
||||
await applyKStateDiffToRoom(row.room_id, diff)
|
||||
// There is a listener on m.room.power_levels to do this same update,
|
||||
// but we update it here anyway since the homeserver does not always deliver the event round-trip.
|
||||
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(row.power_level, row.room_id, row.mxid)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports._getAffectedRooms = _getAffectedRooms
|
||||
module.exports.applyPower = applyPower
|
12
src/matrix/power.test.js
Normal file
12
src/matrix/power.test.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const power = require("./power")
|
||||
|
||||
test("power: get affected rooms", t => {
|
||||
t.deepEqual(power._getAffectedRooms(), [{
|
||||
mxid: "@test_auto_invite:example.org",
|
||||
power_level: 100,
|
||||
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
|
||||
}])
|
||||
})
|
85
src/matrix/read-registration.js
Normal file
85
src/matrix/read-registration.js
Normal file
|
@ -0,0 +1,85 @@
|
|||
// @ts-check
|
||||
|
||||
const fs = require("fs")
|
||||
const crypto = require("crypto")
|
||||
const assert = require("assert").strict
|
||||
const path = require("path")
|
||||
const yaml = require("js-yaml")
|
||||
|
||||
const registrationFilePath = path.join(process.cwd(), "registration.yaml")
|
||||
|
||||
/** @param {import("../types").AppServiceRegistrationConfig} reg */
|
||||
function checkRegistration(reg) {
|
||||
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)
|
||||
assert(reg.sender_localpart?.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
|
||||
assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
||||
assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash")
|
||||
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
||||
}
|
||||
|
||||
/** @param {import("../types").AppServiceRegistrationConfig} reg */
|
||||
function writeRegistration(reg) {
|
||||
fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2))
|
||||
}
|
||||
|
||||
/** @returns {import("../types").InitialAppServiceRegistrationConfig} reg */
|
||||
function getTemplateRegistration() {
|
||||
return {
|
||||
id: crypto.randomBytes(16).toString("hex"),
|
||||
as_token: crypto.randomBytes(16).toString("hex"),
|
||||
hs_token: crypto.randomBytes(16).toString("hex"),
|
||||
namespaces: {
|
||||
users: [{
|
||||
exclusive: true,
|
||||
regex: "@_ooye_.*:cadence.moe"
|
||||
}],
|
||||
aliases: [{
|
||||
exclusive: true,
|
||||
regex: "#_ooye_.*:cadence.moe"
|
||||
}]
|
||||
},
|
||||
protocols: [
|
||||
"discord"
|
||||
],
|
||||
sender_localpart: "_ooye_bot",
|
||||
rate_limited: false,
|
||||
ooye: {
|
||||
namespace_prefix: "_ooye_",
|
||||
max_file_size: 5000000,
|
||||
content_length_workaround: false,
|
||||
include_user_id_in_mxid: false,
|
||||
invite: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readRegistration() {
|
||||
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
|
||||
let result = null
|
||||
if (fs.existsSync(registrationFilePath)) {
|
||||
const content = fs.readFileSync(registrationFilePath, "utf8")
|
||||
if (content.startsWith("{")) { // Use JSON parser
|
||||
result = JSON.parse(content)
|
||||
checkRegistration(result)
|
||||
} else { // Use YAML parser
|
||||
result = yaml.load(content)
|
||||
checkRegistration(result)
|
||||
// Convert to JSON
|
||||
writeRegistration(result)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
|
||||
let reg = readRegistration()
|
||||
|
||||
module.exports.registrationFilePath = registrationFilePath
|
||||
module.exports.readRegistration = readRegistration
|
||||
module.exports.getTemplateRegistration = getTemplateRegistration
|
||||
module.exports.writeRegistration = writeRegistration
|
||||
module.exports.checkRegistration = checkRegistration
|
||||
module.exports.reg = reg
|
10
src/matrix/read-registration.test.js
Normal file
10
src/matrix/read-registration.test.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
const {test} = require("supertape")
|
||||
const {reg} = require("./read-registration")
|
||||
|
||||
test("reg: has necessary parameters", t => {
|
||||
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
|
||||
t.deepEqual(
|
||||
propertiesToCheck.filter(p => p in reg),
|
||||
propertiesToCheck
|
||||
)
|
||||
})
|
7
src/matrix/txnid.js
Normal file
7
src/matrix/txnid.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
// @ts-check
|
||||
|
||||
let now = Date.now()
|
||||
|
||||
module.exports.makeTxnId = function makeTxnId() {
|
||||
return now++
|
||||
}
|
12
src/matrix/txnid.test.js
Normal file
12
src/matrix/txnid.test.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const txnid = require("./txnid")
|
||||
|
||||
test("txnid: generates different values each run", t => {
|
||||
const one = txnid.makeTxnId()
|
||||
t.ok(one)
|
||||
const two = txnid.makeTxnId()
|
||||
t.ok(two)
|
||||
t.notEqual(two, one)
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue