Skip to content
This repository has been archived by the owner on Aug 10, 2022. It is now read-only.

command aliases #16

Merged
merged 5 commits into from
Nov 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
],
"sideEffects": false,
"scripts": {
"dev": "pnpm -C playground run dev",
"dev": "npm-run-all --parallel --print-label --race dev-*",
"dev-playground": "pnpm -C playground run dev",
"dev-build": "pnpm build -- --watch",
"build": "tsup-node",
"typecheck": "tsc --noEmit",
"test": "jest --colors",
Expand Down Expand Up @@ -104,7 +106,12 @@
},
"jest": {
"transform": {
"^.+\\.tsx?$": "esbuild-jest"
"^.+\\.tsx?$": [
"esbuild-jest",
{
"sourcemap": true
}
]
},
"verbose": true
},
Expand Down
6 changes: 5 additions & 1 deletion playground/src/commands/reverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import type { Gatekeeper } from "@itsmapleleaf/gatekeeper"
export default function defineCommands(gatekeeper: Gatekeeper) {
gatekeeper.addMessageCommand({
name: "reverse message content",
aliases: ["rev"],
run(context) {
context.reply(() =>
context.targetMessage.content.split("").reverse().join(""),
(context.targetMessage.content || "no message content")
.split("")
.reverse()
.join(""),
)
},
})
Expand Down
3 changes: 2 additions & 1 deletion playground/src/commands/spongebob.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Gatekeeper } from "@itsmapleleaf/gatekeeper"

function spongebobify(text: string): string {
return [...text]
return [...(text || "no message content")]
.map((char, index) =>
index % 2 === 0 ? char.toLocaleLowerCase() : char.toLocaleUpperCase(),
)
Expand All @@ -11,6 +11,7 @@ function spongebobify(text: string): string {
export default function defineCommands(gatekeeper: Gatekeeper) {
gatekeeper.addMessageCommand({
name: "spongebob",
aliases: ["sb"],
run(context) {
context.reply(() => spongebobify(context.targetMessage.content))
},
Expand Down
70 changes: 39 additions & 31 deletions src/core/command/message-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export type MessageCommandConfig = {
* The name of the command. This shows up in the context menu for messages.
*/
name: string

/** Aliases: alternate names to call this command with */
aliases?: string[]

/**
* The function to call when the command is ran.
*/
Expand All @@ -26,43 +30,47 @@ export type MessageCommandInteractionContext = InteractionContext & {
targetMessage: Message
}

export function defineMessageCommand(config: MessageCommandConfig): Command {
return createCommand({
name: config.name,
export function createMessageCommands(config: MessageCommandConfig): Command[] {
const names = [config.name, ...(config.aliases || [])]

matchesExisting: (appCommand) => {
return appCommand.name === config.name && appCommand.type === "MESSAGE"
},
return names.map((name) =>
createCommand({
name,

register: async (commandManager) => {
await commandManager.create({
type: "MESSAGE",
name: config.name,
})
},
matchesExisting: (appCommand) => {
return appCommand.name === name && appCommand.type === "MESSAGE"
},

matchesInteraction: (interaction) =>
interaction.isContextMenu() &&
interaction.targetType === "MESSAGE" &&
interaction.commandName === config.name,
register: async (commandManager) => {
await commandManager.create({
type: "MESSAGE",
name,
})
},

run: async (interaction, command) => {
const isMessageInteraction =
matchesInteraction: (interaction) =>
interaction.isContextMenu() &&
interaction.channel &&
interaction.targetType === "MESSAGE"
interaction.targetType === "MESSAGE" &&
interaction.commandName === name,

run: async (interaction, command) => {
const isMessageInteraction =
interaction.isContextMenu() &&
interaction.channel &&
interaction.targetType === "MESSAGE"

if (!isMessageInteraction)
raise("Expected a context menu message interaction")
if (!isMessageInteraction)
raise("Expected a context menu message interaction")

const targetMessage = await interaction.channel.messages.fetch(
interaction.targetId,
)
const targetMessage = await interaction.channel.messages.fetch(
interaction.targetId,
)

await config.run({
...createInteractionContext({ interaction, command }),
targetMessage,
})
},
})
await config.run({
...createInteractionContext({ interaction, command }),
targetMessage,
})
},
}),
)
}
157 changes: 82 additions & 75 deletions src/core/command/slash-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export type SlashCommandConfig<
*/
name: string

/** Aliases: alternate names to call this command with */
aliases?: string[]

/**
* The description of the command.
* Shows up when showing a list of the bot's commands
Expand Down Expand Up @@ -225,82 +228,86 @@ function sortChannelTypes(
)
}

export function defineSlashCommand<Options extends SlashCommandOptionConfigMap>(
config: SlashCommandConfig<Options>,
): Command {
const options: ApplicationCommandOptionData[] = Object.entries(
config.options ?? {},
).map(([name, option]) => ({
name,
description: option.description,
type: option.type,

// discord always returns a boolean, even if the user didn't send one
required: option.required ?? false,

// discord returns undefined if the user passed an empty array,
// so normalize undefined to an empty array
choices: ("choices" in option && option.choices) || [],

// Discord returns channel types in a specific order
channelTypes:
("channelTypes" in option &&
sortChannelTypes(option.channelTypes ?? [])) ||
undefined,
}))

const commandData: ApplicationCommandData = {
name: config.name,
description: config.description,
options,
}

return createCommand({
name: config.name,

matchesExisting: (command) => {
if (command.type !== "CHAT_INPUT") return false

const existingCommandData: ChatInputApplicationCommandData = {
name: command.name,
description: command.description,
// need to use the same shape so they can be compared
options: command.options.map(
(option): ApplicationCommandOptionData => ({
name: option.name,
description: option.description,
type: option.type,
required: option.required,
choices: ("choices" in option && option.choices) || [],
/* option.channelTypes includes "UNKNOWN", but it's not allowed by ApplicationCommandOptionData */
channelTypes:
(("channelTypes" in option &&
option.channelTypes) as ApplicationCommandChannelOptionData["channelTypes"]) ||
undefined,
}),
),
}
export function createSlashCommands<
Options extends SlashCommandOptionConfigMap,
>(config: SlashCommandConfig<Options>): Command[] {
const names = [config.name, ...(config.aliases || [])]

return names.map((name) => {
const options: ApplicationCommandOptionData[] = Object.entries(
config.options ?? {},
).map(([name, option]) => ({
name,
description: option.description,
type: option.type,

// discord always returns a boolean, even if the user didn't send one
required: option.required ?? false,

// discord returns undefined if the user passed an empty array,
// so normalize undefined to an empty array
choices: ("choices" in option && option.choices) || [],

// Discord returns channel types in a specific order
channelTypes:
("channelTypes" in option &&
sortChannelTypes(option.channelTypes ?? [])) ||
undefined,
}))

const commandData: ApplicationCommandData = {
name,
description: config.description,
options,
}

return isDeepEqual(commandData, existingCommandData)
},

register: async (manager) => {
await manager.create(commandData)
},

matchesInteraction: (interaction) => {
return interaction.isCommand() && interaction.commandName === config.name
},

run: async (interaction, command) => {
await config.run({
...createInteractionContext({ interaction, command }),
options: collectSlashCommandOptionValues(
config,
interaction as CommandInteraction,
),
})
},
return createCommand({
name,

matchesExisting: (command) => {
if (command.type !== "CHAT_INPUT") return false

const existingCommandData: ChatInputApplicationCommandData = {
name: command.name,
description: command.description,
// need to use the same shape so they can be compared
options: command.options.map(
(option): ApplicationCommandOptionData => ({
name: option.name,
description: option.description,
type: option.type,
required: option.required,
choices: ("choices" in option && option.choices) || [],
/* option.channelTypes includes "UNKNOWN", but it's not allowed by ApplicationCommandOptionData */
channelTypes:
(("channelTypes" in option &&
option.channelTypes) as ApplicationCommandChannelOptionData["channelTypes"]) ||
undefined,
}),
),
}

return isDeepEqual(commandData, existingCommandData)
},

register: async (manager) => {
await manager.create(commandData)
},

matchesInteraction: (interaction) => {
return interaction.isCommand() && interaction.commandName === name
},

run: async (interaction, command) => {
await config.run({
...createInteractionContext({ interaction, command }),
options: collectSlashCommandOptionValues(
config,
interaction as CommandInteraction,
),
})
},
})
})
}

Expand Down
Loading