// @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} */ function register(username) { console.log(`[api] register: ${username}`) return mreq.mreq("POST", "/client/v3/register", { type: "m.login.application_service", username }) } /** * @returns {Promise} 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} 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} */ 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} */ 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>} */ 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} */ 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>>} */ 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[]} */ let reactions = [] /** @type {string | undefined} */ let nextBatch = undefined do { /** @type {Ty.Pagination>} */ 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} 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} 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