// @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()) ?? -1 if (ai === -1) ai = 999 let bi = b.displayname?.toLowerCase().indexOf(input.toLowerCase()) ?? -1 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, 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 }} let member try { /** @type {Ty.Event.M_Room_Member} */ member = await api.getStateEvent(roomID, "m.room.member", mxid) } catch (e) {} if (!member || member.membership !== "join") { const channelsInGuild = discord.guildChannelMap.get(guild_id) assert(channelsInGuild) const inChannels = channelsInGuild // @ts-ignore .map(/** @returns {DiscordTypes.APIGuildChannel} */ 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