From 233354af4136ab320243be3738b8d9441958d600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aura=20Rom=C3=A1n?= Date: Fri, 9 Feb 2024 17:01:30 +0100 Subject: [PATCH 1/2] feat: add multi-image embeds --- src/commands/Misc/snipe.ts | 11 +-- src/lib/util/constants.ts | 6 ++ src/lib/util/util.ts | 76 ++++++++++--------- src/listeners/guildMessageLog.ts | 11 ++- .../messages/guildMessageDeleteNotify.ts | 6 +- 5 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/commands/Misc/snipe.ts b/src/commands/Misc/snipe.ts index 2d2e9c7ee39..0f9762bcb6d 100644 --- a/src/commands/Misc/snipe.ts +++ b/src/commands/Misc/snipe.ts @@ -1,8 +1,9 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraCommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; +import { Urls } from '#utils/constants'; import { getSnipedMessage } from '#utils/functions'; -import { getColor, getContent, getFullEmbedAuthor, getImage } from '#utils/util'; +import { getColor, getContent, getFullEmbedAuthor, getImages, setMultipleEmbedImages } from '#utils/util'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; @@ -23,16 +24,16 @@ export class UserCommand extends SkyraCommand { if (sniped === null) this.error(LanguageKeys.Commands.Misc.SnipeEmpty); const embed = new EmbedBuilder() - .setTitle(args.t(LanguageKeys.Commands.Misc.SnipeTitle)) + .setURL(Urls.Website) + .setFooter({ text: args.t(LanguageKeys.Commands.Misc.SnipeTitle) }) .setColor(getColor(sniped)) .setAuthor(getFullEmbedAuthor(sniped.author)) .setTimestamp(sniped.createdTimestamp); const content = getContent(sniped); if (content !== null) embed.setDescription(content); - const image = getImage(sniped); - if (image !== null) embed.setImage(image); - return send(message, { embeds: [embed] }); + const embeds = setMultipleEmbedImages(embed, getImages(sniped)); + return send(message, { embeds }); } } diff --git a/src/lib/util/constants.ts b/src/lib/util/constants.ts index 2c2efeb2a8c..4e6ed7ff0b7 100644 --- a/src/lib/util/constants.ts +++ b/src/lib/util/constants.ts @@ -52,6 +52,12 @@ export const enum BrandingColors { Secondary = 0xff9d01 } +export const enum Urls { + GitHubOrganization = 'https://github.com/skyra-project', + GitHubRepository = 'https://github.com/skyra-project/skyra', + Website = 'https://skyra.pw' +} + export const enum CdnUrls { EscapeRopeGif = 'https://cdn.skyra.pw/skyra-assets/escape_rope.gif', RevolvingHeartTwemoji = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@v14.0.2/assets/72x72/1f49e.png', diff --git a/src/lib/util/util.ts b/src/lib/util/util.ts index 206ac4544b5..eb2e7be2847 100644 --- a/src/lib/util/util.ts +++ b/src/lib/util/util.ts @@ -1,13 +1,14 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; import type { GuildMessage } from '#lib/types'; -import { BrandingColors, ZeroWidthSpace } from '#lib/util/constants'; +import { BrandingColors, Urls, ZeroWidthSpace } from '#lib/util/constants'; import { EmbedBuilder } from '@discordjs/builders'; import { container } from '@sapphire/framework'; import { send } from '@sapphire/plugin-editable-commands'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullishOrEmpty, tryParseURL, type Nullish } from '@sapphire/utilities'; +import { isNullishOrEmpty, isNullishOrZero, tryParseURL, type Nullish } from '@sapphire/utilities'; import { PermissionFlagsBits, + StickerFormatType, type APIUser, type EmbedAuthorData, type Guild, @@ -19,6 +20,7 @@ import { type User, type UserResolvable } from 'discord.js'; +import { first } from './common/iterators.js'; const ONE_TO_TEN = new Map([ [0, { emoji: '😪', color: 0x5b1100 }], @@ -80,44 +82,34 @@ export interface ImageAttachment { width: number; } -/** - * Get a image attachment from a message. - * @param message The Message instance to get the image url from - */ -export function getAttachment(message: Message): ImageAttachment | null { - if (message.attachments.size) { - const attachment = message.attachments.find((att) => IMAGE_EXTENSION.test(att.name ?? att.url)); - if (attachment) { - return { - url: attachment.url, - proxyURL: attachment.proxyURL, - height: attachment.height!, - width: attachment.width! - }; - } +export function* getImages(message: Message): IterableIterator { + for (const attachment of message.attachments.values()) { + // Skip if the attachment doesn't have a content type: + if (isNullishOrEmpty(attachment.contentType)) continue; + // Skip if the attachment doesn't have a size: + if (isNullishOrZero(attachment.width) || isNullishOrZero(attachment.height)) continue; + // Skip if the attachment isn't an image: + if (!attachment.contentType.startsWith('image/')) continue; + + yield attachment.proxyURL ?? attachment.url; } for (const embed of message.embeds) { if (embed.image) { - return { - url: embed.image.url, - proxyURL: embed.image.proxyURL!, - height: embed.image.height!, - width: embed.image.width! - }; + yield embed.image.proxyURL ?? embed.image.url; } if (embed.thumbnail) { - return { - url: embed.thumbnail.url, - proxyURL: embed.thumbnail.proxyURL!, - height: embed.thumbnail.height!, - width: embed.thumbnail.width! - }; + yield embed.thumbnail.proxyURL ?? embed.thumbnail.url; } } - return null; + for (const sticker of message.stickers.values()) { + // Skip if the sticker is a lottie sticker: + if (sticker.format === StickerFormatType.Lottie) continue; + + yield sticker.url; + } } /** @@ -125,12 +117,26 @@ export function getAttachment(message: Message): ImageAttachment | null { * @param message The Message instance to get the image url from */ export function getImage(message: Message): string | null { - const attachment = getAttachment(message); - if (attachment) return attachment.proxyURL || attachment.url; + return first(getImages(message)) ?? null; +} - const sticker = message.stickers.first(); - if (sticker) return sticker.url; - return null; +export function setMultipleEmbedImages(embed: EmbedBuilder, urls: IterableIterator) { + const embeds = [embed]; + let count = 0; + for (const url of urls) { + if (count === 0) { + embed.setURL(Urls.Website).setImage(url); + } else { + embeds.push(new EmbedBuilder().setURL(Urls.Website).setImage(url)); + + // We only want to send 4 embeds at most + if (count === 3) break; + } + + count++; + } + + return embeds; } /** diff --git a/src/listeners/guildMessageLog.ts b/src/listeners/guildMessageLog.ts index 7a75addaecb..245d58511e0 100644 --- a/src/listeners/guildMessageLog.ts +++ b/src/listeners/guildMessageLog.ts @@ -10,7 +10,7 @@ export class UserListener extends Listener { guild: Guild, logChannelId: string | Nullish, key: GuildSettingsOfType, - makeMessage: () => Awaitable + makeMessage: () => Awaitable ) { if (isNullish(logChannelId)) return; @@ -23,8 +23,7 @@ export class UserListener extends Listener { // Don't post if it's not possible if (!canSendEmbeds(channel)) return; - const processed = await makeMessage(); - const options: MessageCreateOptions = processed instanceof EmbedBuilder ? { embeds: [processed] } : processed; + const options = this.resolveOptions(await makeMessage()); try { await channel.send(options); } catch (error) { @@ -35,4 +34,10 @@ export class UserListener extends Listener { ); } } + + private resolveOptions(options: MessageCreateOptions | EmbedBuilder | EmbedBuilder[]): MessageCreateOptions { + if (Array.isArray(options)) return { embeds: options }; + if (options instanceof EmbedBuilder) return { embeds: [options] }; + return options; + } } diff --git a/src/listeners/messages/guildMessageDeleteNotify.ts b/src/listeners/messages/guildMessageDeleteNotify.ts index cccdfea0d51..26e64d3e0d1 100644 --- a/src/listeners/messages/guildMessageDeleteNotify.ts +++ b/src/listeners/messages/guildMessageDeleteNotify.ts @@ -2,7 +2,7 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Events, type GuildMessage } from '#lib/types'; import { Colors } from '#utils/constants'; -import { getContent, getFullEmbedAuthor, getImage } from '#utils/util'; +import { getContent, getFullEmbedAuthor, getImages, setMultipleEmbedImages } from '#utils/util'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { isNsfwChannel } from '@sapphire/discord.js-utilities'; @@ -35,10 +35,8 @@ export class UserListener extends Listener { const content = getContent(message); if (!isNullishOrEmpty(content)) embed.setDescription(cutText(content, 1900)); - const image = getImage(message); - if (!isNullish(image)) embed.setImage(image); - return embed; + return setMultipleEmbedImages(embed, getImages(message)); }); } } From 9b9dddcd7f6caee1b10bbcd34c48accdbb51202b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aura=20Rom=C3=A1n?= Date: Fri, 9 Feb 2024 17:18:35 +0100 Subject: [PATCH 2/2] fix(image-logs): handle unsupported image types --- .../messages/guildUserMessageImageNotify.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/listeners/guilds/messages/guildUserMessageImageNotify.ts b/src/listeners/guilds/messages/guildUserMessageImageNotify.ts index 705a55bfc46..b38b8cbf158 100644 --- a/src/listeners/guilds/messages/guildUserMessageImageNotify.ts +++ b/src/listeners/guilds/messages/guildUserMessageImageNotify.ts @@ -2,12 +2,13 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Events, type GuildMessage } from '#lib/types'; import { Colors } from '#utils/constants'; +import { getLogPrefix } from '#utils/functions'; import { getFullEmbedAuthor } from '#utils/util'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { FetchResultTypes, fetch } from '@sapphire/fetch'; import { Listener } from '@sapphire/framework'; -import { isNullish, isNumber } from '@sapphire/utilities'; +import { isNullish, isNullishOrEmpty, isNullishOrZero, isNumber } from '@sapphire/utilities'; import { AttachmentBuilder, type MessageCreateOptions, type TextChannel } from 'discord.js'; import { extname } from 'node:path'; @@ -18,6 +19,8 @@ const MAXIMUM_LENGTH = 1024 * 1024; @ApplyOptions({ event: Events.GuildUserMessage }) export class UserListener extends Listener { + private readonly LogPrefix = getLogPrefix(this); + public async run(message: GuildMessage) { // If there are no attachments, do not post: if (message.attachments.size === 0) return; @@ -44,14 +47,14 @@ export class UserListener extends Listener { // Fetch the image. const result = await fetch(url, FetchResultTypes.Result).catch((error) => { - this.container.logger.error(`ImageLogs[${error}] ${url}`); + this.container.logger.error(`${this.LogPrefix} ${url} ${error}`); return null; }); if (result === null) continue; // Retrieve the content length. const contentLength = result.headers.get('content-length'); - if (contentLength === null) continue; + if (isNullishOrEmpty(contentLength)) continue; // Parse the content length, validate it, and check if it's lower than the threshold. const parsedContentLength = parseInt(contentLength, 10); @@ -75,17 +78,19 @@ export class UserListener extends Listener { return { embeds: [embed], files: [new AttachmentBuilder(buffer, { name: filename })] }; }); } catch (error) { - this.container.logger.fatal(`ImageLogs[${error}] ${url}`); + this.container.logger.fatal(`${this.LogPrefix} ${url} ${error}`); } } } private *getAttachments(message: GuildMessage) { for (const attachment of message.attachments.values()) { - const type = attachment.contentType; - if (type === null) continue; + // Skip if the attachment doesn't have a content type: + if (isNullishOrEmpty(attachment.contentType)) continue; + // Skip if the attachment doesn't have a size: + if (isNullishOrZero(attachment.width) || isNullishOrZero(attachment.height)) continue; - const [kind] = type.split('/', 1); + const [kind] = attachment.contentType.split('/', 1); if (kind !== 'image' && kind !== 'video') continue; yield {