Skip to content

Remove embeds from most messages #227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 33 additions & 24 deletions src/modules/handbook.ts
Original file line number Diff line number Diff line change
@@ -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,52 +15,62 @@ 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 <query>`' });
.setDescription('You can search with `!handbook <query>`')
.build();

export async function handbookModule(bot: Bot) {
bot.registerCommand({
aliases: ['handbook', 'hb'],
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<AlgoliaResult>([
{
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<string, string[]>;

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());
},
});
}
32 changes: 13 additions & 19 deletions src/modules/help.ts
Original file line number Diff line number Diff line change
@@ -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<CommandRegistration>) {
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 <command>\``,
);

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](<https://www.typescriptlang.org/play>) 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());
},
});
}
81 changes: 35 additions & 46 deletions src/modules/playground.ts
Original file line number Diff line number Diff line change
@@ -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<string, Message>(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,41 +83,34 @@ 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();
});

bot.client.on('messageUpdate', async (_oldMsg, msg) => {
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) {
19 changes: 8 additions & 11 deletions src/modules/rep.ts
Original file line number Diff line number Diff line change
@@ -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());
},
});
}
87 changes: 53 additions & 34 deletions src/modules/snippet.ts
Original file line number Diff line number Diff line change
@@ -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(),
);
},
});

86 changes: 86 additions & 0 deletions src/util/messageBuilder.ts
Original file line number Diff line number Diff line change
@@ -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 },
};
}
}
30 changes: 14 additions & 16 deletions src/util/sendPaginatedMessage.ts
Original file line number Diff line number Diff line change
@@ -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', () => {