rendering to channel + simplified adapter interface

This commit is contained in:
MapleLeaf
2021-12-27 20:57:04 -06:00
parent 3682f67bfe
commit ef26b66cb8
17 changed files with 408 additions and 425 deletions

View File

@@ -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<Generics extends AdapterGenerics> = {
/**
* @internal
*/
addComponentInteractionListener(
listener: (interaction: ComponentInteraction) => void,
): void
/**
* @internal
*/
createCommandInteraction(
init: Generics["commandReplyInit"],
): CommandInteraction
/**
* @internal
*/
createChannel(init: Generics["channelInit"]): Channel
}

View File

@@ -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<DiscordJsAdapterGenerics> {
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<DiscordJsAdapterGenerics> {
})
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()
},
}
}

View File

@@ -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<TestMessage>()
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<this["sampleMessages"]>) {
await nextTickPromise()
expect(this.sampleMessages()).toEqual(expected)
}
async assertRender(
content: ReactNode,
expected: ReturnType<this["sampleMessages"]>,
) {
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<TestMessage>,
) {
container.add(this)
}
async edit(options: MessageOptions): Promise<void> {
this.options = options
}
async disableComponents(): Promise<void> {
for (const row of this.options.actionRows) {
for (const action of row) {
if (action.type === "button") {
action.disabled = true
}
}
}
}
async delete(): Promise<void> {
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<TestMessage>) {}
reply(messageOptions: MessageOptions): Promise<Message> {
return Promise.resolve(
new TestMessage(messageOptions, this.messageContainer),
)
}
followUp(messageOptions: MessageOptions): Promise<Message> {
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<void> {
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<void> {
this.message.options = options
}
}
class TestChannel implements Channel {
constructor(private messageContainer: Container<TestMessage>) {}
async send(messageOptions: MessageOptions): Promise<Message> {
return new TestMessage(messageOptions, this.messageContainer)
}
}

View File

@@ -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<Generics extends AdapterGenerics> = {
adapter: Adapter<Generics>
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<Generics extends AdapterGenerics> {
export type ComponentInteractionListener = (
interaction: ComponentInteraction,
) => void
export abstract class Reacord {
private renderers: Renderer[] = []
constructor(private readonly config: ReacordConfig<Generics>) {
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) {

View File

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

View File

@@ -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<TestAdapterGenerics> {
messages: TestMessage[] = []
private constructor() {}
static create(): Adapter<TestAdapterGenerics> & 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<void> {
this.options = options
}
async disableComponents(): Promise<void> {
for (const row of this.options.actionRows) {
for (const action of row) {
if (action.type === "button") {
action.disabled = true
}
}
}
}
async delete(): Promise<void> {
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<Message> {
return Promise.resolve(this.createMesssage(messageOptions))
}
followUp(messageOptions: MessageOptions): Promise<Message> {
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<void> {
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<void> {
this.message.options = options
}
}
export class TestChannel implements Channel {
constructor(private adapter: TestAdapter) {}
async send(messageOptions: MessageOptions): Promise<Message> {
const message = new TestMessage(messageOptions, this.adapter)
this.adapter.messages.push(message)
return message
}
}