From 6909336cac9f3acac8742669f1713f57ebeb3ae6 Mon Sep 17 00:00:00 2001 From: MapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Sat, 25 Dec 2021 20:05:35 -0600 Subject: [PATCH] use adapter to make discord.js optional --- notes.md | 17 ++++--- package.json | 5 ++ playground/main.tsx | 8 ++- src/adapter.ts | 9 ++++ src/button.tsx | 66 ++++++++++-------------- src/discord-js-adapter.ts | 102 +++++++++++++++++++++++++++++++++++++ src/embed/embed-child.ts | 4 +- src/embed/embed-field.tsx | 4 +- src/embed/embed-options.ts | 19 +++++++ src/embed/embed-title.tsx | 4 +- src/embed/embed.tsx | 4 +- src/interaction.ts | 29 +++++++++++ src/main.ts | 4 ++ src/message.ts | 28 ++++++++++ src/node.ts | 9 ++-- src/reacord.ts | 39 ++++++-------- src/renderer.ts | 54 +++++--------------- src/text.ts | 2 +- 18 files changed, 282 insertions(+), 125 deletions(-) create mode 100644 src/adapter.ts create mode 100644 src/discord-js-adapter.ts create mode 100644 src/embed/embed-options.ts create mode 100644 src/interaction.ts create mode 100644 src/message.ts diff --git a/notes.md b/notes.md index ce440ab..8c354a6 100644 --- a/notes.md +++ b/notes.md @@ -1,17 +1,17 @@ # core features -- [x] rendering core -- [ ] render to interaction +- [ ] render to channel +- [x] render to interaction - [ ] ephemeral messages - [x] message content - embed - [x] color - - [x] author + - [ ] author - [x] description - [x] title - text children, url - - [x] footer - icon url, timestamp, text children - - [x] thumbnail - url - - [x] image - url + - [ ] footer - icon url, timestamp, text children + - [ ] thumbnail - url + - [ ] image - url - [x] fields - name, value, inline - message components - [x] buttons @@ -20,11 +20,16 @@ - [ ] action row - [x] button onClick - [ ] select onChange +- [x] deactivate +- [ ] destroy # cool ideas / polish +- [ ] message property on reacord instance - [ ] files - [ ] stickers - [ ] user mention component - [ ] channel mention component - [ ] timestamp component +- [ ] `useMessage` +- [ ] `useReactions` diff --git a/package.json b/package.json index fb5b957..30b6ab3 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,11 @@ "discord.js": "^13.3", "react": ">=17" }, + "peerDependenciesMeta": { + "discord.js": { + "optional": true + } + }, "devDependencies": { "@itsmapleleaf/configs": "^1.1.2", "@typescript-eslint/eslint-plugin": "^5.8.0", diff --git a/playground/main.tsx b/playground/main.tsx index 86c062d..3fcdf9e 100644 --- a/playground/main.tsx +++ b/playground/main.tsx @@ -1,6 +1,7 @@ import { Client } from "discord.js" import "dotenv/config" import React from "react" +import { DiscordJsAdapter } from "../src/discord-js-adapter" import { Reacord } from "../src/main.js" import { createCommandHandler } from "./command-handler.js" import { Counter } from "./counter.js" @@ -9,14 +10,17 @@ const client = new Client({ intents: ["GUILDS"], }) -const reacord = Reacord.create({ client, maxInstances: 2 }) +const reacord = new Reacord({ + adapter: new DiscordJsAdapter(client), + maxInstances: 2, +}) createCommandHandler(client, [ { name: "counter", description: "shows a counter button", run: (interaction) => { - const reply = reacord.reply(interaction) + const reply = reacord.createCommandReply(interaction) reply.render( reply.deactivate()} />) }, }, diff --git a/src/adapter.ts b/src/adapter.ts new file mode 100644 index 0000000..beb40d3 --- /dev/null +++ b/src/adapter.ts @@ -0,0 +1,9 @@ +import type { CommandInteraction, ComponentInteraction } from "./interaction" + +export type Adapter = { + addComponentInteractionListener( + listener: (interaction: ComponentInteraction) => void, + ): void + + createCommandInteraction(interactionInfo: InteractionInit): CommandInteraction +} diff --git a/src/button.tsx b/src/button.tsx index a2dc16d..669eedc 100644 --- a/src/button.tsx +++ b/src/button.tsx @@ -1,24 +1,16 @@ -import type { - ButtonInteraction, - CacheType, - EmojiResolvable, - MessageButtonStyle, - MessageComponentInteraction, - MessageOptions, -} from "discord.js" -import { MessageActionRow } from "discord.js" import { nanoid } from "nanoid" import React from "react" import { ReacordElement } from "./element.js" import { last } from "./helpers/last.js" -import { toUpper } from "./helpers/to-upper.js" +import type { ButtonInteraction, ComponentInteraction } from "./interaction" +import type { MessageOptions } from "./message" import { Node } from "./node.js" export type ButtonProps = { label?: string - style?: Exclude, "link"> + style?: "primary" | "secondary" | "success" | "danger" disabled?: boolean - emoji?: EmojiResolvable + emoji?: string onClick: (interaction: ButtonInteraction) => void } @@ -31,44 +23,38 @@ export function Button(props: ButtonProps) { class ButtonNode extends Node { private customId = nanoid() - private get buttonOptions() { - return { - type: "BUTTON", + override modifyMessageOptions(options: MessageOptions): void { + options.actionRows ??= [] + + let actionRow = last(options.actionRows) + + if ( + actionRow == undefined || + actionRow.length >= 5 || + actionRow[0]?.type === "select" + ) { + actionRow = [] + options.actionRows.push(actionRow) + } + + actionRow.push({ + type: "button", customId: this.customId, - style: toUpper(this.props.style ?? "secondary"), + style: this.props.style ?? "secondary", disabled: this.props.disabled, emoji: this.props.emoji, label: this.props.label, - } as const + }) } - override modifyMessageOptions(options: MessageOptions): void { - options.components ??= [] - - let actionRow = last(options.components) - + override handleComponentInteraction(interaction: ComponentInteraction) { if ( - !actionRow || - actionRow.components.length >= 5 || - actionRow.components[0]?.type === "SELECT_MENU" + interaction.type === "button" && + interaction.customId === this.customId ) { - actionRow = new MessageActionRow() - options.components.push(actionRow) - } - - if (actionRow instanceof MessageActionRow) { - actionRow.addComponents(this.buttonOptions) - } else { - actionRow.components.push(this.buttonOptions) - } - } - - override handleInteraction( - interaction: MessageComponentInteraction, - ) { - if (interaction.isButton() && interaction.customId === this.customId) { this.props.onClick(interaction) return true } + return false } } diff --git a/src/discord-js-adapter.ts b/src/discord-js-adapter.ts new file mode 100644 index 0000000..41ff42e --- /dev/null +++ b/src/discord-js-adapter.ts @@ -0,0 +1,102 @@ +import type * as Discord from "discord.js" +import type { Adapter } from "./adapter" +import { raise } from "./helpers/raise" +import { toUpper } from "./helpers/to-upper" +import type { CommandInteraction, ComponentInteraction } from "./interaction" +import type { Message, MessageOptions } from "./message" + +export class DiscordJsAdapter implements Adapter { + constructor(private client: Discord.Client) {} + + addComponentInteractionListener( + listener: (interaction: ComponentInteraction) => void, + ) { + this.client.on("interactionCreate", (interaction) => { + if (interaction.isButton()) { + listener(createReacordComponentInteraction(interaction)) + } + }) + } + + createCommandInteraction( + interaction: Discord.CommandInteraction, + ): CommandInteraction { + return { + type: "command", + id: interaction.id, + channelId: interaction.channelId, + reply: async (options) => { + const message = await interaction.reply({ + ...getDiscordMessageOptions(options), + fetchReply: true, + }) + return createReacordMessage(message as Discord.Message) + }, + followUp: async (options) => { + const message = await interaction.followUp({ + ...getDiscordMessageOptions(options), + fetchReply: true, + }) + return createReacordMessage(message as Discord.Message) + }, + } + } +} + +function createReacordComponentInteraction( + interaction: Discord.MessageComponentInteraction, +): ComponentInteraction { + return { + type: "button", + id: interaction.id, + channelId: interaction.channelId, + customId: interaction.customId, + update: async (options) => { + await interaction.update(getDiscordMessageOptions(options)) + }, + } +} + +function createReacordMessage(message: Discord.Message): Message { + return { + edit: async (options) => { + await message.edit(getDiscordMessageOptions(options)) + }, + disableComponents: async () => { + for (const actionRow of message.components) { + for (const component of actionRow.components) { + component.setDisabled(true) + } + } + + await message.edit({ + components: message.components, + }) + }, + } +} + +function getDiscordMessageOptions( + options: MessageOptions, +): Discord.MessageOptions { + return { + content: options.content, + embeds: options.embeds, + components: options.actionRows.map((row) => ({ + type: "ACTION_ROW", + components: row.map((component) => { + if (component.type === "button") { + return { + type: "BUTTON", + customId: component.customId, + label: component.label ?? "", + style: toUpper(component.style ?? "secondary"), + disabled: component.disabled, + emoji: component.emoji, + } + } + raise(`Unsupported component type: ${component.type}`) + }), + })), + } +} diff --git a/src/embed/embed-child.ts b/src/embed/embed-child.ts index a4c04c3..0fa58f7 100644 --- a/src/embed/embed-child.ts +++ b/src/embed/embed-child.ts @@ -1,6 +1,6 @@ -import type { MessageEmbedOptions } from "discord.js" import { Node } from "../node.js" +import type { EmbedOptions } from "./embed-options" export abstract class EmbedChildNode extends Node { - abstract modifyEmbedOptions(options: MessageEmbedOptions): void + abstract modifyEmbedOptions(options: EmbedOptions): void } diff --git a/src/embed/embed-field.tsx b/src/embed/embed-field.tsx index 6c3013f..766f0e1 100644 --- a/src/embed/embed-field.tsx +++ b/src/embed/embed-field.tsx @@ -1,7 +1,7 @@ -import type { MessageEmbedOptions } from "discord.js" import React from "react" import { ReacordElement } from "../element.js" import { EmbedChildNode } from "./embed-child.js" +import type { EmbedOptions } from "./embed-options" export type EmbedFieldProps = { name: string @@ -19,7 +19,7 @@ export function EmbedField(props: EmbedFieldProps) { } class EmbedFieldNode extends EmbedChildNode { - override modifyEmbedOptions(options: MessageEmbedOptions): void { + override modifyEmbedOptions(options: EmbedOptions): void { options.fields ??= [] options.fields.push({ name: this.props.name, diff --git a/src/embed/embed-options.ts b/src/embed/embed-options.ts new file mode 100644 index 0000000..0ae3eb6 --- /dev/null +++ b/src/embed/embed-options.ts @@ -0,0 +1,19 @@ +export type EmbedOptions = { + title?: string + description?: string + url?: string + timestamp?: string + color?: number + fields?: EmbedFieldOptions[] + author?: { name: string; url?: string; icon_url?: string } + thumbnail?: { url: string } + image?: { url: string } + video?: { url: string } + footer?: { text: string; icon_url?: string } +} + +export type EmbedFieldOptions = { + name: string + value: string + inline?: boolean +} diff --git a/src/embed/embed-title.tsx b/src/embed/embed-title.tsx index 50958ef..4470ab6 100644 --- a/src/embed/embed-title.tsx +++ b/src/embed/embed-title.tsx @@ -1,7 +1,7 @@ -import type { MessageEmbedOptions } from "discord.js" import React from "react" import { ReacordElement } from "../element.js" import { EmbedChildNode } from "./embed-child.js" +import type { EmbedOptions } from "./embed-options" export type EmbedTitleProps = { children: string @@ -18,7 +18,7 @@ export function EmbedTitle(props: EmbedTitleProps) { } class EmbedTitleNode extends EmbedChildNode { - override modifyEmbedOptions(options: MessageEmbedOptions): void { + override modifyEmbedOptions(options: EmbedOptions): void { options.title = this.props.children options.url = this.props.url } diff --git a/src/embed/embed.tsx b/src/embed/embed.tsx index db5e7a1..b7b19f4 100644 --- a/src/embed/embed.tsx +++ b/src/embed/embed.tsx @@ -1,13 +1,13 @@ -import type { MessageOptions } from "discord.js" import React from "react" import { ReacordElement } from "../element.js" +import type { MessageOptions } from "../message" import { Node } from "../node.js" import { EmbedChildNode } from "./embed-child.js" export type EmbedProps = { description?: string url?: string - timestamp?: Date + timestamp?: string color?: number footer?: { text: string diff --git a/src/interaction.ts b/src/interaction.ts new file mode 100644 index 0000000..ee9cd07 --- /dev/null +++ b/src/interaction.ts @@ -0,0 +1,29 @@ +import type { Message, MessageOptions } from "./message" + +export type Interaction = CommandInteraction | ComponentInteraction + +export type CommandInteraction = { + type: "command" + id: string + channelId: string + reply(messageOptions: MessageOptions): Promise + followUp(messageOptions: MessageOptions): Promise +} + +export type ComponentInteraction = ButtonInteraction | SelectInteraction + +export type ButtonInteraction = { + type: "button" + id: string + channelId: string + customId: string + update(options: MessageOptions): Promise +} + +export type SelectInteraction = { + type: "select" + id: string + channelId: string + customId: string + update(options: MessageOptions): Promise +} diff --git a/src/main.ts b/src/main.ts index 24cbec5..f53b025 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,9 @@ +export * from "./adapter" export * from "./button" +export * from "./discord-js-adapter" export * from "./embed/embed" export * from "./embed/embed-field" export * from "./embed/embed-title" +export * from "./interaction" +export * from "./message" export * from "./reacord" diff --git a/src/message.ts b/src/message.ts new file mode 100644 index 0000000..4303e3b --- /dev/null +++ b/src/message.ts @@ -0,0 +1,28 @@ +import type { EmbedOptions } from "./embed/embed-options" + +export type MessageOptions = { + content: string + embeds: EmbedOptions[] + actionRows: Array< + Array< + | { + type: "button" + customId: string + label?: string + style?: "primary" | "secondary" | "success" | "danger" + disabled?: boolean + emoji?: string + } + | { + type: "select" + customId: string + // todo + } + > + > +} + +export type Message = { + edit(options: MessageOptions): Promise + disableComponents(): Promise +} diff --git a/src/node.ts b/src/node.ts index 0b30cba..346cba2 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,6 +1,7 @@ /* eslint-disable class-methods-use-this */ -import type { MessageComponentInteraction, MessageOptions } from "discord.js" import { Container } from "./container.js" +import type { ComponentInteraction } from "./interaction" +import type { MessageOptions } from "./message" export abstract class Node { readonly children = new Container>() @@ -16,9 +17,7 @@ export abstract class Node { modifyMessageOptions(options: MessageOptions) {} - handleInteraction( - interaction: MessageComponentInteraction, - ): true | undefined { - return undefined + handleComponentInteraction(interaction: ComponentInteraction): boolean { + return false } } diff --git a/src/reacord.ts b/src/reacord.ts index b4597a3..3b8d572 100644 --- a/src/reacord.ts +++ b/src/reacord.ts @@ -1,13 +1,10 @@ -import type { Client, CommandInteraction } from "discord.js" import type { ReactNode } from "react" +import type { Adapter } from "./adapter" import { reconciler } from "./reconciler.js" import { Renderer } from "./renderer.js" -export type ReacordConfig = { - /** - * A Discord.js client. Reacord will listen to interaction events - * and send them to active instances. */ - client: Client +export type ReacordConfig = { + adapter: Adapter /** * The max number of active instances. @@ -21,34 +18,30 @@ export type ReacordInstance = { deactivate: () => void } -export class Reacord { +export class Reacord { private renderers: Renderer[] = [] - private constructor(private readonly config: ReacordConfig) {} + constructor(private readonly config: ReacordConfig) { + config.adapter.addComponentInteractionListener((interaction) => { + for (const renderer of this.renderers) { + if (renderer.handleComponentInteraction(interaction)) return + } + }) + } private get maxInstances() { return this.config.maxInstances ?? 50 } - static create(config: ReacordConfig) { - const manager = new Reacord(config) - - config.client.on("interactionCreate", (interaction) => { - if (!interaction.isMessageComponent()) return - for (const renderer of manager.renderers) { - if (renderer.handleInteraction(interaction)) return - } - }) - - return manager - } - - reply(interaction: CommandInteraction): ReacordInstance { + createCommandReply(target: InteractionInit): ReacordInstance { if (this.renderers.length > this.maxInstances) { this.deactivate(this.renderers[0]!) } - const renderer = new Renderer(interaction) + const renderer = new Renderer( + this.config.adapter.createCommandInteraction(target), + ) + this.renderers.push(renderer) const container = reconciler.createContainer(renderer, 0, false, {}) diff --git a/src/renderer.ts b/src/renderer.ts index 3cb60bd..9256dd0 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,12 +1,9 @@ -import type { - CommandInteraction, - MessageComponentInteraction, - MessageOptions, -} from "discord.js" import type { Subscription } from "rxjs" import { Subject } from "rxjs" import { concatMap } from "rxjs/operators" import { Container } from "./container.js" +import type { CommandInteraction, ComponentInteraction } from "./interaction" +import type { Message, MessageOptions } from "./message" import type { Node } from "./node.js" // keep track of interaction ids which have replies, @@ -20,8 +17,8 @@ type UpdatePayload = { export class Renderer { readonly nodes = new Container>() - private componentInteraction?: MessageComponentInteraction - private messageId?: string + private componentInteraction?: ComponentInteraction + private message?: Message private updates = new Subject() private updateSubscription: Subscription private active = true @@ -52,10 +49,10 @@ export class Renderer { }) } - handleInteraction(interaction: MessageComponentInteraction) { + handleComponentInteraction(interaction: ComponentInteraction) { + this.componentInteraction = interaction for (const node of this.nodes) { - this.componentInteraction = interaction - if (node.handleInteraction(interaction)) { + if (node.handleComponentInteraction(interaction)) { return true } } @@ -65,7 +62,7 @@ export class Renderer { const options: MessageOptions = { content: "", embeds: [], - components: [], + actionRows: [], } for (const node of this.nodes) { node.modifyMessageOptions(options) @@ -74,24 +71,9 @@ export class Renderer { } private async updateMessage({ options, action }: UpdatePayload) { - if (action === "deactivate" && this.messageId) { + if (action === "deactivate" && this.message) { this.updateSubscription.unsubscribe() - - const message = await this.interaction.channel?.messages.fetch( - this.messageId, - ) - if (!message) return - - for (const actionRow of message.components) { - for (const component of actionRow.components) { - component.setDisabled(true) - } - } - - await this.interaction.channel?.messages.edit(message.id, { - components: message.components, - }) - + await this.message.disableComponents() return } @@ -102,25 +84,17 @@ export class Renderer { return } - if (this.messageId) { - await this.interaction.channel?.messages.edit(this.messageId, options) + if (this.message) { + await this.message.edit(options) return } if (repliedInteractionIds.has(this.interaction.id)) { - const message = await this.interaction.followUp({ - ...options, - fetchReply: true, - }) - this.messageId = message.id + this.message = await this.interaction.followUp(options) return } repliedInteractionIds.add(this.interaction.id) - const message = await this.interaction.reply({ - ...options, - fetchReply: true, - }) - this.messageId = message.id + this.message = await this.interaction.reply(options) } } diff --git a/src/text.ts b/src/text.ts index 24cab0f..a4baeeb 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,4 +1,4 @@ -import type { MessageOptions } from "discord.js" +import type { MessageOptions } from "./message" import { Node } from "./node.js" export class TextNode extends Node {