Skip to content
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

feat: add multi-image embeds #2568

Merged
merged 2 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
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
11 changes: 6 additions & 5 deletions src/commands/Misc/snipe.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 });
}
}
6 changes: 6 additions & 0 deletions src/lib/util/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]/assets/72x72/1f49e.png',
Expand Down
76 changes: 41 additions & 35 deletions src/lib/util/util.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,6 +20,7 @@ import {
type User,
type UserResolvable
} from 'discord.js';
import { first } from './common/iterators.js';

const ONE_TO_TEN = new Map<number, UtilOneToTenEntry>([
[0, { emoji: '😪', color: 0x5b1100 }],
Expand Down Expand Up @@ -80,57 +82,61 @@ 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<string> {
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;
}
}

/**
* Get the image url from a message.
* @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<string>) {
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;
}

/**
Expand Down
11 changes: 8 additions & 3 deletions src/listeners/guildMessageLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class UserListener extends Listener {
guild: Guild,
logChannelId: string | Nullish,
key: GuildSettingsOfType<string | Nullish>,
makeMessage: () => Awaitable<EmbedBuilder | MessageCreateOptions>
makeMessage: () => Awaitable<EmbedBuilder | EmbedBuilder[] | MessageCreateOptions>
) {
if (isNullish(logChannelId)) return;

Expand All @@ -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) {
Expand All @@ -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;
}
}
19 changes: 12 additions & 7 deletions src/listeners/guilds/messages/guildUserMessageImageNotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,6 +19,8 @@ const MAXIMUM_LENGTH = 1024 * 1024;

@ApplyOptions<Listener.Options>({ 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;
Expand All @@ -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);
Expand All @@ -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 {
Expand Down
6 changes: 2 additions & 4 deletions src/listeners/messages/guildMessageDeleteNotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
});
}
}