feat(bot): finish setup, select

This commit is contained in:
xHyroM 2023-04-09 15:36:53 +02:00
parent d1b58b4496
commit aff5c3227d
No known key found for this signature in database
GPG key ID: BE0423F386C436AA
15 changed files with 762 additions and 73 deletions

View file

@ -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,
});
},
});

View file

@ -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<TextInputBuilder>()
.addComponents(
new TextInputBuilder()
.setLabel("Webhook Name")
.setCustomId("name")
.setPlaceholder("Roles Bot")
.setStyle(TextInputStyle.Short)
.setMaxLength(80)
.setRequired(true),
)
.toJSON(),
new ActionRowBuilder<TextInputBuilder>()
.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!",
content: "Setup completed!",
flags: MessageFlags.Ephemeral,
},
});
}
}
},
});

View file

@ -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) =>
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 });

View file

@ -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!",
});
},
});

View file

@ -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;

View file

@ -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;
};
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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(/<?(?:(a):)?(\w{2,32}):(\d{17,19})?>?/);
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) };
}

View file

@ -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,10 +26,7 @@ export default async function (ctx: Context, rawRole: string) {
const roleName = roles?.find((r) => r.id === rawRole)?.name;
return ctx.returnModal({
title: `${roleName?.slice(0, 39)} Role`,
custom_id: "setup:part-roles-lpe",
components: [
const components = [
new ActionRowBuilder<TextInputBuilder>()
.addComponents(
new TextInputBuilder()
@ -36,17 +34,7 @@ export default async function (ctx: Context, rawRole: string) {
.setCustomId("label")
.setPlaceholder("Ping")
.setStyle(TextInputStyle.Short)
.setRequired(false),
)
.toJSON(),
new ActionRowBuilder<TextInputBuilder>()
.addComponents(
new TextInputBuilder()
.setLabel("Placeholder")
.setCustomId("placeholder")
.setPlaceholder("pingping pong pong")
.setStyle(TextInputStyle.Short)
.setRequired(false),
.setRequired(true),
)
.toJSON(),
new ActionRowBuilder<TextInputBuilder>()
@ -59,6 +47,44 @@ export default async function (ctx: Context, rawRole: string) {
.setRequired(false),
)
.toJSON(),
],
];
switch (data.selecting) {
case "buttons": {
components.push(
new ActionRowBuilder<TextInputBuilder>()
.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<TextInputBuilder>()
.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,
});
}

View file

@ -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<APIMessageActionRowComponent>[];
} = {
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<APIMessageActionRowComponent>[] = [];
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})`,
});
}
}
}
}

View file

@ -0,0 +1,7 @@
export default function <T>(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;
}

View file

@ -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";

View file

@ -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<APIEmbedAuthor, "icon_url" | "proxy_icon_url">;
export type EmbedAuthorOptions = Omit<EmbedAuthorData, "proxyIconURL">;
export type EmbedFooterData = IconData &
Omit<APIEmbedFooter, "icon_url" | "proxy_icon_url">;
export type EmbedFooterOptions = Omit<EmbedFooterData, "proxyIconURL">;
export interface EmbedImageData extends Omit<APIEmbedImage, "proxy_url"> {
/**
* 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<APIEmbedField>): 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<APIEmbedField>) {
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 };
}
}

View file

@ -7,7 +7,7 @@ export class RedisAPIClient {
this.host = host;
}
public async get(key: string): Promise<string> {
public async get(key: string): Promise<string | null> {
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<string> {
@ -60,6 +61,7 @@ export class RedisAPIClient {
public async del(key: string): Promise<string> {
const url = `${this.host}/del?key=${key}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
Authorization: this.apiKey,
},