From aff5c3227d4cfe598d80fab7bb355f5a71c2060e Mon Sep 17 00:00:00 2001 From: xHyroM Date: Sun, 9 Apr 2023 15:36:53 +0200 Subject: [PATCH] feat(bot): finish setup, select --- packages/bot/src/components/select.ts | 61 ++++ packages/bot/src/components/setup.ts | 80 ++++-- packages/bot/src/index.ts | 8 +- packages/bot/src/modals/setup.ts | 84 +++++- packages/bot/src/structs/Component.ts | 3 + packages/bot/src/types.d.ts | 20 ++ packages/bot/src/utils/hexToRGB.ts | 12 + packages/bot/src/utils/resolveButtonStyle.ts | 16 ++ packages/bot/src/utils/resolveEmoji.ts | 18 ++ packages/bot/src/utils/returnRoleLpe.ts | 92 +++--- packages/bot/src/utils/sendFinal.ts | 164 +++++++++++ packages/bot/src/utils/splitArray.ts | 7 + packages/builders/src/index.ts | 2 + packages/builders/src/messages/embed/Embed.ts | 262 ++++++++++++++++++ packages/redis-api-client/src/index.ts | 6 +- 15 files changed, 762 insertions(+), 73 deletions(-) create mode 100644 packages/bot/src/components/select.ts create mode 100644 packages/bot/src/utils/hexToRGB.ts create mode 100644 packages/bot/src/utils/resolveButtonStyle.ts create mode 100644 packages/bot/src/utils/resolveEmoji.ts create mode 100644 packages/bot/src/utils/sendFinal.ts create mode 100644 packages/bot/src/utils/splitArray.ts create mode 100644 packages/builders/src/messages/embed/Embed.ts diff --git a/packages/bot/src/components/select.ts b/packages/bot/src/components/select.ts new file mode 100644 index 0000000..9e90c1c --- /dev/null +++ b/packages/bot/src/components/select.ts @@ -0,0 +1,61 @@ +import { + ComponentType, + MessageFlags, + RouteBases, + Routes, +} from "discord-api-types/v10"; +import { Component } from "../structs/Component"; + +new Component({ + id: "select:role", + default: true, + flags: MessageFlags.Ephemeral, + run: async (ctx) => { + if (!ctx.guildId) + return ctx.editReply({ + content: "Guild not found.", + }); + + if (!ctx.interaction.member) + return ctx.editReply({ + content: "Member not found.", + }); + + const roleId = + ctx.interaction.data.component_type === ComponentType.StringSelect + ? ctx.interaction.data.values[0].startsWith("role:") + ? ctx.interaction.data.values[0].split(":")[1] + : // support for legacy select menus + ctx.interaction.data.values[0] + : ctx.interaction.data.custom_id.startsWith("role:") + ? ctx.interaction.data.custom_id.split(":")[1] + : // support for legacy select menus + ctx.interaction.data.custom_id; + + const content = !ctx.interaction.member?.roles.includes(roleId) + ? `Gave the <@&${roleId}> role!` + : `Removed the <@&${roleId}> role!`; + + const method = !ctx.interaction.member?.roles.includes(roleId) + ? "PUT" + : "DELETE"; + + await fetch( + `${RouteBases.api}${Routes.guildMemberRole( + ctx.guildId, + ctx.interaction.member.user.id, + roleId, + )}`, + { + method, + headers: { + Authorization: `Bot ${ctx.env.token}`, + }, + }, + ); + + await ctx.editReply({ + content, + }); + }, +}); diff --git a/packages/bot/src/components/setup.ts b/packages/bot/src/components/setup.ts index 00fc396..7ccf1cc 100644 --- a/packages/bot/src/components/setup.ts +++ b/packages/bot/src/components/setup.ts @@ -15,6 +15,7 @@ import { import returnRoleLpe from "../utils/returnRoleLpe"; import { REDIS } from "../things"; import { encodeToHex, decodeFromString } from "serialize"; +import sendFinal from "../utils/sendFinal"; // Part 2 Channels ## select button/dropdowns new Component({ @@ -137,7 +138,7 @@ new Component({ ); return rawRoleIds.length > 0 - ? returnRoleLpe(ctx, rawRoleIds[0]) + ? returnRoleLpe(data, ctx, rawRoleIds[0]) : ctx.returnModal({ title: "Message Preview", custom_id: "setup:part-messageContent", @@ -191,17 +192,15 @@ new Component({ }, }); -// Part 7 Send As ## finish +// Part 7 Send As ## finish or open modal for webhook preview new Component({ id: "setup:part-sendAs", acknowledge: false, run: async (ctx) => { - if (!ctx.interaction.guild_id) + if (!ctx.guildId) return await ctx.editReply({ content: "Guild not found." }); - const rawData = await REDIS.get( - `roles-bot-setup:${ctx.interaction.guild_id}`, - ); + const rawData = await REDIS.get(`roles-bot-setup:${ctx.guildId}`); if (!rawData) return ctx.respond({ type: InteractionResponseType.ChannelMessageWithSource, @@ -212,29 +211,58 @@ new Component({ }); const data = decodeFromString(rawData); - console.log(data); + const sendAs = ctx.interaction.data.custom_id.split(":")[2]; + data.sendAs = sendAs; - // delete data - await REDIS.del(`roles-bot-setup:${ctx.guildId}`); + await REDIS.setex( + `roles-bot-setup:${ctx.interaction.guild_id}`, + encodeToHex(data), + 600, + ); - // TODO: finish sending - const actionRow = new ActionRowBuilder(); - - /*switch (selecting) { - case "buttons": { - // TOOD: finish + switch (sendAs) { + case "webhook": { + return ctx.returnModal({ + title: "Webhook Preview", + custom_id: "setup:part-webhook", + components: [ + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel("Webhook Name") + .setCustomId("name") + .setPlaceholder("Roles Bot") + .setStyle(TextInputStyle.Short) + .setMaxLength(80) + .setRequired(true), + ) + .toJSON(), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel("Webhook Avatar URL") + .setCustomId("avatarUrl") + .setPlaceholder( + "https://raw.githubusercontent.com/Hyro-Blobs/blobs/main/base/hyro_blob-upscaled.png", + ) + .setStyle(TextInputStyle.Short) + .setRequired(false), + ) + .toJSON(), + ], + }); } - case "dropdowns": { - // TODO: finish - } - }*/ + case "bot": { + sendFinal(ctx, data); - return ctx.respond({ - type: InteractionResponseType.ChannelMessageWithSource, - data: { - content: "Done!", - flags: MessageFlags.Ephemeral, - }, - }); + return ctx.respond({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: "Setup completed!", + flags: MessageFlags.Ephemeral, + }, + }); + } + } }, }); diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts index 42f0ef8..86ce2e3 100644 --- a/packages/bot/src/index.ts +++ b/packages/bot/src/index.ts @@ -1,5 +1,6 @@ import "./commands/setup"; import "./components/setup"; +import "./components/select"; import "./modals/setup"; import { @@ -92,9 +93,10 @@ export default { } case InteractionType.MessageComponent: { const context = new ComponentContext(interaction, env); - const component = COMPONENTS.find((cmp) => - context.interaction.data.custom_id.startsWith(cmp.id), - ); + const component = + COMPONENTS.find((cmp) => + context.interaction.data.custom_id.startsWith(cmp.id), + ) ?? COMPONENTS.find((cmp) => cmp.default); if (!component) return new Response("Unknown component", { status: 404 }); diff --git a/packages/bot/src/modals/setup.ts b/packages/bot/src/modals/setup.ts index 6c8583a..3f887cb 100644 --- a/packages/bot/src/modals/setup.ts +++ b/packages/bot/src/modals/setup.ts @@ -1,16 +1,21 @@ import { + APIWebhook, ButtonStyle, - InteractionResponseType, MessageFlags, + RouteBases, + Routes, } from "discord-api-types/v10"; import { Modal } from "../structs/Modal"; import { ActionRowBuilder, ButtonBuilder } from "builders"; import { encodeToHex, decodeFromString } from "serialize"; import { REDIS } from "../things"; +import sendFinal from "../utils/sendFinal"; +import { RoleId } from "../types"; // Part 5 Roles ## add label, placeholder, emoji OR message content new Modal({ id: "setup:part-roles-lpe", + flags: MessageFlags.Ephemeral, run: async (ctx) => { const rawData = await REDIS.get( `roles-bot-setup:${ctx.interaction.guild_id}`, @@ -22,17 +27,28 @@ new Modal({ const data = decodeFromString(rawData); const rawRoleIds = data.rawRoleIds as string[]; - const roleIds = (data.roleIds ?? []) as { - label: string; - placeholder: string; - emoji: string; - }[]; + const roleIds = (data.roleIds ?? []) as RoleId[]; const label = ctx.interaction.data.components[0].components[0].value; - const placeholder = ctx.interaction.data.components[1].components[0].value; - const emoji = ctx.interaction.data.components[2].components[0].value; + const emoji = ctx.interaction.data.components[1].components[0].value; - roleIds.push({ label, placeholder, emoji }); + switch (data.selecting) { + case "buttons": { + const style = ctx.interaction.data.components[2].components[0].value; + + roleIds.push({ label, style, emoji, id: rawRoleIds[0] }); + + break; + } + case "dropdowns": { + const description = + ctx.interaction.data.components[2].components[0].value; + + roleIds.push({ label, description, emoji, id: rawRoleIds[0] }); + + break; + } + } rawRoleIds.shift(); data.rawRoleIds = rawRoleIds; @@ -121,3 +137,53 @@ new Modal({ }); }, }); + +// Part 8 (ONLY IF WEBHOOK) Webhook ## send webhook +new Modal({ + id: "setup:part-webhook", + flags: MessageFlags.Ephemeral, + run: async (ctx) => { + const rawData = await REDIS.get( + `roles-bot-setup:${ctx.interaction.guild_id}`, + ); + if (!rawData) + return await ctx.editReply({ + content: "Data not found. Try running setup again.", + }); + + const data = decodeFromString(rawData); + + const webhookName = ctx.interaction.data.components[0].components[0].value; + const webhookAvatarUrl = + ctx.interaction.data.components[1].components[0].value; + + const webhook = (await ( + await fetch( + `${RouteBases.api}${Routes.channelWebhooks(data.channelId)}`, + { + method: "POST", + headers: { + Authorization: `Bot ${ctx.env.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Roles Bot Webhook", + }), + }, + ) + ).json()) as APIWebhook; + + data.webhook = { + name: webhookName, + avatarUrl: webhookAvatarUrl, + id: webhook.id, + token: webhook.token, + }; + + sendFinal(ctx, data); + + await ctx.editReply({ + content: "Setup completed!", + }); + }, +}); diff --git a/packages/bot/src/structs/Component.ts b/packages/bot/src/structs/Component.ts index 5e888bd..e7905bb 100644 --- a/packages/bot/src/structs/Component.ts +++ b/packages/bot/src/structs/Component.ts @@ -5,6 +5,7 @@ import { ComponentContext } from "./contexts/ComponentContext"; interface ComponentOptions { id: string; acknowledge?: boolean; + default?: boolean; flags?: MessageFlags; run: (interaction: ComponentContext) => void; } @@ -12,12 +13,14 @@ interface ComponentOptions { export class Component { public id: string; public acknowledge: boolean; + public default: boolean; public flags: MessageFlags | undefined; public run: (interaction: ComponentContext) => void | Response; constructor(options: ComponentOptions) { this.id = options.id; this.acknowledge = options.acknowledge ?? true; + this.default = options.default ?? false; this.flags = options.flags; this.run = options.run; diff --git a/packages/bot/src/types.d.ts b/packages/bot/src/types.d.ts index 861973d..b56c6d6 100644 --- a/packages/bot/src/types.d.ts +++ b/packages/bot/src/types.d.ts @@ -21,3 +21,23 @@ declare interface Env { redisApiClientKey: string; redisApiClientHost: string; } + +declare interface RoleId { + label: string; + description?: string; + emoji: string; + id: string; + style?: string; +} + +declare interface BasicData { + channelId: string; + selecting: "buttons" | "dropdowns"; + roleIds: RoleId[]; + message: { + content: string; + embedTitle: string; + embedDescription: string; + embedColor: string; + }; +} diff --git a/packages/bot/src/utils/hexToRGB.ts b/packages/bot/src/utils/hexToRGB.ts new file mode 100644 index 0000000..f09bac0 --- /dev/null +++ b/packages/bot/src/utils/hexToRGB.ts @@ -0,0 +1,12 @@ +import { RGBTuple } from "builders"; + +export default function (hex: string): RGBTuple | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16), + ] + : null; +} diff --git a/packages/bot/src/utils/resolveButtonStyle.ts b/packages/bot/src/utils/resolveButtonStyle.ts new file mode 100644 index 0000000..255f3a6 --- /dev/null +++ b/packages/bot/src/utils/resolveButtonStyle.ts @@ -0,0 +1,16 @@ +import { ButtonStyle } from "discord-api-types/v10"; + +export default function (style: string) { + switch (style.toLowerCase()) { + case "primary": + return ButtonStyle.Primary; + case "secondary": + return ButtonStyle.Secondary; + case "success": + return ButtonStyle.Success; + case "danger": + return ButtonStyle.Danger; + default: + return ButtonStyle.Primary; + } +} diff --git a/packages/bot/src/utils/resolveEmoji.ts b/packages/bot/src/utils/resolveEmoji.ts new file mode 100644 index 0000000..a7cbf1a --- /dev/null +++ b/packages/bot/src/utils/resolveEmoji.ts @@ -0,0 +1,18 @@ +// https://github.com/discordjs/discord.js/blob/6412da4921a5fd9ed0987205508bacd2b4868fd6/packages/discord.js/src/util/Util.js#L90-L109 + +export function parseEmoji(text: string) { + if (text.includes("%")) text = decodeURIComponent(text); + if (!text.includes(":")) + return { animated: false, name: text, id: undefined }; + const match = text.match(/?/); + return match && { animated: Boolean(match[1]), name: match[2], id: match[3] }; +} + +export function resolvePartialEmoji(emoji: string) { + if (!emoji) return null; + if (typeof emoji === "string") + return /^\d{17,19}$/.test(emoji) ? { id: emoji } : parseEmoji(emoji); + const { id, name, animated } = emoji; + if (!id && !name) return null; + return { id, name, animated: Boolean(animated) }; +} diff --git a/packages/bot/src/utils/returnRoleLpe.ts b/packages/bot/src/utils/returnRoleLpe.ts index cbbf78f..b55e581 100644 --- a/packages/bot/src/utils/returnRoleLpe.ts +++ b/packages/bot/src/utils/returnRoleLpe.ts @@ -8,8 +8,9 @@ import { } from "discord-api-types/v10"; import { REDIS } from "../things"; import { decodeFromString } from "serialize"; +import { BasicData } from "../types"; -export default async function (ctx: Context, rawRole: string) { +export default async function (data: BasicData, ctx: Context, rawRole: string) { const rolesRaw = await REDIS.get(`roles-bot-setup-roles:${ctx.guildId}`); if (!rolesRaw) return ctx.respond({ @@ -25,40 +26,65 @@ export default async function (ctx: Context, rawRole: string) { const roleName = roles?.find((r) => r.id === rawRole)?.name; + const components = [ + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel("Label") + .setCustomId("label") + .setPlaceholder("Ping") + .setStyle(TextInputStyle.Short) + .setRequired(true), + ) + .toJSON(), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel("Emoji") + .setCustomId("emoji") + .setPlaceholder("emoji 💡") + .setStyle(TextInputStyle.Short) + .setRequired(false), + ) + .toJSON(), + ]; + + switch (data.selecting) { + case "buttons": { + components.push( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel("Style") + .setCustomId("style") + .setPlaceholder("Primary, Secondary, Success or Danger") + .setStyle(TextInputStyle.Short) + .setRequired(true), + ) + .toJSON(), + ); + + break; + } + case "dropdowns": { + components.push( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel("Description") + .setCustomId("description") + .setPlaceholder("pingping pong pong") + .setStyle(TextInputStyle.Short) + .setRequired(false), + ) + .toJSON(), + ); + } + } + return ctx.returnModal({ title: `${roleName?.slice(0, 39)} Role`, custom_id: "setup:part-roles-lpe", - components: [ - new ActionRowBuilder() - .addComponents( - new TextInputBuilder() - .setLabel("Label") - .setCustomId("label") - .setPlaceholder("Ping") - .setStyle(TextInputStyle.Short) - .setRequired(false), - ) - .toJSON(), - new ActionRowBuilder() - .addComponents( - new TextInputBuilder() - .setLabel("Placeholder") - .setCustomId("placeholder") - .setPlaceholder("pingping pong pong") - .setStyle(TextInputStyle.Short) - .setRequired(false), - ) - .toJSON(), - new ActionRowBuilder() - .addComponents( - new TextInputBuilder() - .setLabel("Emoji") - .setCustomId("emoji") - .setPlaceholder("emoji 💡") - .setStyle(TextInputStyle.Short) - .setRequired(false), - ) - .toJSON(), - ], + components, }); } diff --git a/packages/bot/src/utils/sendFinal.ts b/packages/bot/src/utils/sendFinal.ts new file mode 100644 index 0000000..3d23586 --- /dev/null +++ b/packages/bot/src/utils/sendFinal.ts @@ -0,0 +1,164 @@ +import { + APIActionRowComponent, + APIEmbed, + APIMessageActionRowComponent, + ButtonStyle, + RouteBases, + Routes, +} from "discord-api-types/v10"; +import { Context } from "../structs/contexts/Context"; +import { REDIS } from "../things"; +import { + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, +} from "builders"; +import hexToRGB from "./hexToRGB"; +import splitArray from "./splitArray"; +import { resolvePartialEmoji } from "./resolveEmoji"; +import { BasicData } from "../types"; +import resolveButtonStyle from "./resolveButtonStyle"; + +type DataBot = BasicData & { + sendAs: "bot"; +}; + +type DataWebhook = BasicData & { + sendAs: "webhook"; + webhook: { + name: string; + avatarUrl: string; + id: string; + token: string; + }; +}; + +type Data = DataBot | DataWebhook; + +export default async function (ctx: Context, data: Data) { + await REDIS.del(`roles-bot-setup:${ctx.guildId}`); + await REDIS.del(`roles-bot-setup-roles:${ctx.guildId}`); + + const payload: { + content?: string; + embeds?: APIEmbed[]; + components: APIActionRowComponent[]; + } = { + components: [], + }; + + if (data.message.content) payload.content = data.message.content; + if (data.message.embedTitle || data.message.embedDescription) { + payload.embeds = [ + new EmbedBuilder() + .setTitle(data.message.embedTitle) + .setDescription(data.message.embedDescription) + .setColor( + data.message.embedColor ? hexToRGB(data.message.embedColor) : null, + ) + .toJSON(), + ]; + } + + const components: APIActionRowComponent[] = []; + const array = splitArray(data.roleIds, 25); + for (const items of array) { + const actionRow = new ActionRowBuilder(); + + const selectMenu = new StringSelectMenuBuilder().setCustomId("select:role"); + + for (const item of items) { + switch (data.selecting) { + case "buttons": { + const button = new ButtonBuilder() + .setLabel(item.label) + .setCustomId(`role:${item.id}`) + // rome-ignore lint/style/noNonNullAssertion: defined + .setStyle(resolveButtonStyle(item.style!)); + + if (item.emoji && resolvePartialEmoji(item.emoji)) + // rome-ignore lint/style/noNonNullAssertion: its fine + button.setEmoji(resolvePartialEmoji(item.emoji)!); + + actionRow.addComponents(button); + + break; + } + case "dropdowns": { + const option = new StringSelectMenuOptionBuilder() + .setLabel(item.label) + // rome-ignore lint/style/noNonNullAssertion: defined + .setDescription(item.description!) + .setValue(`role:${item.id}`); + + if (item.emoji && resolvePartialEmoji(item.emoji)) + // rome-ignore lint/style/noNonNullAssertion: its fine + option.setEmoji(resolvePartialEmoji(item.emoji)!); + + option.setValue(item.id); + selectMenu.addOptions(option); + } + } + } + + if (data.selecting === "dropdowns") actionRow.addComponents(selectMenu); + + // @ts-expect-error i know i know + components.push(actionRow); + } + + payload.components = components; + + switch (data.sendAs) { + case "bot": { + const res = await fetch( + `${RouteBases.api}${Routes.channelMessages(data.channelId)}`, + { + method: "POST", + headers: { + Authorization: `Bot ${ctx.env.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }, + ); + + if (!res.ok) { + const json: { message: string; code: string } = await res.json(); + await ctx.editReply({ + content: `Error: ${json.message} (${json.code})`, + }); + } + + break; + } + case "webhook": { + const res = await fetch( + `${RouteBases.api}${Routes.webhook( + data.webhook.id, + data.webhook.token, + )}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...payload, + username: data.webhook.name, + avatar_url: data.webhook.avatarUrl, + }), + }, + ); + + if (!res.ok) { + const json: { message: string; code: string } = await res.json(); + await ctx.editReply({ + content: `Error: ${json.message} (${json.code})`, + }); + } + } + } +} diff --git a/packages/bot/src/utils/splitArray.ts b/packages/bot/src/utils/splitArray.ts new file mode 100644 index 0000000..ccf8edb --- /dev/null +++ b/packages/bot/src/utils/splitArray.ts @@ -0,0 +1,7 @@ +export default function (array: T[], x: number): T[][] { + const result: T[][] = []; + for (let i = 0; i < array.length; i += x) { + result.push(array.slice(i, i + x)); + } + return result; +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 23f1689..ba44271 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -192,6 +192,8 @@ limitations under the License. */ +export * from "./messages/embed/Embed.js"; + export * from "./components/ActionRow"; export * from "./components/button/Button"; export * from "./components/Component"; diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts new file mode 100644 index 0000000..a19b80e --- /dev/null +++ b/packages/builders/src/messages/embed/Embed.ts @@ -0,0 +1,262 @@ +import type { + APIEmbed, + APIEmbedAuthor, + APIEmbedField, + APIEmbedFooter, + APIEmbedImage, +} from "discord-api-types/v10"; +import { normalizeArray, type RestOrArray } from "../../util/normalizeArray.js"; + +export type RGBTuple = [red: number, green: number, blue: number]; + +export interface IconData { + /** + * The URL of the icon + */ + iconURL?: string; + /** + * The proxy URL of the icon + */ + proxyIconURL?: string; +} + +export type EmbedAuthorData = IconData & + Omit; + +export type EmbedAuthorOptions = Omit; + +export type EmbedFooterData = IconData & + Omit; + +export type EmbedFooterOptions = Omit; + +export interface EmbedImageData extends Omit { + /** + * The proxy URL for the image + */ + proxyURL?: string; +} +/** + * Represents a embed in a message (image/video preview, rich embed, etc.) + */ +export class EmbedBuilder { + public readonly data: APIEmbed; + + public constructor(data: APIEmbed = {}) { + this.data = { ...data }; + if (data.timestamp) + this.data.timestamp = new Date(data.timestamp).toISOString(); + } + + /** + * Appends fields to the embed + * + * @remarks + * This method accepts either an array of fields or a variable number of field parameters. + * The maximum amount of fields that can be added is 25. + * @example + * Using an array + * ```ts + * const fields: APIEmbedField[] = ...; + * const embed = new EmbedBuilder() + * .addFields(fields); + * ``` + * @example + * Using rest parameters (variadic) + * ```ts + * const embed = new EmbedBuilder() + * .addFields( + * { name: 'Field 1', value: 'Value 1' }, + * { name: 'Field 2', value: 'Value 2' }, + * ); + * ``` + * @param fields - The fields to add + */ + public addFields(...fields: RestOrArray): this { + // eslint-disable-next-line no-param-reassign + fields = normalizeArray(fields); + + if (this.data.fields) this.data.fields.push(...fields); + else this.data.fields = fields; + return this; + } + + /** + * Removes, replaces, or inserts fields in the embed. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice}. + * The maximum amount of fields that can be added is 25. + * + * It's useful for modifying and adjusting order of the already-existing fields of an embed. + * @example + * Remove the first field + * ```ts + * embed.spliceFields(0, 1); + * ``` + * @example + * Remove the first n fields + * ```ts + * const n = 4 + * embed.spliceFields(0, n); + * ``` + * @example + * Remove the last field + * ```ts + * embed.spliceFields(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The number of fields to remove + * @param fields - The replacing field objects + */ + public spliceFields( + index: number, + deleteCount: number, + ...fields: APIEmbedField[] + ): this { + if (this.data.fields) + this.data.fields.splice(index, deleteCount, ...fields); + else this.data.fields = fields; + return this; + } + + /** + * Sets the embed's fields + * + * @remarks + * This method is an alias for {@link EmbedBuilder.spliceFields}. More specifically, + * it splices the entire array of fields, replacing them with the provided fields. + * + * You can set a maximum of 25 fields. + * @param fields - The fields to set + */ + public setFields(...fields: RestOrArray) { + this.spliceFields( + 0, + this.data.fields?.length ?? 0, + ...normalizeArray(fields), + ); + return this; + } + + /** + * Sets the author of this embed + * + * @param options - The options for the author + */ + + public setAuthor(options: EmbedAuthorOptions | null): this { + if (options === null) { + this.data.author = undefined; + return this; + } + + this.data.author = { + name: options.name, + url: options.url, + icon_url: options.iconURL, + }; + return this; + } + + /** + * Sets the color of this embed + * + * @param color - The color of the embed + */ + public setColor(color: RGBTuple | number | null): this { + if (Array.isArray(color)) { + const [red, green, blue] = color; + this.data.color = (red << 16) + (green << 8) + blue; + return this; + } + + this.data.color = color ?? undefined; + return this; + } + + /** + * Sets the description of this embed + * + * @param description - The description + */ + public setDescription(description: string | null): this { + this.data.description = description ?? undefined; + return this; + } + + /** + * Sets the footer of this embed + * + * @param options - The options for the footer + */ + public setFooter(options: EmbedFooterOptions | null): this { + if (options === null) { + this.data.footer = undefined; + return this; + } + + this.data.footer = { text: options.text, icon_url: options.iconURL }; + return this; + } + + /** + * Sets the image of this embed + * + * @param url - The URL of the image + */ + public setImage(url: string | null): this { + this.data.image = url ? { url } : undefined; + return this; + } + + /** + * Sets the thumbnail of this embed + * + * @param url - The URL of the thumbnail + */ + public setThumbnail(url: string | null): this { + this.data.thumbnail = url ? { url } : undefined; + return this; + } + + /** + * Sets the timestamp of this embed + * + * @param timestamp - The timestamp or date + */ + public setTimestamp(timestamp: number | null = Date.now()): this { + this.data.timestamp = timestamp + ? new Date(timestamp).toISOString() + : undefined; + return this; + } + + /** + * Sets the title of this embed + * + * @param title - The title + */ + public setTitle(title: string | null): this { + this.data.title = title ?? undefined; + return this; + } + + /** + * Sets the URL of this embed + * + * @param url - The URL + */ + public setURL(url: string | null): this { + this.data.url = url ?? undefined; + return this; + } + + /** + * Transforms the embed to a plain object + */ + public toJSON(): APIEmbed { + return { ...this.data }; + } +} diff --git a/packages/redis-api-client/src/index.ts b/packages/redis-api-client/src/index.ts index 07534c5..75d1b9f 100644 --- a/packages/redis-api-client/src/index.ts +++ b/packages/redis-api-client/src/index.ts @@ -7,7 +7,7 @@ export class RedisAPIClient { this.host = host; } - public async get(key: string): Promise { + public async get(key: string): Promise { const url = `${this.host}/get?key=${key}`; const response = await fetch(url, { headers: { @@ -15,7 +15,8 @@ export class RedisAPIClient { }, }); - return response.text(); + const text = await response.text(); + return text === "null" ? null : text; } public async set(key: string, value: string): Promise { @@ -60,6 +61,7 @@ export class RedisAPIClient { public async del(key: string): Promise { const url = `${this.host}/del?key=${key}`; const response = await fetch(url, { + method: "DELETE", headers: { Authorization: this.apiKey, },