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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
// <Embed>this bot has been running for {prettyMilliseconds(uptime)}</Embed>
|
||||
// )
|
||||
// }
|
||||
|
||||
// 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(<UptimeCounter />)
|
||||
// })
|
||||
|
||||
createCommandHandler(client, [
|
||||
{
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -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'}
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
<Button label="outside button" onClick={() => {}} />
|
||||
<ActionRow>
|
||||
|
||||
2
test/discord-js.test.tsx
Normal file
2
test/discord-js.test.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
test.todo("discord js integration")
|
||||
export {}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react"
|
||||
import { ReacordTester } from "../library/core/reacord-tester"
|
||||
import {
|
||||
Embed,
|
||||
EmbedAuthor,
|
||||
@@ -8,14 +9,13 @@ import {
|
||||
EmbedThumbnail,
|
||||
EmbedTitle,
|
||||
} from "../library/main"
|
||||
import { setupReacordTesting } from "./setup-testing"
|
||||
|
||||
const { assertRender } = setupReacordTesting()
|
||||
const testing = new ReacordTester()
|
||||
|
||||
test("kitchen sink", async () => {
|
||||
const now = new Date()
|
||||
|
||||
await assertRender(
|
||||
await testing.assertRender(
|
||||
<>
|
||||
<Embed color={0xfe_ee_ef}>
|
||||
<EmbedAuthor name="author" iconUrl="https://example.com/author.png" />
|
||||
@@ -75,7 +75,7 @@ test("kitchen sink", async () => {
|
||||
})
|
||||
|
||||
test("author variants", async () => {
|
||||
await assertRender(
|
||||
await testing.assertRender(
|
||||
<>
|
||||
<Embed>
|
||||
<EmbedAuthor iconUrl="https://example.com/author.png">
|
||||
@@ -110,7 +110,7 @@ test("author variants", async () => {
|
||||
})
|
||||
|
||||
test("field variants", async () => {
|
||||
await assertRender(
|
||||
await testing.assertRender(
|
||||
<>
|
||||
<Embed>
|
||||
<EmbedField name="field name" value="field value" />
|
||||
@@ -157,7 +157,7 @@ test("field variants", async () => {
|
||||
test("footer variants", async () => {
|
||||
const now = new Date()
|
||||
|
||||
await assertRender(
|
||||
await testing.assertRender(
|
||||
<>
|
||||
<Embed>
|
||||
<EmbedFooter text="footer text" />
|
||||
@@ -213,7 +213,7 @@ test("footer variants", async () => {
|
||||
test("embed props", async () => {
|
||||
const now = new Date()
|
||||
|
||||
await assertRender(
|
||||
await testing.assertRender(
|
||||
<Embed
|
||||
title="title text"
|
||||
description="description text"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from "react"
|
||||
import { ReacordTester } from "../library/core/reacord-tester"
|
||||
import { Link } from "../library/main"
|
||||
import { setupReacordTesting } from "./setup-testing"
|
||||
|
||||
const { assertRender } = setupReacordTesting()
|
||||
const tester = new ReacordTester()
|
||||
|
||||
test("link", async () => {
|
||||
await assertRender(
|
||||
await tester.assertRender(
|
||||
<>
|
||||
<Link url="https://example.com/">link text</Link>
|
||||
<Link label="link text" url="https://example.com/" />
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { Button, Embed, EmbedField, EmbedTitle } from "../library/main"
|
||||
import { TestCommandInteraction } from "../library/testing"
|
||||
import { setupReacordTesting } from "./setup-testing"
|
||||
import {
|
||||
Button,
|
||||
Embed,
|
||||
EmbedField,
|
||||
EmbedTitle,
|
||||
ReacordTester,
|
||||
} from "../library/main"
|
||||
|
||||
test("rendering behavior", async () => {
|
||||
const { reacord, adapter, assertMessages } = setupReacordTesting()
|
||||
const tester = new ReacordTester()
|
||||
|
||||
const reply = reacord.reply(new TestCommandInteraction(adapter))
|
||||
const reply = tester.reply()
|
||||
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
|
||||
|
||||
await assertMessages([
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 0",
|
||||
embeds: [],
|
||||
@@ -35,8 +39,8 @@ test("rendering behavior", async () => {
|
||||
},
|
||||
])
|
||||
|
||||
adapter.findButtonByLabel("show embed").click()
|
||||
await assertMessages([
|
||||
tester.findButtonByLabel("show embed").click()
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 0",
|
||||
embeds: [{ title: "the counter" }],
|
||||
@@ -62,8 +66,8 @@ test("rendering behavior", async () => {
|
||||
},
|
||||
])
|
||||
|
||||
adapter.findButtonByLabel("clicc").click()
|
||||
await assertMessages([
|
||||
tester.findButtonByLabel("clicc").click()
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 1",
|
||||
embeds: [
|
||||
@@ -94,8 +98,8 @@ test("rendering behavior", async () => {
|
||||
},
|
||||
])
|
||||
|
||||
adapter.findButtonByLabel("clicc").click()
|
||||
await assertMessages([
|
||||
tester.findButtonByLabel("clicc").click()
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 2",
|
||||
embeds: [
|
||||
@@ -126,8 +130,8 @@ test("rendering behavior", async () => {
|
||||
},
|
||||
])
|
||||
|
||||
adapter.findButtonByLabel("hide embed").click()
|
||||
await assertMessages([
|
||||
tester.findButtonByLabel("hide embed").click()
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 2",
|
||||
embeds: [],
|
||||
@@ -153,8 +157,8 @@ test("rendering behavior", async () => {
|
||||
},
|
||||
])
|
||||
|
||||
adapter.findButtonByLabel("clicc").click()
|
||||
await assertMessages([
|
||||
tester.findButtonByLabel("clicc").click()
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 3",
|
||||
embeds: [],
|
||||
@@ -180,8 +184,8 @@ test("rendering behavior", async () => {
|
||||
},
|
||||
])
|
||||
|
||||
adapter.findButtonByLabel("deactivate").click()
|
||||
await assertMessages([
|
||||
tester.findButtonByLabel("deactivate").click()
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 3",
|
||||
embeds: [],
|
||||
@@ -210,8 +214,8 @@ test("rendering behavior", async () => {
|
||||
},
|
||||
])
|
||||
|
||||
adapter.findButtonByLabel("clicc").click()
|
||||
await assertMessages([
|
||||
tester.findButtonByLabel("clicc").click()
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "count: 3",
|
||||
embeds: [],
|
||||
@@ -242,9 +246,9 @@ test("rendering behavior", async () => {
|
||||
})
|
||||
|
||||
test("delete", async () => {
|
||||
const { reacord, adapter, assertMessages } = setupReacordTesting()
|
||||
const tester = new ReacordTester()
|
||||
|
||||
const reply = reacord.reply(new TestCommandInteraction(adapter))
|
||||
const reply = tester.reply()
|
||||
reply.render(
|
||||
<>
|
||||
some text
|
||||
@@ -253,7 +257,7 @@ test("delete", async () => {
|
||||
</>,
|
||||
)
|
||||
|
||||
await assertMessages([
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "some text",
|
||||
embeds: [{ description: "some embed" }],
|
||||
@@ -264,7 +268,7 @@ test("delete", async () => {
|
||||
])
|
||||
|
||||
reply.destroy()
|
||||
await assertMessages([])
|
||||
await tester.assertMessages([])
|
||||
})
|
||||
|
||||
// test multiple instances that can be updated independently,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { jest } from "@jest/globals"
|
||||
import React, { useState } from "react"
|
||||
import { Button, Option, Select } from "../library/main"
|
||||
import { setupReacordTesting } from "./setup-testing"
|
||||
|
||||
const { adapter, reply, assertRender, assertMessages } = setupReacordTesting()
|
||||
import { Button, Option, ReacordTester, Select } from "../library/main"
|
||||
|
||||
test("single select", async () => {
|
||||
const tester = new ReacordTester()
|
||||
const onSelect = jest.fn()
|
||||
|
||||
function TestSelect() {
|
||||
@@ -30,7 +28,7 @@ test("single select", async () => {
|
||||
}
|
||||
|
||||
async function assertSelect(values: string[], disabled = false) {
|
||||
await assertMessages([
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "",
|
||||
embeds: [],
|
||||
@@ -54,23 +52,26 @@ test("single select", async () => {
|
||||
])
|
||||
}
|
||||
|
||||
const reply = tester.reply()
|
||||
|
||||
reply.render(<TestSelect />)
|
||||
await assertSelect([])
|
||||
expect(onSelect).toHaveBeenCalledTimes(0)
|
||||
|
||||
adapter.findSelectByPlaceholder("choose one").select("2")
|
||||
tester.findSelectByPlaceholder("choose one").select("2")
|
||||
await assertSelect(["2"])
|
||||
expect(onSelect).toHaveBeenCalledWith({ values: ["2"] })
|
||||
|
||||
adapter.findButtonByLabel("disable").click()
|
||||
tester.findButtonByLabel("disable").click()
|
||||
await assertSelect(["2"], true)
|
||||
|
||||
adapter.findSelectByPlaceholder("choose one").select("1")
|
||||
tester.findSelectByPlaceholder("choose one").select("1")
|
||||
await assertSelect(["2"], true)
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("multiple select", async () => {
|
||||
const tester = new ReacordTester()
|
||||
const onSelect = jest.fn()
|
||||
|
||||
function TestSelect() {
|
||||
@@ -91,7 +92,7 @@ test("multiple select", async () => {
|
||||
}
|
||||
|
||||
async function assertSelect(values: string[]) {
|
||||
await assertMessages([
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "",
|
||||
embeds: [],
|
||||
@@ -115,31 +116,34 @@ test("multiple select", async () => {
|
||||
])
|
||||
}
|
||||
|
||||
const reply = tester.reply()
|
||||
|
||||
reply.render(<TestSelect />)
|
||||
await assertSelect([])
|
||||
expect(onSelect).toHaveBeenCalledTimes(0)
|
||||
|
||||
adapter.findSelectByPlaceholder("select").select("1", "3")
|
||||
tester.findSelectByPlaceholder("select").select("1", "3")
|
||||
await assertSelect(expect.arrayContaining(["1", "3"]))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
values: expect.arrayContaining(["1", "3"]),
|
||||
})
|
||||
|
||||
adapter.findSelectByPlaceholder("select").select("2")
|
||||
tester.findSelectByPlaceholder("select").select("2")
|
||||
await assertSelect(expect.arrayContaining(["2"]))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
values: expect.arrayContaining(["2"]),
|
||||
})
|
||||
|
||||
adapter.findSelectByPlaceholder("select").select()
|
||||
tester.findSelectByPlaceholder("select").select()
|
||||
await assertSelect([])
|
||||
expect(onSelect).toHaveBeenCalledWith({ values: [] })
|
||||
})
|
||||
|
||||
test("optional onSelect + unknown value", async () => {
|
||||
reply.render(<Select placeholder="select" />)
|
||||
adapter.findSelectByPlaceholder("select").select("something")
|
||||
await assertMessages([
|
||||
const tester = new ReacordTester()
|
||||
tester.reply().render(<Select placeholder="select" />)
|
||||
tester.findSelectByPlaceholder("select").select("something")
|
||||
await tester.assertMessages([
|
||||
{
|
||||
content: "",
|
||||
embeds: [],
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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 { Reacord } from "../library/main"
|
||||
import { TestAdapter, TestCommandInteraction } from "../library/testing"
|
||||
|
||||
const nextTickPromise = promisify(nextTick)
|
||||
|
||||
export function setupReacordTesting() {
|
||||
const adapter = TestAdapter.create()
|
||||
const reacord = new Reacord({ adapter })
|
||||
const reply = reacord.reply(new TestCommandInteraction(adapter))
|
||||
|
||||
async function assertMessages(expected: ReturnType<typeof sampleMessages>) {
|
||||
await nextTickPromise() // wait for the render to complete
|
||||
expect(sampleMessages(adapter)).toEqual(expected)
|
||||
}
|
||||
|
||||
async function assertRender(
|
||||
content: ReactNode,
|
||||
expected: ReturnType<typeof sampleMessages>,
|
||||
) {
|
||||
reply.render(content)
|
||||
await assertMessages(expected)
|
||||
}
|
||||
|
||||
function logMessages() {
|
||||
logPretty(sampleMessages(adapter))
|
||||
}
|
||||
|
||||
return {
|
||||
reacord,
|
||||
adapter,
|
||||
reply,
|
||||
assertMessages,
|
||||
assertRender,
|
||||
logMessages,
|
||||
}
|
||||
}
|
||||
|
||||
function sampleMessages(adapter: TestAdapter) {
|
||||
return adapter.messages.map((message) => ({
|
||||
...message.options,
|
||||
actionRows: message.options.actionRows.map((row) =>
|
||||
row.map((component) =>
|
||||
omit(component, ["customId", "onClick", "onSelect", "onSelectValue"]),
|
||||
),
|
||||
),
|
||||
}))
|
||||
}
|
||||
7
todo.md
7
todo.md
@@ -1,6 +1,6 @@
|
||||
# core features
|
||||
|
||||
- [ ] render to channel
|
||||
- [x] render to channel
|
||||
- [x] render to interaction
|
||||
- [ ] ephemeral messages
|
||||
- [x] message content
|
||||
@@ -43,5 +43,6 @@
|
||||
- [ ] max instance count per guild
|
||||
- [ ] max instance count per channel
|
||||
- [ ] uncontrolled select
|
||||
- [ ] single class/helper function for testing `ReacordTester`
|
||||
- [ ] some failsafes and fallbacks in DJS adapter
|
||||
- [x] single class/helper function for testing `ReacordTester`
|
||||
- [ ] handle deletion outside of reacord
|
||||
- [ ] for more easily writing adapters, address discord API nuances at the reacord level instead of the adapter level. the goal being that adapters can just take the objects and send them to discord
|
||||
|
||||
Reference in New Issue
Block a user