break up library, move bots to their own repositories

This commit is contained in:
JB
2026-02-12 21:05:15 -05:00
parent 415aa3dbfe
commit cb39431a11
289 changed files with 1931 additions and 6235 deletions

View File

@@ -0,0 +1,15 @@
# discord
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,7 @@
import { createChatCommand } from '@/commands';
export default createChatCommand({
name: 'test1', description: 'Test command 1' },
async () => {},
);

View File

@@ -0,0 +1,7 @@
import { createChatCommand } from '@/commands';
export default createChatCommand({
name: 'test2', description: 'Test command 2' },
async () => {},
);

View File

@@ -0,0 +1,74 @@
{
"name": "@star-kitten/discord",
"version": "0.0.1",
"description": "Star Kitten Discord Library.",
"type": "module",
"license": "MIT",
"homepage": "https://git.f302.me/jb/star-kitten#readme",
"bugs": {
"url": "https://git.f302.me/jb/star-kitten/issues"
},
"repository": {
"type": "git",
"url": "git+https://git.f302.me/jb/star-kitten.git"
},
"author": "JB <j-b-3.deviate267@passmail.net>",
"files": [
"dist"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index*.d.ts"
},
"./commands": {
"require": "./dist/commands/index.js",
"import": "./dist/commands/index.js",
"types": "./dist/types/commands/index.d.ts"
},
"./components": {
"types": "./dist/types/components/index.d.ts",
"require": "./dist/components/index.js",
"import": "./dist/components/index.js"
},
"./pages": {
"require": "./dist/pages/index.js",
"import": "./dist/pages/index.js",
"types": "./dist/types/pages/index.d.ts"
},
"./common": {
"require": "./dist/common/index.js",
"import": "./dist/common/index.js"
}
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/bun": "^1.3.5",
"@types/lodash": "^4.17.20",
"@vitest/coverage-v8": "^3.2.4",
"bumpp": "^10.1.0",
"prettier-plugin-multiline-arrays": "^4.0.3",
"tsdown": "^0.14.2",
"typescript": "beta"
},
"dependencies": {
"@star-kitten/util": "link:@star-kitten/util",
"@projectdysnomia/dysnomia": "github:projectdysnomia/dysnomia#dev",
"date-fns": "^4.1.0",
"fp-filters": "^0.5.4",
"lodash": "^4.17.21"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"link": "bun link",
"test": "bun test",
"typecheck": "tsc --noEmit",
"release": "bumpp && npm publish"
}
}

View File

@@ -0,0 +1,81 @@
import { Constants } from '@projectdysnomia/dysnomia';
import type {
CommandInteraction,
ExecutableInteraction,
Interaction,
AutocompleteInteraction,
ComponentInteraction,
ModalSubmitInteraction,
PingInteraction,
SelectMenuInteraction,
ButtonInteraction,
} from '../types';
export function isApplicationCommand(interaction: Interaction): interaction is CommandInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND;
}
export function isModalSubmit(interaction: Interaction): interaction is ModalSubmitInteraction {
return interaction.type === Constants.InteractionTypes.MODAL_SUBMIT;
}
export function isMessageComponent(interaction: Interaction): interaction is ComponentInteraction {
return interaction.type === Constants.InteractionTypes.MESSAGE_COMPONENT;
}
export function isAutocomplete(interaction: Interaction): interaction is AutocompleteInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE;
}
export function isPing(interaction: Interaction): interaction is PingInteraction {
return interaction.type === Constants.InteractionTypes.PING;
}
export function commandHasName(interaction: Interaction, name: string): boolean {
return isApplicationCommand(interaction) && interaction.data.name === name;
}
export function commandHasIdPrefix(interaction: Interaction, prefix: string): boolean {
return (isModalSubmit(interaction) || isMessageComponent(interaction)) && interaction.data.custom_id.startsWith(prefix);
}
export function getCommandName(interaction: ExecutableInteraction): string | undefined {
if (isApplicationCommand(interaction) || isAutocomplete(interaction)) {
console.log(`command name: ${interaction.data.name}`);
return interaction.data.name;
}
return undefined;
}
export function augmentInteraction(interaction: Interaction): Interaction {
interaction.isApplicationCommand = function (): this is CommandInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND;
};
interaction.isModalSubmit = function (): this is ModalSubmitInteraction {
return interaction.type === Constants.InteractionTypes.MODAL_SUBMIT;
};
interaction.isMessageComponent = function (): this is ComponentInteraction {
return interaction.type === Constants.InteractionTypes.MESSAGE_COMPONENT;
};
interaction.isSelectMenu = function (): this is SelectMenuInteraction {
return interaction.isMessageComponent() && interaction.data.component_type in [3, 5, 6, 7, 8];
};
interaction.isButton = function (): this is ButtonInteraction {
return interaction.isMessageComponent() && interaction.data.component_type === Constants.ComponentTypes.BUTTON;
};
interaction.isAutocomplete = function (): this is AutocompleteInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE;
};
interaction.isPing = function (): this is PingInteraction {
return interaction.type === Constants.InteractionTypes.PING;
};
interaction.isExecutable = function (): this is ExecutableInteraction {
return (
interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND ||
interaction.type === Constants.InteractionTypes.MODAL_SUBMIT ||
interaction.type === Constants.InteractionTypes.MESSAGE_COMPONENT ||
interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE
);
};
return interaction;
}

View File

@@ -0,0 +1,74 @@
import { type InteractionModalContent, type Component, Constants } from '@projectdysnomia/dysnomia';
import type { CommandContext, PartialContext, ExecutableInteraction } from '../types';
import _ from 'lodash';
export function injectInteraction(interaction: ExecutableInteraction, ctx: PartialContext): [ExecutableInteraction, CommandContext] {
// Wrap the interaction methods to inject command tracking ids into all custom_ids for modals and components.
if (ctx.state.name) {
if ('createModal' in interaction) {
const _originalCreateModal = interaction.createModal.bind(interaction);
interaction.createModal = (content: InteractionModalContent) => {
validateCustomIdLength(content.custom_id);
content.custom_id = `${content.custom_id}_${ctx.state.id}`;
return _originalCreateModal(content);
};
}
if ('createMessage' in interaction) {
const _originalCreateMessage = interaction.createMessage.bind(interaction);
interaction.createMessage = (content) => {
if (typeof content === 'string') return _originalCreateMessage(content);
if (content.components) {
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
}
return _originalCreateMessage(content);
};
}
if ('editMessage' in interaction) {
const _originalEditMessage = interaction.editMessage.bind(interaction);
interaction.editMessage = (messageID, content) => {
if (typeof content === 'string') return _originalEditMessage(messageID, content);
if (content.components) {
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
}
return _originalEditMessage(messageID, content);
};
}
if ('createFollowup' in interaction) {
const _originalCreateFollowup = interaction.createFollowup.bind(interaction);
interaction.createFollowup = (content) => {
if (typeof content === 'string') return _originalCreateFollowup(content);
if (content.components) {
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
}
return _originalCreateFollowup(content);
};
}
}
return [interaction, ctx as CommandContext];
}
function validateCustomIdLength(customId: string) {
if (customId.length > 80) {
throw new Error(`Custom ID too long: ${customId.length} characters (max 80) with this framework. Consider using shorter IDs.`);
}
}
function addCommandIdToComponentCustomIds(components: Component[], commandId: string) {
components.forEach((component) => {
if (!component) return;
if ('custom_id' in component) {
validateCustomIdLength(component.custom_id as string);
component.custom_id = `${component.custom_id}_${commandId}`;
}
if ('components' in component && Array.isArray(component.components)) {
addCommandIdToComponentCustomIds(component.components, commandId);
}
});
}

View File

@@ -0,0 +1,48 @@
import { createReactiveState } from '@star-kitten/util/reactive-state.js';
import { isApplicationCommand, isAutocomplete } from './command-helpers';
import type { CommandState, ExecutableInteraction, PartialContext } from '../types';
export async function getCommandState<T>(interaction: ExecutableInteraction, ctx: PartialContext): Promise<CommandState<T>> {
const id = instanceIdFromInteraction(interaction);
let state: CommandState<T>;
// get state from kv store if possible
if (ctx.kv.has(`command-state:${id}`)) {
state = await ctx.kv.get<CommandState<T>>(`command-state:${id}`);
}
if (!state) {
state = { id: id, name: '', data: {} as T };
}
const [reactiveState, subscribe] = createReactiveState(state);
subscribe(async (newState) => {
if (ctx.kv) {
await ctx.kv.set(`command-state:${id}`, newState);
}
});
ctx.state = reactiveState;
return reactiveState;
}
function instanceIdFromInteraction(interaction: ExecutableInteraction) {
if (isAutocomplete(interaction)) {
// autocomplete should not be stateful, they get no id
return '';
}
if (isApplicationCommand(interaction)) {
// for application commands, we create a new instance id
const instance_id = crypto.randomUUID();
return instance_id;
}
const interact = interaction;
const customId: string = interact.data.custom_id;
const commandId = customId.split('_').pop();
interaction;
// command id should be a uuid
if (commandId && /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(commandId)) {
return commandId;
}
console.error(`Invalid command id extracted from interaction: ${customId}`);
return '';
}

View File

@@ -0,0 +1,134 @@
import {
Constants,
type AutocompleteInteractionData,
type ChatInputApplicationCommandStructure,
type CommandInteractionData,
type InteractionDataOption,
type InteractionDataOptions,
type MessageApplicationCommandStructure,
type PrimaryEntryPointApplicationCommandStructure,
type UserApplicationCommandStructure,
} from '@projectdysnomia/dysnomia';
import type { ExecutableInteraction } from '../types/interaction.type';
import type { AuthorizedFn, ChatCommandDefinition, CommandContext, CommandOptions, ExecuteFn } from '../types';
import { addCommand } from './handle-commands';
export function createChatCommand(def: ChatCommandDefinition, execute: ExecuteFn, options: Partial<CommandOptions> = {}) {
const definition = def as ChatInputApplicationCommandStructure;
definition.type = Constants.ApplicationCommandTypes.CHAT_INPUT;
const command = {
definition,
execute,
options,
};
addCommand(command);
return command;
}
export function createUserAppCommand(
def: Omit<UserApplicationCommandStructure, 'type'>,
execute: ExecuteFn,
options: Partial<CommandOptions> = {},
) {
const definition = def as UserApplicationCommandStructure;
definition.type = Constants.ApplicationCommandTypes.USER;
const command = {
definition,
execute,
options,
};
addCommand(command);
return command;
}
export function createPrimaryEntryCommand(
def: Omit<PrimaryEntryPointApplicationCommandStructure, 'type'>,
execute: ExecuteFn,
options: Partial<CommandOptions> = {},
) {
const definition = def as PrimaryEntryPointApplicationCommandStructure;
definition.type = Constants.ApplicationCommandTypes.PRIMARY_ENTRY_POINT;
const command = {
definition,
execute,
options,
};
addCommand(command);
return command;
}
export function createMessageCommand(
def: Omit<MessageApplicationCommandStructure, 'type'>,
execute: ExecuteFn,
options: Partial<CommandOptions> = {},
) {
const definition = def as MessageApplicationCommandStructure;
definition.type = Constants.ApplicationCommandTypes.MESSAGE;
const command = {
definition,
execute,
options,
};
addCommand(command);
return command;
}
export function hasAnyRole(roleIds: string[]): AuthorizedFn {
return (interaction: ExecutableInteraction, ctx: CommandContext) => {
const member = interaction.member;
if (!member) return false;
for (let i = 0; i < roleIds.length; ++i) {
if (member.roles.indexOf(roleIds[i]) !== -1) {
return true;
}
}
return false;
};
}
export const Permissions = Constants.Permissions;
export function hasPermissions(permissions: bigint): AuthorizedFn {
return (interaction: ExecutableInteraction, ctx: CommandContext) => {
const member = interaction.member;
if (!member) return false;
return (member.permissions.allow & permissions) !== 0n;
};
}
export function hasUserId(userIds: string[]): AuthorizedFn {
return (interaction: ExecutableInteraction, ctx: CommandContext) => {
return userIds.indexOf(interaction.user?.id) !== -1;
};
}
export function isAdministrator(): AuthorizedFn {
return (interaction: ExecutableInteraction, ctx: CommandContext) => {
const member = interaction.member;
if (!member) return false;
return (member.permissions.allow & Permissions.administrator) !== 0n;
};
}
type SubCommandRouter = Record<string, SubCommandExecuteFn>;
type SubCommandExecuteFn = (
interaction: ExecutableInteraction,
ctx: CommandContext,
data: CommandInteractionData | AutocompleteInteractionData,
) => any | Promise<any>;
export function subCommandRouter(handlers: Record<string, SubCommandExecuteFn | SubCommandRouter>) {
return (interaction: ExecutableInteraction, ctx: CommandContext) => {
// console.log(JSON.stringify(interaction));
if (interaction.isMessageComponent() || interaction.isModalSubmit()) return;
const name = interaction.data.options[0].name;
if (handlers[name]) {
if (typeof handlers[name] === 'function') {
return handlers[name](interaction, ctx, interaction.data.options[0] as any);
} else {
const subName = (interaction.data.options[0] as any).options[0].name;
return handlers[name][subName](interaction, ctx, interaction.data.options[0].options[0]);
}
}
};
}

View File

@@ -0,0 +1,146 @@
import { Constants, type ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import { injectInteraction } from './command-injection';
import { getCommandState } from './command-state';
import { type ExecutableInteraction } from '../types/interaction.type';
import type { CommandContext, CommandHandler, CommandOptions, PartialContext } from '../types';
import { augmentInteraction, getCommandName } from './command-helpers';
import { awaitMaybePromise } from '@star-kitten/util/promise';
export async function handleCommands(
interaction: ExecutableInteraction,
commands: Map<string, CommandHandler<ApplicationCommandStructure>>,
ctx: PartialContext,
) {
let commandContext = ctx as CommandContext;
commandContext.state = await getCommandState(interaction, commandContext);
if (!commandContext.state.name) {
commandContext.state.name = getCommandName(interaction);
}
if (interaction.isAutocomplete() && commandContext.state.name) {
const acCommand = commands.get(commandContext.state.name);
if (acCommand.options?.authorize && !(await awaitMaybePromise(acCommand.options.authorize(interaction, commandContext)))) {
return;
}
return acCommand.execute(interaction, commandContext as any);
}
if (!commandContext.state.id) {
console.error(`No command ID found for interaction ${interaction.id}`);
return;
}
const command = commands.get(commandContext.state.name || '');
if (!command) {
console.warn(`No command found for interaction: ${JSON.stringify(interaction, undefined, 2)}`);
return;
}
if (command.options?.authorize && !(await awaitMaybePromise(command.options.authorize(interaction, commandContext)))) {
if (interaction.isApplicationCommand()) {
interaction.createMessage({
flags: Constants.MessageFlags.EPHEMERAL,
content: `You are not authorized to execute the \`${command.definition.name}\` command.`,
});
}
return;
}
cleanInteractionCustomIds(interaction, commandContext.state.id);
const [injectedInteraction, fullContext] = await injectInteraction(interaction, commandContext);
return command.execute(injectedInteraction, fullContext);
}
function cleanInteractionCustomIds(interaction: ExecutableInteraction, id: string) {
if ('components' in interaction && Array.isArray(interaction.components) && id) {
removeCommandIdFromComponentCustomIds(interaction.components, id);
}
if ('data' in interaction && id) {
if ('custom_id' in interaction.data && typeof interaction.data.custom_id === 'string') {
interaction.data.custom_id = interaction.data.custom_id.replace(`_${id}`, '');
}
if ('components' in interaction.data && Array.isArray(interaction.data.components)) {
removeCommandIdFromComponentCustomIds(interaction.data.components as any, id);
}
}
}
function removeCommandIdFromComponentCustomIds(components: { custom_id?: string; components?: any[] }[], commandId: string) {
components.forEach((component) => {
if ('custom_id' in component) {
component.custom_id = component.custom_id.replace(`_${commandId}`, '');
}
if ('components' in component && Array.isArray(component.components)) {
removeCommandIdFromComponentCustomIds(component.components, commandId);
}
if ('component' in component && 'custom_id' in (component as any).component && Array.isArray(component.components)) {
(component.component as any).custom_id = (component.component as any).custom_id.replace(`_${commandId}`, '');
}
});
}
const commandRegistry = new Map<string, Map<string, CommandHandler>>();
export async function registerCommands(ctx: PartialContext) {
if (!ctx.client) throw new Error('Client not initialized');
if (!(await ctx.client.getCommands()).length || process.env.RESET_COMMANDS === 'true' || process.env.NODE_ENV === 'development') {
console.debug('Registering commands...');
const registry = commandRegistry.get(ctx.client.commandKey);
const defs: Map<string, ApplicationCommandStructure[]> = new Map();
for (const cmd of registry.values().toArray()) {
if (cmd.options?.guilds) {
for (const guildId of cmd.options.guilds) {
if (defs.get(guildId)) {
defs.get(guildId).push(cmd.definition);
} else {
defs.set(guildId, [cmd.definition]);
}
}
} else {
if (defs.get('global')) {
defs.get('global').push(cmd.definition);
} else {
defs.set('global', [cmd.definition]);
}
}
}
for (const guildId of defs.keys().toArray()) {
if (guildId === 'global') {
const response = await ctx.client.bulkEditCommands(defs.get(guildId));
console.debug(`Registered ${response.length} global commands`);
} else {
const response = await ctx.client.bulkEditGuildCommands(guildId, defs.get(guildId));
console.debug(`Registered ${response.length} guild commands on ${guildId}`);
for (const cmdResponse of response) {
const cmd = registry.get(cmdResponse.name);
if (cmd.options.guildPermissions && cmd.options.guildPermissions[guildId]) {
console.debug(`Editing permissions for command on ${guildId}`);
const response = await ctx.client.editCommandPermissions(guildId, cmdResponse.id, cmd.options.guildPermissions[guildId]);
console.debug(`Edited permissions for command on ${guildId}`);
}
}
}
}
}
ctx.client.on('interactionCreate', async (_interaction) => {
const interaction = augmentInteraction(_interaction as any);
if (interaction.isExecutable()) {
const registry = commandRegistry.get(ctx.client.commandKey);
handleCommands(interaction, registry, ctx);
}
});
return commandRegistry.values().toArray();
}
export function addCommand(command: CommandHandler) {
const commandKey = command.options.commandKey || 'default';
if (!commandRegistry.get(commandKey)) {
commandRegistry.set(commandKey, new Map());
}
const registry = commandRegistry.get(commandKey);
registry.set(command.definition.name, command);
}

View File

@@ -0,0 +1,19 @@
import { Glob } from 'bun';
import { join } from 'node:path';
import type { ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import type { CommandHandler } from '../types';
export async function importCommands(
pattern: string = '**/*.command.{js,ts,jsx,tsx}',
baseDir: string = join(process.cwd(), 'src'),
commandRegistry: Record<string, CommandHandler<ApplicationCommandStructure>> = {},
): Promise<Record<string, CommandHandler<ApplicationCommandStructure>>> {
const glob = new Glob(pattern);
for await (const file of glob.scan({ cwd: baseDir, absolute: true })) {
const command = (await import(file)).default as CommandHandler<ApplicationCommandStructure>;
commandRegistry[command.definition.name] = command;
}
return commandRegistry;
}

View File

@@ -0,0 +1,6 @@
// Exports that will be made publicly available outside this library
export * from './create';
export * from './option-builders';
import * as options from './option-builders';
export { options };

View File

@@ -0,0 +1,80 @@
import {
Constants,
type ApplicationCommandOptions,
type ApplicationCommandOptionsBoolean,
type ApplicationCommandOptionsInteger,
type ApplicationCommandOptionsMentionable,
type ApplicationCommandOptionsNumber,
type ApplicationCommandOptionsRole,
type ApplicationCommandOptionsString,
type ApplicationCommandOptionsSubCommand,
type ApplicationCommandOptionsSubCommandGroup,
type ApplicationCommandOptionsUser,
} from '@projectdysnomia/dysnomia';
export type StringOptionDefinition = Omit<ApplicationCommandOptionsString, 'type'> & { autocomplete?: boolean };
export function stringOption(options: StringOptionDefinition): ApplicationCommandOptionsString {
const def = options as ApplicationCommandOptionsString;
def.type = Constants.ApplicationCommandOptionTypes.STRING;
return def;
}
export type IntegerOptionDefinition = Omit<ApplicationCommandOptionsInteger, 'type'> & { autocomplete?: boolean };
export function integerOption(options: IntegerOptionDefinition): ApplicationCommandOptionsInteger {
const def = options as ApplicationCommandOptionsInteger;
def.type = Constants.ApplicationCommandOptionTypes.INTEGER;
return def;
}
export type BooleanOptionDefinition = Omit<ApplicationCommandOptionsBoolean, 'type'>;
export function booleanOption(options: BooleanOptionDefinition): ApplicationCommandOptionsBoolean {
const def = options as ApplicationCommandOptionsBoolean;
def.type = Constants.ApplicationCommandOptionTypes.BOOLEAN;
return def;
}
export type UserOptionDefinition = Omit<ApplicationCommandOptionsUser, 'type'> & { autocomplete?: boolean };
export function userOption(options: UserOptionDefinition): ApplicationCommandOptionsUser {
const def = options as ApplicationCommandOptionsUser;
def.type = Constants.ApplicationCommandOptionTypes.USER;
return def;
}
export type ChannelOptionDefinition = Omit<ApplicationCommandOptions, 'type'> & { autocomplete?: boolean };
export function channelOption(options: ChannelOptionDefinition): ApplicationCommandOptions {
const def = options as ApplicationCommandOptions;
def.type = Constants.ApplicationCommandOptionTypes.CHANNEL;
return def;
}
export type RoleOptionDefinition = Omit<ApplicationCommandOptionsRole, 'type'> & { autocomplete?: boolean };
export function roleOption(options: RoleOptionDefinition): ApplicationCommandOptionsRole {
const def = options as ApplicationCommandOptionsRole;
def.type = Constants.ApplicationCommandOptionTypes.ROLE;
return def;
}
export type MentionableOptionDefinition = Omit<ApplicationCommandOptionsMentionable, 'type'>;
export function mentionableOption(options: MentionableOptionDefinition): ApplicationCommandOptionsMentionable {
const def = options as ApplicationCommandOptionsMentionable;
def.type = Constants.ApplicationCommandOptionTypes.MENTIONABLE;
return def;
}
export type NumberOptionDefinition = Omit<ApplicationCommandOptionsNumber, 'type'> & { autocomplete?: boolean };
export function numberOption(options: NumberOptionDefinition): ApplicationCommandOptionsNumber {
const def = options as ApplicationCommandOptionsNumber;
def.type = Constants.ApplicationCommandOptionTypes.NUMBER;
return def;
}
export type AttachmentOptionDefinition = Omit<ApplicationCommandOptions, 'type'>;
export function attachmentOption(options: AttachmentOptionDefinition): ApplicationCommandOptions {
const def = options as ApplicationCommandOptions;
def.type = Constants.ApplicationCommandOptionTypes.ATTACHMENT;
return def;
}
export type SubCommandOptionDefinition = Omit<ApplicationCommandOptionsSubCommand, 'type'>;
export function subCommandOption(options: SubCommandOptionDefinition): ApplicationCommandOptionsSubCommand {
const def = options as ApplicationCommandOptionsSubCommand;
def.type = Constants.ApplicationCommandOptionTypes.SUB_COMMAND;
return def;
}
export type SubCommandGroupOptionDefinition = Omit<ApplicationCommandOptionsSubCommandGroup, 'type'>;
export function subCommandGroupOption(options: SubCommandGroupOptionDefinition): ApplicationCommandOptionsSubCommandGroup {
const def = options as ApplicationCommandOptionsSubCommandGroup;
def.type = Constants.ApplicationCommandOptionTypes.SUB_COMMAND_GROUP;
return def;
}

View File

@@ -0,0 +1,367 @@
import {
Constants,
type ActionRow,
type Button,
type ChannelSelectMenu,
type GuildChannelTypes,
type MentionableSelectMenu,
type PartialEmoji,
type RoleSelectMenu,
type StringSelectMenu,
type TextInput,
type UserSelectMenu,
type LabelComponent,
type ContainerComponent,
type TextDisplayComponent,
type SectionComponent,
type MediaGalleryComponent,
type SeparatorComponent,
type FileComponent,
type InteractionButton,
type URLButton,
type PremiumButton,
type ThumbnailComponent,
type ModalSubmitInteractionData,
type FileUploadComponent,
} from '@projectdysnomia/dysnomia';
export type ActionRowItem = Button | StringSelectMenu | UserSelectMenu | RoleSelectMenu | MentionableSelectMenu | ChannelSelectMenu;
export const actionRow = (...components: ActionRowItem[]): ActionRow => ({
type: Constants.ComponentTypes.ACTION_ROW,
components: components.filter((c) => c),
});
export enum ButtonStyle {
PRIMARY = 1,
SECONDARY = 2,
SUCCESS = 3,
DANGER = 4,
}
export interface ButtonOptions {
style?: ButtonStyle;
emoji?: PartialEmoji;
disabled?: boolean;
}
export const button = (label: string, custom_id: string, options?: ButtonOptions): InteractionButton => ({
type: Constants.ComponentTypes.BUTTON,
style: options?.style ?? Constants.ButtonStyles.PRIMARY,
label,
custom_id,
...options,
});
export interface URLButtonOptions {
emoji?: PartialEmoji;
disabled?: boolean;
}
export const urlButton = (label: string, url: string, options?: URLButtonOptions): URLButton => ({
type: Constants.ComponentTypes.BUTTON,
style: Constants.ButtonStyles.LINK,
label,
url,
...options,
});
export interface PremiumButtonOptions {
emoji?: PartialEmoji;
disabled?: boolean;
}
export const premiumButton = (sku_id: string, options?: PremiumButtonOptions): PremiumButton => ({
type: Constants.ComponentTypes.BUTTON,
style: Constants.ButtonStyles.PREMIUM,
sku_id,
...options,
});
export interface StringSelectOpts {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean; // Note: not actually a property of StringSelectMenu, but useful for modals
}
export interface StringSelectOption {
label: string;
value: string;
description?: string;
emoji?: PartialEmoji;
default?: boolean;
}
export const option = (option: StringSelectOption): StringSelectOption => option;
export interface StringSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean;
}
export const stringSelect = (custom_id: string, selectOpts: StringSelectOpts, ...options: StringSelectOption[]): StringSelectMenu => ({
type: Constants.ComponentTypes.STRING_SELECT,
custom_id,
options,
placeholder: selectOpts.placeholder,
min_values: selectOpts.min_values ?? 1,
max_values: selectOpts.max_values ?? 1,
disabled: selectOpts.disabled ?? false,
required: selectOpts.required ?? false, // Note: not actually a property of StringSelectMenu, but useful for modals
});
export interface InputOptions {
isParagraph?: boolean;
label?: string;
min_length?: number;
max_length?: number;
required?: boolean;
value?: string;
placeholder?: string;
}
export const input = (custom_id: string, options?: InputOptions): TextInput => ({
type: Constants.ComponentTypes.TEXT_INPUT,
custom_id,
style: options.isParagraph ? Constants.TextInputStyles.PARAGRAPH : Constants.TextInputStyles.SHORT,
label: options?.label,
min_length: options?.min_length ?? 0,
max_length: options?.max_length ?? 4000,
required: options?.required ?? false,
value: options?.value,
placeholder: options?.placeholder,
});
export interface UserSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'user' }>;
}
export const userSelect = (custom_id: string, options?: UserSelectOptions): UserSelectMenu => ({
type: Constants.ComponentTypes.USER_SELECT,
custom_id,
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface RoleSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'role' }>;
}
export const roleSelect = (custom_id: string, options?: RoleSelectOptions): RoleSelectMenu => ({
type: Constants.ComponentTypes.ROLE_SELECT,
custom_id,
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface MentionableSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'user' | 'role' }>;
}
export const mentionableSelect = (custom_id: string, options?: MentionableSelectOptions): MentionableSelectMenu => ({
type: Constants.ComponentTypes.MENTIONABLE_SELECT,
custom_id,
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface ChannelSelectOptions {
channel_types?: GuildChannelTypes[];
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'channel' }>;
}
export const channelSelect = (custom_id: string, options?: ChannelSelectOptions): ChannelSelectMenu => ({
type: Constants.ComponentTypes.CHANNEL_SELECT,
custom_id,
channel_types: options?.channel_types ?? [],
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface SectionOptions {
components: Array<TextDisplayComponent>;
accessory: Button | ThumbnailComponent;
}
export const section = (accessory: Button | ThumbnailComponent, ...components: Array<TextDisplayComponent>): SectionComponent => ({
type: Constants.ComponentTypes.SECTION,
accessory,
components: components.filter((c) => c),
});
/**
* Creates a text display component where the text will be displayed similar to a message: supports markdown
* @param content The text content to display.
* @returns The created text display component.
*/
export const text = (content: string) => ({
type: Constants.ComponentTypes.TEXT_DISPLAY,
content,
});
export interface ThumbnailOptions {
media: {
url: string; // Supports arbitrary urls and attachment://<filename> references
};
description?: string;
spoiler?: boolean;
}
export const thumbnail = (url: string, description?: string, spoiler?: boolean): ThumbnailComponent => ({
type: Constants.ComponentTypes.THUMBNAIL,
media: {
url,
},
description,
spoiler,
});
export interface MediaItem {
url: string; // Supports arbitrary urls and attachment://<filename> references
description?: string;
spoiler?: boolean;
}
export const gallery = (...items: MediaItem[]): MediaGalleryComponent => ({
type: Constants.ComponentTypes.MEDIA_GALLERY,
items: items.map((item) => ({
type: Constants.ComponentTypes.FILE,
media: { url: item.url },
description: item.description,
spoiler: item.spoiler,
})),
});
export interface FileOptions {
url: string; // Supports only attachment://<filename> references
spoiler?: boolean;
}
export const file = (url: string, spoiler?: boolean): FileComponent => ({
type: Constants.ComponentTypes.FILE,
file: {
url,
},
spoiler,
});
export enum Padding {
SMALL = 1,
LARGE = 2,
}
export interface SeparatorOptions {
divider?: boolean;
spacing?: Padding;
}
export const separator = (spacing?: Padding, divider?: boolean): SeparatorComponent => ({
type: Constants.ComponentTypes.SEPARATOR,
divider,
spacing: spacing ?? Padding.SMALL,
});
export interface ContainerOptions {
accent_color?: number;
spoiler?: boolean;
}
export type ContainerItems =
| ActionRow
| TextDisplayComponent
| SectionComponent
| MediaGalleryComponent
| SeparatorComponent
| FileComponent;
export const container = (options: ContainerOptions, ...components: ContainerItems[]): ContainerComponent => ({
type: Constants.ComponentTypes.CONTAINER,
...options,
components: components.filter((c) => c),
});
// Modals
export interface LabelOptions {
label: string;
description?: string;
}
export const label = (options: LabelOptions, component: LabelComponent['component']): LabelComponent => ({
type: Constants.ComponentTypes.LABEL,
label: options.label,
description: options.description,
component,
});
export const modal = (
options: { custom_id?: string; title?: string },
...components: Array<LabelComponent | ActionRow | TextDisplayComponent>
): ModalSubmitInteractionData =>
({
type: 9 as any, // Modal type
custom_id: options.custom_id ?? '',
title: options.title ?? '',
components: components.filter((c) => c),
} as any);
export interface ComponentsV2Options {
ephemeral?: boolean;
}
export const componentsV2 = (
options: ComponentsV2Options,
...components: Array<
| ContainerComponent
| ActionRow
| TextDisplayComponent
| Button
| SectionComponent
| MediaGalleryComponent
| SeparatorComponent
| FileComponent
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
| StringSelectMenu
>
) => ({
flags: Constants.MessageFlags.IS_COMPONENTS_V2 | (options.ephemeral ? Constants.MessageFlags.EPHEMERAL : 0),
components: components.filter((c) => c),
});

View File

@@ -0,0 +1,28 @@
import {
Constants,
type ComponentBase,
type ModalSubmitInteractionDataLabelComponent,
type ModalSubmitInteractionDataSelectComponent,
type ModalSubmitInteractionDataTextInputComponent,
type StringSelectMenu,
} from '@projectdysnomia/dysnomia';
export function isModalLabel(component: ComponentBase): component is ModalSubmitInteractionDataLabelComponent {
return component.type === Constants.ComponentTypes.LABEL;
}
export function isModalTextInput(component: ComponentBase): component is ModalSubmitInteractionDataTextInputComponent {
return component.type === Constants.ComponentTypes.TEXT_INPUT;
}
export function isModalSelect(component: ComponentBase): component is ModalSubmitInteractionDataSelectComponent {
return component.type === Constants.ComponentTypes.STRING_SELECT;
}
export function componentHasIdPrefix(component: ComponentBase, prefix: string): boolean {
return (isModalTextInput(component) || isModalSelect(component)) && component.custom_id.startsWith(prefix);
}
export function isStringSelectMenu(component: ComponentBase): component is StringSelectMenu {
return component.type === Constants.ComponentTypes.STRING_SELECT;
}

View File

@@ -0,0 +1,5 @@
export * from './helpers';
export * from './builders';
import * as components from './builders';
export { components };

View File

@@ -0,0 +1,2 @@
export * from './text';
export * from './locale';

View File

@@ -0,0 +1,34 @@
export enum Locale {
EN_US = 'en-US',
EN_GB = 'en-GB',
DE = 'de',
ES_ES = 'es-ES',
FR = 'fr',
JA = 'ja',
KO = 'ko',
RU = 'ru',
ZH_CN = 'zh-CN',
ID = 'id',
DA = 'da',
ES_419 = 'es-419',
HR = 'hr',
IT = 'it',
LT = 'lt',
HU = 'hu',
NL = 'nl',
NO = 'no',
PL = 'pl',
PT_BR = 'pt-BR',
RO = 'ro',
FI = 'fi',
SV_SE = 'sv-SE',
VI = 'vi',
TR = 'tr',
CS = 'cs',
EL = 'el',
BG = 'bg',
UK = 'uk',
HI = 'hi',
TH = 'th',
ZH_TW = 'zh-TW',
}

View File

@@ -0,0 +1,2 @@
export const WHITE_SPACE = ' '; // non-breaking space
export const BREAKING_WHITE_SPACE = '\u200B';

View File

@@ -0,0 +1,49 @@
import { Client as DJSClient } from '@projectdysnomia/dysnomia';
import kv, { asyncKV } from '@star-kitten/util/kv.js';
import type { KVStore } from './kv-store.type.ts.ts';
import type { Cache } from './cache.type.ts';
import { registerCommands } from '../commands/handle-commands.ts';
import type { Client } from './client.ts';
export interface DiscordBotOptions {
token?: string;
intents?: number[];
commandKey?: string;
keyStore?: KVStore;
cache?: Cache;
onError?: (error: Error) => void;
onReady?: () => void;
}
export function startBot({
token = process.env.DISCORD_BOT_TOKEN || '',
intents = [],
commandKey = 'default',
keyStore = asyncKV,
cache = kv,
onError,
onReady,
}: DiscordBotOptions = {}): Client {
const client = new DJSClient(`Bot ${token}`, {
gateway: {
intents,
},
}) as Client;
client.commandKey = commandKey;
client.on('ready', async () => {
console.debug(`Logged in as ${client.user?.username}#${client.user?.discriminator}`);
onReady?.();
await registerCommands({ client, cache, kv: keyStore });
console.debug('Bot is ready and command handling is initialized.');
});
client.on('error', (error) => {
console.error('An error occurred:', error);
onError?.(error);
});
client.connect().catch(console.error);
return client;
}

View File

@@ -0,0 +1,6 @@
export interface Cache {
get: <T>(key: string) => T | undefined;
set: <T>(key: string, value: T, ttl?: number | string) => boolean;
del: (key: string | string[]) => number;
has: (key: string) => boolean;
}

View File

@@ -0,0 +1,5 @@
import { Client as DJSClient } from '@projectdysnomia/dysnomia';
export interface Client extends DJSClient {
commandKey: string;
}

View File

@@ -0,0 +1,3 @@
export * from './bot.ts';
export * from './cache.type.ts';
export * from './kv-store.type.ts.ts';

View File

@@ -0,0 +1,7 @@
export interface KVStore {
get: <T>(key: string) => Promise<T | undefined>;
set: (key: string, value: any) => Promise<boolean>;
delete: (key: string) => Promise<number>;
has: (key: string) => Promise<boolean>;
clear: () => Promise<void>;
}

View File

@@ -0,0 +1,6 @@
export * from './constants';
export * from './commands';
export * from './core';
export * from './components';
export * from './pages';
export * from './types';

View File

@@ -0,0 +1,2 @@
export * from './pages';
export * from './subroutes';

View File

@@ -0,0 +1,162 @@
import { Constants, type InteractionContentEdit, type InteractionModalContent } from '@projectdysnomia/dysnomia';
import type { CommandContext, ExecutableInteraction } from '../types';
export enum PageType {
MODAL = 'modal',
MESSAGE = 'message',
FOLLOWUP = 'followup',
}
export interface Page<T> {
key: string;
type?: PageType; // defaults to MESSAGE
followUpFlags?: number;
render: (
ctx: PageContext<T>,
) =>
| (InteractionModalContent | InteractionContentEdit)
| Promise<InteractionModalContent | InteractionContentEdit>;
}
export interface PagesOptions<T> {
pages: Record<string, Page<T>>;
initialPage?: string;
timeout?: number; // in seconds
ephemeral?: boolean; // whether the initial message should be ephemeral
useEmbeds?: boolean; // will not enable components v2
initialStateData?: T; // initial state to merge with default state
router?: (ctx: PageContext<T>) => string; // function to determine the next page key
}
export interface PageState<T> {
currentPage: string;
timeoutAt: number; // timestamp in ms
lastInteractionAt?: number; // timestamp in ms
messageId?: string;
channelId?: string;
data: T;
}
export interface PageContext<T> {
state: PageState<T>;
custom_id: string; // current interaction custom_id
interaction: ExecutableInteraction;
goToPage: (pageKey: string) => Promise<InteractionContentEdit>;
}
function createPageContext<T>(interaction: ExecutableInteraction, options: PagesOptions<T>, state: PageState<T>): PageContext<T> {
return {
state,
interaction,
custom_id: 'custom_id' in interaction.data ? interaction.data.custom_id : options.initialPage ?? 'root',
goToPage: (pageKey: string) => {
const page = options.pages[pageKey];
state.currentPage = pageKey;
if (!page) {
throw new Error(`Page with key "${pageKey}" not found`);
}
return page.render(createPageContext(interaction, options, { ...state, currentPage: pageKey })) as Promise<InteractionContentEdit>;
},
};
}
function defaultPageState<T>(options: PagesOptions<T>): PageState<T> {
const timeoutAt = options.timeout ? Date.now() + options.timeout * 1000 : Infinity;
return {
currentPage: options.initialPage ?? options.pages[0].key,
timeoutAt,
lastInteractionAt: Date.now(),
data: options.initialStateData ?? ({} as T),
};
}
function getPageState<T>(options: PagesOptions<T>, cmdCtx: CommandContext & { state: { __pageState?: PageState<T> } }) {
const cmdState = cmdCtx.state;
if ('__pageState' in cmdState && cmdState.__pageState) {
return cmdState.__pageState as PageState<T>;
}
cmdState.__pageState = defaultPageState(options);
return cmdState.__pageState as PageState<T>;
}
function validateOptions<T>(options: PagesOptions<T>) {
const keys = Object.keys(options.pages);
const uniqueKeys = new Set(keys);
if (uniqueKeys.size !== keys.length) {
throw new Error('Duplicate page keys found');
}
}
function getFlags(options: PagesOptions<any>) {
let flags = 0;
if (options.ephemeral) {
flags |= Constants.MessageFlags.EPHEMERAL;
}
if (!options.useEmbeds) {
flags |= Constants.MessageFlags.IS_COMPONENTS_V2;
}
return flags;
}
export async function usePages<T>(options: PagesOptions<T>, interaction: ExecutableInteraction, cmdCtx: CommandContext) {
if (interaction.isAutocomplete() || interaction.isPing()) {
throw new Error('usePages cannot be used with autocomplete or ping interactions');
}
const pagesInteraction = interaction;
validateOptions(options);
const pageState = getPageState(options, cmdCtx);
const pageContext = createPageContext(pagesInteraction, options, pageState);
const pageKey = options.router
? options.router(pageContext)
: pageContext.custom_id ?? options.initialPage ?? Object.keys(options.pages)[0];
// if we have subroutes, we only want the main route from the page key
const page = options.pages[pageKey.split(':')[0]] ?? options.pages[0];
pageContext.state.currentPage = page.key;
if (page.type === PageType.MODAL && !pagesInteraction.isModalSubmit()) {
// we don't defer modals and can't respond to a modal with a modal.
const maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
return await pagesInteraction.createModal(content as InteractionModalContent);
}
if (page.type === PageType.FOLLOWUP) {
if (!pageState.messageId) {
throw new Error('Cannot send a followup message before an initial message has been sent');
}
const flags = page.type === PageType.FOLLOWUP ? page.followUpFlags ?? getFlags(options) : getFlags(options);
await pagesInteraction.defer(flags);
const maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
return await pagesInteraction.createFollowup({
flags,
...content as any,
});
}
if (pageState.messageId && (pagesInteraction.isMessageComponent() || pagesInteraction.isModalSubmit())) {
await pagesInteraction.deferUpdate();
const maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
return await pagesInteraction.editMessage(pageState.messageId, content as any);
}
{
await pagesInteraction.defer(getFlags(options));
const maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
const message = await pagesInteraction.createFollowup({
flags: getFlags(options),
...content as any,
});
pageState.messageId = message.id;
pageState.channelId = message.channel?.id;
return message;
}
}
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as Promise<T>)?.then === 'function';
}

View File

@@ -0,0 +1,99 @@
import type { PartialEmoji } from '@projectdysnomia/dysnomia';
import { actionRow, button, gallery, type ButtonOptions, type ContainerItems } from '@/components';
import type { PageContext } from './pages';
export function getSubrouteKey(prefix: string, subroutes: string[]) {
return `${prefix}:${subroutes.join(':')}`;
}
export function parseSubrouteKey(key: string, expectedPrefix: string, expectedLength: number, defaults: string[] = []) {
const parts = key.split(':');
if (parts[0] !== expectedPrefix) {
throw new Error(`Unexpected prefix: ${parts[0]}`);
}
if (parts.length - 1 < expectedLength && defaults.length) {
// fill in defaults
parts.push(...defaults.slice(parts.length - 1));
}
if (parts.length !== expectedLength + 1) {
throw new Error(`Expected ${expectedLength} subroutes, but got ${parts.length - 1}`);
}
return parts.slice(1);
}
export function renderSubrouteButtons(
currentSubroute: string,
subRoutes: string[],
subrouteIndex: number,
prefix: string,
subroutes: { label: string; value: string; emoji?: PartialEmoji }[],
options?: Partial<ButtonOptions>,
) {
return subroutes
.filter((sr) => sr !== undefined)
.map(({ label, value, emoji }) => {
const routes = [...subRoutes];
routes[subrouteIndex] = currentSubroute == value ? '_' : value;
return button(label, getSubrouteKey(prefix, routes), {
...options,
disabled: value === currentSubroute,
emoji,
});
});
}
export interface SubrouteOptions {
label: string;
value: string;
emoji?: PartialEmoji;
}
export function renderSubroutes<T, CType = ContainerItems>(
context: PageContext<T>,
prefix: string,
subroutes: (SubrouteOptions & {
banner?: string;
actionRowPosition?: 'top' | 'bottom';
})[][],
render: (currentSubroute: string, ctx: PageContext<T>) => CType,
btnOptions?: Partial<ButtonOptions>,
defaultSubroutes?: string[], // if not provided, will use the first option of each subroute
): CType[] {
const currentSubroutes = parseSubrouteKey(
context.custom_id,
prefix,
subroutes.length,
defaultSubroutes || subroutes.map((s) => s[0].value),
);
const components = subroutes
.filter((sr) => sr.length > 0)
.map((srOpts, index) => {
const opts = srOpts.filter((sr) => sr !== undefined);
if (opts.length === 0) return undefined;
// find the current subroute, or default to the first
const sri = opts.findIndex((s) => s.value === currentSubroutes[index]);
const current = opts[sri] || opts[0];
const components = [];
const actionRow = actionRow(...renderSubrouteButtons(current.value, currentSubroutes, index, prefix, opts, btnOptions));
if (current.banner) {
components.push(gallery({ url: current.banner }));
}
if (!current.actionRowPosition || current.actionRowPosition === 'top') {
components.push(actionRow);
}
components.push(render(current.value, context));
if (current.actionRowPosition === 'bottom') {
components.push(actionRow);
}
return components;
})
.flat()
.filter((c) => c !== undefined);
return components;
}

View File

@@ -0,0 +1,50 @@
import type { ChatInputApplicationCommandStructure, ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import type { ExecutableInteraction } from './interaction.type';
import type { Cache } from '@/core/cache.type';
import type { KVStore } from '@/core/kv-store.type.ts';
import type { Client } from '@/core/client';
export interface CommandState<T = any> {
id: string; // unique id for this command instance
name: string; // command name
data: T; // internal data storage
}
export interface PartialContext<T = any> {
client: Client;
cache: Cache;
kv: KVStore;
id?: string; // unique id for this command instance
state?: CommandState<T>; // state associated with this command instance
}
export enum PermissionType {
ROLE = 1,
USER = 2,
CHANNEL = 3,
}
export interface CommandPermission {
id: string;
permission: boolean;
type: PermissionType;
}
export interface CommandOptions {
commandKey: string;
authorize: AuthorizedFn;
guilds: string[]; // make this a guild command
guildPermissions: Record<string, CommandPermission[]>; // only works if a guild command
}
export type CommandContext<T = any> = Required<PartialContext<T>>;
export type ChatCommandDefinition = Omit<ChatInputApplicationCommandStructure, 'type'>;
export type ExecuteFn = (interaction: ExecutableInteraction, ctx: CommandContext) => any | Promise<any>;
export type AuthorizedFn = (interaction: ExecutableInteraction, ctx: CommandContext) => boolean | Promise<boolean>;
export interface CommandHandler<T extends ApplicationCommandStructure = ApplicationCommandStructure> {
definition: T;
execute: ExecuteFn;
options?: Partial<CommandOptions>;
}

View File

@@ -0,0 +1,2 @@
export * from './command.type';
export * from './interaction.type';

View File

@@ -0,0 +1,30 @@
import type Dysnomia from '@projectdysnomia/dysnomia';
export type Interaction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction | AutocompleteInteraction | PingInteraction;
export type ExecutableInteraction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction | AutocompleteInteraction;
export interface InteractionAugments {
isApplicationCommand: () => this is Dysnomia.CommandInteraction;
isModalSubmit: () => this is Dysnomia.ModalSubmitInteraction;
isMessageComponent: () => this is Dysnomia.ComponentInteraction;
isSelectMenu: () => this is SelectMenuInteraction;
isButton: () => this is ButtonInteraction;
isAutocomplete: () => this is Dysnomia.AutocompleteInteraction;
isPing: () => this is Dysnomia.PingInteraction;
isExecutable: () => this is ExecutableInteraction;
}
export type CommandInteraction = Dysnomia.CommandInteraction & InteractionAugments;
export type ModalSubmitInteraction = Dysnomia.ModalSubmitInteraction & InteractionAugments;
export type ComponentInteraction = Dysnomia.ComponentInteraction & InteractionAugments;
export type AutocompleteInteraction = Dysnomia.AutocompleteInteraction & InteractionAugments;
export type PingInteraction = Dysnomia.PingInteraction & InteractionAugments;
export interface SelectMenuInteraction extends ComponentInteraction {
data: Dysnomia.ComponentInteractionSelectMenuData;
}
export interface ButtonInteraction extends ComponentInteraction {
data: Dysnomia.ComponentInteractionButtonData;
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"strict": false,
"noImplicitAny": false,
"skipLibCheck": true,
"lib": ["ESNext"],
"typeRoots": ["src/types", "./node_modules/@types"],
"paths": {
"@/*": ["./src/*"],
},
"emitDeclarationOnly": true,
"noEmit": false,
"noEmitOnError": false,
"declaration": true,
"outDir": "dist/types",
"rootDir": ".",
"allowImportingTsExtensions": true
},
"include": ["src", "fixtures"],
"exclude": ["node_modules", "build", "**/*.test.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: [
'./src/index.ts',
'./src/commands/index.ts',
'./src/components/index.ts',
'./src/pages/index.ts',
'./src/common/index.ts',
],
platform: 'node',
dts: true,
minify: false,
sourcemap: true,
unbundle: true,
external: ['bun:sqlite', 'bun'],
},
]);

View File

@@ -0,0 +1,193 @@
# Discord
Star Kitten's discord functionality is built on top of [Project Dysnomia](https://github.com/projectdysnomia/dysnomia) to provide up to date and performance interactions with the Discord API.
## Create a bot
Running a bot with Star Kitten is as simple as calling startBot()!
_src/main.ts_
```ts
import { startBot } from '@star-kitten/lib/discord';
startBot({
token: 'XXXXXX', // can be omitted if you set a DISCORD_BOT_TOKEN environment variable
});
```
You can configure the bot further through environment variables:
_I recommend using dotenvx to encrypt your .env files rather than using plaintext .env files_
```.env
# Required
DISCORD_BOT_TOKEN="XXXXXX" # https://discord.com/developers/applications
# Optional
DEBUG="true" # Enable debug mode
NODE_ENV="development"
LOG_LEVEL="debug"
RESET_COMMANDS="true" # "true" or NODE_ENV === "development" will have the bot delete and register all commands on every startup, useful for development
STAR_KITTEN_KV_DB_PATH="./kv.db" # Allows the bot to remember commands between restarts for resume functionality and recovery from crashes. If not set, :memory: is used so commands created before any restart will no longer function
# if using EVE ESI functionality
EVE_CLIENT_ID="XXXXX"
EVE_CLIENT_SECRET="XXXX"
EVE_CALLBACK_URL="http://my.callback.url"
ESI_USER_AGENT="XXXX" # provided with each ESI request so CCP knows who to contact if there is any issue
JANICE_KEY="XXXXX" # If you have one for using janice to get pricing data or appraisals
# AI Features
PERPLEXITY_API_KEY="XXXXX"
```
Although, a bot without any commands isn't very useful, so on to Commands
## Commands
Star Kitten will automatically register commands for you at startup. By default, any file in your `./src` directory that matches the pattern `**/*.command.{js,ts,jsx,tsx}`, like `./src/ping.command.ts` or `./src/commands/time.command.tsx`.
### Command File
A command file's exports must match the [CommandHandler](../src/discord/commands/command-handler.ts#14) structure.
definition: Defines the command to Discord, providing the command name, description, parameters and more.
execute: Function that is ran when this command is executed. The execute method will get called for every interaction associated with that command, so ensure you properly handle different interaction types, like Autocomplete vs. ApplicationCommand.
Example:
```tsx
import { Constants, type ChatInputApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import { type ExecutableInteraction, type CommandContext, isApplicationCommand, Locale } from '@star-kitten/lib/discord';
const definition: ChatInputApplicationCommandStructure = {
type: Constants.ApplicationCommandTypes.CHAT_INPUT,
name: 'time',
nameLocalizations: {
[Locale.DE]: 'zeit',
},
description: 'Get the current EVE time',
descriptionLocalizations: {
[Locale.DE]: 'Holen Sie sich die aktuelle EVE-Zeit',
},
};
const eveTimeText = {
[Locale.EN_US]: 'EVE Time',
[Locale.DE]: 'EVE-Zeit',
};
function renderTimeDisplay(locale: string = 'en-US') {
const now = new Date();
const eveTime = now.toISOString().split('T')[1].split('.')[0];
const eveDate = now.toLocaleDateString(locale, {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: '2-digit',
weekday: 'long',
});
return (
<container>
<textDisplay>
{`### ${eveTimeText[locale] || eveTimeText[Locale.EN_US]}
${eveTime}
${eveDate}`}
</textDisplay>
</container>
);
}
async function execute(interaction: ExecutableInteraction, ctx: CommandContext) {
if (!isApplicationCommand(interaction)) return;
interaction.createMessage({
flags: Constants.MessageFlags.IS_COMPONENTS_V2,
components: [renderTimeDisplay(interaction.locale)],
});
}
export default {
definition,
execute,
};
```
## Components V2
Star Kitten commands should utilize components v2. We also have a JSX syntax for creating commands. Look at the examples below to compare using JSX vs plain JavaScript objecst to define your components.
### JSX Components
```tsx
export function renderAppraisalModal(interaction: Interaction) {
return (
<modal customId="appraisalResult" title="Appraise Items">
<label label="Select a market">
<stringSelect customId="market" placeholder="Select a market" minValues={1} maxValues={1}>
{markets.map((m) => (
<option
label={m.name}
value={m.id.toString()}
default={m.id === 2} // Jita
/>
))}
</stringSelect>
</label>
<label label="Enter items to appraise">
<textInput
customId="input"
isParagraph={true}
placeholder={`e.g. Tritanium 22222
Pyerite 8000
Mexallon 2444`}
/>
</label>
</modal>
);
}
```
### Plain JS Object
```ts
export function renderAppraisalModal(interaction: Interaction) {
return {
type: Constants.InteractionResponseTypes.MODAL,
custom_id: 'appraisalResult',
title: 'Appraise Items',
components: [
{
type: Constants.ComponentTypes.LABEL,
label: 'Select a market',
component: {
type: Constants.ComponentTypes.STRING_SELECT,
custom_id: 'market',
placeholder: 'Select a market',
min_values: 1,
max_values: 1,
options: markets.map((m) => ({
label: m.name,
value: m.id.toString(),
default: m.id === 2, // Jita
})),
},
},
{
type: Constants.ComponentTypes.LABEL,
label: 'Enter items to appraise',
component: {
type: Constants.ComponentTypes.TEXT_INPUT,
custom_id: 'input',
style: Constants.TextInputStyles.PARAGRAPH,
placeholder: `e.g. Tritanium 22222
Pyerite 8000
Mexallon 2444`,
},
},
],
};
}
```