rendering to channel + simplified adapter interface
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
214
library/core/reacord-tester.ts
Normal file
214
library/core/reacord-tester.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user