From c0fc04add3da1abb3b6920a59268f50fb8d6f840 Mon Sep 17 00:00:00 2001 From: Gerrit Birkeland Date: Sun, 20 Aug 2023 17:08:28 -0600 Subject: [PATCH] Remove embeds from most messages --- CHANGELOG.md | 5 ++ src/modules/handbook.ts | 57 ++++++++++++--------- src/modules/help.ts | 32 +++++------- src/modules/playground.ts | 81 +++++++++++++---------------- src/modules/rep.ts | 19 +++---- src/modules/snippet.ts | 87 +++++++++++++++++++------------- src/util/messageBuilder.ts | 86 +++++++++++++++++++++++++++++++ src/util/sendPaginatedMessage.ts | 30 +++++------ 8 files changed, 247 insertions(+), 150 deletions(-) create mode 100644 src/util/messageBuilder.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f7e39..58d5b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 2023-08-20 + +- Reworked modules to avoid sending messages in embeds. +- Show up to 5 search results from `!hb`. + # 2022-12-16 - Remove `!close`, update `!helper` to include thread tags. diff --git a/src/modules/handbook.ts b/src/modules/handbook.ts index ae8115f..38e8418 100644 --- a/src/modules/handbook.ts +++ b/src/modules/handbook.ts @@ -1,9 +1,8 @@ -import { EmbedBuilder } from 'discord.js'; import algoliasearch from 'algoliasearch/lite'; import { sendWithMessageOwnership } from '../util/send'; -import { TS_BLUE } from '../env'; import { decode } from 'html-entities'; import { Bot } from '../bot'; +import { MessageBuilder } from '../util/messageBuilder'; const ALGOLIA_APP_ID = 'BGCDYOIYZ5'; const ALGOLIA_API_KEY = '37ee06fa68db6aef451a490df6df7c60'; @@ -16,11 +15,11 @@ type AlgoliaResult = { url: string; }; -const HANDBOOK_EMBED = new EmbedBuilder() - .setColor(TS_BLUE) +const HANDBOOK_HELP = new MessageBuilder() .setTitle('The TypeScript Handbook') .setURL('https://www.typescriptlang.org/docs/handbook/intro.html') - .setFooter({ text: 'You can search with `!handbook `' }); + .setDescription('You can search with `!handbook `') + .build(); export async function handbookModule(bot: Bot) { bot.registerCommand({ @@ -28,40 +27,50 @@ export async function handbookModule(bot: Bot) { description: 'Search the TypeScript Handbook', async listener(msg, content) { if (!content) { - return await sendWithMessageOwnership(msg, { - embeds: [HANDBOOK_EMBED], - }); + return await sendWithMessageOwnership(msg, HANDBOOK_HELP); } - console.log('Searching algolia for', [content]); const data = await algolia.search([ { indexName: ALGOLIA_INDEX_NAME, query: content, params: { offset: 0, - length: 1, + length: 5, }, }, ]); - console.log('Algolia response:', data); - const hit = data.results[0].hits[0]; - if (!hit) + + if (!data.results[0].hits.length) { return await sendWithMessageOwnership( msg, ':x: No results found for that query', ); - const hierarchyParts = [0, 1, 2, 3, 4, 5, 6] - .map(i => hit.hierarchy[`lvl${i}`]) - .filter(x => x); - const embed = new EmbedBuilder() - .setColor(TS_BLUE) - .setTitle(decode(hierarchyParts[hierarchyParts.length - 1])) - .setAuthor({ - name: decode(hierarchyParts.slice(0, -1).join(' / ')), - }) - .setURL(hit.url); - await sendWithMessageOwnership(msg, { embeds: [embed] }); + } + + const response = new MessageBuilder(); + + const pages = {} as Record; + + for (const hit of data.results[0].hits) { + const hierarchyParts = [0, 1, 2, 3, 4, 5, 6] + .map(i => hit.hierarchy[`lvl${i}`]) + .filter(x => x); + + const page = hierarchyParts[0]!; + const path = decode(hierarchyParts.slice(1).join(' / ')); + pages[page] ??= []; + pages[page].push(`[${path}](<${hit.url}>)`); + } + + for (const [page, entries] of Object.entries(pages)) { + response.addFields({ + name: page, + value: `- ${entries.join('\n- ')}`, + }); + } + + await sendWithMessageOwnership(msg, response.build()); }, }); } diff --git a/src/modules/help.ts b/src/modules/help.ts index c5ff24a..8841baf 100644 --- a/src/modules/help.ts +++ b/src/modules/help.ts @@ -1,7 +1,7 @@ -import { EmbedBuilder } from 'discord.js'; import { Bot, CommandRegistration } from '../bot'; import { Snippet } from '../entities/Snippet'; import { sendWithMessageOwnership } from '../util/send'; +import { MessageBuilder } from '../util/messageBuilder'; function getCategoryHelp(cat: string, commands: Iterable) { const out: string[] = []; @@ -44,31 +44,25 @@ export function helpModule(bot: Bot) { if (!msg.guild) return; if (!cmdTrigger) { - const embed = new EmbedBuilder() - .setAuthor({ - name: msg.guild.name, - iconURL: msg.guild.iconURL() || undefined, - }) + const response = new MessageBuilder() .setTitle('Bot Usage') .setDescription( `Hello ${msg.author.username}! Here is a list of all commands in me! To get detailed description on any specific command, do \`help \``, ); for (const cat of getCommandCategories(bot.commands.values())) { - embed.addFields({ - name: `**${cat} Commands:**`, + response.addFields({ + name: `${cat} Commands:`, value: getCategoryHelp(cat, bot.commands.values()), }); } - embed - .setFooter({ - text: bot.client.user.username, - iconURL: bot.client.user.displayAvatarURL(), - }) - .setTimestamp(); + response.addFields({ + name: 'Playground Links:', + value: 'I will shorten any [TypeScript Playground]() links in a message or attachment and display a preview of the code. You can choose specific lines to embed by selecting them before copying the link.', + }); - return await sendWithMessageOwnership(msg, { embeds: [embed] }); + return await sendWithMessageOwnership(msg, response.build()); } let cmd: { description?: string; aliases?: string[] } = @@ -95,25 +89,25 @@ export function helpModule(bot: Bot) { `:x: Command not found`, ); - const embed = new EmbedBuilder().setTitle( + const builder = new MessageBuilder().setTitle( `\`${cmdTrigger}\` Usage`, ); // Get rid of duplicates, this can happen if someone adds the method name as an alias const triggers = new Set(cmd.aliases ?? [cmdTrigger]); if (triggers.size > 1) { - embed.addFields({ + builder.addFields({ name: 'Aliases', value: Array.from(triggers, t => `\`${t}\``).join(', '), }); } - embed.addFields({ + builder.addFields({ name: 'Description', value: `*${ splitCategoryDescription(cmd.description ?? '')[1] }*`, }); - await sendWithMessageOwnership(msg, { embeds: [embed] }); + await sendWithMessageOwnership(msg, builder.build()); }, }); } diff --git a/src/modules/playground.ts b/src/modules/playground.ts index 593f7ba..1ba13f8 100644 --- a/src/modules/playground.ts +++ b/src/modules/playground.ts @@ -1,11 +1,10 @@ -import { EmbedBuilder, Message, User } from 'discord.js'; +import { Message, User } from 'discord.js'; import { compressToEncodedURIComponent, decompressFromEncodedURIComponent, } from 'lz-string'; import { format } from 'prettier'; import { URLSearchParams } from 'url'; -import { TS_BLUE } from '../env'; import { makeCodeBlock, findCode, @@ -16,14 +15,18 @@ import { LimitedSizeMap } from '../util/limitedSizeMap'; import { addMessageOwnership, sendWithMessageOwnership } from '../util/send'; import { fetch } from 'undici'; import { Bot } from '../bot'; +import { MessageBuilder } from '../util/messageBuilder'; const PLAYGROUND_BASE = 'https://www.typescriptlang.org/play/#code/'; const LINK_SHORTENER_ENDPOINT = 'https://tsplay.dev/api/short'; -const MAX_EMBED_LENGTH = 512; -const DEFAULT_EMBED_LENGTH = 256; +const MAX_PREVIEW_LENGTH = 512; +const DEFAULT_PREVIEW_LENGTH = 256; export async function playgroundModule(bot: Bot) { - const editedLongLink = new LimitedSizeMap(1000); + const editedLongLink = new LimitedSizeMap< + string, + [Message, MessageBuilder] + >(1000); bot.registerCommand({ aliases: ['playground', 'pg', 'playg'], @@ -41,11 +44,10 @@ export async function playgroundModule(bot: Bot) { ":warning: couldn't find a codeblock!", ); } - const embed = new EmbedBuilder() - .setURL(PLAYGROUND_BASE + compressToEncodedURIComponent(code)) + const builder = new MessageBuilder() .setTitle('View in Playground') - .setColor(TS_BLUE); - await sendWithMessageOwnership(msg, { embeds: [embed] }); + .setURL(PLAYGROUND_BASE + compressToEncodedURIComponent(code)); + await sendWithMessageOwnership(msg, builder.build()); }, }); @@ -54,20 +56,19 @@ export async function playgroundModule(bot: Bot) { if (msg.content[0] === '!') return; const exec = matchPlaygroundLink(msg.content); if (!exec) return; - const embed = createPlaygroundEmbed(msg.author, exec); + const builder = createPlaygroundMessage(msg.author, exec); if (exec.isWholeMatch) { // Message only contained the link - await sendWithMessageOwnership(msg, { - embeds: [embed], - }); + await sendWithMessageOwnership(msg, builder.build()); await msg.delete(); } else { // Message also contained other characters - const botMsg = await msg.channel.send({ - embeds: [embed], - content: `${msg.author} Here's a shortened URL of your playground link! You can remove the full link from your message.`, - }); - editedLongLink.set(msg.id, botMsg); + builder.setFooter( + `${msg.author} Here's a shortened URL of your playground link! You can remove the full link from your message.`, + ); + builder.setAllowMentions('users'); + const botMsg = await msg.channel.send(builder.build()); + editedLongLink.set(msg.id, [botMsg, builder]); await addMessageOwnership(botMsg, msg.author); } }); @@ -82,10 +83,8 @@ export async function playgroundModule(bot: Bot) { // put the rest of the message in msg.content if (!exec?.isWholeMatch) return; const shortenedUrl = await shortenPlaygroundLink(exec.url); - const embed = createPlaygroundEmbed(msg.author, exec, shortenedUrl); - await sendWithMessageOwnership(msg, { - embeds: [embed], - }); + const builder = createPlaygroundMessage(msg.author, exec, shortenedUrl); + await sendWithMessageOwnership(msg, builder.build()); if (!msg.content) await msg.delete(); }); @@ -93,30 +92,25 @@ export async function playgroundModule(bot: Bot) { if (msg.partial) msg = await msg.fetch(); const exec = matchPlaygroundLink(msg.content); if (msg.author.bot || !editedLongLink.has(msg.id) || exec) return; - const botMsg = editedLongLink.get(msg.id); - // Edit the message to only have the embed and not the "please edit your message" message - await botMsg?.edit({ - content: '', - embeds: [botMsg.embeds[0]], - }); + const [botMsg, builder] = editedLongLink.get(msg.id)!; + // Edit the message to only have the preview and not the "please edit your message" message + await botMsg?.edit(builder.setFooter('').setAllowMentions().build()); editedLongLink.delete(msg.id); }); } // Take care when messing with the truncation, it's extremely finnicky -function createPlaygroundEmbed( +function createPlaygroundMessage( author: User, { url: _url, query, code, isEscaped }: PlaygroundLinkMatch, url: string = _url, ) { - const embed = new EmbedBuilder() - .setColor(TS_BLUE) - .setTitle('Playground Link') - .setAuthor({ name: author.tag, iconURL: author.displayAvatarURL() }) - .setURL(url); + const builder = new MessageBuilder().setAuthor( + `From ${author}: [View in Playground](<${url}>)`, + ); const unzipped = decompressFromEncodedURIComponent(code); - if (!unzipped) return embed; + if (!unzipped) return builder; // Without 'normalized' you can't get consistent lengths across platforms // Matters because the playground uses the line breaks of whoever created it @@ -135,19 +129,19 @@ function createPlaygroundEmbed( const startChar = startLine ? lineIndices[startLine - 1] : 0; const cutoff = endLine - ? Math.min(lineIndices[endLine], startChar + MAX_EMBED_LENGTH) - : startChar + DEFAULT_EMBED_LENGTH; + ? Math.min(lineIndices[endLine], startChar + MAX_PREVIEW_LENGTH) + : startChar + DEFAULT_PREVIEW_LENGTH; // End of the line containing the cutoff const endChar = lineIndices.find(len => len >= cutoff) ?? normalized.length; let pretty; try { - // Make lines as short as reasonably possible, so they fit in the embed. + // Make lines as short as reasonably possible, so they fit in the preview. // We pass prettier the full string, but only format part of it, so we can // calculate where the endChar is post-formatting. pretty = format(normalized, { parser: 'typescript', - printWidth: 55, + printWidth: 72, tabWidth: 2, semi: false, bracketSpacing: false, @@ -167,15 +161,10 @@ function createPlaygroundEmbed( (prettyEndChar === pretty.length ? '' : '\n...'); if (!isEscaped) { - embed.setDescription('**Preview:**' + makeCodeBlock(content)); - if (!startLine && !endLine) { - embed.setFooter({ - text: 'You can choose specific lines to embed by selecting them before copying the link.', - }); - } + builder.addFields({ name: 'Preview:', value: makeCodeBlock(content) }); } - return embed; + return builder; } async function shortenPlaygroundLink(url: string) { diff --git a/src/modules/rep.ts b/src/modules/rep.ts index ac9c138..e8e47eb 100644 --- a/src/modules/rep.ts +++ b/src/modules/rep.ts @@ -1,10 +1,11 @@ -import { Message, EmbedBuilder } from 'discord.js'; -import { repEmoji, TS_BLUE } from '../env'; +import { Message } from 'discord.js'; +import { repEmoji } from '../env'; import { Rep } from '../entities/Rep'; import { sendPaginatedMessage } from '../util/sendPaginatedMessage'; import { getMessageOwner, sendWithMessageOwnership } from '../util/send'; import { Bot } from '../bot'; +import { MessageBuilder } from '../util/messageBuilder'; // The Chinese is outside the group on purpose, because CJK languages don't have word bounds. Therefore we only look for key characters @@ -184,7 +185,7 @@ export function repModule(bot: Bot) { if (!user) { await sendWithMessageOwnership( msg, - 'Unable to find user to give rep', + 'User has no reputation history.', ); return; } @@ -216,12 +217,9 @@ export function repModule(bot: Bot) { return acc; }, []) .map(page => page.join('\n')); - const embed = new EmbedBuilder().setColor(TS_BLUE).setAuthor({ - name: user.tag, - iconURL: user.displayAvatarURL(), - }); + const builder = new MessageBuilder().setAuthor(user.tag); await sendPaginatedMessage( - embed, + builder, pages, msg.member, msg.channel, @@ -286,8 +284,7 @@ export function repModule(bot: Bot) { recipient: string; sum: number; }[]; - const embed = new EmbedBuilder() - .setColor(TS_BLUE) + const builder = new MessageBuilder() .setTitle(`Top 10 Reputation ${text}`) .setDescription( data @@ -301,7 +298,7 @@ export function repModule(bot: Bot) { ) .join('\n'), ); - await msg.channel.send({ embeds: [embed] }); + await msg.channel.send(builder.build()); }, }); } diff --git a/src/modules/snippet.ts b/src/modules/snippet.ts index 096ab0b..534eb65 100644 --- a/src/modules/snippet.ts +++ b/src/modules/snippet.ts @@ -1,10 +1,16 @@ -import { EmbedBuilder, TextChannel, User } from 'discord.js'; +import { + EmbedBuilder, + MessageCreateOptions, + TextChannel, + User, +} from 'discord.js'; import { Snippet } from '../entities/Snippet'; import { BLOCKQUOTE_GREY } from '../env'; import { sendWithMessageOwnership } from '../util/send'; import { getReferencedMessage } from '../util/getReferencedMessage'; import { splitCustomCommand } from '../util/customCommand'; import { Bot } from '../bot'; +import { MessageBuilder } from '../util/messageBuilder'; // https://stackoverflow.com/a/3809435 const LINK_REGEX = @@ -37,18 +43,35 @@ export function snippetModule(bot: Bot) { } const owner = await bot.client.users.fetch(snippet.owner); - const embed = new EmbedBuilder({ - ...snippet, - // image is in an incompatible format, so we have to set it later - image: undefined, - }); - if (match.id.includes(':')) - embed.setAuthor({ - name: owner.tag, - iconURL: owner.displayAvatarURL(), + + let toSend: MessageCreateOptions; + if (snippet.image) { + // This snippet originated from an embed, send it back as an embed. + + const embed = new EmbedBuilder({ + ...snippet, + // image is in an incompatible format, so we have to set it later + image: undefined, }); - if (snippet.image) embed.setImage(snippet.image); - await sendWithMessageOwnership(msg, { embeds: [embed] }, onDelete); + if (match.id.includes(':')) + embed.setAuthor({ + name: owner.tag, + iconURL: owner.displayAvatarURL(), + }); + embed.setImage(snippet.image); + + toSend = { embeds: [embed] }; + } else { + // Don't need an embed, send as plain text + + toSend = new MessageBuilder() + .setAuthor(`<@${snippet.owner}>`) + .setTitle(snippet.title) + .setDescription(snippet.description) + .build(); + } + + await sendWithMessageOwnership(msg, toSend, onDelete); }); bot.registerCommand({ @@ -61,28 +84,24 @@ export function snippetModule(bot: Bot) { specifier || '*', limit + 1, ); - await sendWithMessageOwnership(msg, { - embeds: [ - new EmbedBuilder() - .setColor(BLOCKQUOTE_GREY) - .setTitle( - `${ - matches.length > limit - ? `${limit}+` - : matches.length - } Matches Found`, - ) - .setDescription( - matches - .slice(0, limit) - .map( - s => - `- \`${s.id}\` with **${s.uses}** uses`, - ) - .join('\n'), - ), - ], - }); + await sendWithMessageOwnership( + msg, + new MessageBuilder() + .setTitle( + `${ + matches.length > limit + ? `${limit}+` + : matches.length + } Matches Found`, + ) + .setDescription( + matches + .slice(0, limit) + .map(s => `- \`${s.id}\` with **${s.uses}** uses`) + .join('\n'), + ) + .build(), + ); }, }); diff --git a/src/util/messageBuilder.ts b/src/util/messageBuilder.ts new file mode 100644 index 0000000..54d44c3 --- /dev/null +++ b/src/util/messageBuilder.ts @@ -0,0 +1,86 @@ +import { MessageCreateOptions, MessageMentionTypes } from 'discord.js'; + +/** + * Roughly based on Discord.js's EmbedBuilder, but doesn't build an embed + * so that bot messages for users with embeds turned off work nicely. + * + * By default, disables mentions. + */ +export class MessageBuilder { + private author?: string | null = null; + private title?: string | null = null; + private url?: string | null = null; + private description?: string | null = null; + private fields: { name: string; value: string }[] = []; + private footer?: string | null = null; + private allowMentions: MessageMentionTypes[] = []; + + setAuthor(name: string | null | undefined): this { + this.author = name; + return this; + } + + setTitle(title: string | null | undefined): this { + this.title = title; + return this; + } + + setURL(url: string | null | undefined): this { + this.url = url; + return this; + } + + setDescription(description: string | null | undefined): this { + this.description = description; + return this; + } + + addFields(...fields: { name: string; value: string }[]): this { + this.fields.push(...fields); + return this; + } + + setFooter(footer: string | null | undefined): this { + this.footer = footer; + return this; + } + + setAllowMentions(...mentions: MessageMentionTypes[]): this { + this.allowMentions = mentions; + return this; + } + + build(): MessageCreateOptions { + const message: string[] = []; + + if (this.author) { + message.push(this.author); + } + + if (this.title) { + if (this.url) { + message.push(`## [${this.title}](<${this.url}>)`); + } else { + message.push(`## ${this.title}`); + } + } + + if (this.description) { + message.push(this.description); + } + + for (const field of this.fields) { + message.push(`### ${field.name}`); + message.push(field.value); + } + + if (this.footer) { + message.push('', this.footer); + } + + return { + content: message.join('\n'), + allowedMentions: { parse: this.allowMentions }, + }; + } +} diff --git a/src/util/sendPaginatedMessage.ts b/src/util/sendPaginatedMessage.ts index 0a96995..d7a586a 100644 --- a/src/util/sendPaginatedMessage.ts +++ b/src/util/sendPaginatedMessage.ts @@ -1,10 +1,10 @@ import { GuildMember, - EmbedBuilder, MessageReaction, TextBasedChannel, User, } from 'discord.js'; +import { MessageBuilder } from './messageBuilder'; const emojis = { back: '◀', @@ -15,20 +15,19 @@ const emojis = { }; export async function sendPaginatedMessage( - embed: EmbedBuilder, + builder: MessageBuilder, pages: string[], member: GuildMember, channel: TextBasedChannel, timeout: number = 100000, ) { let curPage = 0; - const message = await channel.send({ - embeds: [ - embed - .setDescription(pages[curPage]) - .setFooter({ text: `Page ${curPage + 1} of ${pages.length}` }), - ], - }); + const message = await channel.send( + builder + .setDescription(pages[curPage]) + .setFooter(`Page ${curPage + 1} of ${pages.length}`) + .build(), + ); if (pages.length === 1) return; await message.react(emojis.first); @@ -66,13 +65,12 @@ export async function sendPaginatedMessage( break; } - await message.edit({ - embeds: [ - embed.setDescription(pages[curPage]).setFooter({ - text: `Page ${curPage + 1} of ${pages.length}`, - }), - ], - }); + await message.edit( + builder + .setDescription(pages[curPage]) + .setFooter(`Page ${curPage + 1} of ${pages.length}`) + .build(), + ); }); collector.on('end', () => {