Merge pull request #1 from jaronpate/ft/rewrite

ft/rewrite
This commit is contained in:
2025-01-12 20:05:32 -06:00
committed by GitHub
48 changed files with 3105 additions and 1879 deletions
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
run: yarn test --ci --coverage --maxWorkers=2 run: yarn test --ci --coverage --maxWorkers=2
- name: Build - name: Build
run: yarn build run: yarn build
+174 -2
View File
@@ -1,3 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env .env
/dist .env.development.local
/node_modules .env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
-7
View File
@@ -1,7 +0,0 @@
{
"singleQuote": true,
"printWidth": 120,
"semi": true,
"tabWidth": 4,
"trailingComma": "none"
}
+146 -40
View File
@@ -1,24 +1,33 @@
<div align="center"> <div align="center">
<h2>discord.ts</h2> <img src="./assets/unicord.png" alt="unicord" width="200"/>
<p>Build good bots fast</p> <h2>unicord</h2>
<p><em>Build good bots fast</em></p>
</div> </div>
> This package is in an early Alpha stage and not yet ready for consumption > This package is in early development and is not yet ready for production use. Please be aware that the API may change at any time. Use at your own risk.
A solid and simple Discord API wrapper. This library is designed with three pillars in mind: **Simple**, **Maintainable**, and **Scalable**. It isn't guaranteed to be the fastest or the most feature rich, but you will probably find it to be the easiest to use and understand.
It's typecript first and has some nifty features like hydration and argument type resolution. The goal here is to not have to worry about if you are using the Gateway, Webhooks, or Application commands. Ideally there is a single API that can be used to inferface with Discord.
## Installation ## Installation
Install with your package manager of choice.
``` ```
yarn add @jaronp/discord.ts npm install unicord
yarn add unicord
bun add unicord
``` ```
## Quick Start ## Quick Start
A small example implementation. A small example implementation.
```ts ```typescript
import { Client } from '@jaronp/discord.ts'; import { Client, type Context } from 'unicord';
// Initialize and configure // Initialize and configure
const bot = new Client({ const client = new Client({
token: '<token>', token: '<token>',
application_id: '<application_id>', application_id: '<application_id>',
intents: [ intents: [
@@ -26,52 +35,149 @@ const bot = new Client({
Intent.GUILD_MESSAGES, Intent.GUILD_MESSAGES,
Intent.GUILD_MESSAGE_REACTIONS, Intent.GUILD_MESSAGE_REACTIONS,
Intent.MESSAGE_CONTENT Intent.MESSAGE_CONTENT
] ],
prefix: '??'
}); });
// Register a listener // Register arbitrary event handler to the client bus
bot.handlers.register('READY', (client, context, data) => { client.on('READY', async (_, payload) => {
console.log(`Logged in as ${client.user.tag}`); console.log(`Logged in as ${payload.user.username}${payload.user.discriminator?.length > 0 ? `#${payload.user.discriminator}` : ''}`);
}); });
// Register a command // Register a command
bot.commands.register('ping', (client, context, args) => { client.chatCommands.register('ping', (context: Context, args) => {
context.reply('Pong!', true); context.reply('Pong!', true);
}); });
// Connect // Connect
bot.connect(); client.connect();
``` ```
## Type Resolution and Saftey
This library is designed to be type safe and resolve types for you. Because of this you can write code that is both safe and easy to understand. With the `createCommandHandler` utility you can provide types for command arguments. The client will then check and resolve them for you.
```typescript
import { createCommandHandler, CommandOptionType, type Context } from 'unicord';
// <...>
// Define the command handler with the utility function
const avatarCommand = createCommandHandler({
description: 'Get a users avatar',
args: [
{
id: 'user',
name: 'user',
type: CommandOptionType.User,
description: 'The user to get the avatar for',
required: true
}
],
execute: async (context: Context, args) => {
// Args will be typed here as { user: User }
context.reply(args.user.avatarURL);
}
});
// You can then register the command with the client
// The class generated can be used for both chat and application commands
client.chatCommands.register('avatar', avatarCommand);
client.applicationCommands.register('avatar', ApplicationCommandType.Chat, avatarCommand);
// <...>
```
## Hydration
Hydration allows you to fetch and fill data on Discord objects. This is useful for when you need to fetch additional data about an object.
```typescript
client.chatCommands.register('ping', async (context: Context, args: any[]) => {
// Hydrate the message id from the context
const { message } = await context.hydrate(context, [Expectation.Message]);
// Send a message to the channel
const response = await context.reply(`Pong!: got ${args.join(', ')}`, true);
// Calculate the time it took to send the message
const time = message.timestamp.getTime() - message.timestamp.getTime();
// Edit the message to include the time
await context.editMessage(response, `Pong! Latency: ${time}ms`);
});
```
This is a powerful feature that allows you to fetch and use data from Discord objects in a type safe way. It also ensures the minimum amount of API calls are made and that no extra data is fetched or passed.
You can also generate a hydration function that returns a type narrowing boolean. This can be used to check if the object is hydrated or not.
```typescript
client.chatCommands.register('me', async (context: Context, _args: any[]) => {
// Hydrate the message id from the context
const hydrate = await context.hydrator(hydrated, [Expectation.Guild]);
// Check if the object is hydrated
const hasGuild = hydrate(hydrated);
if (hasGuild) {
// Send a message stating the user that they are in a guild
await context.reply(`You are ${message.author.username} and this is ${message.guild.name}`, true);
} else {
// Send a message stating the user that they are not in a guild
await context.reply(`You are ${message.author.username} and we are not in a server`, true);
}
});
```
Additionally you can hydrate repeatedly if you need to fetch multiple objects at different times.
## Interactions ## Interactions
First create the interactions. First create the interactions.
```ts
bot.commands.register('test', (_client, context, _args) => { ```typescript
// Add components to use interactions client.chatCommands.register('demo', async (context: Context, _args: any[]) => {
const msg = new Message().setContent('Test interaction!').addComponent( const msg = new Message()
// This generations an action row which can optionaly contain content .setContent('Hello World!')
'This is an action row', .addComponent(
// Create components with helper functions 'This is an action row',
Message.button({ // Max buttons in a row is 5
label: 'Click me!', // Custom ID is required and must be unique
style: ComponentStyle.Primary, // Non-Link buttons can not have a URL
// Assign id's to reference them later // Link buttons can not have a custom ID
custom_id: 'click_me', Message.button({
emoji: { label: 'Click me!',
name: '🔥', style: ComponentStyle.Primary,
id: null, custom_id: 'button_1'
animated: false }),
} Message.button({
}) label: 'No me!',
); style: ComponentStyle.Secondary,
custom_id: 'button_2',
disabled: true
}),
Message.button({
label: 'Never, me!',
style: ComponentStyle.Success,
custom_id: 'button_3'
}),
// Link requires url and can not have custom_id
Message.button({
label: 'Piss off.',
style: ComponentStyle.Link,
url: 'https://example.com'
})
);
context.reply(msg); context.reply(msg);
}); });
``` ```
Then provide a handler for them. Then provide a handler for them.
```ts
bot.interactions.register('click_me', async (client, context, interaction) => { ```typescript
const response = context.reply( const buttonInteractionHandler = (context: Context, data: InteractionCommpoentData) => {
`You clicked me! Thank you ${interaction.member.user.username} <3` context.reply(`You clicked the ${data.custom_id} button!`);
); };
});
``` client.interactions.register('button_1', buttonInteractionHandler);
client.interactions.register('button_2', buttonInteractionHandler);
client.interactions.register('button_3', buttonInteractionHandler);
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Executable
BIN
View File
Binary file not shown.
+29 -18
View File
@@ -1,27 +1,38 @@
{ {
"name": "@jaronp/discord.ts", "name": "unicord",
"version": "0.1.1", "description": "A simple discord library primarily for TypeScript. REST like and easy to use.",
"main": "dist/index.js", "version": "0.0.1",
"types": "dist/index.d.ts", "module": "src/index.ts",
"author": "Jaron Pate", "type": "module",
"license": "Unlicense", "main": "out/index.js",
"repository": "https://github.com/jaronpate/discord.ts", "types": "out/index.d.ts",
"files": [ "files": [
"/dist" "out"
], ],
"scripts": { "scripts": {
"start": "npx ts-node test/main.test.ts", "test": "bun run testing/demo.test.ts",
"start:watch": "npx ts-node-dev --respawn test/main.test.ts", "test:inspect": "bun --inspect testing/demo.test.ts",
"build": "npx tsc"
"build": "bun run build:types && bun run build:core",
"build:packages": "bun run build:types && bun run build:core -- --packages external",
"build:nocheck": "bun run build:types:nocheck && bun run build:core",
"build:core": "bun build src/index.ts --outdir out --drop=console --drop=debugger",
"build:types": "tsc --emitDeclarationOnly --project tsconfig.json",
"build:types:nocheck": "bun run build:types -- --noCheck",
"build:watch": "concurrently \"bun run build:types -- --watch\" \"bun run build:core -- --watch\"",
"build:packages:watch": "concurrently \"bun run build:types -- --watch\" \"bun run build:core -- --packages external --watch\""
}, },
"devDependencies": { "devDependencies": {
"@types/ws": "^8.5.3", "@types/bun": "latest",
"dotenv": "^16.0.2", "commander": "^13.0.0",
"ts-node-dev": "^2.0.0", "concurrently": "^9.1.0"
"typescript": "^4.8.3" },
"peerDependencies": {
"typescript": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"axios": "^0.27.2", "ws": "^8.18.0"
"ws": "^8.8.1"
} }
} }
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bun
/**
* Not used at the moment, was just playing around with the idea of using the bun JavaScript API to build the project.
*/
import { Command } from 'commander';
import { build, type BuildConfig } from 'bun';
import concurrently from 'concurrently';
// Initialize Commander to handle CLI commands and options
const program = new Command();
// Paths and base TypeScript compilation command
const srcFile = 'src/index.ts';
const outDir = 'out';
const tscBaseCmd = 'tsc --emitDeclarationOnly --project tsconfig.json';
/**
* Handles the build process with optional watch and type emission.
*
* @param options - Object containing flags for watch, packages, noCheck, and typesOnly.
*/
async function runBuild(options: {
watch?: boolean;
packages?: boolean;
noCheck?: boolean;
typesOnly?: boolean;
}) {
// Handle TypeScript-only builds (e.g., for emitting declaration files without bundling)
if (options.typesOnly) {
// Construct the TypeScript command dynamically based on flags
const tscCmd = `${tscBaseCmd}${options.noCheck ? ' --noCheck' : ''}${options.watch ? ' --watch' : ''}`;
// Run TypeScript compilation (watch mode if specified)
await Bun.spawn(tscCmd.split(' ')).exited;
return; // Exit early since no Bun build is needed for types-only mode
}
// Define options for the Bun build process
const buildOptions: BuildConfig = {
entrypoints: [srcFile],
outdir: outDir,
drop: ['console', 'debugger'], // Strip console and debugger statements
packages: options.packages ? 'external' : undefined
};
// Handle watch mode: run both TypeScript and Bun build concurrently
if (options.watch) {
concurrently([
{
// Run TypeScript watch for continuous type emission
command: `${tscBaseCmd} --watch${options.noCheck ? ' --noCheck' : ''}`,
name: 'tsc', // Label the TypeScript process for logging
},
{
// Run Bun build in watch mode
command: `bun build ${srcFile} --outdir ${outDir} ${
options.packages ? '--packages external' : ''
} ${options.noCheck ? '--noCheck' : ''} --watch`,
name: 'bun', // Label the Bun process for logging
},
], {
});
} else {
// Non-watch mode: Run TypeScript once, then trigger Bun build
await Bun.spawn(tscBaseCmd.split(' ')).exited;
// Perform a standard Bun build
await build(buildOptions);
console.log(`Build completed.`);
}
}
// Register the build command and attach optional flags
program
.command('build')
.description('Build project with options')
.option('--watch', 'Watch for changes and rebuild')
.option('--packages', 'Bundle external packages')
.option('--nocheck', 'Skip type checking')
.option('--types-only', 'Emit types without compiling the project')
.action((opts) => {
runBuild({
watch: opts.watch,
packages: opts.packages,
noCheck: opts.nocheck,
typesOnly: opts.typesOnly,
});
});
// Parse and execute CLI input
program.parse();
+18 -177
View File
@@ -1,177 +1,18 @@
import { ComponentStyle, Context, Gateway, Guilds, Message, Embed } from './lib'; // Base classes
import { SlashCommand, SlashCommandFunction, ChatCommand, Command, CommandFunction, Intent, Presence } from './types'; export { Client } from './services/client';
export { export { Intent, Trait } from './types/common';
ComponentStyle, // Consumable types
Context, export type { ClientConfig } from './types/common';
Gateway, export type { Context } from './types/context';
Guilds, export { Expectation } from './types/common';
Message, // Consumable util classes
Embed, export { Guild } from './types/guild';
Command, export { Role } from './types/role';
CommandFunction, export { Member } from './types/member';
ChatCommand, export { User } from './types/user';
Intent, export { Message, type MessagePayload, ComponentStyle, ComponentType } from './types/message';
Presence export { Embed } from './types/embed';
}; export { Emoji } from './types/emoji';
// Other
type Config = { export { createCommandHandler, type InteractionData, type InteractionCommpoentData, type InteractionCommandData, type InteractionHandlerFunction } from './types/handler';
token: string; export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand';
application_id: string;
prefix?: string;
intents?: Intent[];
};
export class Client {
readonly prefix: string;
readonly token: string;
readonly application_id: string;
private readonly gateway: Gateway;
private readonly client_commands: Map<string, ChatCommand | CommandFunction> = new Map();
private readonly slash_commands: Map<string, SlashCommand | SlashCommandFunction> = new Map();
readonly guilds: Guilds;
constructor({ token, application_id, prefix, intents }: Config) {
this.token = token;
this.application_id = application_id;
this.prefix = prefix ?? '!';
this.gateway = new Gateway({ token, intents, client: this });
this.guilds = new Guilds({ token, gateway: this.gateway });
// Do some default stuff
this.initialize();
}
get user() {
return this.gateway.user;
}
get handlers() {
return this.gateway.handlers;
}
get interactions() {
return this.gateway.interactions;
}
get api() {
return this.gateway.api;
}
public connect = () => {
this.gateway.connect();
return this;
};
public reconnect = () => {
this.gateway.reconnect();
return this;
};
public setPresence = (presence: Presence) => {
this.gateway.setPresence(presence);
return this;
};
send = (channel_id: string, message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return this.api.post(`/channels/${channel_id}/messages`, message);
};
public commands = {
chat: {
register: (command: ChatCommand | string, handler?: CommandFunction) => {
if (typeof command === 'string') {
this.client_commands.set(command, handler);
} else {
this.client_commands.set(command.name, command);
}
},
get: (command_name: string) => {
return this.client_commands.get(command_name);
},
execute: (command_name: string, context: Context, ...args: any[]) => {
const command = this.client_commands.get(command_name);
if (command) {
command instanceof ChatCommand
? command.execute(this, context, args.slice(1))
: command(this, context, args.slice(1));
}
},
delete: (command_name: string) => {
this.client_commands.delete(command_name);
},
list: () => {
return Array.from(this.client_commands.entries());
},
map: () => {
return this.client_commands;
}
},
application: {
register: (command: SlashCommand | Record<string, string>, handler?: SlashCommandFunction) => {
const payload =
command instanceof SlashCommand
? command.toJSON()
: {
name: command.name,
description: command.description,
type: command.type ?? 1
};
this.api
.post(`/applications/${this.application_id}/commands`, payload)
.then((response) => {
console.log(response);
if (command instanceof SlashCommand) {
this.slash_commands.set(command.name, command);
} else {
this.slash_commands.set(command.name, handler);
}
})
.catch((error) => {
this.log.error('Error registering application command', error);
});
},
get: (command_name: string) => {
return this.slash_commands.get(command_name);
},
list: () => {
return Array.from(this.slash_commands.entries());
},
map: () => {
return this.slash_commands;
}
}
};
private log = {
error: (error_or_message: Error | string, error?: Error) => {
if (error && typeof error_or_message === 'string') {
console.error(`[discord.ts] ${error_or_message}:`, error);
} else {
console.error('[discord.ts] Error:', error_or_message);
}
}
};
private initialize = () => {
// Register default help command
this.commands.chat.register('help', (client, context, _args) => {
const embed = new Embed()
.setTitle('Commands')
.setDescription('This is a list of commands you can use.')
.setColor('#f6c221');
const commands = client.commands.chat.list();
for (const [name, command] of commands) {
embed.addField({
name: name,
value: command instanceof ChatCommand ? command.description : 'No description provided'
});
}
context.reply(embed.toMessage());
});
};
}
-37
View File
@@ -1,37 +0,0 @@
import { Context, Gateway } from '.';
export class ObjectCache<T> {
cache = new Map<string, T>();
private readonly token: string;
private readonly type: string;
private readonly gateway: Gateway;
constructor({ token, gateway, type, events }: { token: string; gateway: Gateway; type: string; events: string[] }) {
this.token = token;
this.type = type;
this.gateway = gateway;
gateway.handlers.register(events, (_client, _context: Context, e: Record<string, any>) => {
this.cache.set(e.id, e as T);
});
}
async fetch(object_id: string): Promise<T> {
return this.gateway.api.get(`${this.type}/${object_id}`).then((res) => {
const object: T & { id: string } = res.data;
this.cache.set(object.id, object);
return object;
});
}
get(object_id: string): T | Promise<T> {
if (this.cache.has(object_id)) {
return Promise.resolve(this.cache.get(object_id)!);
} else {
return this.fetch(object_id);
}
}
list(): Map<string, T> {
return this.cache;
}
}
-52
View File
@@ -1,52 +0,0 @@
import { Client } from '..';
import { Message } from '../lib';
export class Context {
private readonly client: Client;
// Public properties
message_id: string;
channel_id: string;
guild_id: string;
constructor({ client, message }: { client: Client; message: Partial<Message> }) {
this.client = client;
this.message_id = message.id;
this.channel_id = message.channel_id;
this.guild_id = message.guild_id;
}
get guild() {
return this.client.guilds.get(this.guild_id);
}
/**
* Reply to the message that this command
* @param message The message to send to the channel
* @param reference If true the message will be a reference the the message that triggered the command
* @returns The message that was sent
*/
public reply = (message: Message | string, reference: boolean = false) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
if (reference) {
message.message_reference = {
message_id: this.message_id,
channel_id: this.channel_id,
guild_id: this.guild_id
};
}
return this.client.send(this.channel_id, message);
};
public delete = (message_id?: string) => {
return this.client.api.delete(`/channels/${this.channel_id}/messages/${message_id ?? this.message_id}`);
};
public edit = (message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return this.client.api.patch(`/channels/${this.channel_id}/messages/${this.message_id}`, message);
};
}
-374
View File
@@ -1,374 +0,0 @@
import { WebSocket, Data } from 'ws';
import axios, { AxiosInstance } from 'axios';
import { Client } from '..';
import {
Session,
User,
Payload,
Intent,
Handler,
HandlerFunction,
Command,
Status,
Presence,
ActivityType,
CommandFunction,
Interaction,
InteractionFunction,
SlashCommand
} from '../types';
import { Context } from './context';
import { InteractionContext } from './interaction';
export class Gateway {
socket: WebSocket;
axios: AxiosInstance;
// Config
readonly token: string;
readonly client: Client;
private intents: number = 513;
private commandNotFound: CommandFunction = null;
// Discord gateway data
private readonly url: string = 'wss://gateway.discord.gg/?v=10&encoding=json';
private heartbeat_timeout: NodeJS.Timeout;
private heartbeat_function: NodeJS.Timer;
private heartbeat_interval: number | null = null;
private session: Session = null;
public user: User = null;
// Utils
public event_handlers: Map<string, Array<Handler | HandlerFunction>> = new Map();
public interaction_handlers: Map<string, Array<Interaction | InteractionFunction>> = new Map();
constructor({
token,
intents,
client,
commandNotFound
}: {
token: string;
intents?: Intent[];
client: Client;
commandNotFound?: CommandFunction;
}) {
// Copy config
this.token = token;
this.client = client;
this.commandNotFound = commandNotFound;
// Initialize socket
this.socket = new WebSocket(this.url);
// Initialize axios
this.axios = axios.create({
headers: {
'User-Agent': 'DiscordBot (discord.ts, 0.0.1)',
Authorization: `Bot ${this.token}`
},
baseURL: 'https://discord.com/api/v10'
});
this.axios.interceptors.response.use((response) => response.data);
// Calculate intents
if (intents) {
this.intents = intents.reduce((acc, intent) => acc | intent, 0);
}
}
public connect = () => {
// Initialize socket
this.socket = new WebSocket(this.url);
// Register the listeners
this.socket.on('open', this.onOpen);
this.socket.on('message', this.onMessage);
// this.socket.on('close', this.onClose);
// this.socket.on('error', this.onError);
return this;
};
public reconnect = () => {
this.socket.terminate();
return this.connect();
};
public setPresence = (presence: Presence) => {
this.send({
op: 3,
d: presence
});
};
public handlers = {
register: (events: string | string[], handler: Handler | HandlerFunction) => {
this.registerHandler(events, handler, 'command');
},
get: (event: string) => {
return this.event_handlers.get(event);
}
};
public interactions = {
register: (custom_ids: string | string[], handler: Interaction | InteractionFunction) => {
this.registerHandler(custom_ids, handler, 'interaction');
},
get: (event: string) => {
return this.interaction_handlers.get(event);
}
};
public api = {
get: (url: string, params?: any) => {
return this.axios.get<unknown, Record<string, any>>(url, { params });
},
post: (url: string, data?: any) => {
return this.axios.post<unknown, Record<string, any>>(url, data);
},
patch: (url: string, data?: any) => {
return this.axios.patch<unknown, Record<string, any>>(url, data);
},
delete: (url: string, data?: any) => {
return this.axios.delete<unknown, Record<string, any>>(url, data);
}
};
// prettier-ignore
private registerHandler(events: string | string[], handler: Handler | HandlerFunction, type: 'command'): void;
// prettier-ignore
private registerHandler(events: string | string[], handler: Interaction | InteractionFunction, type: 'interaction'): void;
private registerHandler(events: string | string[], handler: any, type: 'command' | 'interaction') {
const handler_map = type === 'command' ? this.event_handlers : this.interaction_handlers;
if (Array.isArray(events)) {
// If Array register handler for each event
for (const event of events) {
const current_handlers = handler_map.get(event) ?? [];
handler_map.set(event, [...current_handlers, handler]);
}
} else {
// Otherwise register for the single event
const current_handlers = handler_map.get(events) ?? [];
handler_map.set(events, [...current_handlers, handler]);
}
}
private send = (payload: Partial<Payload>) => {
payload.s = this.session?.s ?? null;
console.log('sending: %s', payload);
this.socket.send(JSON.stringify(payload));
};
private indentify = () => {
this.send({
op: 2,
d: {
token: this.token,
intents: this.intents,
properties: {
os: process.platform,
browser: 'discord.ts',
device: 'discord.ts'
}
}
});
};
private heartbeat = () => {
if (!this.heartbeat_interval) {
throw new Error('No heartbeat interval set');
}
if (this.heartbeat_function) {
clearInterval(this.heartbeat_function);
}
if (this.heartbeat_timeout) {
clearTimeout(this.heartbeat_timeout);
}
this.heartbeat_function = setInterval(() => {
this.send({
op: 1,
d: {}
});
}, this.heartbeat_interval);
this.heartbeat_timeout = setTimeout(() => {
this.reconnect();
}, this.heartbeat_interval * 2);
};
private initialize = (payload: Payload) => {
this.session = payload.d.session;
this.user = payload.d.user;
this.user.tag = `${this.user.username}#${this.user.discriminator}`;
// Construct presence
const status: Presence = {
status: Status.Online,
since: null,
activities: [
{
name: payload.d.guilds.length + ' servers',
type: ActivityType.Watching,
created_at: Date.now()
}
],
afk: false
};
// Set presence
this.setPresence(status);
// Cache guilds? (these are not whole guilds, just guild ids)
// for (const guild of payload.d.guilds) {
// this.guilds.set(guild.id, guild);
// }
console.log('session: %s', this.session);
console.log('user: %s', this.user);
};
private parseEvent = (payload: Payload) => {
// If not a dispatch event, return
if (!payload.t) {
return;
}
// Run default handlers
switch (payload.t) {
case 'READY':
this.initialize(payload);
break;
case 'INTERACTION_CREATE':
this.handleInteractionCreate(payload);
break;
case 'MESSAGE_CREATE':
this.handleMessageCreate(payload);
break;
case 'MESSAGE_UPDATE':
this.handleMessageCreate(payload);
break;
default:
break;
}
// Call event handlers
const handlers = this.handlers.get(payload.t);
const context = new Context({ client: this.client, message: payload.d });
if (handlers) {
for (const handler of handlers) {
handler instanceof Handler
? handler.execute(this.client, context, payload.d)
: handler(this.client, context, payload.d);
}
}
};
private handleInteractionCreate = (payload: Payload) => {
if (payload.d.type === 2) {
this.handleShashInteraction(payload);
} else {
this.handleChatInteraction(payload);
}
};
private handleShashInteraction = (payload: Payload) => {
console.log(payload.d);
const context = new InteractionContext({ client: this.client, interaction: payload.d });
const handler = this.client.commands.application.get(payload.d.data.name);
if (handler) {
if (handler instanceof SlashCommand) {
handler.execute(this.client, context, payload.d);
} else {
handler(this.client, context, payload.d);
}
}
};
private handleChatInteraction = (payload: Payload) => {
const context = new InteractionContext({ client: this.client, interaction: payload.d });
const handlers = this.client.interactions.get(payload.d.data.custom_id);
if (handlers) {
for (const handler of handlers) {
if (handler instanceof Interaction) {
handler.execute(this.client, context, payload.d);
} else {
handler(this.client, context, payload.d);
}
}
}
};
private handleMessageCreate = async (payload: Payload) => {
// Ignore bots
if (payload.d.author.bot) {
return;
}
console.log(payload.d);
const content = payload.d.content.trim();
// Is this a command?
if (content.startsWith(this.client.prefix) | content.startsWith(`<@${this.client.user.id}>`)) {
// Parse command
const args = content
.slice(this.client.prefix.length)
.trim()
.split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g);
// Check if command exists
const handler = this.client.commands.chat.get(args[0]);
// Generate context
const context = new Context({ client: this.client, message: payload.d });
if (handler) {
// Execute command
handler instanceof Command
? handler.execute(this.client, context, args.slice(1))
: handler(this.client, context, args.slice(1));
} else {
let reply = null;
// Send error message if command does not exist
if (this.commandNotFound) {
// If custom command not found handler is set, use that
reply = this.commandNotFound(this.client, context);
} else {
// Otherwise send default message
reply = await context.reply('Command not found :(', true);
}
if (reply) {
// If reply is set, delete it after 3 seconds
setTimeout(() => {
context.delete(reply.id);
}, 3000);
}
}
}
};
private parseOpCode = (payload: Payload) => {
switch (payload.op) {
case 0:
this.parseEvent(payload);
break;
case 9:
setTimeout(this.reconnect, 500);
break;
case 10:
this.heartbeat_interval = payload.d.heartbeat_interval;
this.heartbeat();
this.indentify();
break;
case 11:
console.log('heartbeat acknowledged');
this.heartbeat_timeout.refresh();
break;
default:
break;
}
};
private onMessage = (packet: Data): void => {
console.log('============================');
let data: Payload | null = null;
try {
data = JSON.parse(packet.toString());
} catch (e) {
console.error(e);
}
if (data) {
console.log('received: %s', data);
console.log('received data: %s', data.d);
if (this.session && data.s && data.s > this.session.s) {
this.session.s = data.s;
} else {
this.parseOpCode(data);
}
}
};
private onOpen = () => {
console.log('Socket open');
};
}
-8
View File
@@ -1,8 +0,0 @@
import { Gateway, ObjectCache } from ".";
import { Guild } from "../types";
export class Guilds extends ObjectCache<Guild> {
constructor ({ token, gateway }: { token: string, gateway: Gateway }) {
super({token, gateway, type: 'guilds', events: ['GUILD_CREATE', 'GUILD_UPDATE']});
}
}
-7
View File
@@ -1,7 +0,0 @@
export * from './gateway';
export * from './cache';
export * from './guilds';
export * from './context';
export * from './message';
export * from './embed';
export * from './interaction';
-61
View File
@@ -1,61 +0,0 @@
import { Client } from "..";
import { InteractionResponseType } from "../types";
import { Message } from "./message";
export class InteractionContext {
public client: Client;
public interaction: Record<string, any>;
private is_loading: boolean;
constructor({ client, interaction, is_loading = false }: { client: Client; interaction: Record<string, any>, is_loading?: boolean }) {
this.client = client;
this.interaction = interaction;
this.is_loading = is_loading;
}
ack = (type: InteractionResponseType = InteractionResponseType.Pong, data?: Record<string, any>) => {
return this.client.api.post(`/interactions/${this.interaction.id}/${this.interaction.token}/callback`, {
type,
data
});
};
reply = (message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return this.ack(InteractionResponseType.Reply, {
...message
});
};
edit = (message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
if (!this.is_loading) {
return this.ack(InteractionResponseType.UpdateMessage, {
...message
});
} else {
return this.client.api.patch(
`/webhooks/${this.client.application_id}/${this.interaction.token}/messages/@original`,
{
type: InteractionResponseType.DeferredUpdateMessage,
...message
}
);
}
};
deleteOriginal = () => {
return this.client.api.delete(
`/webhooks/${this.client.application_id}/${this.interaction.token}/messages/@original`
);
};
loading = async () => {
await this.ack(InteractionResponseType.DeferredReply);
return new InteractionContext({ client: this.client, interaction: this.interaction, is_loading: true });
};
}
-151
View File
@@ -1,151 +0,0 @@
import { Embed } from '.';
export type Component = Partial<Button | SelectMenu> & {
type: ComponentType;
content?: string;
components?: Component[];
};
// {
// content?: string;
// type?: ComponentType;
// description?: string;
// label?: string;
// value?: string;
// style?: ComponentStyle;
// custom_id?: string;
// url?: string;
// disabled?: boolean;
// required?: boolean;
// emoji?: {
// name: string;
// id?: string;
// animated?: boolean;
// }
// options?: Component[];
// placeholder?: string;
// min_values?: number;
// max_values?: number;
// components?: Component[];
// }
export type Emoji = {
name: string | null;
id: string | null;
animated?: boolean;
};
export type Button = {
style: ComponentStyle;
label: string;
emoji?: Emoji;
custom_id?: string;
url?: string;
disabled?: boolean;
};
export type SelectMenu = {
custom_id: string;
options: {
label: string;
value: string;
description?: string;
emoji?: Emoji;
default?: boolean;
}[];
placeholder?: string;
min_values?: number;
max_values?: number;
};
export enum ComponentStyle {
Primary = 1,
Secondary,
Success,
Danger,
Link
}
export enum ComponentType {
ActionRow = 1,
Button,
SelectMenu,
TextInput
}
export class Message {
id?: string;
channel_id?: string;
guild_id?: string;
public content?: string;
public embeds: Embed[] = [];
public components: Component[] = [];
public message_reference?: {
message_id?: string;
channel_id?: string;
guild_id?: string;
fail_if_not_exists?: boolean;
};
constructor() {}
public static button(config: Button): Component {
return {
type: ComponentType.Button,
...config
};
}
public static selectMenu(config: SelectMenu): Component {
return {
type: ComponentType.SelectMenu,
...config
};
}
public setContent = (content: string) => {
this.content = content;
return this;
};
public addEmbed = (embed: Embed) => {
this.embeds.push(embed);
return this;
};
public addComponent = (content: Component | string, ...components: Component[]) => {
if (typeof content === 'string') {
console.log('string');
this.components.push({
type: ComponentType.ActionRow,
content,
components
});
} else {
this.components.push({
type: ComponentType.ActionRow,
components: [content, ...components]
});
}
return this;
};
public setReference = (message: Partial<Message>) => {
this.message_reference = {
message_id: message.id,
channel_id: message.channel_id,
guild_id: message.guild_id,
fail_if_not_exists: false
};
return this;
};
public toJSON = () => {
return {
content: this.content,
embeds: this.embeds,
components: this.components,
message_reference: this.message_reference
};
};
}
+78
View File
@@ -0,0 +1,78 @@
import { exists, log } from "../utils";
import type { Client } from "./client";
export class API {
private readonly base_url: string = 'https://discord.com/api/v10';
constructor(private client: Client) {}
async request<T = any>(method: string, path: string, data?: any): Promise<T> {
const url = `${this.base_url}${path.startsWith('/') ? '' : '/'}${path}`;
const headers = {
Authorization: `Bot ${this.client.token}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
};
const response = await fetch(url, {
method,
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
// console.error(`Failed to fetch: ${response.statusText} ${response.url} ${response.status}`);
console.log('Failed Request Response: ', await response.text());
throw new Error(`Failed to fetch: ${response.statusText}`);
}
const body = await response.json();
// console.log(`API Request: ${method} ${url}`);
// console.log(`API Body: ${JSON.stringify(data, null, 4)}`);
// console.log('API Response:', JSON.stringify(body, null, 4));
// Extract rate limit headers
const remaining = response.headers.get('X-RateLimit-Remaining');
const limit = response.headers.get('X-RateLimit-Limit');
const reset = response.headers.get('X-RateLimit-Reset');
const resetSeconds = response.headers.get('X-RateLimit-Reset-After');
const limitBucket = response.headers.get('X-RateLimit-Bucket');
// Log rate limit info for debugging
log(`Rate Limit: ${remaining}/${limit} - Reset: ${reset} (${resetSeconds}s) - Bucket: ${limitBucket}`);
// If status is 429, we are being rate limited
if (response.status === 429) {
// Look for the retry-after header
const retryAfter = response.headers.get('Retry-After');
if (exists(retryAfter)) {
// Wait for the retry-after time
await new Promise(resolve => setTimeout(resolve, parseInt(retryAfter) * 1000));
} else {
// TODO: Handle this case better
// Maybe wait some arbitrary time??
throw new Error('Rate limited without Retry-After header');
}
}
return body;
}
async get<T = any>(path: string): Promise<T> {
return this.request('GET', path);
}
async post<T = any>(path: string, data: any): Promise<T> {
return this.request('POST', path, data);
}
async patch<T = any>(path: string, data: any): Promise<T> {
return this.request('PATCH', path, data);
}
async delete<T = any>(path: string): Promise<T> {
return this.request('DELETE', path);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { EventEmitter } from 'node:events';
import type { Context } from '../types/context';
const EventBus = new EventEmitter() as Emitter;
export type Emitter = EventEmitter & {
emit: (event_name: string, context: Context | null, ...args: any[]) => boolean
}
export { EventBus };
+71
View File
@@ -0,0 +1,71 @@
import { fromDiscord } from "../../types/common";
import type { Context } from "../../types/context";
import type { API } from "../api";
import type { Processor } from "../processor";
export class ObjectCache<T, K extends T> {
cache = new Map<string, K>();
requests = new Map<string, Promise<K>>();
readonly type: string;
constructor(protected api: API, processor: Processor, type: string, private readonly factory: { [fromDiscord]: (data: any) => K } & (new (data: T) => T), events: string[]) {
this.type = type;
processor.events.register(events, async (_context: Context, e: Record<string, any>) => {
this.cache.set(e.id, factory[fromDiscord](e));
});
}
set(object_id: string, object: K): void {
this.cache.set(object_id, object);
}
// update(object_id: string, object: Partial<T>): void {
// const existing = this.cache.get(object_id);
// if (exists(existing)) {
// // TODO: Idk wtf happens if this is called on two classes
// this.cache.set(object_id, { ...existing, ...object });
// } else {
// this.cache.set(object_id, object as T);
// }
// }
async fetch(object_id: string, ..._args: any[]): Promise<K> {
if (this.requests.has(object_id)) {
return this.requests.get(object_id)!;
// } else if (this.cache.has(object_id)) {
// return Promise.resolve(this.cache.get(object_id)!);
} else {
// Dispatch the request
const req = this.api.get(`/${this.type}/${object_id}`).then((data) => {
// Create the object
const object = this.factory[fromDiscord](data);
// Save the object
this.cache.set(object_id, object);
// Return the object
return object;
});
// Save the request
this.requests.set(object_id, req);
// Remove the request from the cache if it succeeds
req.then(() => this.requests.delete(object_id));
// Remove the request from the cache if it fails
req.catch(() => this.requests.delete(object_id));
// Return the request
return req;
}
}
async get(object_id: string, ..._args: any[]): Promise<K> {
if (this.cache.has(object_id)) {
return await Promise.resolve(this.cache.get(object_id)!);
} else {
return this.fetch(object_id);
}
}
list(): Map<string, Promise<T> | T> {
return this.cache;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Guild } from "../../types/guild";
import type { API } from "../api";
import type { Processor } from "../processor";
import { ObjectCache } from "./cache";
export class Guilds extends ObjectCache<Guild, Guild> {
constructor (api: API, processor: Processor) {
super(api, processor, 'guilds', Guild, ['GUILD_CREATE', 'GUILD_UPDATE']);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { fromDiscord } from "../../types/common";
import { Message, type MessagePayload } from "../../types/message";
import type { API } from "../api";
import type { Client } from "../client";
import type { Processor } from "../processor";
import { ObjectCache } from "./cache";
export class Messages extends ObjectCache<Message, MessagePayload> {
constructor (protected client: Client, api: API, processor: Processor) {
super(api, processor, 'message', Message, ['MESSAGE_CREATE', 'MESSAGE_UPDATE']);
}
async fetch(channel_id: string, message_id: string): Promise<MessagePayload> {
if (this.requests.has(message_id)) {
return this.requests.get(message_id)!;
} else {
// Dispatch the request
const req = this.api.get(`/channels/${channel_id}/messages/${message_id}`).then((data) => {
const message = Message[fromDiscord](data);
this.cache.set(message_id, message);
return message;
});
// Save the request
this.requests.set(message_id, req);
// Remove the request from the cache if it succeeds
req.then(() => this.requests.delete(message_id));
// Remove the request from the cache if it fails
req.catch(() => this.requests.delete(message_id));
// Return the request
return req;
}
}
async get(channel_id: string, message_id: string): Promise<MessagePayload> {
if (this.cache.has(message_id)) {
return await Promise.resolve(this.cache.get(message_id)!);
} else {
return this.fetch(channel_id, message_id);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { User } from "../../types/user";
import type { API } from "../api";
import type { Processor } from "../processor";
import { ObjectCache } from "./cache";
export class Users extends ObjectCache<User, User> {
constructor (api: API, processor: Processor) {
super(api, processor, 'users', User, []);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { API } from './api';
import { Gateway } from './connectors/gateway';
import { Processor } from './processor';
import { Expectation, HandlerType, Intent, type ClientConfig } from '../types/common';
import { exists, isNil } from '../utils/index';
import { type DiscordMessage, Message } from '../types/message';
import { Guilds } from './caches/guilds';
import { Messages } from './caches/messages';
import { Users } from './caches/users';
import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator';
import { EventBus, type Emitter } from './bus';
export class Client {
readonly token: string;
readonly application_id: string;
readonly intents: Intent[] = [Intent.DEFAULT];
prefix: string;
// Internal services
protected bus: Emitter;
protected api: API;
protected processor: Processor;
protected gateway: Gateway;
guilds: Guilds;
users: Users;
messages: Messages;
chatCommands: Processor[HandlerType.ChatCommands];
applicationCommands: Processor[HandlerType.ApplicationCommands];
interactions: Processor[HandlerType.Interactions];
constructor(config: ClientConfig) {
// Validate the configuration
Client.validateConfig(config);
// Save the configuration
this.token = config.token;
this.application_id = config.application_id;
this.intents = config.intents ?? this.intents;
this.prefix = config.prefix ?? '!';
// Initialize internal services
this.bus = EventBus;
this.api = new API(this);
this.processor = new Processor(this, this.api, this.bus);
this.gateway = new Gateway(this, this.processor, this.api, this.bus);
this.guilds = new Guilds(this.api, this.processor);
this.users = new Users(this.api, this.processor);
this.messages = new Messages(this, this.api, this.processor);
// Save references to command and event handlers
this.chatCommands = this.processor[HandlerType.ChatCommands];
this.applicationCommands = this.processor[HandlerType.ApplicationCommands];
this.interactions = this.processor[HandlerType.Interactions];
}
private static validateConfig(config: ClientConfig) {
if (isNil(config.token)) {
throw new Error('Token is required');
}
if (isNil(config.application_id)) {
throw new Error('Application ID is required');
}
if (exists(config.prefix) && typeof config.prefix !== 'string') {
throw new Error('Prefix must be a string');
}
if (exists(config.intents)) {
if (!Array.isArray(config.intents)) {
throw new Error('Intents must be an array');
}
for (const intent of config.intents) {
if (!Intent[intent]) {
throw new Error(`Invalid intent: ${intent}`);
}
}
}
}
get self() {
return this.gateway.user;
}
/**
* Hydrates a given data object based on the provided expectations.
*
* @template T - The type of the data object to be hydrated. Must extend `Hydrateable`.
* @template K - An array of `Expectation` types that specify what properties should be hydrated.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
*
* @returns A promise that resolves to the hydrated data object.
*
* @throws Will throw an error if an expectation cannot be resolved.
*/
hydrate = async <T extends Hydrateable | Hydrated<Hydrateable, Array<Expectation>>, K extends Array<Expectation>>(data: T, expectations: K) => {
return hydrate<T, K>(data, expectations, this, this.api);
}
/**
* A wrapper function that attempts to hydrate a given data object and returns a type guard function.
*
* @template T - The type of the data object to be hydrated. Must extend `Hydrateable`.
* @template K - An array of `Expectation` types that specify what properties should be hydrated.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
*
* @returns A promise that resolves to a type guard function. The type guard function returns `true` if the data object was successfully hydrated, otherwise `false`.
*/
hydrator = async<T extends Hydrateable, K extends Expectation[]>(data: T, expectations: K) => {
return hydrator<T, K>(data, expectations, this, this.api);
}
sendMessage = async (channel_id: string, message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return Message.fromDiscord(await this.api.post<DiscordMessage>(`/channels/${channel_id}/messages`, message.toJSON()));
}
// chatCommands = {
// register: (name: string | string[], handler: Handler) => this.processor.chat_commands.register(name, handler),
// unregister: (name: string | string[]) => this.processor.chat_commands.unregister(name),
// has: (name: string) => this.processor.chat_commands.has(name)
// }
// applicationCommands = {
// register: (name: string, type: ApplicationCommandType, handler: Handler) => this.processor.application_commands.register(name, type, handler),
// unregister: (name: string) => this.processor.application_commands.unregister(name),
// has: (name: string) => this.processor.application_commands.has(name)
// }
on = (event: string, handler: (...args: any[]) => void) => this.bus.on(event, handler);
once = (event: string, handler: (...args: any[]) => void) => this.bus.once(event, handler);
off = (event: string, handler: (...args: any[]) => void) => this.bus.off(event, handler);
connect = () => this.gateway.connect();
}
+308
View File
@@ -0,0 +1,308 @@
import { WebSocket, type Data } from 'ws';
import type { Client } from "../client";
import { fromDiscord, HandlerType, type Payload } from "../../types/common";
import { exists, isNil, isObject, log } from "../../utils";
import type { API } from "../api";
import type { Processor } from '../processor';
import { Context } from '../../types/context';
import { type DiscordMessage, Message } from '../../types/message';
import { DiscordUser, User } from '../../types/user';
import { Guild } from '../../types/guild';
import type { Emitter } from '../bus';
import type { InteractionCommandData, InteractionCommpoentData, InteractionPayload } from '../../types/handler';
export class Gateway {
private socket: WebSocket | null = null;
private heartbeat_interval: number | null = null;
private heartbeat_timeout: Timer | null = null;
private sequence: number | null = null;
// Internal state
user: User | null = null;
constructor(private client: Client, private processor: Processor, private api: API, private bus: Emitter) {}
private onOpen = () => {
log('Connected to gateway');
}
private onMessage = async (packet: Data) => {
// Allocate a variable to store the parsed payload
let data: Payload | null = null;
try {
// Attempt to parse the packet data
data = JSON.parse(packet.toString());
} catch (e) {
console.error(e);
}
if (exists(data)) {
// NOTE: Uncomment for debugging recieved event data
// log('='.repeat(25));
// log('received: ', data.op);
// log('received event: ', data.t ?? 'no event');
// log('received data: ', exists(data.d) ? Object.keys(data.d) : 'no data');
// log('='.repeat(25));
// If the data is successfully parsed check the opcode
const code = data.op;
if (code === 0) {
// Event received
await this.parseEvent(data);
} else if (code === 1) {
// Heartbeat requested
this.send({ op: 1, d: this.sequence });
} else if (code === 9) {
// Reconnect requested
} else if (code === 10) {
// Hello
if (!isObject(data.d)) {
throw new Error('Invalid HELLO payload received');
}
// Store the heartbeat interval
this.heartbeat_interval = data.d?.heartbeat_interval;
// Kick off the heartbeat loop
this.heartbeat();
// Identify with the gateway
// TODO: Is there a way identify without the timeout? Or waiting for the heartbeat?
// We need to wait a bit before sending the identify payload so that the heartbeat can be established
setTimeout(this.indentify, 2500);
} else if (code === 11) {
// Heartbeat ACK
log('Heartbeat ACK');
}
}
}
private heartbeat() {
if (exists(this.heartbeat_interval)) {
// If a previous heartbeat timeout exists, clear it
if (exists(this.heartbeat_timeout)) {
clearTimeout(this.heartbeat_timeout);
}
// Set a timeout to ensure the heartbeat is sent within the interval
this.heartbeat_timeout = setTimeout(() => {
this.send({ op: 1, d: this.sequence });
this.heartbeat();
}, this.heartbeat_interval * Math.random());
} else {
throw new Error('Heartbeat interval not set');
}
}
protected send = (payload: Partial<Payload>) => {
if (isNil(this.socket)) {
throw new Error('Socket is not connected');
}
// log('='.repeat(25));
// log('sending: ', JSON.stringify(payload, null, 4));
// log('='.repeat(25));
this.socket.send(JSON.stringify(payload));
};
private indentify = () => {
this.send({
op: 2,
d: {
token: this.client.token,
intents: this.client.intents.reduce((acc, intent) => acc | intent, 0),
properties: {
os: process.platform,
browser: 'unicord',
device: 'unicord'
}
}
});
};
private async parseEvent(payload: Payload) {
if (isNil(payload.t)) {
throw new Error('Event name is required');
}
// log('Event:', payload.t);
// Extract the event name and data
const event_name = payload.t;
const event_data = payload.d;
// Simple validation on the event data
if (!isObject(event_data)) {
throw new Error('Invalid event data received');
}
// Check if this is a known event
if (event_name === 'READY') {
this.user = User[fromDiscord](DiscordUser.fromAPIResponse(event_data.user));
} else if (event_name === 'INTERACTION_CREATE') {
this.handleInteractionCreate(payload);
} else if (event_name === 'MESSAGE_CREATE') {
await this.handleMessageCreate(payload as Payload);
} else if (event_name === 'MESSAGE_UPDATE') {
// this.handleMessageCreate(payload);
}
// Allocate a var to store the context
let context = null;
// If the event is a message event, create a context from the message
if (['MESSAGE_CREATE', 'MESSAGE_UPDATE'].includes(event_name)) {
// If the event is a message event, create a message object
const message = Message[fromDiscord](event_data as DiscordMessage);
// And then generate the context
context = new Context(this.client, this.api, { message });
}
// Call event handlers
this.bus.emit(event_name, context, event_data);
// this.processor.events.execute(event_name, context, event_data);
}
private handleMessageCreate = async (payload: Payload) => {
if (isNil(this.user)) {
throw new Error('Cannot handle MESSAGE_CREATE event without a user');
}
if (!isObject(payload.d)) {
throw new Error('Invalid MESSAGE_CREATE payload received');
}
// Ignore bots
if (payload.d.author.bot) {
return;
}
// Ignore self
if (payload.d.author.id === this.user.id) {
return;
}
// Extract the author
const author = User.fromDiscord(DiscordUser.fromAPIResponse(payload.d.author));
// Store the author in the cache
this.client.users.set(author.id, author);
// Extract the content
const content = payload.d.content.trim();
// Is this a command?
if (content.startsWith(this.client.prefix) || content.startsWith(`<@${this.user.id}>`)) {
// Determine the prefix
const prefix = content.startsWith(this.client.prefix) ? this.client.prefix : `<@${this.user.id}> `;
// Parse command
const args = content
.slice(prefix.length)
.trim()
.split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g);
// Ignore empty commands
if (args.length === 0) {
return;
}
// Extract the command name
// TODO: Case insensitive config?
const command = args.shift()!;
// Create a new message object from the payload
const message = Message.fromDiscord(payload.d as DiscordMessage);
// Store the message in the cache
this.client.messages.set(message.id, message);
// Generate context
const context = new Context(this.client, this.api, { message });
// Dispatch event for the command
this.bus.emit(`command:${command}`, context, args);
// // Check if command exists
// if (this.processor[HandlerType.ChatCommands].has(command)) {
// // Execute the handler
// this.processor[HandlerType.ChatCommands].execute(command, context, args);
// } else {
// // TODO: Custom unknown command handler
// context.reply(`Unknown command: ${command}`);
// }
}
};
private handleInteractionCreate = (payload: Payload) => {
if (isNil(payload.d)) {
throw new Error('Invalid INTERACTION_CREATE payload received');
}
if (!isObject(payload.d)) {
throw new Error('Invalid INTERACTION_CREATE data received');
}
if (typeof payload.d.type !== 'number') {
throw new Error('Invalid INTERACTION_CREATE type received');
}
if (payload.d.type === 2) {
this.handleShashInteraction(payload as Payload & { d: InteractionPayload & { data: InteractionCommandData } });
} else if (payload.d.type === 3) {
this.handleChatInteraction(payload as Payload & { d: InteractionPayload & { data: InteractionCommpoentData } });
} else {
// this.handleChatInteraction(payload);
}
};
private handleShashInteraction = (payload: Payload & { d: InteractionPayload & { data: InteractionCommandData } }) => {
if (isNil(payload.d.data)) {
throw new Error('Invalid INTERACTION_CREATE data received');
}
const command = payload.d.data.name;
const args = payload.d.data.options ?? [];
const context = new Context(this.client, this.api, { interaction: payload.d });
if (payload.d.guild) {
const guild = Guild.fromDiscord(payload.d.guild);
this.client.guilds.set(guild.id, guild);
}
if (payload.d.member) {
const member = User.fromDiscord(payload.d.member?.user);
this.client.users.set(member.id, member);
}
if (payload.d.user) {
const user = User.fromDiscord(payload.d.user);
this.client.users.set(user.id, user);
}
// TODO: Fix when channel class is implemented
// if (payload.d.data.channel) {
// const channel = Channel[fromDiscord](payload.d.data.channel);
// this.client.channels.set(channel.id, channel);
// }
if (this.processor.application_commands.has(command)) {
this.processor.application_commands.execute(command, context, args?.map(arg => arg.value));
} else {
// TODO: Custom unknown command handler
context.reply(`Unknown command: ${command}`);
}
};
private handleChatInteraction = (payload: Payload & { d: InteractionPayload & { data: InteractionCommpoentData } }) => {
const context = new Context(this.client, this.api, { interaction: payload.d });
if (exists(payload.d.data)) {
if (this.processor[HandlerType.Interactions].has(payload.d.data.custom_id)) {
this.processor[HandlerType.Interactions].execute(payload.d.data.custom_id, context, payload.d.data);
} else {
context.reply(`Unknown interaction: \`${payload.d.data.custom_id}\``);
}
} else {
context.reply('Invalid interaction data received');
}
};
async connect() {
// Fetch the Discord gateway URL
const response = await this.api.request('GET', '/gateway/bot');
// Append the query parameters to the URL
const url = new URL(response.url);
url.searchParams.append('v', '10');
url.searchParams.append('encoding', 'json');
// Open a new WebSocket connection to the Discord gateway
this.socket = new WebSocket(url);
// Register event listeners
this.socket.on('open', this.onOpen);
this.socket.on('message', this.onMessage);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { Expectation } from "../types/common";
import { Context, type HydratedContext } from "../types/context";
import { Message, type HydratedMessage } from "../types/message";
import { exists } from "../utils";
import type { API } from "./api";
import type { Client } from "./client";
export type GatewayObject = Message;
export type Hydrateable = Context | GatewayObject
export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> =
T extends Message ? HydratedMessage<K> :
T extends Context ? HydratedContext<K> : never;
/**
* Hydrates a given data object based on the provided expectations.
*
* @template T - The type of the data object to be hydrated. Must extend `Hydrateable`.
* @template K - An array of `Expectation` types that specify what properties should be hydrated.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
* @param client - The client instance used to fetch additional data required for hydration.
* @param api - The API instance used to fetch additional data required for hydration.
*
* @returns A promise that resolves to the hydrated data object.
*
* @throws Will throw an error if an expectation cannot be resolved.
*/
export async function hydrate<T extends Hydrateable | Hydrated<any, any>, K extends Array<Expectation>>(
data: T,
expectations: K,
client: Client,
api: API
): Promise<T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>> {
// TODO: add clone trait. Make it a requirement for hydrateable objects.
// Then clone the object and return the hydrated object.
let hydrated: Hydrated<T, K>;
if (data instanceof Context) {
for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_id)) {
let context = data as unknown as HydratedContext<[Expectation.Message, ...Expectation[]]>;
context.message = await client.messages.get(data.channel_id, data.message_id);
} else if (expectation === Expectation.Guild && exists(data.guild_id)) {
let context = data as unknown as HydratedContext<[Expectation.Guild, ...Expectation[]]>;
context.guild = await client.guilds.get(data.guild_id);
} else {
throw new Error(`Invalid expectation requested for Context: ${expectation}`);
}
}
} else if (data instanceof Message) {
for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_reference?.id)) {
let message = data as unknown as HydratedMessage<[Expectation.Message, ...Expectation[]]>;
message.reference = await client.messages.get(data.message_reference.channel_id, data.message_reference.id);
} else if (expectation === Expectation.Guild && exists(data.guild_id)) {
let message = data as unknown as HydratedMessage<[Expectation.Guild, ...Expectation[]]>;
message.guild = await client.guilds.get(data.guild_id);
} else {
throw new Error(`Invalid expectation requested for Message: ${expectation}`);
}
}
}
return data as unknown as (T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>);
}
/**
* A wrapper function that attempts to hydrate a given data object and returns a type guard function.
*
* @template T - The type of the data object to be hydrated. Must extend `Hydrateable`.
* @template K - An array of `Expectation` types that specify what properties should be hydrated.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
* @param client - The client instance used to fetch additional data required for hydration.
* @param api - The API instance used to fetch additional data required for hydration.
*
* @returns A promise that resolves to a type guard function. The type guard function returns `true` if the data object was successfully hydrated, otherwise `false`.
*/
export async function hydrator<T extends Hydrateable | Hydrated<any, any>, K extends Array<Expectation>>(
data: T,
expectations: K,
client: Client,
api: API
): Promise<(data: unknown) => data is T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>> {
let hydrated = true;
try {
// TODO: When clone is implemented we will receive a clone of the object here
// So in hydrator we will need to copy the properties from the clone to the original object
await hydrate(data, expectations, client, api);
} catch (error) {
hydrated = false;
}
// BUG: Resolves to never after hydrations on an already hydrated object
return function (data: unknown): data is T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K> {
return hydrated;
}
}
+190
View File
@@ -0,0 +1,190 @@
import type { Client } from "./client";
import type { Context } from "../types/context";
import { HandlerType, Trait, type EventPayload } from "../types/common";
import { CommandHandler, type Handler, type ArgsFromOptions, OptionConstructorMap, type InteractionData } from "../types/handler";
import type { API } from "./api";
import { ApplicationCommandOptionType, ApplicationCommandType, type ApplicationCommandOption } from "../types/applicationCommand";
import type { Emitter } from "./bus";
export class Processor {
private handlers = {
[HandlerType.Events]: new Map<string, Handler[]>(),
[HandlerType.ChatCommands]: new Map<string, Handler[]>(),
[HandlerType.ApplicationCommands]: new Map<string, Handler[]>(),
[HandlerType.Interactions]: new Map<string, Handler[]>()
};
constructor(private client: Client, private api: API, private bus: Emitter) {};
public register(type: HandlerType, event: string | string[], handler: Handler) {
// Normalize events into an array
const events = Array.isArray(event) ? event : [event];
// Register the handler for each event
for (const e of events) {
const handlers = this.handlers[type].get(e) || [];
handlers.push(handler);
this.handlers[type].set(e, handlers);
}
};
public unregister(type: HandlerType, event: string | string[], handler?: Handler) {
// Normalize events into an array
const events = Array.isArray(event) ? event : [event];
// Unregister the handler for each event
for (const e of events) {
if (handler) {
// Get the handlers for the event
const handlers = this.handlers[type].get(e);
if (handlers) {
// Find the index of the handler
const index = handlers.indexOf(handler);
if (index !== -1) {
// Remove the handler from the array
handlers.splice(index, 1);
}
}
} else {
// If no handler is provided, clear all handlers for the event
this.handlers[type].clear();
}
}
};
public async execute(type: HandlerType.Events, event: string, context: Context | null, payload: EventPayload): Promise<void>
public async execute(type: HandlerType.Interactions, event: string, context: Context | null, payload: InteractionData): Promise<void>
public async execute(type: Omit<HandlerType, HandlerType.Events | HandlerType.Interactions>, event: string, context: Context, args: any[]): Promise<void>
public async execute(type: HandlerType, event: string, context: Context | null, argsOrPayload: any[] | EventPayload) {
const handlers = this.handlers[type].get(event);
if (handlers) {
for (const handler of handlers) {
if (Processor.isCommandHandler(handler)) {
// TODO: Enforce this?
if (type === HandlerType.Events) {
throw new Error('Event handlers cannot be a CommandHandler');
}
// Validate and resolve the args
const resolvedArgs = await this.validateAndResolveArgs(argsOrPayload, handler.args);
// Execute the handler
await Promise.resolve(handler[Trait.execute](context!, resolvedArgs));
} else {
// TODO: Fix ts error
// @ts-ignore - Haven't figured out how to type narrow this yet
await Promise.resolve(handler(context, argsOrPayload));
}
}
}
}
private validateAndResolveArgs = async <T extends readonly ApplicationCommandOption[]>(args: any[], definition: T): Promise<ArgsFromOptions<T>> => {
// Zip the args and definition together
const zipped: [ApplicationCommandOption, any][] = args.map((arg, i) => [definition[i], arg]);
// Validate the args
const validated: Record<string, any> = {};
// TODO: Attempt some type coresion/resolution here (mostly for text commands)
for (const [def, arg] of zipped) {
if (def.choices) {
// If the definition has choices, validate the arg against them
const choice = def.choices.find((choice: any) => choice.value === arg);
if (!choice) {
throw new Error(`Invalid choice for ${def.name}: expected one of ${def.choices.map((c: any) => c.value).join(', ')}, got ${arg}`);
}
validated[def.name] = choice.value;
} else if (def.type === ApplicationCommandOptionType.User) {
// If the definition is a user, resolve the user
// Check if the arg is a string
if (typeof arg === 'string') {
// Parse out the user ID as a mention or raw ID
const userId = arg.match(/<@!?(\d+)>/)?.[1] ?? arg.match(/\d+/)?.[0];
if (!userId) {
throw new Error(`Invalid user for ${def.name}: expected a user mention or snowflake id, got ${arg}`);
}
validated[def.name] = await this.client.users.get(userId);
} else if (typeof arg === 'object') {
// Check if the object has an ID
if (arg.id) {
validated[def.name] = await this.client.users.get(arg.id);
} else {
throw new Error(`Invalid user for ${def.name}: expected a user object with an ID, got ${arg}`);
}
} else {
throw new Error(`Invalid user for ${def.name}: expected a user mention, snowflake id, or object, got ${arg}`);
}
} else {
// Otherwise just check the type
if (arg.value instanceof OptionConstructorMap[def.type]) {
// TODO: Coerce the type if possible
throw new Error(`Invalid type for ${def.name}: expected ${def.type}, got ${typeof arg}`);
} else {
validated[def.name] = arg;
}
}
}
// TODO: Fix types so we don't need to cast here
return Promise.resolve(validated as ArgsFromOptions<T>);
};
[HandlerType.Events] = {
register: (event: string | string[], handler: Handler) => this.register(HandlerType.Events, event, handler),
unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Events, event, handler),
execute: async (event: string, context: Context | null, payload: EventPayload) => this.execute(HandlerType.Events, event, context, payload),
has: (event: string) => this.handlers[HandlerType.Events].has(event) && this.handlers[HandlerType.Events].get(event)?.length! > 0
};
[HandlerType.ChatCommands] = {
register: (event: string | string[], handler: Handler) => this.register(HandlerType.ChatCommands, event, handler),
unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.ChatCommands, event, handler),
execute: async (event: string, context: Context, args: any[]) => this.execute(HandlerType.ChatCommands, event, context, args),
has: (event: string) => this.handlers[HandlerType.ChatCommands].has(event) ?? this.handlers[HandlerType.ChatCommands].get(event)?.length! > 0
};
[HandlerType.ApplicationCommands] = {
register: (name: string | string[], type: ApplicationCommandType, handler: Handler) => {
const names = Array.isArray(name) ? name : [name];
// This is an application command so we need to register it with the API
// Ensure the handler is a CommandHandler
// TODO: Don't enforce this?
if (!Processor.isCommandHandler(handler)) {
throw new Error('Handler must be a CommandHandler');
}
// Ensure the command name is valid
// @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming
// TODO: can this sus bind be removed? Is the only way to kill the arrow function?
const commandNameRegex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
const commandNameTest = commandNameRegex.test.bind(commandNameRegex)
if (!names.every(commandNameTest)) {
throw new Error('Invalid application command name');
};
for (const n of names) {
// Register the command with the API
const command: Record<string, any> = handler.toJSON();
command.name = n;
command.type = type;
this.api.post(`/applications/${this.client.application_id}/commands`, command);
}
this.register(HandlerType.ApplicationCommands, names, handler)
},
unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.ApplicationCommands, event, handler),
execute: async (event: string, context: Context, args: any[]) => this.execute(HandlerType.ApplicationCommands, event, context, args),
has: (event: string) => this.handlers[HandlerType.ApplicationCommands].has(event) ?? this.handlers[HandlerType.ApplicationCommands].get(event)?.length! > 0,
};
[HandlerType.Interactions] = {
register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler),
unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Interactions, event, handler),
execute: async (event: string, context: Context, args: InteractionData) => this.execute(HandlerType.Interactions, event, context, args),
has: (event: string) => this.handlers[HandlerType.Interactions].has(event) ?? this.handlers[HandlerType.Interactions].get(event)?.length! > 0
};
static isCommandHandler = (handler: Handler): handler is CommandHandler<ApplicationCommandOption[]> => {
if (typeof handler === 'object') {
return 'args' in handler && Trait.execute in handler && handler instanceof CommandHandler;
} else {
return false;
}
}
}
+167
View File
@@ -0,0 +1,167 @@
import { Trait } from "./common";
// Enum for Application Command Types
export enum ApplicationCommandType {
Chat = 1,
User = 2,
Message = 3,
PrimaryEntryPoint = 4,
}
// Enum for Application Command Option Types
export enum ApplicationCommandOptionType {
SubCommand = 1,
SubCommandGroup = 2,
String = 3,
Integer = 4,
Boolean = 5,
User = 6,
Channel = 7,
Role = 8,
Mentionable = 9,
Number = 10,
Attachment = 11,
}
// Enum for Application Command Contexts
export enum ApplicationCommandContext {
Guild = 0,
DM = 1,
PrivateChannel = 2,
}
// Enum for Channel Types (if applicable for options)
export enum ChannelType {
GuildText = 0,
DM = 1,
GuildVoice = 2,
GroupDm = 3,
GuildCategory = 4,
GuildNews = 5,
GuildStore = 6,
GuildNewsThread = 10,
GuildPublicThread = 11,
GuildPrivateThread = 12,
GuildStageVoice = 13,
GuildDirectory = 14,
GuildForum = 15,
}
// Enum for Application Command Handler Types
export enum ApplicationCommandHandlerType {
AppHandler = 1,
LaunchActivity = 2,
}
// Define the ApplicationCommandOption type
export type ApplicationCommandOption = {
type: ApplicationCommandOptionType;
name: string;
id: string;
name_localizations?: Record<string, string> | null;
description: string;
description_localizations?: Record<string, string> | null;
required?: boolean;
choices?: Array<{ name: string; value: string | number }>;
options?: ApplicationCommandOption[]; // Recursive structure for subcommands
channel_types?: ChannelType[];
min_value?: number;
max_value?: number;
min_length?: number;
max_length?: number;
autocomplete?: boolean;
};
export type ApplicationCommandOptionResult = {
value: any,
type: number,
name: string
};
// Define the DiscordApplicationCommand type
export type DiscordApplicationCommand = {
id: string;
type?: ApplicationCommandType;
application_id: string;
guild_id?: string;
name: string;
name_localizations?: Record<string, string> | null;
description: string;
description_localizations?: Record<string, string> | null;
options?: ApplicationCommandOption[];
default_member_permissions?: string | null;
dm_permission?: boolean;
default_permission?: boolean;
nsfw?: boolean;
integration_types?: number[];
contexts?: string;
version: string;
handler?: ApplicationCommandHandlerType;
};
// Define the ApplicationCommand class
export class ApplicationCommand {
id: string;
type?: ApplicationCommandType;
application_id: string;
guild_id?: string;
name: string;
name_localizations?: Record<string, string> | null;
description: string;
description_localizations?: Record<string, string> | null;
options?: ApplicationCommandOption[];
default_member_permissions?: string | null;
dm_permission?: boolean;
default_permission?: boolean;
nsfw?: boolean;
integration_types?: number[];
contexts?: ApplicationCommandContext[];
version: string;
handler?: ApplicationCommandHandlerType;
constructor(command: DiscordApplicationCommand) {
this.id = command.id;
this.type = command.type;
this.application_id = command.application_id;
this.guild_id = command.guild_id;
this.name = command.name;
this.name_localizations = command.name_localizations;
this.description = command.description;
this.description_localizations = command.description_localizations;
this.options = command.options;
this.default_member_permissions = command.default_member_permissions;
this.dm_permission = command.dm_permission;
this.default_permission = command.default_permission;
this.nsfw = command.nsfw;
this.integration_types = command.integration_types;
this.contexts = command.contexts?.split(',').map((context) => parseInt(context) as ApplicationCommandContext);
this.version = command.version;
this.handler = command.handler;
}
static [Trait.fromDiscord](command: DiscordApplicationCommand): ApplicationCommand {
return new ApplicationCommand(command);
}
public toJSON = (): DiscordApplicationCommand => {
return {
id: this.id,
type: this.type,
application_id: this.application_id,
guild_id: this.guild_id,
name: this.name,
name_localizations: this.name_localizations,
description: this.description,
description_localizations: this.description_localizations,
options: this.options,
default_member_permissions: this.default_member_permissions,
dm_permission: this.dm_permission,
default_permission: this.default_permission,
nsfw: this.nsfw,
integration_types: this.integration_types,
contexts: this.contexts?.join(','),
version: this.version,
handler: this.handler
};
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Trait } from "./common";
export abstract class GatewayObject {
constructor() {}
// TODO: Add a clone method to subclasses
// public abstract [Trait.clone]<T extends GatewayObject>(this: new (data: any) => T): T;
// public get clone() {
// return this[Trait.clone];
// }
static [Trait.fromDiscord]<T extends GatewayObject>(this: new (...args: any[]) => T, data: any): T {
throw new Error('Method should be overridden - you should not see this. If you do, please report it.');
};
static get fromDiscord() {
return this[Trait.fromDiscord];
}
}
+39
View File
@@ -0,0 +1,39 @@
export class Channel {
version: number;
type: number;
position: number;
permission_overwrites: any[];
name: string;
id: string;
flags: number;
topic?: string | null;
rate_limit_per_user?: number;
parent_id?: string;
last_message_id?: string | null;
user_limit?: number;
rtc_region?: string | null;
bitrate?: number;
last_pin_timestamp?: string;
constructor(data: any) {
this.version = data.version;
this.type = data.type;
this.position = data.position;
this.permission_overwrites = data.permission_overwrites;
this.name = data.name;
this.id = data.id;
this.flags = data.flags;
this.topic = data.topic;
this.rate_limit_per_user = data.rate_limit_per_user;
this.parent_id = data.parent_id;
this.last_message_id = data.last_message_id;
this.user_limit = data.user_limit;
this.rtc_region = data.rtc_region;
this.bitrate = data.bitrate;
this.last_pin_timestamp = data.last_pin_timestamp;
}
static fromAPIResponse(data: any): Channel {
return new Channel(data);
}
}
-21
View File
@@ -1,21 +0,0 @@
import { Client } from '..';
import { Context } from '../lib';
export abstract class Command {
readonly name: string;
readonly description: string;
readonly execute: CommandFunction;
constructor(data: Command) {
this.name = data.name;
this.description = data.description;
this.execute = data.execute;
}
}
export type CommandFunction = (client: Client, context: Context, ...args: any[]) => void;
export class ChatCommand extends Command {
constructor(data: ChatCommand) {
super(data);
}
}
+131 -146
View File
@@ -1,79 +1,64 @@
export type ClientConfig = {
token: string;
application_id: string;
prefix?: string;
intents?: Intent[];
}
export enum Expectation {
Guild = 'guild',
Channel = 'channel',
User = 'user',
Message = 'message',
}
export enum HandlerType {
Events = 'events',
ChatCommands = 'chat_commands',
ApplicationCommands = 'application_commands',
Interactions = 'interactions'
}
export type Expects<T extends Array<Expectation>, K extends Expectation> = Extract<K, T[number]> extends never ? false : true;
export const clone: unique symbol = Symbol('clone')
export const fromDiscord: unique symbol = Symbol('fromDiscord')
export const execute: unique symbol = Symbol('execute')
export const Trait: {
clone: typeof clone
fromDiscord: typeof fromDiscord
execute: typeof execute
} = {
clone,
fromDiscord,
execute
}
export type EventPayload = any;
export type Primitive = string | number | boolean | null;
/**
* Represents a payload structure used in the Discord API.
*
* @typedef {Object} Payload
* @property {number} op - The opcode of the payload.
* @property {Record<string, any>} d - The data associated with the payload.
* @property {number | null} s - The sequence number, or null if not applicable.
* @property {string | null} t - The event name, or null if not applicable.
*/
export type Payload = { export type Payload = {
op: number; // opcode op: number;
d: Record<string, any>; // data d: Record<string, any> | Primitive | null;
s: number | null; // sequence number s: number | null;
t: string | null; // event name t: string | null;
}; };
export type Session = { export enum InteractionResponseType {
s: number; // sequence number Pong = 1,
session_id: string; Message = 4,
session_type: string; DeferredMessage = 5
resume_gateway_url: string;
};
export type Guild = Record<string, any>;
export type User = Record<string, any>;
export enum Status {
Online = "online",
DND = "dnd",
Idle = "idle",
Invisible = "invisible",
Offline = "offline"
}
export type Presence = {
since: number | null;
activities: Activity[];
status: Status;
afk: boolean;
}
export type Activity = {
name: string;
type: number;
created_at: number;
url?: string;
timestamps?: {
start?: number;
end?: number;
};
application_id?: string;
details?: string;
state?: string;
emoji?: {
name: string;
id?: string;
animated?: boolean;
};
party?: {
id?: string;
size?: [number, number];
};
assets?: {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
};
secrets?: {
join?: string;
spectate?: string;
match?: string;
};
instance?: boolean;
flags?: number;
}
export enum ActivityType {
Game,
Streaming,
Listening,
Watching,
Custom ,
Competing
} }
export enum Intent { export enum Intent {
@@ -97,107 +82,107 @@ export enum Intent {
ALL = 32767 ALL = 32767
} }
export const IntentGroup : {[key in Intent]: [string[], number]} = { export const IntentGroup: { [key in Intent]?: [string[], number] } = {
[Intent.GUILDS]: [[ [Intent.GUILDS]: [[
"GUILD_CREATE", "GUILD_CREATE",
"GUILD_UPDATE", "GUILD_UPDATE",
"GUILD_DELETE", "GUILD_DELETE",
"GUILD_ROLE_CREATE", "GUILD_ROLE_CREATE",
"GUILD_ROLE_UPDATE", "GUILD_ROLE_UPDATE",
"GUILD_ROLE_DELETE", "GUILD_ROLE_DELETE",
"CHANNEL_CREATE", "CHANNEL_CREATE",
"CHANNEL_UPDATE", "CHANNEL_UPDATE",
"CHANNEL_DELETE", "CHANNEL_DELETE",
"CHANNEL_PINS_UPDATE", "CHANNEL_PINS_UPDATE",
"THREAD_CREATE", "THREAD_CREATE",
"THREAD_UPDATE", "THREAD_UPDATE",
"THREAD_DELETE", "THREAD_DELETE",
"THREAD_LIST_SYNC", "THREAD_LIST_SYNC",
"THREAD_MEMBER_UPDATE", "THREAD_MEMBER_UPDATE",
"THREAD_MEMBERS_UPDATE *", "THREAD_MEMBERS_UPDATE *",
"STAGE_INSTANCE_CREATE", "STAGE_INSTANCE_CREATE",
"STAGE_INSTANCE_UPDATE", "STAGE_INSTANCE_UPDATE",
"STAGE_INSTANCE_DELETE" "STAGE_INSTANCE_DELETE"
], 0], ], 0],
[Intent.GUILD_MEMBERS]: [[ [Intent.GUILD_MEMBERS]: [[
"GUILD_MEMBER_ADD", "GUILD_MEMBER_ADD",
"GUILD_MEMBER_UPDATE", "GUILD_MEMBER_UPDATE",
"GUILD_MEMBER_REMOVE", "GUILD_MEMBER_REMOVE",
"THREAD_MEMBERS_UPDATE *", "THREAD_MEMBERS_UPDATE *",
], 1], ], 1],
[Intent.GUILD_BANS]: [[ [Intent.GUILD_BANS]: [[
"GUILD_BAN_ADD", "GUILD_BAN_ADD",
"GUILD_BAN_REMOVE", "GUILD_BAN_REMOVE",
], 2], ], 2],
[Intent.GUILD_EMOJIS_AND_STICKERS]: [[ [Intent.GUILD_EMOJIS_AND_STICKERS]: [[
"GUILD_EMOJIS_UPDATE", "GUILD_EMOJIS_UPDATE",
"GUILD_STICKERS_UPDATE", "GUILD_STICKERS_UPDATE",
], 3], ], 3],
[Intent.GUILD_INTEGRATIONS]: [[ [Intent.GUILD_INTEGRATIONS]: [[
"GUILD_INTEGRATIONS_UPDATE", "GUILD_INTEGRATIONS_UPDATE",
"INTEGRATION_CREATE", "INTEGRATION_CREATE",
"INTEGRATION_UPDATE", "INTEGRATION_UPDATE",
"INTEGRATION_DELETE", "INTEGRATION_DELETE",
], 4], ], 4],
[Intent.GUILD_WEBHOOKS]: [[ [Intent.GUILD_WEBHOOKS]: [[
"WEBHOOKS_UPDATE" "WEBHOOKS_UPDATE"
], 5], ], 5],
[Intent.GUILD_INVITES]: [[ [Intent.GUILD_INVITES]: [[
"INVITE_CREATE", "INVITE_CREATE",
"INVITE_DELETE", "INVITE_DELETE",
], 6], ], 6],
[Intent.GUILD_VOICE_STATES]: [[ [Intent.GUILD_VOICE_STATES]: [[
"VOICE_STATE_UPDATE" "VOICE_STATE_UPDATE"
], 7], ], 7],
[Intent.GUILD_PRESENCES]: [[ [Intent.GUILD_PRESENCES]: [[
"PRESENCE_UPDATE" "PRESENCE_UPDATE"
], 8], ], 8],
[Intent.GUILD_MESSAGES]: [[ [Intent.GUILD_MESSAGES]: [[
"MESSAGE_CREATE", "MESSAGE_CREATE",
"MESSAGE_UPDATE", "MESSAGE_UPDATE",
"MESSAGE_DELETE", "MESSAGE_DELETE",
"MESSAGE_DELETE_BULK", "MESSAGE_DELETE_BULK",
], 9], ], 9],
[Intent.GUILD_MESSAGE_REACTIONS]: [[ [Intent.GUILD_MESSAGE_REACTIONS]: [[
"MESSAGE_REACTION_ADD", "MESSAGE_REACTION_ADD",
"MESSAGE_REACTION_REMOVE", "MESSAGE_REACTION_REMOVE",
"MESSAGE_REACTION_REMOVE_ALL", "MESSAGE_REACTION_REMOVE_ALL",
"MESSAGE_REACTION_REMOVE_EMOJI", "MESSAGE_REACTION_REMOVE_EMOJI",
], 10], ], 10],
[Intent.GUILD_MESSAGE_TYPING]: [[ [Intent.GUILD_MESSAGE_TYPING]: [[
"TYPING_START" "TYPING_START"
], 11], ], 11],
[Intent.DIRECT_MESSAGES]: [[ [Intent.DIRECT_MESSAGES]: [[
"MESSAGE_CREATE", "MESSAGE_CREATE",
"MESSAGE_UPDATE", "MESSAGE_UPDATE",
"MESSAGE_DELETE", "MESSAGE_DELETE",
"CHANNEL_PINS_UPDATE", "CHANNEL_PINS_UPDATE",
], 12], ], 12],
[Intent.DIRECT_MESSAGE_REACTIONS]: [[ [Intent.DIRECT_MESSAGE_REACTIONS]: [[
"MESSAGE_REACTION_ADD", "MESSAGE_REACTION_ADD",
"MESSAGE_REACTION_REMOVE", "MESSAGE_REACTION_REMOVE",
"MESSAGE_REACTION_REMOVE_ALL", "MESSAGE_REACTION_REMOVE_ALL",
"MESSAGE_REACTION_REMOVE_EMOJI", "MESSAGE_REACTION_REMOVE_EMOJI",
], 13], ], 13],
[Intent.DIRECT_MESSAGE_TYPING]: [[ [Intent.DIRECT_MESSAGE_TYPING]: [[
"TYPING_START" "TYPING_START"
], 14], ], 14],
[Intent.MESSAGE_CONTENT]: [[ [Intent.MESSAGE_CONTENT]: [[
"MESSAGE_CONTENT" "MESSAGE_CONTENT"
], 15] ], 15]
} }
+177
View File
@@ -0,0 +1,177 @@
import type { API } from "../services/api";
import type { Client } from "../services/client";
import { hydrate, hydrator, type Hydrateable, type Hydrated } from "../services/hydrator";
import { isNil, exists } from "../utils";
import { fromDiscord, InteractionResponseType, type Expectation } from "./common";
import type { Guild } from "./guild";
import type { InteractionPayload } from "./handler";
import { type DiscordMessage, Message, type MessagePayload } from "./message";
/**
* Represents the context of a message in a Discord channel.
* Provides methods to interact with the message and the channel it was sent in.
*
* @class Context
*
* @property {string} message_id - The ID of the message that this context is associated with.
* @property {string} channel_id - The ID of the channel where the message was sent.
* @property {string} [guild_id] - The ID of the guild where the message was sent, if applicable.
*
* @constructor
* @param {Client} client - The client instance that is interacting with the Discord API.
* @param {API} api - The API instance used to make requests to the Discord API.
* @param {Message} message - The message object that this context is based on.
*
* @throws {Error} If the message ID is not provided.
* @throws {Error} If the channel ID is not provided.
*/
export type HydratedContext<T extends Array<Expectation>> = Context & {
guild: Extract<Expectation.Guild, T[number]> extends never ? undefined : Guild;
message: Extract<Expectation.Message, T[number]> extends never ? undefined : Message;
};
export type ContextData =
| { channel_id?: string; message_id?: string; guild_id?: string; message?: MessagePayload, interaction: InteractionPayload }
| { channel_id?: string; message_id?: string; guild_id?: string; message: MessagePayload, interaction?: InteractionPayload };
export class Context<D extends ContextData = ContextData> {
// Public properties
message_id?: string;
channel_id: string;
guild_id?: string;
message?: D extends { message: MessagePayload } ? MessagePayload : undefined;
interaction?: D extends { interaction: InteractionPayload } ? InteractionPayload : undefined;
constructor(private client: Client, private api: API, data: D) {
const channel_id = data.channel_id ?? data.message?.channel_id ?? data.interaction?.channel_id;
if (isNil(channel_id)) {
throw new Error('Channel ID is required to create a context');
}
this.client = client;
this.message_id = data.message_id ?? data.message?.id;
this.channel_id = channel_id;
this.guild_id = data.guild_id ?? data.message?.guild_id ?? data.interaction?.guild_id;
this.message = data.message as D extends { message: MessagePayload } ? MessagePayload : undefined;
this.interaction = data.interaction as D extends { interaction: InteractionPayload } ? InteractionPayload : undefined;
}
get self() {
return this.client.self!;
}
/**
* Hydrates a given data object based on the provided expectations.
*
* @template T - The type of the data object to be hydrated. Must extend `Hydrateable`.
* @template K - An array of `Expectation` types that specify what properties should be hydrated.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
*
* @returns A promise that resolves to the hydrated data object.
*
* @throws Will throw an error if an expectation cannot be resolved.
*/
public hydrate = async <
T extends Hydrateable | Hydrated<Hydrateable, Array<Expectation>>,
K extends Array<Expectation>
>(data: T, expectations: K) => {
return hydrate<T, K>(data, expectations, this.client, this.api);
}
/**
* A wrapper function that attempts to hydrate a given data object and returns a type guard function.
*
* @template T - The type of the data object to be hydrated. Must extend `Hydrateable`.
* @template K - An array of `Expectation` types that specify what properties should be hydrated.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
*
* @returns A promise that resolves to a type guard function. The type guard function returns `true` if the data object was successfully hydrated, otherwise `false`.
*/
public hydrator = async <T extends Hydrateable | Hydrated<Hydrateable, Array<Expectation>>, K extends Array<Expectation>>(data: T, expectations: K) => {
return hydrator<T, K>(data, expectations, this.client, this.api);
}
/**
* Reply to the message that this command
* @param message The message to send to the channel
* @param reference If true the message will be a reference the the message that triggered the command
* @returns The message that was sent
*/
public reply = async (message: Message | string, reference: boolean = true): Promise<MessagePayload> => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
if (exists(this.interaction)) {
return this.ack(InteractionResponseType.Message, message.toJSON());
} else if (exists(this.message)) {
if (reference) {
message.setReference(this.message)
}
return this.client.sendMessage(this.channel_id, message);
} else {
throw new Error('Cannot reply without a message or interaction payload');
}
};
/**
* @name sendMessage
* @description Sends a message to the channel that this context is associated with.
* @param {Message | string} message - The message to send to the channel.
* @returns {Promise<Message>} The message that was sent.
*/
public sendMessage = (message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return this.client.sendMessage(this.channel_id, message);
}
/**
*
* @name delete
* @description Deletes a message in the channel that this context is associated with.
* @param {string} [message_id] - The ID of the message to delete. If not provided, the message ID of this context will be used.
* @returns {Promise<void>} A promise that resolves when the message is deleted.
*/
public deleteMessage = (message_id: string): Promise<void> => {
return this.api.delete(`/channels/${this.channel_id}/messages/${message_id ?? this.message_id}`).then(() => {});
};
/**
*
* @name edit
* @description Edits a message in the channel that this context is associated with.
* @param {Message | string} message - The updated message content.
* @returns {Promise<Message>} The updated message.
*/
public editMessage = async (message: Message, content?: string) => {
const clone = new Message(message);
if (content) {
clone.setContent(content);
}
return Message.fromDiscord(await this.api.patch<DiscordMessage>(`/channels/${clone.channel_id}/messages/${clone.id}`, clone));
};
// Interaction methods
public ack = async (type: InteractionResponseType = InteractionResponseType.Pong, data?: Record<string, any>) => {
if (isNil(this.interaction)) {
throw new Error('Cannot acknowledge an interaction without an interaction payload');
}
return this.api.post(`/interactions/${this.interaction.id}/${this.interaction.token}/callback`, {
type,
data
});
};
public defer = async () => {
return this.ack(InteractionResponseType.DeferredMessage);
};
}
+10 -3
View File
@@ -1,4 +1,4 @@
import { Message } from '.'; import { Message } from './message';
type EmbedFooter = { type EmbedFooter = {
text: string; text: string;
@@ -13,6 +13,13 @@ type EmbedImage = {
width?: number; width?: number;
}; };
type EmbedAuthor = {
name: string;
url?: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedVideo = { type EmbedVideo = {
url?: string; url?: string;
height?: number; height?: number;
@@ -42,7 +49,7 @@ export class Embed {
author?: EmbedImage & { author?: EmbedImage & {
name?: string; name?: string;
}; };
fields?: EmbedField[]; fields: EmbedField[];
constructor() { constructor() {
this.fields = []; this.fields = [];
@@ -98,7 +105,7 @@ export class Embed {
return this; return this;
}; };
setAuthor = (author: EmbedImage & { name?: string }) => { setAuthor = (author: EmbedAuthor) => {
this.author = author; this.author = author;
return this; return this;
}; };
+25
View File
@@ -0,0 +1,25 @@
export class Emoji {
version: number;
roles: string[];
require_colons: boolean;
name: string;
managed: boolean;
id: string;
available: boolean;
animated: boolean;
constructor(data: any) {
this.version = data.version;
this.roles = data.roles;
this.require_colons = data.require_colons;
this.name = data.name;
this.managed = data.managed;
this.id = data.id;
this.available = data.available;
this.animated = data.animated;
}
static fromAPIResponse(data: any): Emoji {
return new Emoji(data);
}
}
+168
View File
@@ -0,0 +1,168 @@
import { GatewayObject } from "./base";
import { Channel } from "./channel";
import { Trait } from "./common";
import { Emoji } from "./emoji";
import { Member } from "./member";
import { Role } from "./role";
export class Guild extends GatewayObject {
id: string;
name: string;
description: string | null;
icon: string | null;
banner: string | null;
owner_id: string;
// emojis: Emoji[];
// roles: Role[];
constructor(data: Guild) {
super();
this.id = data.id;
this.name = data.name;
this.description = data.description;
this.icon = data.icon;
this.banner = data.banner;
this.owner_id = data.owner_id;
// this.emojis = data.emojis;
// this.roles = data.roles;
}
public static [Trait.fromDiscord]<T = Guild>(data: DiscordGuild): T {
return new Guild({
id: data.id,
name: data.name,
description: data.description,
icon: data.icon,
banner: data.banner,
owner_id: data.owner_id,
// emojis: data.emojis,
// roles: data.roles,
}) as T;
}
}
export class DiscordGuild {
system_channel_id: string | null;
premium_progress_bar_enabled: boolean;
region: string;
application_id: string | null;
premium_tier: number;
presences: any[];
discovery_splash: string | null;
latest_onboarding_question_id: string | null;
unavailable: boolean;
afk_channel_id: string | null;
large: boolean;
nsfw: boolean;
max_video_channel_users: number;
stickers: any[];
id: string;
premium_subscription_count: number;
stage_instances: any[];
preferred_locale: string;
guild_scheduled_events: any[];
member_count: number;
name: string;
default_message_notifications: number;
system_channel_flags: number;
hub_type: string | null;
lazy: boolean;
soundboard_sounds: any[];
threads: any[];
banner: string | null;
description: string | null;
clan: string | null;
incidents_data: string | null;
application_command_counts: any;
embedded_activities: any[];
icon: string | null;
home_header: string | null;
joined_at: string;
safety_alerts_channel_id: string | null;
verification_level: number;
max_stage_video_channel_users: number;
emojis: Emoji[];
owner_id: string;
public_updates_channel_id: string | null;
features: string[];
rules_channel_id: string | null;
roles: Role[];
afk_timeout: number;
inventory_settings: any;
activity_instances: any[];
max_members: number;
channels: Channel[];
voice_states: any[];
splash: string | null;
mfa_level: number;
members: Member[];
version: number;
vanity_url_code: string | null;
nsfw_level: number;
explicit_content_filter: number;
constructor(data: any) {
this.system_channel_id = data.system_channel_id;
this.premium_progress_bar_enabled = data.premium_progress_bar_enabled;
this.region = data.region;
this.application_id = data.application_id;
this.premium_tier = data.premium_tier;
this.presences = data.presences;
this.discovery_splash = data.discovery_splash;
this.latest_onboarding_question_id = data.latest_onboarding_question_id;
this.unavailable = data.unavailable;
this.afk_channel_id = data.afk_channel_id;
this.large = data.large;
this.nsfw = data.nsfw;
this.max_video_channel_users = data.max_video_channel_users;
this.stickers = data.stickers;
this.id = data.id;
this.premium_subscription_count = data.premium_subscription_count;
this.stage_instances = data.stage_instances;
this.preferred_locale = data.preferred_locale;
this.guild_scheduled_events = data.guild_scheduled_events;
this.member_count = data.member_count;
this.name = data.name;
this.default_message_notifications = data.default_message_notifications;
this.system_channel_flags = data.system_channel_flags;
this.hub_type = data.hub_type;
this.lazy = data.lazy;
this.soundboard_sounds = data.soundboard_sounds;
this.threads = data.threads;
this.banner = data.banner;
this.description = data.description;
this.clan = data.clan;
this.incidents_data = data.incidents_data;
this.application_command_counts = data.application_command_counts;
this.embedded_activities = data.embedded_activities;
this.icon = data.icon;
this.home_header = data.home_header;
this.joined_at = data.joined_at;
this.safety_alerts_channel_id = data.safety_alerts_channel_id;
this.verification_level = data.verification_level;
this.max_stage_video_channel_users = data.max_stage_video_channel_users;
this.emojis = data.emojis.map((emoji: any) => Emoji.fromAPIResponse(emoji));
this.owner_id = data.owner_id;
this.public_updates_channel_id = data.public_updates_channel_id;
this.features = data.features;
this.rules_channel_id = data.rules_channel_id;
this.roles = data.roles.map((role: any) => Role.fromAPIResponse(role));
this.afk_timeout = data.afk_timeout;
this.inventory_settings = data.inventory_settings;
this.activity_instances = data.activity_instances;
this.max_members = data.max_members;
this.channels = data.channels.map((channel: any) => Channel.fromAPIResponse(channel));
this.voice_states = data.voice_states;
this.splash = data.splash;
this.mfa_level = data.mfa_level;
this.members = data.members.map((member: any) => Member.fromAPIResponse(member));
this.version = data.version;
this.vanity_url_code = data.vanity_url_code;
this.nsfw_level = data.nsfw_level;
this.explicit_content_filter = data.explicit_content_filter;
}
static fromAPIResponse(data: any): DiscordGuild {
return new DiscordGuild(data);
}
}
+168 -5
View File
@@ -1,8 +1,171 @@
import { Client, Context } from '..'; import { exists } from "../utils";
import { ApplicationCommandContext, ApplicationCommandOptionType, type ApplicationCommandOption } from "./applicationCommand";
import { Trait, type EventPayload } from "./common";
import type { Context } from "./context";
import type { DiscordGuild } from "./guild";
import type { DiscordMessage } from "./message";
import type { DiscordUser, User } from "./user";
export abstract class Handler { // TODO: Flesh out InteractionPayload type
abstract events: string | string[]; export type InteractionPayload = {
abstract execute: HandlerFunction; id: string;
token: string;
type: number;
data?: InteractionData;
guild?: DiscordGuild;
guild_id: string;
// TODO: Add channel class
// channel?: DiscordChannel;
channel_id: string;
// TODO: Add member class
member?: Record<string, any> & { user: DiscordUser };
user?: DiscordUser
message?: DiscordMessage;
};
// TODO: Flesh out InteractionData type
export type InteractionData = InteractionCommpoentData | InteractionCommandData;
export type InteractionCommpoentData = {
custom_id: string;
component_type: number;
values?: any[];
resolved?: {
users?: Record<string, DiscordUser>;
members?: Record<string, DiscordUser>;
roles?: Record<string, any>;
channels?: Record<string, any>;
messages?: Record<string, any>;
attachments?: Record<string, any>;
};
};
export type InteractionCommandData = {
id: string;
name: string;
type: number;
resolved?: {
users?: Record<string, DiscordUser>;
members?: Record<string, DiscordUser>;
roles?: Record<string, any>;
channels?: Record<string, any>;
messages?: Record<string, any>;
attachments?: Record<string, any>;
}
options?: {
name: string;
type: ApplicationCommandOptionType;
value?: any;
options?: InteractionCommandData[];
focused?: boolean;
}[];
guild_id?: string;
target_id?: string;
} }
export type HandlerFunction = (client: Client, context: Context, data: Record<string, any>) => void; export const OptionConstructorMap = {
[ApplicationCommandOptionType.String]: String,
[ApplicationCommandOptionType.Integer]: Number,
[ApplicationCommandOptionType.Boolean]: Boolean,
[ApplicationCommandOptionType.Number]: Number,
[ApplicationCommandOptionType.User]: String, // Could be a user ID or object depending on your use case
[ApplicationCommandOptionType.Channel]: String, // Could be a channel ID or object
[ApplicationCommandOptionType.Role]: String, // Could be a role ID or object
[ApplicationCommandOptionType.Mentionable]: String, // Could be a mentionable ID or object
[ApplicationCommandOptionType.Attachment]: String, // Could represent attachment IDs
[ApplicationCommandOptionType.SubCommand]: String, // Handled by `options`
[ApplicationCommandOptionType.SubCommandGroup]: String, // Handled by `options`
};
export type OptionTypeMap = {
[ApplicationCommandOptionType.String]: string,
[ApplicationCommandOptionType.Integer]: number,
[ApplicationCommandOptionType.Boolean]: boolean,
[ApplicationCommandOptionType.Number]: number,
[ApplicationCommandOptionType.User]: User, // Could be a user ID or object depending on your use case
[ApplicationCommandOptionType.Channel]: string, // Could be a channel ID or object
[ApplicationCommandOptionType.Role]: string, // Could be a role ID or object
[ApplicationCommandOptionType.Mentionable]: string, // Could be a mentionable ID or object
[ApplicationCommandOptionType.Attachment]: string, // Could represent attachment IDs
[ApplicationCommandOptionType.SubCommand]: string, // Handled by `options`
[ApplicationCommandOptionType.SubCommandGroup]: string, // Handled by `options`
};
// Recursive type to map ApplicationCommandOption to arguments
export type ArgsFromOptions<T extends readonly ApplicationCommandOption[]> = {
[Option in T[number] as Option['name']]: Option extends { options: readonly ApplicationCommandOption[] }
? ArgsFromOptions<Option['options']> // Recursive inference for subcommands
: Option['choices'] extends readonly { value: infer U }[]
? Option['required'] extends true // If required, keep as is; otherwise, make optional
? U
: U | undefined
: Option['type'] extends keyof OptionTypeMap
? Option['required'] extends true // If required, keep as is; otherwise, make optional
? OptionTypeMap[Option['type']]
: OptionTypeMap[Option['type']] | undefined
: Option['required'] extends true
? unknown
: unknown | undefined;
};
export type CommandHandlerExecuteFunction<T extends ApplicationCommandOption[]> = (context: Context, args: ArgsFromOptions<T>) => Promise<any> | any;
export type CommandHandlerInput<T extends ApplicationCommandOption[]> = {
args: T;
description: string;
contexts?: ApplicationCommandContext[];
execute: CommandHandlerExecuteFunction<T>;
};
export class CommandHandler<T extends ApplicationCommandOption[]> {
args: T;
description: string;
contexts?: ApplicationCommandContext[];
[Trait.execute]!: CommandHandlerExecuteFunction<T>;
get execute() {
return this[Trait.execute];
}
set execute(fn: CommandHandlerExecuteFunction<T>) {
this[Trait.execute] = fn;
}
constructor(input: CommandHandlerInput<T>) {
// Validate option names
// @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming
for (const name of input.args.map((arg) => arg.name)) {
if (/^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u.test(name) === false) {
throw new Error(`Invalid option name: ${name}`);
}
}
this.args = input.args;
this.description = input.description
this.contexts = input.contexts;
this[Trait.execute] = input.execute;
}
toJSON() {
return {
options: this.args,
description: this.description,
contexts: this.contexts,
};
}
};
// Helper function to infer type of `args`
export function createCommandHandler<T extends ApplicationCommandOption[]>(
handler: CommandHandlerInput<T>
): CommandHandler<T> {
return new CommandHandler(handler);
}
export type CommandHandlerFunction = (context: Context, args: any[]) => Promise<any> | any;
export type EventHandlerFunction = (context: Context | null, payload: EventPayload) => Promise<any> | any;
export type InteractionHandlerFunction<P = InteractionData> = (context: Context, payload: P) => Promise<any> | any;
export type HandlerWithoutContext = EventHandlerFunction;
export type HanderWithContext = InteractionHandlerFunction | CommandHandlerFunction | CommandHandler<ApplicationCommandOption[]>;
export type Handler = InteractionHandlerFunction<InteractionCommpoentData> | InteractionHandlerFunction<InteractionCommandData> | EventHandlerFunction | CommandHandlerFunction | CommandHandler<ApplicationCommandOption[]>;
-5
View File
@@ -1,5 +0,0 @@
export * from './common';
export * from './command';
export * from './slash_command';
export * from './handler';
export * from './interaction';
-19
View File
@@ -1,19 +0,0 @@
import { Client } from '..';
import { InteractionContext } from '../lib';
export abstract class Interaction {
abstract custom_id: string | string[];
abstract execute: InteractionFunction;
}
export type InteractionFunction = (client: Client, context: InteractionContext, data: Record<string, any>) => void;
export enum InteractionResponseType {
Pong = 1,
Reply = 4,
DeferredReply = 5,
DeferredUpdateMessage = 6,
UpdateMessage = 7,
AutocompleteResult = 8,
Modal = 9
}
+33
View File
@@ -0,0 +1,33 @@
export class Member {
user: any;
roles: string[];
premium_since: string | null;
pending: boolean;
nick: string | null;
mute: boolean;
joined_at: string;
flags: number;
deaf: boolean;
communication_disabled_until: string | null;
banner: string | null;
avatar: string | null;
constructor(data: any) {
this.user = data.user;
this.roles = data.roles;
this.premium_since = data.premium_since;
this.pending = data.pending;
this.nick = data.nick;
this.mute = data.mute;
this.joined_at = data.joined_at;
this.flags = data.flags;
this.deaf = data.deaf;
this.communication_disabled_until = data.communication_disabled_until;
this.banner = data.banner;
this.avatar = data.avatar;
}
static fromAPIResponse(data: any): Member {
return new Member(data);
}
}
+277
View File
@@ -0,0 +1,277 @@
import { exists, isNil } from '../utils';
import { GatewayObject } from './base';
import { fromDiscord, Trait, type Expectation, type Expects } from './common';
import type { Embed } from './embed';
import type { Guild } from './guild';
import { type DiscordUser, User } from './user';
export type Component = Partial<Button | SelectMenu> & {
type: ComponentType;
content?: string;
components?: Component[];
};
// {
// content?: string;
// type?: ComponentType;
// description?: string;
// label?: string;
// value?: string;
// style?: ComponentStyle;
// custom_id?: string;
// url?: string;
// disabled?: boolean;
// required?: boolean;
// emoji?: {
// name: string;
// id?: string;
// animated?: boolean;
// }
// options?: Component[];
// placeholder?: string;
// min_values?: number;
// max_values?: number;
// components?: Component[];
// }
export type Emoji = {
name: string | null;
id: string | null;
animated?: boolean;
};
export type Button = {
style: ComponentStyle;
label: string;
emoji?: Emoji;
custom_id?: string;
url?: string;
disabled?: boolean;
};
export type SelectMenu = {
custom_id: string;
options: {
label: string;
value: string;
description?: string;
emoji?: Emoji;
default?: boolean;
}[];
placeholder?: string;
min_values?: number;
max_values?: number;
};
export enum ComponentStyle {
Primary = 1,
Secondary,
Success,
Danger,
Link
}
export enum ComponentType {
ActionRow = 1,
Button,
SelectMenu,
TextInput,
UserSelect,
RoleSelect,
MentionableSelect,
ChannelSelect
}
export type HydratedMessage<T extends Array<Expectation>> = MessagePayload & {
// channel: Extract<Expectation.Channel, T[number]> extends never ? undefined : Channel;
reference: Extract<Expectation.Message, T[number]> extends never ? undefined : MessagePayload;
guild: Extract<Expectation.Guild, T[number]> extends never ? undefined : Guild;
}
export type MessagePayload = Message & {
id: string;
author: User;
channel_id: string;
content: string;
embeds: Embed[];
components: Component[];
message_reference?: {
id: string;
channel_id: string;
guild_id?: string;
};
timestamp: Date;
};
export class Message extends GatewayObject {
id?: string;
author?: User;
channel_id?: string;
guild_id?: string;
public content: string;
public embeds: Embed[] = [];
public components: Component[] = [];
public message_reference?: {
id: string;
channel_id: string;
guild_id?: string;
};
timestamp?: Date;
constructor(data?: Partial<Message>) {
super();
this.id = data?.id;
this.author = data?.author;
this.channel_id = data?.channel_id;
this.guild_id = data?.guild_id;
this.content = data?.content ?? '';
this.message_reference = data?.message_reference;
this.timestamp = data?.timestamp;
};
// NOTE: Overriden here because the `fromDiscord` method returns a `MessagePayload` object
// and the GatewayObject getter overrides the return type to `Message`
// TODO: Fix type in GatewayObject so this override is not needed
public static get fromDiscord() {
return this[Trait.fromDiscord];
}
public static [Trait.fromDiscord]<T = MessagePayload>(data: DiscordMessage): T {
return new Message({
id: data.id,
author: User[fromDiscord](data.author),
channel_id: data.channel_id,
guild_id: data.guild_id,
content: data.content,
// TODO: Add embeds
// embeds: data.embeds.map(embed => Embed.fromDiscord(embed)),
// TODO: Add components
// components: data.components,
message_reference: exists(data.message_reference) ? {
id: data.message_reference.message_id,
channel_id: data.message_reference.channel_id,
guild_id: data.message_reference.guild_id
} : undefined,
timestamp: new Date(data.timestamp)
}) as T;
};
public static button(config: Button): Component {
return {
type: ComponentType.Button,
...config
};
};
public static selectMenu(config: SelectMenu): Component {
return {
type: ComponentType.SelectMenu,
...config
};
};
public setContent = (content: string) => {
this.content = content;
return this;
};
public addEmbed = (embed: Embed) => {
this.embeds.push(embed);
return this;
};
public addComponent = (content: Component | string, ...components: Component[]) => {
if (typeof content === 'string') {
this.components.push({
type: ComponentType.ActionRow,
content,
components
});
} else {
this.components.push({
type: ComponentType.ActionRow,
components: [content, ...components]
});
}
return this;
};
public setReference = (message: Message | Partial<Message>) => {
if (isNil(message.id) || isNil(message.channel_id)) {
throw new Error('Message ID and Channel ID are required');
}
let ref: Message;
if (!(message instanceof Message)) {
ref = new Message(message);
} else {
ref = message;
}
this.message_reference = {
id: ref.id!,
channel_id: ref.channel_id!,
guild_id: ref.guild_id
};
return this;
};
public toJSON = () => {
return {
content: this.content,
embeds: this.embeds,
components: this.components,
message_reference: this.message_reference ? {
message_id: this.message_reference.id,
channel_id: this.message_reference.channel_id,
guild_id: this.message_reference.guild_id
} : undefined
};
};
}
// Raw API Object
export type DiscordMessage = {
id: string;
channel_id: string;
guild_id?: string;
author: DiscordUser;
// member?: DiscordGuildMember;
content: string;
// ISO8601 timestamp
timestamp: string;
edited_timestamp: string | null;
tts: boolean;
mention_everyone: boolean;
mentions: DiscordUser[];
mention_roles: string[];
mention_channels?: { id: string; guild_id: string; type: number; name: string }[];
attachments: { id: string; filename: string; url: string; proxy_url: string; size: number }[];
// embeds: DiscordEmbed[];
reactions?: { count: number; me: boolean; emoji: Emoji }[];
nonce?: string | number;
pinned: boolean;
webhook_id?: string;
type: number;
activity?: { type: number; party_id?: string };
application?: { id: string; cover_image?: string; description: string; icon?: string; name: string };
application_id?: string;
message_reference?: { message_id: string; channel_id: string; guild_id?: string; fail_if_not_exists?: boolean };
flags?: number;
referenced_message?: DiscordMessage | null;
interaction?: { id: string; type: number; name: string; user: DiscordUser };
thread?: { id: string; name: string; type: number };
components?: any[];
sticker_items?: { id: string; name: string; format_type: number }[];
stickers?: {
id: string;
pack_id?: string;
name: string;
description?: string;
tags: string;
asset: string;
preview_asset?: string;
format_type: number;
}[];
}
+35
View File
@@ -0,0 +1,35 @@
export class Role {
version: number;
unicode_emoji: string | null;
tags: any;
position: number;
permissions: string;
name: string;
mentionable: boolean;
managed: boolean;
id: string;
icon: string | null;
hoist: boolean;
flags: number;
color: number;
constructor(data: any) {
this.version = data.version;
this.unicode_emoji = data.unicode_emoji;
this.tags = data.tags;
this.position = data.position;
this.permissions = data.permissions;
this.name = data.name;
this.mentionable = data.mentionable;
this.managed = data.managed;
this.id = data.id;
this.icon = data.icon;
this.hoist = data.hoist;
this.flags = data.flags;
this.color = data.color;
}
static fromAPIResponse(data: any): Role {
return new Role(data);
}
}
-78
View File
@@ -1,78 +0,0 @@
import { Client } from '..';
import { InteractionContext } from '../lib';
enum SlashCommandType {
ChatInput = 1,
User,
Message
}
enum SlashCommandOptionType {
SubCommand = 1,
SubCommandGroup,
String,
Integer,
Boolean,
User,
Channel,
Role,
Mentionable,
Number,
ATTACHMENT
}
type SlashCommandOptionChoice = {
name: string;
value: string | number;
};
type SlashCommandOption = {
type: SlashCommandOptionType;
name: string;
description: string;
required?: boolean;
choices?: SlashCommandOptionChoice[];
options?: SlashCommandOption[];
min_value?: number;
max_value?: number;
min_length?: number;
max_length?: number;
autocomplete?: boolean;
// channel_types?: number[];
};
export class SlashCommand {
type: SlashCommandType;
name: string;
description: string;
options: SlashCommandOption[];
guild_id?: string;
dm_permission?: boolean;
default_member_permissions?: string;
execute: SlashCommandFunction;
constructor(data: SlashCommand) {
this.type = data.type;
this.name = data.name;
this.description = data.description;
this.options = data.options;
this.guild_id = data.guild_id;
this.dm_permission = data.dm_permission;
this.default_member_permissions = data.default_member_permissions;
this.execute = data.execute;
}
public toJSON() {
return {
type: this.type,
name: this.name,
description: this.description,
options: this.options,
guild_id: this.guild_id,
dm_permission: this.dm_permission,
default_member_permissions: this.default_member_permissions
};
}
}
export type SlashCommandFunction = (client: Client, context: InteractionContext, ...args: any[]) => void;
+147
View File
@@ -0,0 +1,147 @@
import { exists } from "../utils";
import { GatewayObject } from "./base";
import { Trait } from "./common";
export class User extends GatewayObject {
id: string;
username: string;
discriminator: string;
display_name: string;
avatar: string;
bot?: boolean;
system?: boolean;
mfa_enabled?: boolean;
banner?: string;
accent_color?: number;
locale?: string;
verified?: boolean;
email?: string;
// TODO: Add the rest of the properties
get avatarURL() {
// Check if avatar is an animated gif
if (this.avatar?.startsWith('a_')) {
return `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.gif`;
} else {
return `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.png`;
}
}
constructor(data: Partial<User> & { id: string; username: string; discriminator: string; display_name: string; avatar: string }) {
super();
this.id = data.id;
this.username = data.username;
this.discriminator = data.discriminator;
this.display_name = data.display_name;
this.avatar = data.avatar;
this.bot = data.bot;
this.system = data.system;
this.mfa_enabled = data.mfa_enabled;
this.banner = data.banner;
this.accent_color = data.accent_color;
this.locale = data.locale;
this.verified = data.verified;
this.email = data.email;
}
// TODO: Add a clone method to subclasses
// public [Trait.clone]<T = User>(data: DiscordUser): T { ... };
public static [Trait.fromDiscord]<T = User>(data: DiscordUser): T {
return new User({
id: data.id,
username: data.username,
discriminator: data.discriminator,
display_name: data.global_name,
avatar: data.avatar,
bot: data.bot,
system: data.system,
mfa_enabled: data.mfa_enabled,
banner: data.banner,
accent_color: data.accent_color,
locale: data.locale,
verified: data.verified,
email: data.email
}) as T;
};
}
// id snowflake the user's id identify
// username string the user's username, not unique across the platform identify
// discriminator string the user's Discord-tag identify
// global_name ?string the user's display name, if it is set. For bots, this is the application name identify
// avatar ?string the user's avatar hash identify
// bot? boolean whether the user belongs to an OAuth2 application identify
// system? boolean whether the user is an Official Discord System user (part of the urgent message system) identify
// mfa_enabled? boolean whether the user has two factor enabled on their account identify
// banner? ?string the user's banner hash identify
// accent_color? ?integer the user's banner color encoded as an integer representation of hexadecimal color code identify
// locale? string the user's chosen language option identify
// verified? boolean whether the email on this account has been verified email
// email? ?string the user's email email
// flags? integer the flags on a user's account identify
// premium_type? integer the type of Nitro subscription on a user's account identify
// public_flags? integer the public flags on a user's account identify
// avatar_decoration_data?
export class DiscordUser {
id: string;
username: string;
discriminator: string;
global_name: string;
avatar: string;
bot?: boolean;
system?: boolean;
mfa_enabled?: boolean;
banner?: string;
accent_color?: number;
locale?: string;
verified?: boolean;
email?: string;
flags?: number;
premium_type?: number;
public_flags?: number;
avatar_decoration_data?: Record<string, any>;
constructor(data: DiscordUser) {
this.id = data.id;
this.username = data.username;
this.discriminator = data.discriminator;
this.global_name = data.global_name;
this.avatar = data.avatar;
this.bot = data.bot;
this.system = data.system;
this.mfa_enabled = data.mfa_enabled;
this.banner = data.banner;
this.accent_color = data.accent_color;
this.locale = data.locale;
this.verified = data.verified;
this.email = data.email;
this.flags = data.flags;
this.premium_type = data.premium_type;
this.public_flags = data.public_flags;
this.avatar_decoration_data = data.avatar_decoration_data;
}
static fromAPIResponse(data: any): DiscordUser {
return new DiscordUser({
id: data.id,
username: data.username,
discriminator: data.discriminator,
global_name: data.global_name,
avatar: data.avatar,
bot: data.bot,
system: data.system,
mfa_enabled: data.mfa_enabled,
banner: data.banner,
accent_color: data.accent_color,
locale: data.locale,
verified: data.verified,
email: data.email,
flags: data.flags,
premium_type: data.premium_type,
public_flags: data.public_flags,
avatar_decoration_data: data.avatar_decoration_data
});
}
}
+12
View File
@@ -0,0 +1,12 @@
export const isNil = (value: any): value is null | undefined => value === null || value === undefined;
export const exists = <T>(value: T | null | undefined): value is NonNullable<T> => !isNil(value);
export const isObject = (value: any): value is Record<string, any> => typeof value === 'object' && !Array.isArray(value);
export const log = (...args: any[]) => {
const LOG_LEVEL = process.env.UNICORD_LOG_LEVEL?.toLocaleLowerCase();
if (LOG_LEVEL === 'debug') {
const timestamp = `\x1b[37m[${new Date().toLocaleString()}]\x1b[0m`;
const prefix = `\x1b[35m[unicord]`;
console.log(`${timestamp} ${prefix} \x1b[0m`, ...args);
}
};
-162
View File
@@ -1,162 +0,0 @@
import * as dotenv from 'dotenv';
dotenv.config();
import { Client, ChatCommand, ComponentStyle, Intent, Message } from '../src';
const bot = new Client({
token: process.env.BOT_TOKEN!,
application_id: process.env.APPLICATION_ID!,
intents: [Intent.GUILDS, Intent.GUILD_MESSAGES, Intent.GUILD_MESSAGE_REACTIONS, Intent.MESSAGE_CONTENT]
});
bot.commands.chat.register(new ChatCommand({
name: 'ping',
description: 'Check if he is alive',
execute: (client, context, args) => {
context.reply('Pong!', true);
}
}))
bot.commands.application.register({
name: 'ping',
description: 'Check if he is alive'
}, (client, context, args) => {
context.reply('Pong!');
});
// bot.commands.register('help', (client, context, args) => {
// context.reply('Help yourself.');
// });
bot.commands.chat.register('demo', (_client, context, _args) => {
const msg = new Message()
.setContent('Hello World!')
.addComponent(
'This is an action row',
// Max buttons in a row is 5
// Custom ID is required and must be unique
// Non-Link buttons can not have a URL
// Link buttons can not have a custom ID
Message.button({
label: 'Click me!',
style: ComponentStyle.Primary,
custom_id: '1'
}),
Message.button({
label: 'No me!',
style: ComponentStyle.Secondary,
custom_id: '2',
disabled: true
}),
Message.button({
label: 'Never, me!',
style: ComponentStyle.Success,
custom_id: '3'
}),
// Link requires url and can not have custom_id
Message.button({
label: 'Screw Them.',
style: ComponentStyle.Link,
url: 'https://google.com'
}),
Message.button({
label: 'Emoji',
style: ComponentStyle.Secondary,
custom_id: '4',
emoji: {
name: '👍',
id: null,
animated: false
}
})
)
.addComponent(
Message.button({
label: 'Bad :(',
style: ComponentStyle.Danger,
custom_id: '5'
}),
Message.button({
label: 'More Bad :(',
style: ComponentStyle.Danger,
custom_id: '6'
})
)
.addComponent(
Message.selectMenu({
custom_id: 'select_1',
options: [
{
label: 'Select me',
value: 'select_me',
description: 'This is a description',
emoji: {
name: '👍',
id: null,
animated: false
}
}
],
placeholder: 'Select an option',
min_values: 1,
max_values: 1
})
);
context.reply(msg);
});
bot.commands.chat.register('test', (_client, context, _args) => {
const msg = new Message().setContent('This is a test interaction!').addComponent(
'This is an action row',
Message.button({
label: 'Click me!',
style: ComponentStyle.Primary,
custom_id: 'click_me',
emoji: {
name: '🔥',
id: null,
animated: false
}
}),
Message.button({
label: 'Never, me!',
style: ComponentStyle.Success,
custom_id: 'never_me'
}),
// Link requires url and can not have custom_id
Message.button({
label: 'Screw Them.',
style: ComponentStyle.Link,
url: 'https://google.com'
})
);
context.reply(msg);
});
bot.handlers.register('READY', (client, context, data) => {
console.log(`Logged in as ${client.user.tag}`);
});
bot.interactions.register('never_me', async (client, context, interaction) => {
const reply = await context.loading();
setTimeout(() => {
reply.edit(`You clicked me! Thank you ${interaction.member.user.username} <3`);
}, 1000);
});
bot.interactions.register('click_me', async (client, context, interaction) => {
const reply = await context.loading();
setTimeout(() => {
reply.edit(
new Message().addComponent(
Message.button({
label: 'Click me!',
style: ComponentStyle.Primary,
custom_id: '1'
})
)
);
}, 1000);
});
bot.connect();
+243
View File
@@ -0,0 +1,243 @@
import { ApplicationCommandType, Client, CommandOptionType, ComponentStyle, type Context, createCommandHandler, Expectation, Intent, InteractionCommpoentData, type InteractionData, Message, type MessagePayload } from '../src/index';
const client = new Client({
token: process.env.BOT_TOKEN!,
application_id: process.env.APPLICATION_ID!,
intents: [
Intent.GUILDS,
Intent.GUILD_MESSAGES,
Intent.GUILD_MESSAGE_REACTIONS,
Intent.MESSAGE_CONTENT
],
prefix: '!!'
});
// Register arbitrary event handler to the client bus
client.on('READY', async (_, payload) => {
console.log(`Logged in as ${payload.user.username}${payload.user.discriminator?.length > 0 ? `#${payload.user.discriminator}` : ''}`);
});
// Register a chat command
client.chatCommands.register('ping', async (context: Context, args: any[]) => {
const { message } = await context.hydrate(context, [Expectation.Message]);
const response = await context.reply(`Pong!: got ${args.join(', ')}`, true);
// Calculate the time it took to send the message
const time = message.timestamp.getTime() - message.timestamp.getTime();
// Edit the message to include the time
await context.editMessage(response, `Pong! Latency: ${time}ms`);
});
// Register an application command
client.applicationCommands.register('hello', ApplicationCommandType.Chat, createCommandHandler({
description: "Introduce yourself",
args: [
{
type: CommandOptionType.String,
name: "name",
id: "name",
description: "Your name",
required: true
},
{
type: CommandOptionType.Integer,
name: "age",
id: "age",
description: "Your age",
required: true
},
{
type: CommandOptionType.String,
name: "favorite_animal",
id: "favorite_animal",
description: "Your favorite animal",
required: false,
choices: [
{ name: "Dog", value: "dog" },
{ name: "Cat", value: "cat" },
{ name: "Bird", value: "bird" },
{ name: "Shark", value: "shark" },
{ name: "Elephant", value: "elephant" },
{ name: "Tiger", value: "Tiger" },
{ name: "Lion", value: "lion" },
{ name: "Bear", value: "Bear" }
]
}
],
execute: async (context, args) => {
context.reply(
`\
Hello ${args.name}! I see you are ${args.age} years old${!!args.favorite_animal ? ` and your favorite animal is ${args.favorite_animal}.`: '.'}
Nice to meet you, I'm ${context.self.username}!`
, true);
}
}));
// BUG: Repeated hydration does not type narrow correctly. See below.
// Repeated hydration
client.chatCommands.register('me', async (context: Context, _args: any[]) => {
// Note: Testing repeated hydration
const { message } = await context.hydrate(context, [Expectation.Message])
const hydrated = await context.hydrate(message, [Expectation.Channel])
const rehydrated = await context.hydrate(hydrated, [Expectation.Guild]);
rehydrated.guild.name
const hydrate = await context.hydrator(hydrated, [Expectation.Guild]);
const hasGuild = hydrate(hydrated);
if (hasGuild) {
// Send a message stating the user that they are in a guild
await context.reply(`You are ${message.author.username} and this is ${hydrated.guild.name}`, true);
} else {
// Send a message stating the user that they are not in a guild
await context.reply(`You are ${message.author.username} and we are not in a server`, true);
}
// await context.reply(`You are ${message.author.username} ${hasGuild ? message.guild.name : ''}`, true);
});
// Hydrator - hydration function generator
client.chatCommands.register('here', async (context: Context, _args: any[]) => {
const guild = (await context.hydrator(context, [Expectation.Guild]))(context);
if (guild) {
await context.reply(`You are in ${context.guild.name}`, true);
} else {
await context.reply('This is not a server!', true);
}
});
// Interactions
client.chatCommands.register('demo', async (context: Context, _args: any[]) => {
const msg = new Message()
.setContent('Hello World!')
.addComponent(
'This is an action row',
// Max buttons in a row is 5
// Custom ID is required and must be unique
// Non-Link buttons can not have a URL
// Link buttons can not have a custom ID
Message.button({
label: 'Click me!',
style: ComponentStyle.Primary,
custom_id: 'button_1'
}),
Message.button({
label: 'No me!',
style: ComponentStyle.Secondary,
custom_id: 'button_2',
disabled: true
}),
Message.button({
label: 'Never, me!',
style: ComponentStyle.Success,
custom_id: 'button_3'
}),
// Link requires url and can not have custom_id
Message.button({
label: 'Piss off.',
style: ComponentStyle.Link,
url: 'https://example.com'
})
);
context.reply(msg);
});
client.chatCommands.register('demo', async (context: Context, _args: any[]) => {
const msg = new Message()
.setContent('Hello World!')
.addComponent(
'This is an action row',
// Max buttons in a row is 5
// Custom ID is required and must be unique
// Non-Link buttons can not have a URL
// Link buttons can not have a custom ID
Message.button({
label: 'Click me!',
style: ComponentStyle.Primary,
custom_id: 'button_1'
}),
Message.button({
label: 'No me!',
style: ComponentStyle.Secondary,
custom_id: 'button_2',
disabled: true
}),
Message.button({
label: 'Never, me!',
style: ComponentStyle.Success,
custom_id: 'button_3'
}),
// Link requires url and can not have custom_id
Message.button({
label: 'Piss off.',
style: ComponentStyle.Link,
url: 'https://example.com'
}),
Message.button({
label: 'Emoji',
style: ComponentStyle.Secondary,
custom_id: 'button_4',
emoji: {
name: '👍',
id: null,
animated: false
}
})
)
.addComponent(
Message.button({
label: 'Bad :(',
style: ComponentStyle.Danger,
custom_id: 'button_5'
}),
Message.button({
label: 'More Bad :(',
style: ComponentStyle.Danger,
custom_id: 'button_6'
})
)
.addComponent(
Message.selectMenu({
custom_id: 'select_1',
options: [
{
label: 'Select me',
value: 'select_me',
description: 'This is a description',
emoji: {
name: '👍',
id: null,
animated: false
}
}
],
placeholder: 'Select an option',
min_values: 1,
max_values: 1
})
);
context.reply(msg);
});
const buttonInteractionHandler = (context: Context, data: InteractionCommpoentData) => {
context.reply(`You clicked the ${data.custom_id} button!`);
};
client.interactions.register('button_1', buttonInteractionHandler);
client.interactions.register('button_2', buttonInteractionHandler);
client.interactions.register('button_3', buttonInteractionHandler);
client.interactions.register('button_4', buttonInteractionHandler);
client.interactions.register('button_5', buttonInteractionHandler);
client.interactions.register('button_6', buttonInteractionHandler);
client.interactions.register('select_1', async (context: Context, data: InteractionCommpoentData) => {
if (data?.values !== undefined) {
context.reply(`You selected ${data.values[0]}!`);
} else {
context.reply('You selected nothing!');
}
});
client.connect();
+31 -10
View File
@@ -1,11 +1,32 @@
{ {
"compilerOptions": { "files": ["src/index.ts"],
"module": "commonjs", "include": ["src/**/*.ts", "src/**/*.tsx"],
"target": "es2015", "compilerOptions": {
"declaration": true, // Enable latest features
"outDir": "./dist" "lib": ["ESNext", "DOM"],
}, "target": "ESNext",
"include": [ "module": "ESNext",
"src/**/*" "moduleDetection": "force",
] "jsx": "react-jsx",
} "allowJs": true,
"declaration": true,
"outDir": "out",
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"emitDeclarationOnly": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}
-495
View File
@@ -1,495 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@jridgewell/resolve-uri@^3.0.3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
"@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/trace-mapping@0.3.9":
version "0.3.9"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@tsconfig/node10@^1.0.7":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2"
integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==
"@tsconfig/node12@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
"@tsconfig/node14@^1.0.0":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
"@tsconfig/node16@^1.0.2":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
"@types/node@*":
version "18.7.18"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==
"@types/strip-bom@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
integrity sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==
"@types/strip-json-comments@0.0.30":
version "0.0.30"
resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1"
integrity sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==
"@types/ws@^8.5.3":
version "8.5.3"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d"
integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==
dependencies:
"@types/node" "*"
acorn-walk@^8.1.1:
version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
acorn@^8.4.1:
version "8.8.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8"
integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
anymatch@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
axios@^0.27.2:
version "0.27.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==
dependencies:
follow-redirects "^1.14.9"
form-data "^4.0.0"
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
dependencies:
fill-range "^7.0.1"
buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
chokidar@^3.5.1:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies:
delayed-stream "~1.0.0"
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
dotenv@^16.0.2:
version "16.0.2"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.2.tgz#0b0f8652c016a3858ef795024508cddc4bffc5bf"
integrity sha512-JvpYKUmzQhYoIFgK2MOnF3bciIZoItIIoryihy0rIA+H4Jy0FmgyKYAHCTN98P5ybGSJcIFbh6QKeJdtZd1qhA==
dynamic-dedupe@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz#06e44c223f5e4e94d78ef9db23a6515ce2f962a1"
integrity sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==
dependencies:
xtend "^4.0.0"
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
dependencies:
to-regex-range "^5.0.1"
follow-redirects@^1.14.9:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
glob@^7.1.3:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.1.1"
once "^1.3.0"
path-is-absolute "^1.0.0"
has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
dependencies:
function-bind "^1.1.1"
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-core-module@^2.9.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
dependencies:
has "^1.0.3"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
is-glob@^4.0.1, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies:
mime-db "1.52.0"
minimatch@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^1.1.7"
minimist@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
mkdirp@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
dependencies:
wrappy "1"
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
picomatch@^2.0.4, picomatch@^2.2.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.1"
resolve@^1.0.0:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
is-core-module "^2.9.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
rimraf@^2.6.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
dependencies:
glob "^7.1.3"
source-map-support@^0.5.12:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map@^0.6.0:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==
strip-json-comments@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
dependencies:
is-number "^7.0.0"
tree-kill@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
ts-node-dev@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-2.0.0.tgz#bdd53e17ab3b5d822ef519928dc6b4a7e0f13065"
integrity sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==
dependencies:
chokidar "^3.5.1"
dynamic-dedupe "^0.3.0"
minimist "^1.2.6"
mkdirp "^1.0.4"
resolve "^1.0.0"
rimraf "^2.6.1"
source-map-support "^0.5.12"
tree-kill "^1.2.2"
ts-node "^10.4.0"
tsconfig "^7.0.0"
ts-node@^10.4.0:
version "10.9.1"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b"
integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==
dependencies:
"@cspotcode/source-map-support" "^0.8.0"
"@tsconfig/node10" "^1.0.7"
"@tsconfig/node12" "^1.0.7"
"@tsconfig/node14" "^1.0.0"
"@tsconfig/node16" "^1.0.2"
acorn "^8.4.1"
acorn-walk "^8.1.1"
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
tsconfig@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-7.0.0.tgz#84538875a4dc216e5c4a5432b3a4dec3d54e91b7"
integrity sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==
dependencies:
"@types/strip-bom" "^3.0.0"
"@types/strip-json-comments" "0.0.30"
strip-bom "^3.0.0"
strip-json-comments "^2.0.0"
typescript@^4.8.3:
version "4.8.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.3.tgz#d59344522c4bc464a65a730ac695007fdb66dd88"
integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^8.8.1:
version "8.8.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0"
integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==