Compare commits

...

4 commits

9 changed files with 445 additions and 184 deletions

View file

@ -434,7 +434,7 @@ async function unbridgeChannel(channelID) {
async function unbridgeDeletedChannel(channel, guildID) { async function unbridgeDeletedChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID) assert.ok(roomID)
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
assert.ok(row) assert.ok(row)
let botInRoom = true let botInRoom = true
@ -458,7 +458,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
// delete webhook on discord // delete webhook on discord
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
if (webhook) { if (webhook) {
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token) await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token).catch(() => {})
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id)
} }
@ -568,6 +568,7 @@ module.exports.createAllForGuild = createAllForGuild
module.exports.channelToKState = channelToKState module.exports.channelToKState = channelToKState
module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic module.exports._convertNameAndTopic = convertNameAndTopic
module.exports._syncSpaceMember = _syncSpaceMember
module.exports.unbridgeChannel = unbridgeChannel module.exports.unbridgeChannel = unbridgeChannel
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable module.exports.existsOrAutocreatable = existsOrAutocreatable

View file

@ -151,16 +151,9 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
const mxid = await ensureSimJoined(pkMessage, roomID) const mxid = await ensureSimJoined(pkMessage, roomID)
if (shouldActuallySync) { if (shouldActuallySync) {
// Build current profile data // Build current profile data and sync if the hash has changed
const content = await memberToStateContent(pkMessage, author) const content = await memberToStateContent(pkMessage, author)
const currentHash = registerUser._hashProfileContent(content, 0) await registerUser._sendSyncUser(roomID, mxid, content, null)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// Only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) {
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
}
} }
return mxid return mxid

View file

@ -23,6 +23,8 @@ let hasher = null
// @ts-ignore // @ts-ignore
require("xxhash-wasm")().then(h => hasher = h) require("xxhash-wasm")().then(h => hasher = h)
const supportsMsc4069 = api.versions().then(v => !!v?.unstable_features?.["org.matrix.msc4069"]).catch(() => false)
/** /**
* A sim is an account that is being simulated by the bridge to copy events from the other side. * A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser} user
@ -98,6 +100,23 @@ async function ensureSimJoined(user, roomID) {
return mxid return mxid
} }
/**
* @param {DiscordTypes.APIUser} user
*/
async function userToGlobalProfile(user) {
const globalProfile = {}
globalProfile.displayname = user.username
if (user.global_name) globalProfile.displayname = user.global_name
if (user.avatar) {
const avatarPath = file.userAvatar(user) // the user avatar only
globalProfile.avatar_url = await file.uploadDiscordFileToMxc(avatarPath)
}
return globalProfile
}
/** /**
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member * @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
@ -201,21 +220,45 @@ async function syncUser(user, member, channel, guild, roomID) {
const mxid = await ensureSimJoined(user, roomID) const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guild.id) const content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel) const powerLevel = memberToPowerLevel(user, member, guild, channel)
const currentHash = _hashProfileContent(content, powerLevel) await _sendSyncUser(roomID, mxid, content, powerLevel, {
// do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
allowOverwrite: !!member,
globalProfile: await userToGlobalProfile(user)
})
return mxid
}
/**
* @param {string} roomID
* @param {string} mxid
* @param {{displayname: string, avatar_url?: string}} content
* @param {number | null} powerLevel
* @param {{allowOverwrite?: boolean, globalProfile?: {displayname: string, avatar_url?: string}}} [options]
*/
async function _sendSyncUser(roomID, mxid, content, powerLevel, options) {
const currentHash = _hashProfileContent(content, powerLevel ?? 0)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked // only do the actual sync if the hash has changed since we last looked
const hashHasChanged = existingHash !== currentHash const hashHasChanged = existingHash !== currentHash
// however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data // always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present
const wouldOverwritePreExisting = existingHash && !member const overwriteOkay = !existingHash || (options?.allowOverwrite ?? true)
if (hashHasChanged && !wouldOverwritePreExisting) { if (hashHasChanged && overwriteOkay) {
const actions = []
// Update room member state // Update room member state
await api.sendState(roomID, "m.room.member", mxid, content, mxid) actions.push(api.sendState(roomID, "m.room.member", mxid, content, mxid))
// Update power levels // Update power levels
await api.setUserPower(roomID, mxid, powerLevel) if (powerLevel != null) {
actions.push(api.setUserPower(roomID, mxid, powerLevel))
}
// Update global profile (if supported by server)
if (await supportsMsc4069) {
actions.push(api.profileSetDisplayname(mxid, options?.globalProfile?.displayname || content.displayname, true))
actions.push(api.profileSetAvatarUrl(mxid, options?.globalProfile?.avatar_url || content.avatar_url, true))
}
await Promise.all(actions)
// Update cached hash // Update cached hash
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
} }
return mxid
} }
/** /**
@ -254,5 +297,7 @@ module.exports._hashProfileContent = _hashProfileContent
module.exports.ensureSim = ensureSim module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser module.exports.syncUser = syncUser
module.exports._sendSyncUser = _sendSyncUser
module.exports.syncAllUsersInRoom = syncAllUsersInRoom module.exports.syncAllUsersInRoom = syncAllUsersInRoom
module.exports._memberToPowerLevel = memberToPowerLevel module.exports._memberToPowerLevel = memberToPowerLevel
module.exports.supportsMsc4069 = supportsMsc4069

View file

@ -128,16 +128,9 @@ async function syncUser(author, roomID, shouldActuallySync) {
const mxid = await ensureSimJoined(fakeUserID, author, roomID) const mxid = await ensureSimJoined(fakeUserID, author, roomID)
if (shouldActuallySync) { if (shouldActuallySync) {
// Build current profile data // Build current profile data and sync if the hash has changed
const content = await authorToStateContent(author) const content = await authorToStateContent(author)
const currentHash = registerUser._hashProfileContent(content, 0) await registerUser._sendSyncUser(roomID, mxid, content, null)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// Only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) {
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
}
} }
return mxid return mxid

View file

@ -605,7 +605,7 @@ async function eventToMessage(event, guild, di) {
} }
attachments.push({id: "0", filename}) attachments.push({id: "0", filename})
pendingFiles.push({name: filename, mxc: event.content.url}) pendingFiles.push({name: filename, mxc: event.content.url})
} else if (shouldProcessTextEvent) { } else {
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events. // Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
// this event ---is an edit of--> original event ---is a reply to--> past event // this event ---is an edit of--> original event ---is a reply to--> past event
await (async () => { await (async () => {
@ -738,6 +738,7 @@ async function eventToMessage(event, guild, di) {
replyLine = `-# > ${replyLine}${contentPreview}\n` replyLine = `-# > ${replyLine}${contentPreview}\n`
})() })()
if (shouldProcessTextEvent) {
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
let input = event.content.formatted_body let input = event.content.formatted_body
if (event.content.msgtype === "m.emote") { if (event.content.msgtype === "m.emote") {
@ -891,6 +892,7 @@ async function eventToMessage(event, guild, di) {
content = turndownService.escape(content) content = turndownService.escape(content)
} }
} }
}
content = displayNameRunoff + replyLine + content content = displayNameRunoff + replyLine + content

View file

@ -2671,6 +2671,99 @@ test("event2message: rich reply to a state event with no body", async t => {
) )
}) })
test("event2message: rich reply with an image", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
body: "image.png",
info: {
size: 470379,
mimetype: "image/png",
thumbnail_info: {
w: 800,
h: 450,
mimetype: "image/png",
size: 183014
},
w: 1920,
h: 1080,
"xyz.amorgan.blurhash": "L24_wtVt00xuxvR%NFX74Toz?waL",
thumbnail_url: "mxc://cadence.moe/lPtnjlleowWCXGOHKVDyoXGn"
},
msgtype: "m.image",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
},
url: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG"
},
origin_server_ts: 1764127662631,
unsigned: {
membership: "join",
age: 97,
transaction_id: "m1764127662540.2"
},
event_id: "$QOxkw7u8vjTrrdKxEUO13JWSixV7UXAZU1freT1SkHc",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, data.guild.general, {
api: {
getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "you have to check every diff above insane on this set https://osu.ppy.sh/beatmapsets/2263303#osu/4826296"
},
origin_server_ts: 1763639396419,
unsigned: {
membership: "join",
age: 486586696,
transaction_id: "m1763639396324.578"
},
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [
{
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 **Ⓜcadence [they]**: you have to check every diff above insane on this...",
allowed_mentions: {
parse: ["users", "roles"]
},
attachments: [
{
filename: "image.png",
id: "0",
},
],
avatar_url: undefined,
pendingFiles: [
{
mxc: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG",
name: "image.png",
},
],
username: "cadence [they]",
},
]
}
)
})
test("event2message: raw mentioning discord users in plaintext body works", async t => { test("event2message: raw mentioning discord users in plaintext body works", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({

View file

@ -7,6 +7,7 @@
const util = require("util") const util = require("util")
const Ty = require("../types") const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough") const {discord, db, sync, as, select} = require("../passthrough")
const {tag} = require("@cloudrac3r/html-template-tag")
/** @type {import("./actions/send-event")} */ /** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event") const sendEvent = sync.require("./actions/send-event")
@ -121,10 +122,10 @@ async function sendError(roomID, source, type, e, payload) {
// Where // Where
const stack = stringifyErrorStack(e) const stack = stringifyErrorStack(e)
builder.addLine(`Error trace:\n${stack}`, `<details><summary>Error trace</summary><pre>${stack}</pre></details>`) builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
// How // How
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`) builder.addLine("", tag`<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`)
} }
// Send // Send
@ -322,14 +323,25 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
*/ */
async event => { async event => {
if (event.state_key[0] !== "@") return if (event.state_key[0] !== "@") return
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) { if (event.content.membership === "invite" && event.state_key === bot) {
// We were invited to a room. We should join, and register the invite details for future reference in web. // We were invited to a room. We should join, and register the invite details for future reference in web.
let attemptedApiMessage = "According to unsigned invite data."
let inviteRoomState = event.unsigned?.invite_room_state
if (!Array.isArray(inviteRoomState) || inviteRoomState.length === 0) {
try {
inviteRoomState = await api.getInviteState(event.room_id)
attemptedApiMessage = "According to SSS API."
} catch (e) {
attemptedApiMessage = "According to unsigned invite data. SSS API unavailable: " + e.toString()
}
}
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name") const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic") const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url") const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type") const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
if (!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!") if (!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! (${attemptedApiMessage})`)
await api.joinRoom(event.room_id) await api.joinRoom(event.room_id)
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar) db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
@ -342,7 +354,6 @@ async event => {
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// Unregister room's use as a direct chat if the bot itself left // Unregister room's use as a direct chat if the bot itself left
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
if (event.state_key === bot) { if (event.state_key === bot) {
db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id)
} }

View file

@ -137,6 +137,24 @@ function getStateEvent(roomID, type, key) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`)
} }
/**
* @param {string} roomID
* @returns {Promise<Ty.Event.InviteStrippedState[]>}
*/
async function getInviteState(roomID) {
/** @type {Ty.R.SSS} */
const root = await mreq.mreq("POST", "/client/unstable/org.matrix.simplified_msc3575/sync", {
room_subscriptions: {
[roomID]: {
timeline_limit: 0,
required_state: []
}
}
})
const roomResponse = root.rooms[roomID]
return "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state
}
/** /**
* "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." * "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 * @param {string} roomID
@ -299,16 +317,34 @@ async function sendTyping(roomID, isTyping, mxid, duration) {
}) })
} }
async function profileSetDisplayname(mxid, displayname) { /**
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { * @param {string} mxid
* @param {string} displayname
* @param {boolean} [inhibitPropagate]
*/
async function profileSetDisplayname(mxid, displayname, inhibitPropagate) {
const params = {}
if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid, params), {
displayname displayname
}) })
} }
async function profileSetAvatarUrl(mxid, avatar_url) { /**
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { * @param {string} mxid
* @param {string} avatar_url
* @param {boolean} [inhibitPropagate]
*/
async function profileSetAvatarUrl(mxid, avatar_url, inhibitPropagate) {
const params = {}
if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
if (avatar_url) {
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params), {
avatar_url avatar_url
}) })
} else {
await mreq.mreq("DELETE", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params))
}
} }
/** /**
@ -472,6 +508,10 @@ function getProfile(mxid) {
return mreq.mreq("GET", `/client/v3/profile/${mxid}`) return mreq.mreq("GET", `/client/v3/profile/${mxid}`)
} }
function versions() {
return mreq.mreq("GET", "/client/versions")
}
module.exports.path = path module.exports.path = path
module.exports.register = register module.exports.register = register
module.exports.createRoom = createRoom module.exports.createRoom = createRoom
@ -483,6 +523,7 @@ module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent module.exports.getStateEvent = getStateEvent
module.exports.getInviteState = getInviteState
module.exports.getJoinedMembers = getJoinedMembers module.exports.getJoinedMembers = getJoinedMembers
module.exports.getMembers = getMembers module.exports.getMembers = getMembers
module.exports.getHierarchy = getHierarchy module.exports.getHierarchy = getHierarchy
@ -507,3 +548,4 @@ module.exports.getAccountData = getAccountData
module.exports.setAccountData = setAccountData module.exports.setAccountData = setAccountData
module.exports.setPresence = setPresence module.exports.setPresence = setPresence
module.exports.getProfile = getProfile module.exports.getProfile = getProfile
module.exports.versions = versions

81
src/types.d.ts vendored
View file

@ -166,6 +166,37 @@ export namespace Event {
content: any content: any
} }
export type InviteStrippedState = {
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
}
export type M_Room_Create = {
additional_creators: string[]
"m.federate"?: boolean
room_version: string
type?: string
predecessor?: {
room_id: string
event_id?: string
}
}
export type M_Room_JoinRules = {
join_rule: "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted"
allow?: {
type: string
room_id: string
}[]
}
export type M_Room_CanonicalAlias = {
alias?: string
alt_aliases?: string[]
}
export type M_Room_Message = { export type M_Room_Message = {
msgtype: "m.text" | "m.emote" msgtype: "m.text" | "m.emote"
body: string body: string
@ -375,7 +406,57 @@ export namespace R {
room_id: string room_id: string
servers: string[] servers: string[]
} }
export type SSS = {
pos: string
lists: {
[list_key: string]: {
count: number
} }
}
rooms: {
[room_id: string]: {
bump_stamp: number
/** Omitted if user not in room (peeking) */
membership?: Membership
/** Names of lists that match this room */
lists: string[]
}
// If user has been in the room - at least, that's what the spec says. Synapse returns some of these, such as `name` and `avatar`, for invites as well. Go nuts.
& {
name?: string
avatar?: string
heroes?: any[]
/** According to account data */
is_dm?: boolean
/** If false, omitted fields are unchanged from their previous value. If true, omitted fields means the fields are not set. */
initial?: boolean
expanded_timeline?: boolean
required_state?: Event.StateOuter<any>[]
timeline_events?: Event.Outer<any>[]
prev_batch?: string
limited?: boolean
num_live?: number
joined_count?: number
invited_count?: number
notification_count?: number
highlight_count?: number
}
// If user is invited or knocked
& ({
/** @deprecated */
invite_state: Event.InviteStrippedState[]
} | {
stripped_state: Event.InviteStrippedState[]
})
}
extensions: {
[extension_key: string]: any
}
}
}
export type Membership = "invite" | "knock" | "join" | "leave" | "ban"
export type Pagination<T> = { export type Pagination<T> = {
chunk: T[] chunk: T[]