diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index 5970b59..57b8f41 100644 --- a/src/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -21,7 +21,7 @@ const mreq = sync.require("../../matrix/mreq") async function editMessage(message, guild, row) { const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get() const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get() - assert(currentRoom) + if (!currentRoom) return if (historicalRoomOfMessage && historicalRoomOfMessage.room_id !== currentRoom.room_id) return // tombstoned rooms should not have new events (including edits) sent to them diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 7c3109b..1a6ef63 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -4,6 +4,14 @@ const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") const {discord, select, db} = passthrough +const DEBUG_SPEEDBUMP = false + +function debugSpeedbump(message) { + if (DEBUG_SPEEDBUMP) { + console.log(message) + } +} + const SPEEDBUMP_SPEED = 4000 // 4 seconds delay const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours @@ -27,8 +35,8 @@ async function updateCache(channelID, lastChecked) { db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID) } -/** @type {Set} set of messageID */ -const bumping = new Set() +/** @type {Map} messageID -> number of gateway events currently bumping */ +const bumping = new Map() /** * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. @@ -36,9 +44,26 @@ const bumping = new Set() * @returns whether it was deleted */ async function doSpeedbump(messageID) { - bumping.add(messageID) + let value = (bumping.get(messageID) ?? 0) + 1 + bumping.set(messageID, value) + debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`) + await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) - return !bumping.delete(messageID) + + if (!bumping.has(messageID)) { + debugSpeedbump(`[speedbump] DELETED ${messageID}`) + return true + } + value = bumping.get(messageID) - 1 + if (value === 0) { + debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`) + bumping.delete(messageID) + return false + } else { + debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`) + bumping.set(messageID, value) + return true + } } /** diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index 869bb3c..48b7dd3 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -227,8 +227,8 @@ async function editToChanges(message, guild, api) { */ function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) { const content = { - ...newFallbackContent, "m.mentions": {}, + ...newFallbackContent, "m.new_content": { ...newInnerContent }, diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index ffce2f0..8a8e50f 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -107,9 +107,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {DiscordTypes.APIAttachment} attachment + * @param {Omit} attachment + * @param {boolean} [alwaysLink] */ -async function attachmentToEvent(mentions, attachment) { +async function attachmentToEvent(mentions, attachment, alwaysLink) { const external_url = dUtils.getPublicUrlForCdn(attachment.url) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" @@ -130,7 +131,7 @@ async function attachmentToEvent(mentions, attachment) { } } // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (attachment.size > reg.ooye.max_file_size) { + else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { return { $type: "m.room.message", "m.mentions": mentions, @@ -228,6 +229,7 @@ async function pollToEvent(poll) { return matrixAnswer; }) return { + /** @type {"org.matrix.msc3381.poll.start"} */ $type: "org.matrix.msc3381.poll.start", "org.matrix.msc3381.poll.start": { question: { @@ -538,7 +540,7 @@ async function messageToEvent(message, guild, options = {}, di) { // 1. The replied-to event is in a different room to where the reply will be sent (i.e. a room upgrade occurred between) // 2. The replied-to message has no corresponding Matrix event (repliedToUnknownEvent is true) // This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run - if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) { + if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false && events.length === 0) { const latestRoomID = repliedToEventRow ? select("channel_room", "room_id", {channel_id: repliedToEventRow.channel_id}).pluck().get() : null if (latestRoomID !== repliedToEventRow?.room_id) repliedToEventInDifferentRoom = true @@ -741,7 +743,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) + const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment))) // 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. @@ -756,6 +758,101 @@ async function messageToEvent(message, guild, options = {}, di) { } } + // Then components + if (message.components?.length) { + const stack = [new mxUtils.MatrixStringBuilder()] + /** @param {DiscordTypes.APIMessageComponent} component */ + async function processComponent(component) { + // Standalone components + if (component.type === DiscordTypes.ComponentType.TextDisplay) { + const {body, html} = await transformContent(component.content) + stack[0].addParagraph(body, html) + } + else if (component.type === DiscordTypes.ComponentType.Separator) { + stack[0].addParagraph("----", "
") + } + else if (component.type === DiscordTypes.ComponentType.File) { + const ev = await attachmentToEvent({}, {...component.file, filename: component.name, size: component.size}, true) + stack[0].addLine(ev.body, ev.formatted_body) + } + else if (component.type === DiscordTypes.ComponentType.MediaGallery) { + const description = component.items.length === 1 ? component.items[0].description || "Image:" : "Image gallery:" + const images = component.items.map(item => { + const publicURL = dUtils.getPublicUrlForCdn(item.media.url) + return { + url: publicURL, + estimatedName: item.media.url.match(/\/([^/?]+)(\?|$)/)?.[1] || publicURL + } + }) + stack[0].addLine(`🖼️ ${description} ${images.map(i => i.url).join(", ")}`, tag`🖼️ ${description} $${images.map(i => tag`${i.estimatedName}`).join(", ")}`) + } + // string select, text input, user select, role select, mentionable select, channel select + + // Components that can have things nested + else if (component.type === DiscordTypes.ComponentType.Container) { + // May contain action row, text display, section, media gallery, separator, file + stack.unshift(new mxUtils.MatrixStringBuilder()) + for (const innerComponent of component.components) { + await processComponent(innerComponent) + } + let {body, formatted_body} = stack.shift().get() + body = body.split("\n").map(l => "| " + l).join("\n") + formatted_body = `
${formatted_body}
` + if (stack[0].body) stack[0].body += "\n\n" + stack[0].add(body, formatted_body) + } + else if (component.type === DiscordTypes.ComponentType.Section) { + // May contain text display, possibly more in the future + // Accessory may be button or thumbnail + stack.unshift(new mxUtils.MatrixStringBuilder()) + for (const innerComponent of component.components) { + await processComponent(innerComponent) + } + if (component.accessory) { + stack.unshift(new mxUtils.MatrixStringBuilder()) + await processComponent(component.accessory) + const {body, formatted_body} = stack.shift().get() + stack[0].addLine(body, formatted_body) + } + const {body, formatted_body} = stack.shift().get() + stack[0].addParagraph(body, formatted_body) + } + else if (component.type === DiscordTypes.ComponentType.ActionRow) { + const linkButtons = component.components.filter(c => c.type === DiscordTypes.ComponentType.Button && c.style === DiscordTypes.ButtonStyle.Link) + if (linkButtons.length) { + stack[0].addLine("") + for (const linkButton of linkButtons) { + await processComponent(linkButton) + } + } + } + // Components that can only be inside things + else if (component.type === DiscordTypes.ComponentType.Thumbnail) { + // May only be a section accessory + stack[0].add(`🖼️ ${component.media.url}`, tag`🖼️ ${component.media.url}`) + } + else if (component.type === DiscordTypes.ComponentType.Button) { + // May only be a section accessory or in an action row (up to 5) + if (component.style === DiscordTypes.ButtonStyle.Link) { + if (component.label) { + stack[0].add(`[${component.label} ${component.url}] `, tag`${component.label} `) + } else { + stack[0].add(component.url) + } + } + } + + // Not handling file upload or label because they are modal-only components + } + + for (const component of message.components) { + await processComponent(component) + } + + const {body, formatted_body} = stack[0].get() + await addTextEvent(body, formatted_body, "m.text") + } + // Then polls if (message.poll) { const pollEvent = await pollToEvent(message.poll) @@ -773,7 +870,7 @@ async function messageToEvent(message, guild, options = {}, di) { continue // Matrix's own URL previews are fine for images. } - if (embed.type === "video" && !embed.title && !embed.description && message.content.includes(embed.video?.url)) { + if (embed.type === "video" && !embed.title && message.content.includes(embed.video?.url)) { continue // Doesn't add extra information and the direct video URL is already there. } @@ -904,7 +1001,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Strip formatted_body where equivalent to body if (!options.alwaysReturnFormattedBody) { for (const event of events) { - if (["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) { + if (event.$type === "m.room.message" && "msgtype" in event && ["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) { delete event.format delete event.formatted_body } diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js new file mode 100644 index 0000000..7d875a6 --- /dev/null +++ b/src/d2m/converters/message-to-event.test.components.js @@ -0,0 +1,79 @@ +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const data = require("../../../test/data") + +test("message2event components: pk question mark output", async t => { + const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + body: + "| ### Lillith (INX)" + + "\n| " + + "\n| **Display name:** Lillith (she/her)" + + "\n| **Pronouns:** She/Her" + + "\n| **Message count:** 3091" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| **Proxy tags:**" + + "\n| ``l;text``" + + "\n| ``l:text``" + + "\n| ``l.text``" + + "\n| ``textl.``" + + "\n| ``textl;``" + + "\n| ``textl:``" + + "\n" + + "\n-# System ID: `xffgnx` ∙ Member ID: `pphhoh`" + + "\n-# Created: 2025-12-31 03:16:45 UTC" + + "\n[View on dashboard https://dash.pluralkit.me/profile/m/pphhoh] " + + "\n" + + "\n----" + + "\n" + + "\n| **System:** INX (`xffgnx`)" + + "\n| **Member:** Lillith (`pphhoh`)" + + "\n| **Sent by:** infinidoge1337 (@unknown-user:)" + + "\n| " + + "\n| **Account Roles (7)**" + + "\n| §b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| Same hat" + + "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png" + + "\n" + + "\n-# Original Message ID: 1466556003645657118 · ", + format: "org.matrix.custom.html", + formatted_body: "
" + + "

Lillith (INX)

" + + "

Display name: Lillith (she/her)" + + "
Pronouns: She/Her" + + "
Message count: 3091

" + + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` + + "
" + + "

Proxy tags:" + + "
l;text" + + "
l:text" + + "
l.text" + + "
textl." + + "
textl;" + + "
textl:

" + + "

System ID: xffgnx ∙ Member ID: pphhoh
" + + "Created: 2025-12-31 03:16:45 UTC

" + + `View on dashboard ` + + "
" + + "

System: INX (xffgnx)" + + "
Member: Lillith (pphhoh)" + + "
Sent by: infinidoge1337 (@unknown-user:)" + + "

Account Roles (7)" + + "
§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping

" + + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` + + "
" + + "

Same hat

" + + `🖼️ Image: image.png
` + + "

Original Message ID: 1466556003645657118 · <t:1769724599:f>

", + "m.mentions": {}, + msgtype: "m.text", + }]) +}) diff --git a/src/d2m/converters/message-to-event.embeds.test.js b/src/d2m/converters/message-to-event.test.embeds.js similarity index 100% rename from src/d2m/converters/message-to-event.embeds.test.js rename to src/d2m/converters/message-to-event.test.embeds.js diff --git a/src/d2m/converters/message-to-event.pk.test.js b/src/d2m/converters/message-to-event.test.pk.js similarity index 100% rename from src/d2m/converters/message-to-event.pk.test.js rename to src/d2m/converters/message-to-event.test.pk.js diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 7c2e118..c25d1c6 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -274,7 +274,7 @@ module.exports = { // Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes. // If the message content is a string then it includes all interesting fields and is meaningful. // Otherwise, if there are embeds, then the system generated URL preview embeds. - if (!(typeof data.content === "string" || "embeds" in data)) return + if (!(typeof data.content === "string" || "embeds" in data || "components" in data)) return if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! @@ -282,8 +282,10 @@ module.exports = { const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) if (affected) return - // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return + if (!row) { + // Check that the sending-to room exists, and deal with Eventual Consistency(TM) + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return + } /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ // @ts-ignore diff --git a/src/discord/interactions/ping.js b/src/discord/interactions/ping.js new file mode 100644 index 0000000..6487175 --- /dev/null +++ b/src/discord/interactions/ping.js @@ -0,0 +1,201 @@ +// @ts-check + +const assert = require("assert").strict +const Ty = require("../../types") +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, select, from} = require("../../passthrough") +const {id: botID} = require("../../../addbot") +const {InteractionMethods} = require("snowtransfer") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") +/** @type {import("../../web/routes/guild")} */ +const webGuild = sync.require("../../web/routes/guild") + +/** + * @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interactAutocomplete({data, channel}, {api}) { + function exit() { + return {createInteractionResponse: { + /** @type {DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult} */ + type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices: [] + } + }} + } + + // Check it was used in a bridged channel + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + if (!roomID) return yield exit() + + // Check we are in fact autocompleting the first option, the user + if (!data.options?.[0] || data.options[0].type !== DiscordTypes.ApplicationCommandOptionType.String || !data.options[0].focused) { + return yield exit() + } + + /** @type {{displayname: string | null, mxid: string}[][]} */ + const providedMatches = [] + + const input = data.options[0].value + if (input === "") { + const events = await api.getEvents(roomID, "b", {limit: 40}) + const recents = new Set(events.chunk.map(e => e.sender)) + const matches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL LIMIT 25").all() + matches.sort((a, b) => +recents.has(b.mxid) - +recents.has(a.mxid)) + providedMatches.push(matches) + } else if (input.startsWith("@")) { // only autocomplete mxids + const query = input.replaceAll(/[%_$]/g, char => `$${char}`) + "%" + const matches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND mxid LIKE ? ESCAPE '$' LIMIT 25").all(query) + providedMatches.push(matches) + } else { + const query = "%" + input.replaceAll(/[%_$]/g, char => `$${char}`) + "%" + const displaynameMatches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL AND displayname LIKE ? ESCAPE '$' LIMIT 25").all(query) + // prioritise matches closer to the start + displaynameMatches.sort((a, b) => { + let ai = a.displayname.toLowerCase().indexOf(input.toLowerCase()) + if (ai === -1) ai = 999 + let bi = b.displayname.toLowerCase().indexOf(input.toLowerCase()) + if (bi === -1) bi = 999 + return ai - bi + }) + providedMatches.push(displaynameMatches) + let mxidMatches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL AND mxid LIKE ? ESCAPE '$' LIMIT 25").all(query) + mxidMatches = mxidMatches.filter(match => { + // don't include matches in domain part of mxid + if (!match.mxid.match(/^[^:]*/)?.includes(query)) return false + if (displaynameMatches.some(m => m.mxid === match.mxid)) return false + return true + }) + providedMatches.push(mxidMatches) + } + + // merge together + let matches = providedMatches.flat() + + // don't include bot + matches = matches.filter(m => m.mxid !== utils.bot) + + // remove duplicates and count up to 25 + const limitedMatches = [] + const seen = new Set() + for (const match of matches) { + if (limitedMatches.length >= 25) break + if (seen.has(match.mxid)) continue + limitedMatches.push(match) + seen.add(match.mxid) + } + + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices: limitedMatches.map(row => ({name: (row.displayname || row.mxid).slice(0, 100), value: row.mxid.slice(0, 100)})) + } + }} +} + +/** + * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interactCommand({data, channel, guild_id}, {api}) { + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + if (!roomID) { + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + flags: DiscordTypes.MessageFlags.Ephemeral, + content: "This channel isn't bridged to Matrix." + } + }} + } + + assert(data.options?.[0]?.type === DiscordTypes.ApplicationCommandOptionType.String) + const mxid = data.options[0].value + if (!mxid.match(/^@[^:]*:./)) { + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + flags: DiscordTypes.MessageFlags.Ephemeral, + // embeds: [{ + // description: "⚠️ To use /ping, you must select an option from autocomplete, or type a full Matrix ID.", + // footer: { + // text: "Tip: This command is not necessary. You can also ping Matrix users just by typing @their name in your message. It won't look like anything, but it does go through." + // } + // }] + content: "⚠️ To use `/ping`, you must select an option from autocomplete, or type a full Matrix ID.\n> Tip: This command is not necessary. You can also ping Matrix users just by typing @their name in your message. It won't look like anything, but it does go through." + } + }} + } + + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource + }} + + try { + /** @type {Ty.Event.M_Room_Member} */ + var member = await api.getStateEvent(roomID, "m.room.member", mxid) + } catch (e) {} + + if (!member || member.membership !== "join") { + const inChannels = discord.guildChannelMap.get(guild_id) + .map(cid => discord.channels.get(cid)) + .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) + .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid}).get()) + if (inChannels.length) { + return yield {editOriginalInteractionResponse: { + content: `That person isn't in this channel. They have only joined the following channels:\n${inChannels.map(c => `<#${c.id}>`).join(" • ")}\nYou can ask them to join this channel with \`/invite\`.`, + }} + } else { + return yield {editOriginalInteractionResponse: { + content: "That person isn't in this channel. You can invite them with `/invite`." + }} + } + } + + yield {editOriginalInteractionResponse: { + content: "@" + (member.displayname || mxid) + }} + + yield {createFollowupMessage: { + flags: DiscordTypes.MessageFlags.Ephemeral | DiscordTypes.MessageFlags.IsComponentsV2, + components: [{ + type: DiscordTypes.ComponentType.Container, + components: [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: "Tip: This command is not necessary. You can also ping Matrix users just by typing @their name in your message. It won't look like anything, but it does go through." + }] + }] + }} +} + +/* c8 ignore start */ + +/** @param {(DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}) | DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */ +async function interact(interaction) { + if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) { + for await (const response of _interactAutocomplete(interaction, {api})) { + if (response.createInteractionResponse) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } + } + } else { + for await (const response of _interactCommand(interaction, {api})) { + if (response.createInteractionResponse) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } else if (response.editOriginalInteractionResponse) { + await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + } else if (response.createFollowupMessage) { + await discord.snow.interaction.createFollowupMessage(botID, interaction.token, response.createFollowupMessage) + } + } + } +} + +module.exports.interact = interact diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index 63b04b0..b37f28e 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -10,6 +10,7 @@ const permissions = sync.require("./interactions/permissions.js") const reactions = sync.require("./interactions/reactions.js") const privacy = sync.require("./interactions/privacy.js") const poll = sync.require("./interactions/poll.js") +const ping = sync.require("./interactions/ping.js") // User must have EVERY permission in default_member_permissions to be able to use the command @@ -38,6 +39,20 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ description: "The Matrix user to invite, e.g. @username:example.org", name: "user" } + ], +}, { + name: "ping", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Ping a Matrix user.", + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "Display name or ID of the Matrix user", + name: "user", + autocomplete: true, + required: true + } ] }, { name: "privacy", @@ -94,6 +109,8 @@ async function dispatchInteraction(interaction) { await permissions.interactEdit(interaction) } else if (interactionId === "Reactions") { await reactions.interact(interaction) + } else if (interactionId === "ping") { + await ping.interact(interaction) } else if (interactionId === "privacy") { await privacy.interact(interaction) } else { diff --git a/src/matrix/api.js b/src/matrix/api.js index 7e503c2..1cd05d3 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -128,6 +128,19 @@ async function getEventForTimestamp(roomID, ts) { return root } +/** + * @param {string} roomID + * @param {"b" | "f"} dir + * @param {{from?: string, limit?: any}} [pagination] + * @param {any} [filter] + */ +async function getEvents(roomID, dir, pagination = {}, filter) { + filter = filter && JSON.stringify(filter) + /** @type {Ty.Pagination>} */ + const root = await mreq.mreq("GET", path(`/client/v3/rooms/${roomID}/messages`, null, {...pagination, dir, filter})) + return root +} + /** * @param {string} roomID * @returns {Promise[]>} @@ -583,6 +596,7 @@ module.exports.leaveRoom = leaveRoom module.exports.leaveRoomWithReason = leaveRoomWithReason module.exports.getEvent = getEvent module.exports.getEventForTimestamp = getEventForTimestamp +module.exports.getEvents = getEvents module.exports.getAllState = getAllState module.exports.getStateEvent = getStateEvent module.exports.getStateEventOuter = getStateEventOuter diff --git a/src/matrix/utils.js b/src/matrix/utils.js index f299d95..b131510 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -106,7 +106,8 @@ class MatrixStringBuilder { if (formattedBody == undefined) formattedBody = body if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n" this.body += body - formattedBody = `

${formattedBody}

` + const match = formattedBody.match(/^<([a-zA-Z]+[a-zA-Z0-9]*)/) + if (!match || !BLOCK_ELEMENTS.includes(match[1].toUpperCase())) formattedBody = `

${formattedBody}

` this.formattedBody += formattedBody } return this diff --git a/test/data.js b/test/data.js index 786737c..09749e6 100644 --- a/test/data.js +++ b/test/data.js @@ -4975,6 +4975,194 @@ module.exports = { tts: false } }, + message_with_components: { + pk_question_mark_response: { + type: 0, + content: '', + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: '2026-01-30T01:20:07.488000+00:00', + edited_timestamp: null, + flags: 32768, + author: { + id: '772659086046658620', + username: 'cadence.worm', + avatar: '466df0c98b1af1e1388f595b4c1ad1b9', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: 'cadence', + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: '532245108070809601', + identity_enabled: true, + tag: 'doll', + badge: 'dba08126b4e810a0e096cc7cd5bc37f0' + }, + primary_guild: { + identity_guild_id: '532245108070809601', + identity_enabled: true, + tag: 'doll', + badge: 'dba08126b4e810a0e096cc7cd5bc37f0' + } + }, + components: [ + { + type: 17, + id: 1, + accent_color: 1042150, + components: [ + { + type: 9, + id: 2, + components: [ + { type: 10, id: 3, content: '### Lillith (INX)' }, + { + type: 10, + id: 4, + content: '**Display name:** Lillith (she/her)\n' + + '**Pronouns:** She/Her\n' + + '**Message count:** 3091' + } + ], + accessory: { + type: 11, + id: 5, + media: { + id: '1466603856149610687', + url: 'https://files.inx.moe/p/cdn/lillith.webp', + proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp', + width: 256, + height: 256, + placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA', + placeholder_version: 1, + content_scan_metadata: { version: 4, flags: 0 }, + content_type: 'image/webp', + loading_state: 2, + flags: 0 + }, + description: null, + spoiler: false + } + }, + { type: 14, id: 6, spacing: 1, divider: true }, + { + type: 10, + id: 7, + content: '**Proxy tags:**\n' + + '``l;text``\n' + + '``l:text``\n' + + '``l.text``\n' + + '``textl.``\n' + + '``textl;``\n' + + '``textl:``' + } + ], + spoiler: false + }, + { + type: 9, + id: 8, + components: [ + { + type: 10, + id: 9, + content: '-# System ID: `xffgnx` ∙ Member ID: `pphhoh`\n' + + '-# Created: 2025-12-31 03:16:45 UTC' + } + ], + accessory: { + type: 2, + id: 10, + style: 5, + label: 'View on dashboard', + url: 'https://dash.pluralkit.me/profile/m/pphhoh' + } + }, + { type: 14, id: 11, spacing: 1, divider: true }, + { + type: 17, + id: 12, + accent_color: null, + components: [ + { + type: 9, + id: 13, + components: [ + { + type: 10, + id: 14, + content: '**System:** INX (`xffgnx`)\n' + + '**Member:** Lillith (`pphhoh`)\n' + + '**Sent by:** infinidoge1337 (<@197126718400626689>)\n' + + '\n' + + '**Account Roles (7)**\n' + + '§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping' + } + ], + accessory: { + type: 11, + id: 15, + media: { + id: '1466603856149610689', + url: 'https://files.inx.moe/p/cdn/lillith.webp', + proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp', + width: 256, + height: 256, + placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA', + placeholder_version: 1, + content_scan_metadata: { version: 4, flags: 0 }, + content_type: 'image/webp', + loading_state: 2, + flags: 0 + }, + description: null, + spoiler: false + } + }, + { type: 14, id: 16, spacing: 2, divider: true }, + { type: 10, id: 17, content: 'Same hat' }, + { + type: 12, + id: 18, + items: [ + { + media: { + id: '1466603856149610690', + url: 'https://cdn.discordapp.com/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&', + proxy_url: 'https://media.discordapp.net/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&', + width: 285, + height: 126, + placeholder: '0PcBA4BqSIl9t/dnn9f0rm0=', + placeholder_version: 1, + content_scan_metadata: { version: 4, flags: 0 }, + content_type: 'image/png', + loading_state: 2, + flags: 0 + }, + description: null, + spoiler: false + } + ] + } + ], + spoiler: false + }, + { + type: 10, + id: 19, + content: '-# Original Message ID: 1466556003645657118 · ' + } + ] + } + }, message_update: { edit_by_webhook: { application_id: "684280192553844747", diff --git a/test/test.js b/test/test.js index 5ae9f67..81c079a 100644 --- a/test/test.js +++ b/test/test.js @@ -160,8 +160,9 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/d2m/converters/emoji-to-key.test") require("../src/d2m/converters/lottie.test") require("../src/d2m/converters/message-to-event.test") - require("../src/d2m/converters/message-to-event.embeds.test") - require("../src/d2m/converters/message-to-event.pk.test") + require("../src/d2m/converters/message-to-event.test.components") + require("../src/d2m/converters/message-to-event.test.embeds") + require("../src/d2m/converters/message-to-event.test.pk") require("../src/d2m/converters/pins-to-list.test") require("../src/d2m/converters/remove-reaction.test") require("../src/d2m/converters/thread-to-announcement.test")