From ef26b66cb82a66532ea1dbb967c5021262a44fa1 Mon Sep 17 00:00:00 2001 From: MapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Mon, 27 Dec 2021 20:57:04 -0600 Subject: [PATCH] rendering to channel + simplified adapter interface --- library/core/adapters/adapter.ts | 31 --- ...rd-js-adapter.ts => reacord-discord-js.ts} | 120 +++++----- library/core/reacord-tester.ts | 214 ++++++++++++++++++ library/core/reacord.ts | 48 ++-- library/main.ts | 4 +- library/testing.ts | 190 ---------------- package.json | 1 + playground/main.tsx | 38 +++- pnpm-lock.yaml | 14 ++ test/action-row.test.tsx | 6 +- test/discord-js.test.tsx | 2 + test/embed.test.tsx | 14 +- test/link.test.tsx | 6 +- test/reacord.test.tsx | 52 +++-- test/select.test.tsx | 34 +-- test/setup-testing.ts | 52 ----- todo.md | 7 +- 17 files changed, 408 insertions(+), 425 deletions(-) delete mode 100644 library/core/adapters/adapter.ts rename library/core/{adapters/discord-js-adapter.ts => reacord-discord-js.ts} (72%) create mode 100644 library/core/reacord-tester.ts delete mode 100644 library/testing.ts create mode 100644 test/discord-js.test.tsx delete mode 100644 test/setup-testing.ts diff --git a/library/core/adapters/adapter.ts b/library/core/adapters/adapter.ts deleted file mode 100644 index beef898..0000000 --- a/library/core/adapters/adapter.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Channel } from "../../internal/channel" -import type { - CommandInteraction, - ComponentInteraction, -} from "../../internal/interaction" - -export type AdapterGenerics = { - commandReplyInit: unknown - channelInit: unknown -} - -export type Adapter = { - /** - * @internal - */ - addComponentInteractionListener( - listener: (interaction: ComponentInteraction) => void, - ): void - - /** - * @internal - */ - createCommandInteraction( - init: Generics["commandReplyInit"], - ): CommandInteraction - - /** - * @internal - */ - createChannel(init: Generics["channelInit"]): Channel -} diff --git a/library/core/adapters/discord-js-adapter.ts b/library/core/reacord-discord-js.ts similarity index 72% rename from library/core/adapters/discord-js-adapter.ts rename to library/core/reacord-discord-js.ts index 8be3fd4..488591c 100644 --- a/library/core/adapters/discord-js-adapter.ts +++ b/library/core/reacord-discord-js.ts @@ -1,43 +1,35 @@ import type * as Discord from "discord.js" -import { raise } from "../../../helpers/raise" -import { toUpper } from "../../../helpers/to-upper" -import type { Channel } from "../../internal/channel" -import type { - CommandInteraction, - ComponentInteraction, -} from "../../internal/interaction" -import type { Message, MessageOptions } from "../../internal/message" -import type { Adapter } from "./adapter" +import { raise } from "../../helpers/raise" +import { toUpper } from "../../helpers/to-upper" +import type { ComponentInteraction } from "../internal/interaction" +import type { Message, MessageOptions } from "../internal/message" +import type { ReacordConfig, ReacordInstance } from "./reacord" +import { Reacord } from "./reacord" -type DiscordJsAdapterGenerics = { - commandReplyInit: Discord.CommandInteraction - channelInit: Discord.TextBasedChannel -} +export class ReacordDiscordJs extends Reacord { + constructor(client: Discord.Client, config: ReacordConfig = {}) { + super(config) -export class DiscordJsAdapter implements Adapter { - constructor(private client: Discord.Client) {} - - /** - * @internal - */ - addComponentInteractionListener( - listener: (interaction: ComponentInteraction) => void, - ) { - this.client.on("interactionCreate", (interaction) => { + client.on("interactionCreate", (interaction) => { if (interaction.isMessageComponent()) { - listener(createReacordComponentInteraction(interaction)) + this.handleComponentInteraction( + createReacordComponentInteraction(interaction), + ) } }) } - /** - * @internal - */ - // eslint-disable-next-line class-methods-use-this - createCommandInteraction( - interaction: Discord.CommandInteraction, - ): CommandInteraction { - return { + override send(channel: Discord.TextBasedChannel): ReacordInstance { + return this.createChannelRendererInstance({ + send: async (options) => { + const message = await channel.send(getDiscordMessageOptions(options)) + return createReacordMessage(message) + }, + }) + } + + override reply(interaction: Discord.CommandInteraction): ReacordInstance { + return this.createCommandReplyRendererInstance({ type: "command", id: interaction.id, channelId: interaction.channelId, @@ -55,20 +47,7 @@ export class DiscordJsAdapter implements Adapter { }) return createReacordMessage(message as Discord.Message) }, - } - } - - /** - * @internal - */ - // eslint-disable-next-line class-methods-use-this - createChannel(channel: Discord.TextBasedChannel): Channel { - return { - send: async (options) => { - const message = await channel.send(getDiscordMessageOptions(options)) - return createReacordMessage(message) - }, - } + }) } } @@ -103,33 +82,14 @@ function createReacordComponentInteraction( raise(`Unsupported component interaction type: ${interaction.type}`) } -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, - }) - }, - delete: async () => { - await message.delete() - }, - } -} - +// TODO: this could be a part of the core library, +// and also handle some edge cases, e.g. empty messages function getDiscordMessageOptions( options: MessageOptions, ): Discord.MessageOptions { return { - content: options.content, + // eslint-disable-next-line unicorn/no-null + content: options.content || null, embeds: options.embeds, components: options.actionRows.map((row) => ({ type: "ACTION_ROW", @@ -163,3 +123,25 @@ function getDiscordMessageOptions( })), } } + +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, + }) + }, + delete: async () => { + await message.delete() + }, + } +} diff --git a/library/core/reacord-tester.ts b/library/core/reacord-tester.ts new file mode 100644 index 0000000..2095b40 --- /dev/null +++ b/library/core/reacord-tester.ts @@ -0,0 +1,214 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable require-await */ +import { nanoid } from "nanoid" +import { nextTick } from "node:process" +import { promisify } from "node:util" +import type { ReactNode } from "react" +import { logPretty } from "../../helpers/log-pretty" +import { omit } from "../../helpers/omit" +import { raise } from "../../helpers/raise" +import type { Channel } from "../internal/channel" +import { Container } from "../internal/container" +import type { + ButtonInteraction, + CommandInteraction, + SelectInteraction, +} from "../internal/interaction" +import type { + Message, + MessageButtonOptions, + MessageOptions, + MessageSelectOptions, +} from "../internal/message" +import type { ReacordInstance } from "./reacord" +import { Reacord } from "./reacord" + +const nextTickPromise = promisify(nextTick) + +export class ReacordTester extends Reacord { + private messageContainer = new Container() + + constructor() { + super({ maxInstances: 2 }) + } + + get messages(): readonly TestMessage[] { + return [...this.messageContainer] + } + + send(): ReacordInstance { + return this.createChannelRendererInstance( + new TestChannel(this.messageContainer), + ) + } + + reply(): ReacordInstance { + return this.createCommandReplyRendererInstance( + new TestCommandInteraction(this.messageContainer), + ) + } + + async assertMessages(expected: ReturnType) { + await nextTickPromise() + expect(this.sampleMessages()).toEqual(expected) + } + + async assertRender( + content: ReactNode, + expected: ReturnType, + ) { + const instance = this.reply() + instance.render(content) + await this.assertMessages(expected) + instance.destroy() + } + + logMessages() { + logPretty(this.sampleMessages()) + } + + sampleMessages() { + return this.messages.map((message) => ({ + ...message.options, + actionRows: message.options.actionRows.map((row) => + row.map((component) => + omit(component, ["customId", "onClick", "onSelect", "onSelectValue"]), + ), + ), + })) + } + + findButtonByLabel(label: string) { + for (const message of this.messageContainer) { + for (const component of message.options.actionRows.flat()) { + if (component.type === "button" && component.label === label) { + return this.createButtonActions(component, message) + } + } + } + raise(`Couldn't find button with label "${label}"`) + } + + findSelectByPlaceholder(placeholder: string) { + for (const message of this.messageContainer) { + for (const component of message.options.actionRows.flat()) { + if ( + component.type === "select" && + component.placeholder === placeholder + ) { + return this.createSelectActions(component, message) + } + } + } + raise(`Couldn't find select with placeholder "${placeholder}"`) + } + + private createButtonActions( + button: MessageButtonOptions, + message: TestMessage, + ) { + return { + click: () => { + this.handleComponentInteraction( + new TestButtonInteraction(button.customId, message), + ) + }, + } + } + + private createSelectActions( + component: MessageSelectOptions, + message: TestMessage, + ) { + return { + select: (...values: string[]) => { + this.handleComponentInteraction( + new TestSelectInteraction(component.customId, message, values), + ) + }, + } + } +} + +class TestMessage implements Message { + constructor( + public options: MessageOptions, + private container: Container, + ) { + container.add(this) + } + + async edit(options: MessageOptions): Promise { + this.options = options + } + + async disableComponents(): Promise { + for (const row of this.options.actionRows) { + for (const action of row) { + if (action.type === "button") { + action.disabled = true + } + } + } + } + + async delete(): Promise { + this.container.remove(this) + } +} + +class TestCommandInteraction implements CommandInteraction { + readonly type = "command" + readonly id = "test-command-interaction" + readonly channelId = "test-channel-id" + + constructor(private messageContainer: Container) {} + + reply(messageOptions: MessageOptions): Promise { + return Promise.resolve( + new TestMessage(messageOptions, this.messageContainer), + ) + } + + followUp(messageOptions: MessageOptions): Promise { + return Promise.resolve( + new TestMessage(messageOptions, this.messageContainer), + ) + } +} + +class TestButtonInteraction implements ButtonInteraction { + readonly type = "button" + readonly id = nanoid() + readonly channelId = "test-channel-id" + + constructor(readonly customId: string, readonly message: TestMessage) {} + + async update(options: MessageOptions): Promise { + this.message.options = options + } +} + +class TestSelectInteraction implements SelectInteraction { + readonly type = "select" + readonly id = nanoid() + readonly channelId = "test-channel-id" + + constructor( + readonly customId: string, + readonly message: TestMessage, + readonly values: string[], + ) {} + + async update(options: MessageOptions): Promise { + this.message.options = options + } +} + +class TestChannel implements Channel { + constructor(private messageContainer: Container) {} + + async send(messageOptions: MessageOptions): Promise { + return new TestMessage(messageOptions, this.messageContainer) + } +} diff --git a/library/core/reacord.ts b/library/core/reacord.ts index 9ee41d7..ba3c0bc 100644 --- a/library/core/reacord.ts +++ b/library/core/reacord.ts @@ -1,13 +1,15 @@ import type { ReactNode } from "react" +import type { Channel } from "../internal/channel" import { ChannelMessageRenderer } from "../internal/channel-message-renderer" import { CommandReplyRenderer } from "../internal/command-reply-renderer.js" +import type { + CommandInteraction, + ComponentInteraction, +} from "../internal/interaction" import { reconciler } from "../internal/reconciler.js" import type { Renderer } from "../internal/renderer" -import type { Adapter, AdapterGenerics } from "./adapters/adapter" - -export type ReacordConfig = { - adapter: Adapter +export type ReacordConfig = { /** * The max number of active instances. * When this limit is exceeded, the oldest instances will be disabled. @@ -21,33 +23,37 @@ export type ReacordInstance = { destroy: () => void } -export class Reacord { +export type ComponentInteractionListener = ( + interaction: ComponentInteraction, +) => void + +export abstract class Reacord { private renderers: Renderer[] = [] - constructor(private readonly config: ReacordConfig) { - config.adapter.addComponentInteractionListener((interaction) => { - for (const renderer of this.renderers) { - if (renderer.handleComponentInteraction(interaction)) return - } - }) + constructor(private readonly config: ReacordConfig = {}) {} + + abstract send(channel: unknown): ReacordInstance + + abstract reply(commandInteraction: unknown): ReacordInstance + + protected handleComponentInteraction(interaction: ComponentInteraction) { + for (const renderer of this.renderers) { + if (renderer.handleComponentInteraction(interaction)) return + } } private get maxInstances() { return this.config.maxInstances ?? 50 } - send(init: Generics["channelInit"]): ReacordInstance { - return this.createInstance( - new ChannelMessageRenderer(this.config.adapter.createChannel(init)), - ) + protected createChannelRendererInstance(channel: Channel) { + return this.createInstance(new ChannelMessageRenderer(channel)) } - reply(init: Generics["commandReplyInit"]): ReacordInstance { - return this.createInstance( - new CommandReplyRenderer( - this.config.adapter.createCommandInteraction(init), - ), - ) + protected createCommandReplyRendererInstance( + commandInteraction: CommandInteraction, + ): ReacordInstance { + return this.createInstance(new CommandReplyRenderer(commandInteraction)) } private createInstance(renderer: Renderer) { diff --git a/library/main.ts b/library/main.ts index ba513f1..a71f695 100644 --- a/library/main.ts +++ b/library/main.ts @@ -1,5 +1,3 @@ -export * from "./core/adapters/adapter" -export * from "./core/adapters/discord-js-adapter" export * from "./core/components/action-row" export * from "./core/components/button" export * from "./core/components/embed" @@ -13,3 +11,5 @@ export * from "./core/components/link" export * from "./core/components/option" export * from "./core/components/select" export * from "./core/reacord" +export * from "./core/reacord-discord-js" +export * from "./core/reacord-tester" diff --git a/library/testing.ts b/library/testing.ts deleted file mode 100644 index 05cb745..0000000 --- a/library/testing.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* eslint-disable class-methods-use-this */ -/* eslint-disable require-await */ -import { nanoid } from "nanoid" -import { raise } from "../helpers/raise" -import type { Adapter } from "./core/adapters/adapter" -import type { Channel } from "./internal/channel" -import type { - ButtonInteraction, - CommandInteraction, - ComponentInteraction, - SelectInteraction, -} from "./internal/interaction" -import type { - Message, - MessageButtonOptions, - MessageOptions, - MessageSelectOptions, -} from "./internal/message" - -type TestAdapterGenerics = { - commandReplyInit: TestCommandInteraction - channelInit: TestChannel -} - -export class TestAdapter implements Adapter { - messages: TestMessage[] = [] - - private constructor() {} - - static create(): Adapter & TestAdapter { - return new TestAdapter() - } - - private componentInteractionListener: ( - interaction: ComponentInteraction, - ) => void = () => {} - - addComponentInteractionListener( - listener: (interaction: ComponentInteraction) => void, - ): void { - this.componentInteractionListener = listener - } - - createCommandInteraction( - interaction: CommandInteraction, - ): CommandInteraction { - return interaction - } - - createChannel(channel: TestChannel): Channel { - return channel - } - - findButtonByLabel(label: string) { - for (const message of this.messages) { - for (const component of message.options.actionRows.flat()) { - if (component.type === "button" && component.label === label) { - return this.createButtonActions(component, message) - } - } - } - raise(`Couldn't find button with label "${label}"`) - } - - findSelectByPlaceholder(placeholder: string) { - for (const message of this.messages) { - for (const component of message.options.actionRows.flat()) { - if ( - component.type === "select" && - component.placeholder === placeholder - ) { - return this.createSelectActions(component, message) - } - } - } - raise(`Couldn't find select with placeholder "${placeholder}"`) - } - - removeMessage(message: TestMessage) { - this.messages = this.messages.filter((m) => m !== message) - } - - private createButtonActions( - button: MessageButtonOptions, - message: TestMessage, - ) { - return { - click: () => { - this.componentInteractionListener( - new TestButtonInteraction(button.customId, message), - ) - }, - } - } - - private createSelectActions( - component: MessageSelectOptions, - message: TestMessage, - ) { - return { - select: (...values: string[]) => { - this.componentInteractionListener( - new TestSelectInteraction(component.customId, message, values), - ) - }, - } - } -} - -export class TestMessage implements Message { - constructor(public options: MessageOptions, private adapter: TestAdapter) {} - - async edit(options: MessageOptions): Promise { - this.options = options - } - - async disableComponents(): Promise { - for (const row of this.options.actionRows) { - for (const action of row) { - if (action.type === "button") { - action.disabled = true - } - } - } - } - - async delete(): Promise { - this.adapter.removeMessage(this) - } -} - -export class TestCommandInteraction implements CommandInteraction { - readonly type = "command" - readonly id = "test-command-interaction" - readonly channelId = "test-channel-id" - - constructor(private adapter: TestAdapter) {} - - private createMesssage(messageOptions: MessageOptions): Message { - const message = new TestMessage(messageOptions, this.adapter) - this.adapter.messages.push(message) - return message - } - - reply(messageOptions: MessageOptions): Promise { - return Promise.resolve(this.createMesssage(messageOptions)) - } - - followUp(messageOptions: MessageOptions): Promise { - return Promise.resolve(this.createMesssage(messageOptions)) - } -} - -export class TestButtonInteraction implements ButtonInteraction { - readonly type = "button" - readonly id = nanoid() - readonly channelId = "test-channel-id" - - constructor(readonly customId: string, readonly message: TestMessage) {} - - async update(options: MessageOptions): Promise { - this.message.options = options - } -} - -export class TestSelectInteraction implements SelectInteraction { - readonly type = "select" - readonly id = nanoid() - readonly channelId = "test-channel-id" - - constructor( - readonly customId: string, - readonly message: TestMessage, - readonly values: string[], - ) {} - - async update(options: MessageOptions): Promise { - this.message.options = options - } -} - -export class TestChannel implements Channel { - constructor(private adapter: TestAdapter) {} - - async send(messageOptions: MessageOptions): Promise { - const message = new TestMessage(messageOptions, this.adapter) - this.adapter.messages.push(message) - return message - } -} diff --git a/package.json b/package.json index d6340fa..66be061 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "lodash-es": "^4.17.21", "nodemon": "^2.0.15", "prettier": "^2.5.1", + "pretty-ms": "^7.0.1", "react": "^17.0.2", "tsup": "^5.11.9", "type-fest": "^2.8.0", diff --git a/playground/main.tsx b/playground/main.tsx index f84cb69..0972c52 100644 --- a/playground/main.tsx +++ b/playground/main.tsx @@ -1,7 +1,7 @@ import { Client } from "discord.js" import "dotenv/config" import React from "react" -import { DiscordJsAdapter, Reacord } from "../library/main" +import { ReacordDiscordJs } from "../library/main" import { createCommandHandler } from "./command-handler" import { Counter } from "./counter" import { FruitSelect } from "./fruit-select" @@ -10,10 +10,38 @@ const client = new Client({ intents: ["GUILDS"], }) -const reacord = new Reacord({ - adapter: new DiscordJsAdapter(client), - maxInstances: 2, -}) +const reacord = new ReacordDiscordJs(client) + +// client.on("ready", async () => { +// const now = new Date() + +// function UptimeCounter() { +// const [uptime, setUptime] = React.useState(0) + +// React.useEffect(() => { +// const interval = setInterval(() => { +// setUptime(Date.now() - now.getTime()) +// }, 5000) +// return () => clearInterval(interval) +// }, []) + +// return ( +// this bot has been running for {prettyMilliseconds(uptime)} +// ) +// } + +// const channelId = "671787605624487941" + +// const channel = +// client.channels.cache.get(channelId) || +// (await client.channels.fetch(channelId)) + +// if (!channel?.isText()) { +// throw new Error("channel is not text") +// } + +// reacord.send(channel).render() +// }) createCommandHandler(client, [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5ddf03..3b044ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,7 @@ importers: nanoid: ^3.1.30 nodemon: ^2.0.15 prettier: ^2.5.1 + pretty-ms: ^7.0.1 react: ^17.0.2 react-reconciler: ^0.26.2 rxjs: ^7.5.0 @@ -74,6 +75,7 @@ importers: lodash-es: 4.17.21 nodemon: 2.0.15 prettier: 2.5.1 + pretty-ms: 7.0.1 react: 17.0.2 tsup: 5.11.9_typescript@4.5.4 type-fest: 2.8.0 @@ -5534,6 +5536,11 @@ packages: lines-and-columns: 1.2.4 dev: true + /parse-ms/2.1.0: + resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} + engines: {node: '>=6'} + dev: true + /parse5/6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} dev: true @@ -5686,6 +5693,13 @@ packages: react-is: 17.0.2 dev: true + /pretty-ms/7.0.1: + resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} + engines: {node: '>=10'} + dependencies: + parse-ms: 2.1.0 + dev: true + /progress/2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} diff --git a/test/action-row.test.tsx b/test/action-row.test.tsx index 39dc757..9c81132 100644 --- a/test/action-row.test.tsx +++ b/test/action-row.test.tsx @@ -1,11 +1,11 @@ import React from "react" +import { ReacordTester } from "../library/core/reacord-tester" import { ActionRow, Button, Select } from "../library/main" -import { setupReacordTesting } from "./setup-testing" -const { assertRender } = setupReacordTesting() +const testing = new ReacordTester() test("action row", async () => { - await assertRender( + await testing.assertRender( <>