103 Commits

Author SHA1 Message Date
jaron 8b1eecd823 cleaning up client merging 2025-11-03 10:28:44 -06:00
jaron 5beda9328b refactor: update command and event handling, improve logging, and enhance argument validation, add client inheritance 2025-11-02 01:03:30 -06:00
jaron 898ceec96d some comments and console.log cleanup 2025-11-01 12:16:28 -05:00
jaron 40d76aae80 change timestap logging style 2025-11-01 11:59:54 -05:00
jaron f0f526e5c5 refactor: add default help command and embed structure 2025-10-29 10:33:39 -05:00
jaron 2dae0b066f some cleanup 2025-10-27 18:36:22 -05:00
jaron ebaf7d711c prototype for default help function 2025-10-27 18:36:01 -05:00
jaron d10a22424e command manager hooked up to client 2025-10-27 10:31:24 -05:00
jaron e711473b7f refactored command management into a seperate service for future features 2025-10-27 02:38:45 -05:00
jaron 6c59334c27 starting a huge rewrite 2025-10-27 02:05:43 -05:00
jaron 444faea651 Merge branch 'master' into v0.0.5 2025-10-14 00:42:55 -05:00
jaron 012a08cb9e style: Adjust hero section padding and alignment for improved layout 2025-10-14 00:40:39 -05:00
jaron 215b10e608 feat: Refactor client and API structure to use config object cache getters 2025-10-14 00:39:28 -05:00
jaron 3da661743c Merge pull request #6 from jaronpate/ft/website
ft/website — getting out of hand
2025-10-14 00:01:26 -05:00
jaron 7b07c9561c Update website/src/app/page.module.css
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-14 00:00:59 -05:00
jaron 5e2ff898c0 Update website/src/components/CodeExample.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-14 00:00:15 -05:00
jaron fb5509cf79 feat: Add Prettier configuration and improve code formatting across multiple files 2025-10-13 23:53:20 -05:00
jaron ff6ef6930e Add new image to website 2025-08-17 15:54:03 -05:00
jaron 91ed529f28 Website tweaks and new logo 2025-08-17 15:37:43 -05:00
jaron 3a6c3acaef Fixed some metadata and things 2025-02-23 02:28:41 -06:00
jaron db30e0d0ec Final prototype of website 2025-02-23 01:08:05 -06:00
jaron 25f7baa1d5 feat: Add navigation links to header with responsive styling 2025-02-23 01:02:01 -06:00
jaron b33c32c0d8 fix: Resolve TypeScript type error in package manager command copying 2025-02-23 01:01:59 -06:00
jaron 969c4a916e refactor: Migrate component styles to page.module.css
This commit updates the components to use styles from page.module.css instead of separate module CSS files, simplifying the styling approach and reducing the number of CSS files in the project.

Key changes:
- Removed separate module CSS files for components
- Updated import statements to use page.module.css
- Adjusted class names to match existing styles in page.module.css
- Maintained component functionality and structure
2025-02-23 00:46:09 -06:00
jaron 2e6ab53d34 Small style change 2025-02-23 00:43:51 -06:00
jaron 6eefe763e3 refactor: Update Section and PackageManager components for improved type safety and consistency
This commit includes the following changes:
- Refactored PackageManager to use a more type-safe approach
- Updated Section component to use semantic `<section>` tag
- Adjusted background prop types and default values
- Simplified type definitions
- Improved code readability and maintainability
2025-02-23 00:42:07 -06:00
jaron c200bff8d0 refactor: Create shared components and global styles for improved code organization 2025-02-23 00:38:48 -06:00
jaron bd8b7c4fd8 refactor: Remove redundant install section from page 2025-02-23 00:36:00 -06:00
jaron f58c14d51c feat: Relocate and style install section with wordmark title 2025-02-23 00:35:49 -06:00
jaron f6115b0d17 feat: Add interactive package manager installation section with tabs and copy functionality 2025-02-23 00:32:17 -06:00
jaron 934db0c285 Tweaked type section 2025-02-23 00:27:31 -06:00
jaron ddfcfa798a feat: Add type coercion section with code example and styling 2025-02-23 00:24:20 -06:00
jaron aab2f7a546 More tweaks 2025-02-23 00:21:53 -06:00
jaron 26e9a7addf Fix scrolling over code examples 2025-02-22 23:56:47 -06:00
jaron 9f1498b3ad feat: Prevent scroll capture in Monaco Editor with wrapper and CSS 2025-02-22 23:53:20 -06:00
jaron e080d368cc added hydration section 2025-02-22 23:50:08 -06:00
jaron 6338f3ab0d feat: Add reusable CodeExample component with hydration section 2025-02-22 23:32:23 -06:00
jaron 72f05e762a style: Format CSS with consistent indentation and spacing 2025-02-22 23:32:21 -06:00
jaron d169adbf3c feat: Add three-pillar section with responsive design and hover effects 2025-02-22 23:26:41 -06:00
jaron 6c7fc895f8 Monaco code example working 2025-02-22 23:24:31 -06:00
jaron ae4bc3e0a9 feat: Adjust code example styling for dynamic height and responsive layout 2025-02-22 23:11:34 -06:00
jaron 713761386c feat: Migrate CodeExample to @monaco-editor/react for Turbopack compatibility 2025-02-22 23:06:37 -06:00
jaron fda26a892c Trying to add monaco 2025-02-22 23:05:30 -06:00
jaron 16c81c7a80 feat: Add Monaco Editor component for code example 2025-02-22 23:00:37 -06:00
jaron 161b78f097 Website init 2025-02-22 22:56:09 -06:00
jaron 62254a7576 Merge pull request #5 from jaronpate/ft/lru-cache
Add LRU cache implementation
2025-02-15 17:52:28 -06:00
jaron b001b1b756 Too much logging 2025-02-15 17:51:50 -06:00
jaron fc77394786 Merge branch 'master' of github.com:jaronpate/unicord into ft/lru-cache 2025-02-15 17:47:27 -06:00
jaron 1fca25ef31 Merge pull request #4 from jaronpate/ft/channel-class-aider
Added a Channel class and channel hydration on message & context
2025-02-15 17:44:41 -06:00
jaron aaa99f9120 feat: Expose additional channel-related enums in library exports 2025-02-15 15:51:01 -06:00
jaron 1dc1371e34 improved repeated hydration test case 2025-02-15 15:49:04 -06:00
jaron 211c1ca4c6 refactor: Improve hydration type handling with Omit and preserve original properties 2025-02-15 12:14:07 -06:00
jaron 69f32aa067 refactor: Improve hydration to preserve previous properties and types 2025-02-15 12:11:27 -06:00
jaron 9043c637c0 refactor: Simplify hydration type inference in hydrate function 2025-02-15 12:09:39 -06:00
jaron 929cd71881 refactor: Simplify hydrator type signature in Context 2025-02-15 12:07:52 -06:00
jaron c756bbbc30 refactor: Simplify hydrator type signature to fix type conflicts 2025-02-15 12:04:48 -06:00
jaron b4a8f412a1 feat: Add channel property to HydratedMessage and HydratedContext types 2025-02-15 12:00:28 -06:00
jaron 817e37c4e4 feat: Add channel hydration support for Message and Context objects 2025-02-15 11:59:15 -06:00
jaron 1cafdbdcc8 feat: Add Channels cache to Client with ObjectCache implementation 2025-02-15 11:57:07 -06:00
jaron 7f745d9a38 fix: Handle Date conversion for last_pin_timestamp in Channel 2025-02-15 11:43:03 -06:00
jaron 962a1e9909 fix channel fromDiscord implementation 2025-02-15 11:41:45 -06:00
jaron a3514801fe feat: Make Channel.fromDiscord method generic to match base class signature 2025-02-15 11:40:38 -06:00
jaron 1227580083 feat: Add DefaultReaction type to channel types 2025-02-15 11:39:20 -06:00
jaron 835271a4c7 feat: Add DefaultReaction type to message types 2025-02-15 11:33:51 -06:00
jaron 6f385a68e7 reformat channel type 2025-02-15 11:32:25 -06:00
jaron dbad7168e8 feat: Add Channel types with enums and Discord/library interfaces 2025-02-15 11:30:41 -06:00
jaron 43f83a1964 Fix import in channel class 2025-02-15 11:19:59 -06:00
jaron 1480905468 feat: Enhance Channel type with comprehensive Discord channel structure support 2025-02-15 11:18:20 -06:00
jaron fb81a55035 Added channel api shape and class 2025-02-15 11:05:59 -06:00
jaron 3bb42e3751 Added aider to .gitignore 2025-02-15 11:03:11 -06:00
jaron 8333ab2d44 Merge pull request #3 from jaronpate/ft/v0.0.4
Added workflow and fixed bug with command handling
2025-01-15 13:51:16 -06:00
jaron 1aa0fbb9e2 Added workflow and fixed bug with command handling 2025-01-15 14:50:48 -05:00
jaron be82580f90 Published v0.0.3 because im dumb 2025-01-12 20:20:04 -06:00
jaron 63755fe17a Merge pull request #2 from jaronpate/ft/v0.0.2
Fixed image...i know
2025-01-12 20:10:35 -06:00
jaron 93b7fe2ed5 Fixed image...i know 2025-01-12 20:09:46 -06:00
jaron 3e2b0f24bf Merge pull request #1 from jaronpate/ft/rewrite
ft/rewrite
2025-01-12 20:05:32 -06:00
jaron a57cfc6528 Fully renamed to unicord 2025-01-12 19:53:53 -06:00
jaron 0dbd14da04 Renamed to unicord 2025-01-12 19:29:49 -06:00
jaron 8811dab6ec Fix build script 2025-01-12 19:02:09 -06:00
jaron 486387e905 Update README.md for beta release 2025-01-12 18:55:04 -06:00
jaron e6e4fd1a02 Refactor and update types and handlers
- README changes: removed 'as const' annotation and note.
- Connector changes: dispatched event for commands in gateway.
- Updated processor type guard for command handlers.
- Removed readonly from CommandHandler types in handler definitions.
- Test adjustments: updated demo tests to reliable command registration.
2025-01-12 15:36:01 -06:00
jaron 8943b5a06e Fixed interaction payload/data type 2025-01-05 14:43:12 -06:00
jaron 202255d4ff Added simple component interaction support 2025-01-02 03:10:54 -06:00
jaron fc7f6548a4 Updated the README some for rewrite 2025-01-02 01:49:22 -06:00
jaron 2f20325c03 Added a description to the package 2025-01-01 23:01:05 -06:00
jaron 7a31b09a0d Added some stuff for beta release. Toying with a build script. 2025-01-01 22:21:36 -06:00
jaron 57d86e0e1c Added user type resolution. Fixed bug in arg validation. Small polish here and there 2024-12-30 16:00:24 -06:00
jaron 3bd0a0899b fixed command arg types 2024-12-19 16:15:07 -06:00
jaron 5f038026d3 Some build process stuff 2024-12-18 13:32:15 -06:00
jaron e85083035e Some build proccess stuff 2024-12-16 12:48:18 -06:00
jaron 13b25ca7d2 added event bus. working on a gateway object base class. 2024-12-15 15:11:59 -06:00
jaron fbdff25c14 WE ARE COOKING (slash commands with some arg validation proto working) 2024-11-16 17:07:31 -06:00
jaron 7848ea0082 embarassing ass typo 2024-11-16 12:50:19 -06:00
jaron 119ddfefd1 Prototyping application commands & base command handler 2024-11-16 11:55:10 -06:00
Jaron f3b1546d54 regorganizing and attempting to allow rehydration 2024-11-15 13:49:33 -06:00
Jaron 5b1fa15e66 Hydrator is coming along 2024-11-13 13:34:43 -06:00
Jaron f419e82579 Messages service, ceched promises, adding 'hydration' models 2024-11-12 00:38:08 -06:00
Jaron 17646e7f36 add back github workflows 2024-11-10 01:57:01 -06:00
Jaron 56e10f043a init for rewrite 2024-11-10 01:55:07 -06:00
Jaron Pate 57a787e7ec working application commands? 2022-10-07 01:12:31 -05:00
Jaron Pate 230aee97fa starting to add slash commands 2022-09-20 19:32:41 -05:00
Jaron Pate a346d56f9b add desc to readme for npm 2022-09-18 01:25:31 -05:00
Jaron Pate 72483a5070 little file refactor 2022-09-18 01:18:38 -05:00
87 changed files with 9531 additions and 1682 deletions
@@ -1,4 +1,4 @@
name: CI name: CI (template)
on: workflow_dispatch on: workflow_dispatch
jobs: jobs:
build: build:
+11 -10
View File
@@ -4,14 +4,15 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 # Pull repo
- uses: actions/setup-node@v1 - uses: actions/checkout@v2
with: # Install bun
node-version: 14 - uses: oven-sh/setup-bun@v2
registry-url: https://registry.npmjs.org/ # Run tests
- run: yarn # TODO: Add tests
- run: yarn build # Build
# - run: yarn test - run: bun build
- run: yarn publish --access public # Publish
- run: bun publish
env: env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} NPM_CONFIG_REGISTRY: https://registry.npmjs.org/_authToken=${{ secrets.NPM_TOKEN }}
+175 -2
View File
@@ -1,3 +1,176 @@
# 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
.aider*
+1
View File
@@ -0,0 +1 @@
src/services/processor.ts
-7
View File
@@ -1,7 +0,0 @@
{
"singleQuote": true,
"printWidth": 120,
"semi": true,
"tabWidth": 4,
"trailingComma": "none"
}
+189 -38
View File
@@ -1,22 +1,33 @@
<div align="center"> <div align="center">
<h2>discord.ts</h2> <img src="https://raw.githubusercontent.com/jaronpate/unicord/master/assets/unicord.png" width="150" height="150">
<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.
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: [
@@ -24,52 +35,192 @@ 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);
```
## Multi-Context Support
This is an example of how a single function can be used for both chat and application commands.
```typescript
const quotesCommandHandler = createCommandHandler({
description: 'Get a list of a users quotes',
args: [
{
id: 'user',
name: 'user',
type: CommandOptionType.User,
description: 'The user to get quotes for',
required: true
}
] as const,
execute: async (context: Context, args) => {
// Lookup user
const user = await findUserByDiscordId(args.user.id);
if (user === undefined) {
return context.reply('User not found', true);
}
const quotes = await getQuotesByUser(user.id);
if (quotes.length === 0) {
return context.reply('No quotes found', true);
} else {
const embed = new Embed()
.setAuthor({ icon_url: args.user.avatarURL, name: args.user.display_name })
.setDescription(quotes.map(q => `"${q.quote_content}"`).join('\n\n'))
.setColor(0xFFFFFF);
return context.reply(embed.toMessage(), true);
}
}
});
client.chatCommands.register('quotes', quotesCommandHandler);
client.applicationCommands.register('quotes', ApplicationCommandType.Chat, quotesCommandHandler);
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Executable
BIN
View File
Binary file not shown.
+35 -17
View File
@@ -1,27 +1,45 @@
{ {
"name": "@jaronp/discord.ts", "name": "unicord",
"version": "0.1.1", "description": "A simple discord library primarily for TypeScript. Web server like and easy to use.",
"main": "dist/index.js", "version": "0.0.4",
"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"
], ],
"repository": "github:jaronpate/unicord",
"scripts": { "scripts": {
"start": "npx ts-node-dev src/index.ts", "test": "bun run testing/demo.test.ts",
"start:watch": "npx ts-node-dev --respawn src/index.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=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" },
"prettier": {
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 4
} }
} }
+163
View File
@@ -0,0 +1,163 @@
import type { Unicord } from '..';
import { Trait } from '../types/common';
export class UnicordObjectCache<Entity> {
cache: LRUCache<string, Entity>;
requests = new Map<string, Promise<Entity>>();
readonly type: string;
constructor(
private readonly self: Unicord,
type: string,
private readonly factory: { [Trait.fromDiscord]: (data: any) => Entity } & (new (data: any) => Entity),
events: string[],
maxCapacity: number = 10_000,
) {
this.cache = new LRUCache<string, Entity>(maxCapacity);
this.type = type;
for (const event of events) {
this.self.onEvent(event, async (_context, e) => {
this.cache.set(e.id, factory[Trait.fromDiscord](e));
});
}
}
set(object_id: string, object: Entity): 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<Entity> {
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.self.discordAPI.get(`/${this.type}/${object_id}`).then((data) => {
// Create the object
const object = this.factory[Trait.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 when it finishes
req.finally(() => this.requests.delete(object_id));
// Return the request
return req;
}
}
async get(object_id: string, ..._args: any[]): Promise<Entity> {
if (this.cache.has(object_id)) {
return await Promise.resolve(this.cache.get(object_id)!);
} else {
return this.fetch(object_id);
}
}
}
class ListNode<K, V> {
key: K;
value: V;
prev: ListNode<K, V> | null = null;
next: ListNode<K, V> | null = null;
constructor(key: K, value: V) {
this.key = key;
this.value = value;
}
}
class LRUCache<K, V> {
private capacity: number;
private map: Map<K, ListNode<K, V>>;
private head: ListNode<K, V>;
private tail: ListNode<K, V>;
constructor(capacity: number) {
this.capacity = capacity;
this.map = new Map();
// Dummy head and tail to simplify boundary conditions
this.head = new ListNode<K, V>(null as any, null as any);
this.tail = new ListNode<K, V>(null as any, null as any);
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: K): V | undefined {
if (!this.map.has(key)) return undefined;
const node = this.map.get(key)!;
this.moveToFront(node);
return node.value;
}
has(key: K): boolean {
return this.map.has(key);
}
set(key: K, value: V): void {
if (this.map.has(key)) {
// Update existing node value and move it to front
const node = this.map.get(key)!;
node.value = value;
this.moveToFront(node);
} else {
// Create a new node
const newNode = new ListNode(key, value);
this.map.set(key, newNode);
this.addToFront(newNode);
// If over capacity, remove the least recently used (LRU) node
if (this.map.size > this.capacity) {
this.removeLRU();
}
}
}
private moveToFront(node: ListNode<K, V>): void {
this.removeNode(node);
this.addToFront(node);
}
private addToFront(node: ListNode<K, V>): void {
node.next = this.head.next;
node.prev = this.head;
this.head.next!.prev = node;
this.head.next = node;
}
private removeNode(node: ListNode<K, V>): void {
node.prev!.next = node.next;
node.next!.prev = node.prev;
}
private removeLRU(): void {
const lruNode = this.tail.prev!;
this.removeNode(lruNode);
this.map.delete(lruNode.key);
}
*[Symbol.iterator](): IterableIterator<[K, V]> {
let current = this.head.next;
while (current && current !== this.tail) {
yield [current.key, current.value];
current = current.next;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Unicord } from "..";
import { Channel } from "../types/channel";
import { UnicordObjectCache } from "./cache";
export class UnicordChannelsCache extends UnicordObjectCache<Channel> {
constructor(self: Unicord) {
super(self, 'channels', Channel, ['CHANNEL_CREATE', 'CHANNEL_UPDATE']);
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { Unicord } from "..";
import { Guild } from "../types/guild";
import { UnicordObjectCache } from "./cache";
export class UnicordGuildsCache extends UnicordObjectCache<Guild> {
constructor(self: Unicord) {
super(self, 'guilds', Guild, ['GUILD_CREATE', 'GUILD_UPDATE']);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './users';
export * from './channels';
export * from './guilds';
+9
View File
@@ -0,0 +1,9 @@
import type { Unicord } from '..';
import { User } from '../types/user';
import { UnicordObjectCache } from './cache';
export class UnicordUsersCache extends UnicordObjectCache<User> {
constructor(self: Unicord) {
super(self, 'users', User, []);
}
}
+170
View File
@@ -0,0 +1,170 @@
import type {
ArgumentTypeFromOptions,
Unicord,
UnicordArgumentDefinition,
UnicordCommandHandler,
UnicordCommandOptions,
UnicordHandler,
} from '.';
import { UnicordCommandContext } from './context';
import type { UnicordEventProcessor } from './eventProcessor';
import { Embed } from './types';
import { UnicordArgumentType, UnicordEventType } from './types/common';
import { log } from './utils';
type UnicordCommandDefinition<Options extends UnicordCommandOptions> = {
options: Options;
handler: UnicordCommandHandler<Options>;
};
export const ArgumentConstructorMap = {
[UnicordArgumentType.String]: String,
[UnicordArgumentType.Integer]: Number,
[UnicordArgumentType.Boolean]: Boolean,
[UnicordArgumentType.Number]: Number,
[UnicordArgumentType.User]: String, // Could be a user ID or object depending on your use case
[UnicordArgumentType.Channel]: String, // Could be a channel ID or object
[UnicordArgumentType.Role]: String, // Could be a role ID or object
[UnicordArgumentType.Mentionable]: String, // Could be a mentionable ID or object
[UnicordArgumentType.Attachment]: String, // Could represent attachment IDs
[UnicordArgumentType.SubCommand]: String, // Handled by `options`
[UnicordArgumentType.SubCommandGroup]: String, // Handled by `options`
};
export class UnicordCommandManager {
private handlers = {
[UnicordEventType.ChatCommands]: new Map<
string,
UnicordCommandDefinition<any> & { wrappedHandler: UnicordCommandHandler<any> }
>(),
[UnicordEventType.ApplicationCommands]: new Map<
string,
UnicordCommandDefinition<any> & { wrappedHandler: UnicordCommandHandler<any> }
>(),
};
constructor(private readonly self: Unicord, private readonly eventProcessor: UnicordEventProcessor) {
this.handlers = {
[UnicordEventType.ChatCommands]: new Map(),
[UnicordEventType.ApplicationCommands]: new Map(),
};
this.registerDefaultHelpCommand();
}
private async validateAndResolveArgs<const Options extends UnicordCommandOptions>(
args: any[],
definitions: Options['args'],
): Promise<ArgumentTypeFromOptions<Options['args']>> {
// Zip the args and definition together
const zipped: [UnicordArgumentDefinition, any][] = args.map((arg, i) => [definitions[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 (false) {
} else {
validated[def.name] = arg;
}
}
// TODO: Fix types so we don't need to cast here
return Promise.resolve(validated as ArgumentTypeFromOptions<Options['args']>);
}
registerDefaultHelpCommand() {
this.registerChatCommand(
'help',
async (context) => {
// Just list chat commands for now
const reply = new Embed().setTitle('Available Commands');
for (const [command, def] of this.handlers[UnicordEventType.ChatCommands]) {
let fieldValue = def.options.description ?? 'No description';
fieldValue += '\n';
if (def.options.args.length > 0) {
fieldValue += `\n\`${command}`;
for (const arg of def.options.args) {
fieldValue += arg.required ? ` <${arg.name}>` : ` <?${arg.name}>`;
}
fieldValue += '`\n';
for (const arg of def.options.args) {
fieldValue += `- \`${arg.name}\` (${UnicordArgumentType[arg.type]}${
arg.required ? ', required' : ''
}): ${arg.description ?? 'No description'}\n`;
}
}
fieldValue += '\n';
reply.fields.push({
name: command,
value: fieldValue,
});
}
await context.reply(reply.toMessage());
},
{
description: 'Lists all available commands',
args: [],
},
);
}
all() {
return this.handlers;
}
has(type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, event: string) {
return this.handlers[type].has(event);
}
merge(other: UnicordCommandManager) {
const handlers = other.all();
for (const type in handlers) {
const typeKey = type as UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands;
for (const [event, definition] of handlers[typeKey]) {
this.register(typeKey, event, definition.handler, definition.options);
}
}
this.registerDefaultHelpCommand();
}
unregister(type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, event: string) {
const handler = this.handlers[type].get(event);
if (handler) {
this.eventProcessor.unregister(`${type}:${event}`, handler.wrappedHandler);
this.handlers[type].delete(event);
}
return this.self;
}
// TODO: Register application commands with the DiscordAPI
register<const Options extends UnicordCommandOptions>(
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
event: string,
handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options,
) {
if (this.has(type, event)) {
this.unregister(type, event);
}
const wrappedHandler = (async (context: UnicordCommandContext, args: any[]) => {
const resolvedArgs = await this.validateAndResolveArgs<Options>(args, options.args);
return handler(context, resolvedArgs);
}) as UnicordCommandHandler<Options>;
this.handlers[type].set(event, { options, handler, wrappedHandler });
this.eventProcessor.register(`${type}:${event}`, wrappedHandler);
return this.self;
}
registerChatCommand = this.register.bind(this, UnicordEventType.ChatCommands);
registerApplicationCommand = this.register.bind(this, UnicordEventType.ApplicationCommands);
}
+102
View File
@@ -0,0 +1,102 @@
import type { Unicord } from '.';
import { hydrate } from './hydrator';
import type { MessagePayload } from './types';
import { Message } from './types';
import { exists, isNil } from './utils';
export type UnicordCommandContextInput =
| {
message: MessagePayload;
channel_id?: string;
message_id?: string;
guild_id?: string;
}
| {
channel_id: string;
message_id: string;
guild_id?: string;
message?: MessagePayload;
};
/**
* 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 {Unicord} self - The client instance.
* @param {UnicordCommandContextInput} data - The payload data for the context.
*
* @throws {Error} If the message ID is not provided.
* @throws {Error} If the channel ID is not provided.
*/
export class UnicordCommandContext {
// Public properties
message_id: string;
channel_id: string;
guild_id?: string;
message?: Message;
constructor(private self: Unicord, data: UnicordCommandContextInput) {
if (isNil(data.message)) {
if (isNil(data.message_id)) {
throw new Error('Message ID is required to create a context');
}
if (isNil(data.channel_id)) {
throw new Error('Channel ID is required to create a context');
}
this.message_id = data.message_id;
this.channel_id = data.channel_id;
this.guild_id = data.guild_id;
} else {
this.message = data.message;
this.message_id = data.message.id;
this.channel_id = data.message.channel_id;
this.guild_id = data.message.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 = async (message: Message | string, reference: boolean = true): Promise<Message> => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
if (exists(this.message)) {
if (reference) {
message.setReference(this.message);
}
return this.self.sendMessage(this.channel_id, message);
} else {
throw new Error('Cannot reply without a message payload');
}
};
/**
* Hydrates a given data object based on the provided expectations.
*
* @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.
*/
get hydrate() {
return hydrate.bind(null, this.self);
}
}
+78
View File
@@ -0,0 +1,78 @@
import type { Unicord } from '.';
import { exists, log } from './utils';
export class UnicordDiscordAPI {
private readonly base_url: string = 'https://discord.com/api';
private readonly version: number = 10;
constructor(private self: Unicord) {}
async request<T = any>(method: string, path: string, data?: any): Promise<T> {
const url = `${this.base_url}/v${this.version.toString()}${path.startsWith('/') ? '' : '/'}${path}`;
const headers = {
Authorization: `Bot ${this.self.config.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: ${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);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { EventEmitter } from 'node:events';
import type { Unicord } from '.';
import type { UnicordCommandContext } from './context';
import type { UnicordCommandOptions, UnicordEventContext, UnicordHandler } from './types/common';
export class UnicordEventProcessor extends EventEmitter {
constructor(private readonly self: Unicord) {
super();
}
register<const Options extends UnicordCommandOptions>(event: string, handler: UnicordHandler<Options>) {
return super.on(event, handler);
}
unregister<const Options extends UnicordCommandOptions>(event: string, handler: UnicordHandler<Options>) {
return super.off(event, handler);
}
emit(event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]): boolean {
return super.emit(event, context, args);
}
}
+208
View File
@@ -0,0 +1,208 @@
import { WebSocket, type Data } from 'ws';
import { Unicord } from '.';
import { UnicordCommandContext } from './context';
import type { DiscordMessage, Payload } from './types';
import { fromDiscord, Message, UnicordEventContext, UnicordEventType, User } from './types';
import { exists, isNil, isObject, log } from './utils';
export class UnicordGatewayManager {
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 readonly self: Unicord) {}
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
this.indentify();
} 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);
}
// Send the heartbeat payload
this.send({ op: 1, d: this.sequence });
// Set a timeout to ensure the heartbeat is sent within the interval
this.heartbeat_timeout = setTimeout(() => {
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.self.config.token,
intents: this.self.config.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');
}
if (event_name === 'READY') {
this.user = User[fromDiscord](event_data.user);
} else if (event_name === 'INTERACTION_CREATE') {
// await this.handleInteractionCreate(payload);
} else if (event_name === 'MESSAGE_CREATE') {
await this.handleMessageCreate(payload);
} else if (event_name === 'MESSAGE_UPDATE') {
// TODO: setting to call commands again here on message edit
// await this.handleMessageUpdate(payload);
}
// TODO: Make a real UnicordEventContext class
this.self.emit(UnicordEventType.SystemEvent, event_name, event_data as UnicordEventContext, []);
}
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 content
const content = payload.d.content.trim();
const hasPrefixInConfig = exists(this.self.config.prefix) && this.self.config.prefix.length > 0;
const userMentionPrefix = `<@${this.user.id}>`;
// Is this a command?
if (
(hasPrefixInConfig && content.startsWith(this.self.config.prefix)) ||
content.startsWith(userMentionPrefix)
) {
// Determine the prefix
const prefix =
hasPrefixInConfig && content.startsWith(this.self.config.prefix)
? this.self.config.prefix!
: userMentionPrefix;
// 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()!;
// Generate context
const message = Message.fromDiscord(payload.d as DiscordMessage);
const context: UnicordCommandContext = new UnicordCommandContext(this.self, { message });
// Dispatch event for the command
this.self.emit(UnicordEventType.ChatCommands, command, context, args);
}
};
async connect() {
// Fetch the Discord gateway URL
const response = await this.self.discordAPI.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);
}
}
+70
View File
@@ -0,0 +1,70 @@
import type { Expects, Unicord } from '.';
import { Expectation } from '.';
import { UnicordCommandContext } from './context';
import type { Channel, Guild, Message } from './types';
import { exists } from './utils';
export type Hydrateable = UnicordCommandContext;
export type UnicordHydratedObject<Object, Expectations extends Array<Expectation>> = Object & {
channel: Expects<Expectations, Expectation.Channel, Channel>;
guild: Expects<Expectations, Expectation.Guild, Guild>;
message: Expects<Expectations, Expectation.Message, Message>;
};
export type Hydrated<Object extends Hydrateable, Expectations extends Array<Expectation>> = UnicordHydratedObject<
Object,
Expectations
>;
// export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> = T extends UnicordCommandContext
// ? HydratedCommandContext<K>
// : never;
// NOTE: Dont forget to update the typedoc comment in src/index.ts and src/context.ts when making changes here
/**
* Hydrates a given data object based on the provided expectations.
*
* @param self - The client instance used to fetch additional data required for hydration.
* @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.
*/
export async function hydrate<T extends Hydrateable, K extends Array<Expectation>>(
self: Unicord,
data: T,
expectations: K,
): Promise<UnicordHydratedObject<T, K>> {
// TODO: add clone trait. Make it a requirement for hydrateable objects.
// Then clone the object and return the hydrated object.
let hydrated: UnicordHydratedObject<T, K>;
if (data instanceof UnicordCommandContext) {
for (const expectation of expectations) {
// if (expectation === Expectation.Message && exists(data.message_id)) {
// let context = data as unknown as UnicordHydratedObject<[Expectation.Message, ...Expectation[]]>;
// context.message = await self.caches.messages.get(data.channel_id, data.message_id);
// } else if (expectation === Expectation.Guild && exists(data.guild_id)) {
if (expectation === Expectation.Guild && exists(data.guild_id)) {
let context = data as unknown as UnicordHydratedObject<UnicordCommandContext, [Expectation.Guild]>;
context.guild = await self.caches.guilds.get(data.guild_id);
} else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let context = data as unknown as UnicordHydratedObject<UnicordCommandContext, [Expectation.Channel]>;
context.channel = await self.caches.channels.get(data.channel_id);
} else {
throw new Error(`Invalid expectation requested for Context: ${expectation}`);
}
}
} else {
throw new Error(`Hydration for the provided type is not implemented.`);
}
// Create a new object that preserves all existing properties
const result = { ...data } as any;
// Ensure the result is properly typed with both old and new hydrated properties
return result as T & Hydrated<T, K>;
}
+135
View File
@@ -0,0 +1,135 @@
import { UnicordChannelsCache, UnicordGuildsCache, UnicordUsersCache } from './caches';
import { UnicordCommandManager } from './commandManager';
import type { UnicordCommandContext } from './context';
import { UnicordDiscordAPI } from './discordAPI';
import { UnicordEventProcessor } from './eventProcessor';
import { UnicordGatewayManager } from './gatewayManager';
import { hydrate } from './hydrator';
import type {
UnicordCommandHandler,
UnicordCommandOptions,
UnicordConfig,
UnicordConfigWithDefaults,
UnicordEventContext,
UnicordEventHandler,
UnicordHandler,
} from './types/common';
import { Intent, UnicordEventType } from './types/common';
import type { DiscordMessage } from './types/message';
import { Message } from './types/message';
import { setDefaults } from './utils';
const UNICORD_CONFIG_DEFAULTS = {
intents: [Intent.DEFAULT],
};
export class Unicord {
readonly config: UnicordConfigWithDefaults;
readonly discordAPI: UnicordDiscordAPI;
private readonly eventProcessor: UnicordEventProcessor;
private readonly gatewayManager: UnicordGatewayManager;
private readonly commandManager: UnicordCommandManager;
readonly caches: {
readonly users: UnicordUsersCache;
readonly channels: UnicordChannelsCache;
readonly guilds: UnicordGuildsCache;
};
constructor(userConfig: UnicordConfig = {}) {
this.config = setDefaults<UnicordConfigWithDefaults>(userConfig, UNICORD_CONFIG_DEFAULTS);
this.discordAPI = new UnicordDiscordAPI(this);
this.eventProcessor = new UnicordEventProcessor(this);
this.gatewayManager = new UnicordGatewayManager(this);
this.commandManager = new UnicordCommandManager(this, this.eventProcessor);
this.caches = {
users: new UnicordUsersCache(this),
channels: new UnicordChannelsCache(this),
guilds: new UnicordGuildsCache(this),
};
}
/**
* Hydrates a given data object based on the provided expectations.
*
* @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.
*/
get hydrate() {
return hydrate.bind(null, this);
}
private __onEvent<Options extends UnicordCommandOptions>(
type: UnicordEventType,
event: string,
handler: UnicordHandler<Options>,
) {
return this.eventProcessor.register(`${type}:${event}`, handler);
}
use(client: Unicord): this {
this.commandManager.merge(client.commandManager);
// TODO: inherit event listeners too
return this;
}
emit(type: UnicordEventType, event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]) {
this.eventProcessor.emit(`${type}:${event}`, context, args);
}
// TODO: can we automate defining these methods with some TS magic?
registerChatCommand<const Options extends UnicordCommandOptions>(
event: string,
handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options,
): this {
this.commandManager.registerChatCommand(event, handler, options);
return this;
}
registerApplicationCommand<const Options extends UnicordCommandOptions>(
event: string,
handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options,
): this {
this.commandManager.registerApplicationCommand(event, handler, options);
return this;
}
onEvent(event: string, handler: UnicordEventHandler): this {
this.__onEvent(UnicordEventType.SystemEvent, event, handler);
return this;
}
sendMessage = async (channel_id: string, message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return Message.fromDiscord(
await this.discordAPI.post<DiscordMessage>(`/channels/${channel_id}/messages`, message.toJSON()),
);
};
login(token: string | undefined = this.config.token): this {
if (token === null || token === undefined) {
throw new Error(
'You must provide a token to login. Either in the Unicord config or when calling `login()`',
);
}
this.config.token = token;
this.gatewayManager.connect();
return this;
}
}
export * from './types/common';
// export * from './types/message';
// export * from './types/channel';
// export * from './types/guild';
// export * from './types/embed';
+23
View File
@@ -0,0 +1,23 @@
import { Trait } from './common';
export abstract class UnicordGatewayObject<UnicordClass, DiscordObject> {
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 UnicordGatewayObject<any, any>>(
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];
}
}
+272
View File
@@ -0,0 +1,272 @@
import { UnicordGatewayObject } from './base';
import { Trait } from './common';
import { User, type DiscordUser } from './user';
export enum ChannelType {
GUILD_TEXT = 0,
DM = 1,
GUILD_VOICE = 2,
GROUP_DM = 3,
GUILD_CATEGORY = 4,
GUILD_ANNOUNCEMENT = 5,
ANNOUNCEMENT_THREAD = 10,
PUBLIC_THREAD = 11,
PRIVATE_THREAD = 12,
GUILD_STAGE_VOICE = 13,
GUILD_DIRECTORY = 14,
GUILD_FORUM = 15,
GUILD_MEDIA = 16,
}
export enum ChannelFlags {
PINNED = 1 << 1,
REQUIRE_TAG = 1 << 4,
HIDE_MEDIA_DOWNLOAD_OPTIONS = 1 << 15,
}
export enum VideoQualityMode {
AUTO = 1,
FULL = 2,
}
export enum SortOrderType {
LATEST_ACTIVITY = 0,
CREATION_DATE = 1,
}
export enum ForumLayoutType {
NOT_SET = 0,
LIST_VIEW = 1,
GALLERY_VIEW = 2,
}
export type PermissionOverwrite = {
id: string;
type: 0 | 1; // 0 for role, 1 for member
allow: string;
deny: string;
};
export type ThreadMetadata = {
archived: boolean;
auto_archive_duration: number;
archive_timestamp: string;
locked: boolean;
invitable?: boolean;
create_timestamp?: string | null;
};
export type ThreadMember = {
id?: string;
user_id?: string;
join_timestamp: string;
flags: number;
};
export type ForumTag = {
id: string;
name: string;
moderated: boolean;
emoji_id?: string | null;
emoji_name?: string | null;
};
export type DefaultReaction = {
emoji_id?: string | null; // the id of a guild's custom emoji
emoji_name?: string | null; // the unicode character of the emoji
};
export class Channel extends UnicordGatewayObject<Channel, DiscordChannel> {
id: string;
type: ChannelType;
guild_id?: string;
position?: number;
permission_overwrites?: PermissionOverwrite[];
name?: string;
topic?: string | null;
nsfw?: boolean;
last_message_id?: string | null;
bitrate?: number;
user_limit?: number;
rate_limit_per_user?: number;
recipients?: User[];
icon?: string | null;
owner_id?: string;
application_id?: string;
managed?: boolean;
parent_id?: string | null;
last_pin_timestamp?: Date | null;
rtc_region?: string | null;
video_quality_mode?: VideoQualityMode;
message_count?: number;
member_count?: number;
thread_metadata?: ThreadMetadata;
member?: ThreadMember;
default_auto_archive_duration?: number;
permissions?: string;
flags?: number;
total_message_sent?: number;
available_tags?: ForumTag[];
applied_tags?: string[];
default_reaction_emoji?: DefaultReaction;
default_thread_rate_limit_per_user?: number;
default_sort_order?: SortOrderType | null;
default_forum_layout?: ForumLayoutType;
constructor(data: DiscordChannel) {
super();
this.id = data.id!;
this.type = data.type!;
this.guild_id = data.guild_id;
this.position = data.position;
this.permission_overwrites = data.permission_overwrites;
this.name = data.name;
this.topic = data.topic;
this.nsfw = data.nsfw;
this.last_message_id = data.last_message_id;
this.bitrate = data.bitrate;
this.user_limit = data.user_limit;
this.rate_limit_per_user = data.rate_limit_per_user;
this.recipients = data.recipients?.map((u) => User.fromDiscord(u));
this.icon = data.icon;
this.owner_id = data.owner_id;
this.application_id = data.application_id;
this.managed = data.managed;
this.parent_id = data.parent_id;
this.last_pin_timestamp = data.last_pin_timestamp ? new Date(data.last_pin_timestamp) : null;
this.rtc_region = data.rtc_region;
this.video_quality_mode = data.video_quality_mode;
this.message_count = data.message_count;
this.member_count = data.member_count;
this.thread_metadata = data.thread_metadata;
this.member = data.member;
this.default_auto_archive_duration = data.default_auto_archive_duration;
this.permissions = data.permissions;
this.flags = data.flags;
this.total_message_sent = data.total_message_sent;
this.available_tags = data.available_tags;
this.applied_tags = data.applied_tags;
this.default_reaction_emoji = data.default_reaction_emoji;
this.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user;
this.default_sort_order = data.default_sort_order;
this.default_forum_layout = data.default_forum_layout;
}
public static [Trait.fromDiscord]<T = Channel>(data: DiscordChannel): T {
return new Channel({
id: data.id,
type: data.type,
guild_id: data.guild_id,
position: data.position,
permission_overwrites: data.permission_overwrites,
name: data.name,
topic: data.topic,
nsfw: data.nsfw,
last_message_id: data.last_message_id,
bitrate: data.bitrate,
user_limit: data.user_limit,
rate_limit_per_user: data.rate_limit_per_user,
recipients: data.recipients,
icon: data.icon,
owner_id: data.owner_id,
application_id: data.application_id,
managed: data.managed,
parent_id: data.parent_id,
last_pin_timestamp: data.last_pin_timestamp,
rtc_region: data.rtc_region,
video_quality_mode: data.video_quality_mode,
message_count: data.message_count,
member_count: data.member_count,
thread_metadata: data.thread_metadata,
member: data.member,
default_auto_archive_duration: data.default_auto_archive_duration,
permissions: data.permissions,
flags: data.flags,
total_message_sent: data.total_message_sent,
available_tags: data.available_tags,
applied_tags: data.applied_tags,
default_reaction_emoji: data.default_reaction_emoji,
default_thread_rate_limit_per_user: data.default_thread_rate_limit_per_user,
default_sort_order: data.default_sort_order,
default_forum_layout: data.default_forum_layout,
}) as T;
}
}
// === Channel Structure ===
// | Field | Type | Description |
// |-------------------------------------|-----------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
// | id | snowflake | the id of this channel |
// | type | integer | the [type of channel](/docs/resources/channel#channel-object-channel-types) |
// | guild_id? | snowflake | the id of the guild (may be missing for some channel objects received over gateway guild dispatches) |
// | position? | integer | sorting position of the channel (channels with the same position are sorted by id) |
// | permission_overwrites? | array of [overwrite](/docs/resources/channel#overwrite-object) objects | explicit permission overwrites for members and roles |
// | name? | ?string | the name of the channel (1-100 characters) |
// | topic? | ?string | the channel topic (0-4096 characters for `GUILD_FORUM` and `GUILD_MEDIA` channels, 0-1024 characters for all others) |
// | nsfw? | boolean | whether the channel is nsfw |
// | last_message_id? | ?snowflake | the id of the last message sent in this channel (or thread for `GUILD_FORUM` or `GUILD_MEDIA` channels) (may not point to an existing or valid message or thread) |
// | bitrate? | integer | the bitrate (in bits) of the voice channel |
// | user_limit? | integer | the user limit of the voice channel |
// | rate_limit_per_user?\* | integer | amount of seconds a user has to wait before sending another message (0-21600); bots, as well as users with the permission `manage_messages` or `manage_channel`, are unaffected |
// | recipients? | array of [user](/docs/resources/user#user-object) objects | the recipients of the DM |
// | icon? | ?string | icon hash of the group DM |
// | owner_id? | snowflake | id of the creator of the group DM or thread |
// | application_id? | snowflake | application id of the group DM creator if it is bot-created |
// | managed? | boolean | for group DM channels: whether the channel is managed by an application via the `gdm.join` OAuth2 scope |
// | parent_id? | ?snowflake | for guild channels: id of the parent category for a channel (each parent category can contain up to 50 channels), for threads: id of the text channel this thread was created |
// | last_pin_timestamp? | ?ISO8601 timestamp | when the last pinned message was pinned. This may be `null` in events such as `GUILD_CREATE` when a message is not pinned. |
// | rtc_region? | ?string | [voice region](/docs/resources/voice#voice-region-object) id for the voice channel, automatic when set to null |
// | video_quality_mode? | integer | the camera [video quality mode](/docs/resources/channel#channel-object-video-quality-modes) of the voice channel, 1 when not present |
// | message_count?\*\* | integer | number of messages (not including the initial message or deleted messages) in a thread. |
// | member_count? | integer | an approximate count of users in a thread, stops counting at 50 |
// | thread_metadata? | a [thread metadata](/docs/resources/channel#thread-metadata-object) object | thread-specific fields not needed by other channels |
// | member? | a [thread member](/docs/resources/channel#thread-member-object) object | thread member object for the current user, if they have joined the thread, only included on certain API endpoints |
// | default_auto_archive_duration? | integer | default duration, copied onto newly created threads, in minutes, threads will stop showing in the channel list after the specified period of inactivity, can be set to: 60, 1440, 4320, 10080 |
// | permissions? | string | computed permissions for the invoking user in the channel, including overwrites, only included when part of the `resolved` data received on an interaction. This does not include [implicit permissions](/docs/topics/permissions#implicit-permissions), which may need to be checked separately |
// | flags? | integer | [channel flags](/docs/resources/channel#channel-object-channel-flags) combined as a [bitfield](https://en.wikipedia.org/wiki/Bit_field) |
// | total_message_sent? | integer | number of messages ever sent in a thread, it's similar to `message_count` on message creation, but will not decrement the number when a message is deleted |
// | available_tags? | array of [tag](/docs/resources/channel#forum-tag-object) objects | the set of tags that can be used in a `GUILD_FORUM` or a `GUILD_MEDIA` channel |
// | applied_tags? | array of snowflakes | the IDs of the set of tags that have been applied to a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel |
// | default_reaction_emoji? | ?[default reaction](/docs/resources/channel#default-reaction-object) object | the emoji to show in the add reaction button on a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel |
// | default_thread_rate_limit_per_user? | integer | the initial `rate_limit_per_user` to set on newly created threads in a channel. this field is copied to the thread at creation time and does not live update. |
// | default_sort_order? | ?integer | the [default sort order type](/docs/resources/channel#channel-object-sort-order-types) used to order posts in `GUILD_FORUM` and `GUILD_MEDIA` channels. Defaults to `null`, which indicates a preferred sort order hasn't been set by a channel admin |
// | default_forum_layout? | integer | the [default forum layout view](/docs/resources/channel#channel-object-forum-layout-types) used to display posts in `GUILD_FORUM` channels. Defaults to `0`, which indicates a layout view has not been set by a channel admin |
export interface DiscordChannel {
id: string;
type: number;
guild_id?: string;
position?: number;
permission_overwrites?: PermissionOverwrite[];
name?: string;
topic?: string | null;
nsfw?: boolean;
last_message_id?: string | null;
bitrate?: number;
user_limit?: number;
rate_limit_per_user?: number;
recipients?: DiscordUser[];
icon?: string | null;
owner_id?: string;
application_id?: string;
managed?: boolean;
parent_id?: string | null;
last_pin_timestamp?: string | null;
rtc_region?: string | null;
video_quality_mode?: number;
message_count?: number;
member_count?: number;
thread_metadata?: ThreadMetadata;
member?: ThreadMember;
default_auto_archive_duration?: number;
permissions?: string;
flags?: number;
total_message_sent?: number;
available_tags?: ForumTag[];
applied_tags?: string[];
default_reaction_emoji?: DefaultReaction;
default_thread_rate_limit_per_user?: number;
default_sort_order?: number | null;
default_forum_layout?: number;
}
+409
View File
@@ -0,0 +1,409 @@
import { UnicordCommandContext } from '../context';
export type Primitive = string | number | boolean | null;
export interface UnicordConfig {
application_id?: string;
token?: string;
prefix?: string;
intents?: Intent[];
}
export interface UnicordConfigWithDefaults extends UnicordConfig {
application_id: string;
token: string;
intents: Intent[];
}
export type Brand<T, K> = T & { __brand: K };
export type EventName = Brand<string, 'event-name'>;
export enum UnicordApplicationCommandType {
Chat = 1,
User = 2,
Message = 3,
PrimaryEntryPoint = 4,
}
export enum UnicordArgumentType {
SubCommand = 1,
SubCommandGroup = 2,
String = 3,
Integer = 4,
Boolean = 5,
User = 6,
Channel = 7,
Role = 8,
Mentionable = 9,
Number = 10,
Attachment = 11,
}
export enum UnicordChannelType {
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,
GuildMedia = 16,
}
// Old 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 interface UnicordArgumentDefinition {
name: string;
description: string;
type: UnicordArgumentType;
required: boolean;
choices?: Array<{ name: string; value: string | number }>;
}
export interface UnicordCommandOptions {
description?: string;
args: UnicordArgumentDefinition[];
}
export enum UnicordEventType {
SystemEvent = 'system',
ChatCommands = 'chat_commands',
ApplicationCommands = 'application_commands',
Interactions = 'interactions',
Custom = 'custom',
}
export interface UnicordBaseContext extends Record<string, any> {
type: UnicordEventType;
}
export interface UnicordEventContext extends UnicordBaseContext {}
export type DefinitionTypeMap = {
[UnicordArgumentType.String]: string;
[UnicordArgumentType.Integer]: number;
[UnicordArgumentType.Boolean]: boolean;
[UnicordArgumentType.Number]: number;
// [UnicordArgumentType.User]: User, // Could be a user ID or object depending on your use case
[UnicordArgumentType.Channel]: string; // Could be a channel ID or object
[UnicordArgumentType.Role]: string; // Could be a role ID or object
[UnicordArgumentType.Mentionable]: string; // Could be a mentionable ID or object
[UnicordArgumentType.Attachment]: string; // Could represent attachment IDs
[UnicordArgumentType.SubCommand]: string; // Handled by `options`
[UnicordArgumentType.SubCommandGroup]: string; // Handled by `options`
};
export type ArgumentTypeFromOptions<T extends readonly UnicordArgumentDefinition[]> = {
[Definition in T[number] as Definition['name']]: Definition['type'] extends keyof DefinitionTypeMap
? Definition['choices'] extends readonly { value: infer U }[]
? Definition['required'] extends true // If required, keep as is; otherwise, make optional
? U
: U | undefined
: Definition['required'] extends true // If required, keep as is; otherwise, make optional
? DefinitionTypeMap[Definition['type']]
: DefinitionTypeMap[Definition['type']] | undefined
: unknown;
};
export type UnicordCommandHandler<Options extends UnicordCommandOptions> = (
context: UnicordCommandContext,
args: ArgumentTypeFromOptions<Options['args']>,
) => any;
// TODO: Fill out signature
export type UnicordEventHandler = (context: UnicordEventContext, payload: any) => any;
export type UnicordHandler<Options extends UnicordCommandOptions> =
| UnicordCommandHandler<Options>
| UnicordEventHandler;
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, R = true> = Extract<K, T[number]> extends never
? undefined
: R;
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;
/**
* 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 = {
op: number;
d: Record<string, any> | Primitive | null;
s: number | null;
t: string | null;
};
export enum InteractionResponseType {
Pong = 1,
Message = 4,
DeferredMessage = 5,
}
export enum Intent {
GUILDS = 1 << 0,
GUILD_MEMBERS = 1 << 1,
GUILD_BANS = 1 << 2,
GUILD_EMOJIS_AND_STICKERS = 1 << 3,
GUILD_INTEGRATIONS = 1 << 4,
GUILD_WEBHOOKS = 1 << 5,
GUILD_INVITES = 1 << 6,
GUILD_VOICE_STATES = 1 << 7,
GUILD_PRESENCES = 1 << 8,
GUILD_MESSAGES = 1 << 9,
GUILD_MESSAGE_REACTIONS = 1 << 10,
GUILD_MESSAGE_TYPING = 1 << 11,
DIRECT_MESSAGES = 1 << 12,
DIRECT_MESSAGE_REACTIONS = 1 << 13,
DIRECT_MESSAGE_TYPING = 1 << 14,
MESSAGE_CONTENT = 1 << 15,
DEFAULT = 513,
ALL = 32767,
}
export const IntentGroup: { [key in Intent]?: [string[], number] } = {
[Intent.GUILDS]: [
[
'GUILD_CREATE',
'GUILD_UPDATE',
'GUILD_DELETE',
'GUILD_ROLE_CREATE',
'GUILD_ROLE_UPDATE',
'GUILD_ROLE_DELETE',
'CHANNEL_CREATE',
'CHANNEL_UPDATE',
'CHANNEL_DELETE',
'CHANNEL_PINS_UPDATE',
'THREAD_CREATE',
'THREAD_UPDATE',
'THREAD_DELETE',
'THREAD_LIST_SYNC',
'THREAD_MEMBER_UPDATE',
'THREAD_MEMBERS_UPDATE *',
'STAGE_INSTANCE_CREATE',
'STAGE_INSTANCE_UPDATE',
'STAGE_INSTANCE_DELETE',
],
0,
],
[Intent.GUILD_MEMBERS]: [
['GUILD_MEMBER_ADD', 'GUILD_MEMBER_UPDATE', 'GUILD_MEMBER_REMOVE', 'THREAD_MEMBERS_UPDATE *'],
1,
],
[Intent.GUILD_BANS]: [['GUILD_BAN_ADD', 'GUILD_BAN_REMOVE'], 2],
[Intent.GUILD_EMOJIS_AND_STICKERS]: [['GUILD_EMOJIS_UPDATE', 'GUILD_STICKERS_UPDATE'], 3],
[Intent.GUILD_INTEGRATIONS]: [
['GUILD_INTEGRATIONS_UPDATE', 'INTEGRATION_CREATE', 'INTEGRATION_UPDATE', 'INTEGRATION_DELETE'],
4,
],
[Intent.GUILD_WEBHOOKS]: [['WEBHOOKS_UPDATE'], 5],
[Intent.GUILD_INVITES]: [['INVITE_CREATE', 'INVITE_DELETE'], 6],
[Intent.GUILD_VOICE_STATES]: [['VOICE_STATE_UPDATE'], 7],
[Intent.GUILD_PRESENCES]: [['PRESENCE_UPDATE'], 8],
[Intent.GUILD_MESSAGES]: [['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK'], 9],
[Intent.GUILD_MESSAGE_REACTIONS]: [
[
'MESSAGE_REACTION_ADD',
'MESSAGE_REACTION_REMOVE',
'MESSAGE_REACTION_REMOVE_ALL',
'MESSAGE_REACTION_REMOVE_EMOJI',
],
10,
],
[Intent.GUILD_MESSAGE_TYPING]: [['TYPING_START'], 11],
[Intent.DIRECT_MESSAGES]: [['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'CHANNEL_PINS_UPDATE'], 12],
[Intent.DIRECT_MESSAGE_REACTIONS]: [
[
'MESSAGE_REACTION_ADD',
'MESSAGE_REACTION_REMOVE',
'MESSAGE_REACTION_REMOVE_ALL',
'MESSAGE_REACTION_REMOVE_EMOJI',
],
13,
],
[Intent.DIRECT_MESSAGE_TYPING]: [['TYPING_START'], 14],
[Intent.MESSAGE_CONTENT]: [['MESSAGE_CONTENT'], 15],
};
export type DiscordAPIEvent =
| 'READY'
| 'RESUMED'
| 'CHANNEL_CREATE'
| 'CHANNEL_UPDATE'
| 'CHANNEL_DELETE'
| 'CHANNEL_PINS_UPDATE'
| 'THREAD_CREATE'
| 'THREAD_UPDATE'
| 'THREAD_DELETE'
| 'THREAD_LIST_SYNC'
| 'THREAD_MEMBER_UPDATE'
| 'THREAD_MEMBERS_UPDATE *'
| 'STAGE_INSTANCE_CREATE'
| 'STAGE_INSTANCE_UPDATE'
| 'STAGE_INSTANCE_DELETE'
| 'GUILD_CREATE'
| 'GUILD_UPDATE'
| 'GUILD_DELETE'
| 'GUILD_ROLE_CREATE'
| 'GUILD_ROLE_UPDATE'
| 'GUILD_ROLE_DELETE'
| 'GUILD_MEMBER_ADD'
| 'GUILD_MEMBER_UPDATE'
| 'GUILD_MEMBER_REMOVE'
| 'GUILD_MEMBERS_CHUNK *'
| 'GUILD_BAN_ADD'
| 'GUILD_BAN_REMOVE'
| 'GUILD_EMOJIS_UPDATE'
| 'GUILD_STICKERS_UPDATE'
| 'GUILD_INTEGRATIONS_UPDATE'
| 'INTEGRATION_CREATE'
| 'INTEGRATION_UPDATE'
| 'INTEGRATION_DELETE'
| 'WEBHOOKS_UPDATE'
| 'INVITE_CREATE'
| 'INVITE_DELETE'
| 'VOICE_STATE_UPDATE'
| 'MESSAGE_CREATE'
| 'MESSAGE_UPDATE'
| 'MESSAGE_DELETE'
| 'MESSAGE_DELETE_BULK'
| 'MESSAGE_REACTION_ADD'
| 'MESSAGE_REACTION_REMOVE'
| 'MESSAGE_REACTION_REMOVE_ALL'
| 'MESSAGE_REACTION_REMOVE_EMOJI'
| 'TYPING_START'
| 'PRESENCE_UPDATE'
| 'APPLICATION_COMMAND_CREATE'
| 'APPLICATION_COMMAND_UPDATE'
| 'APPLICATION_COMMAND_DELETE';
export enum DiscordAPIEvents {
READY = 'READY',
RESUMED = 'RESUMED',
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',
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',
GUILD_MEMBER_ADD = 'GUILD_MEMBER_ADD',
GUILD_MEMBER_UPDATE = 'GUILD_MEMBER_UPDATE',
GUILD_MEMBER_REMOVE = 'GUILD_MEMBER_REMOVE',
GUILD_MEMBERS_CHUNK = 'GUILD_MEMBERS_CHUNK *',
GUILD_BAN_ADD = 'GUILD_BAN_ADD',
GUILD_BAN_REMOVE = 'GUILD_BAN_REMOVE',
GUILD_EMOJIS_UPDATE = 'GUILD_EMOJIS_UPDATE',
GUILD_STICKERS_UPDATE = 'GUILD_STICKERS_UPDATE',
GUILD_INTEGRATIONS_UPDATE = 'GUILD_INTEGRATIONS_UPDATE',
INTEGRATION_CREATE = 'INTEGRATION_CREATE',
INTEGRATION_UPDATE = 'INTEGRATION_UPDATE',
INTEGRATION_DELETE = 'INTEGRATION_DELETE',
WEBHOOKS_UPDATE = 'WEBHOOKS_UPDATE',
INVITE_CREATE = 'INVITE_CREATE',
INVITE_DELETE = 'INVITE_DELETE',
VOICE_STATE_UPDATE = 'VOICE_STATE_UPDATE',
MESSAGE_CREATE = 'MESSAGE_CREATE',
MESSAGE_UPDATE = 'MESSAGE_UPDATE',
MESSAGE_DELETE = 'MESSAGE_DELETE',
MESSAGE_DELETE_BULK = 'MESSAGE_DELETE_BULK',
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',
TYPING_START = 'TYPING_START',
PRESENCE_UPDATE = 'PRESENCE_UPDATE',
APPLICATION_COMMAND_CREATE = 'APPLICATION_COMMAND_CREATE',
APPLICATION_COMMAND_UPDATE = 'APPLICATION_COMMAND_UPDATE',
APPLICATION_COMMAND_DELETE = 'APPLICATION_COMMAND_DELETE',
}
+182
View File
@@ -0,0 +1,182 @@
import { Message } from './message';
type EmbedFooter = {
text: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedImage = {
url?: string;
proxy_url?: string;
height?: number;
width?: number;
};
type EmbedAuthor = {
name: string;
url?: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedVideo = {
url?: string;
height?: number;
width?: number;
};
type EmbedField = {
name: string;
value: string;
inline?: boolean;
};
// === Embed Structure ===
// | Field | Type | Description |
// |--------------|--------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|
// | title? | string | title of embed |
// | type? | string | [type of embed](/docs/resources/message#embed-object-embed-types) (always "rich" for webhook embeds) |
// | description? | string | description of embed |
// | url? | string | url of embed |
// | timestamp? | ISO8601 timestamp | timestamp of embed content |
// | color? | integer | color code of the embed |
// | footer? | [embed footer](/docs/resources/message#embed-object-embed-footer-structure) object | footer information |
// | image? | [embed image](/docs/resources/message#embed-object-embed-image-structure) object | image information |
// | thumbnail? | [embed thumbnail](/docs/resources/message#embed-object-embed-thumbnail-structure) object | thumbnail information |
// | video? | [embed video](/docs/resources/message#embed-object-embed-video-structure) object | video information |
// | provider? | [embed provider](/docs/resources/message#embed-object-embed-provider-structure) object | provider information |
// | author? | [embed author](/docs/resources/message#embed-object-embed-author-structure) object | author information |
// | fields? | array of [embed field](/docs/resources/message#embed-object-embed-field-structure) objects | fields information, max of 25 |
export class Embed {
title?: string;
description?: string;
url?: string;
timestamp?: string;
color?: number;
footer?: EmbedFooter;
image?: EmbedImage;
thumbnail?: EmbedImage;
video?: EmbedVideo;
provider?: {
name?: string;
url?: string;
};
author?: EmbedImage & {
name?: string;
};
fields: EmbedField[];
constructor() {
this.fields = [];
}
setTitle = (title: string) => {
this.title = title;
return this;
};
setDescription = (description: string) => {
this.description = description;
return this;
};
setUrl = (url: string) => {
this.url = url;
return this;
};
setTimestamp = (timestamp: string) => {
this.timestamp = timestamp;
return this;
};
setColor = (color: number | string) => {
this.color = typeof color === 'string' ? parseInt(`0x${color.replace('#', '')}`) : color;
return this;
};
setFooter = (footer: EmbedFooter) => {
this.footer = footer;
return this;
};
setImage = (image: EmbedImage) => {
this.image = image;
return this;
};
setThumbnail = (thumbnail: EmbedImage) => {
this.thumbnail = thumbnail;
return this;
};
setVideo = (video: EmbedVideo) => {
this.video = video;
return this;
};
setProvider = (provider: { name?: string; url?: string }) => {
this.provider = provider;
return this;
};
setAuthor = (author: EmbedAuthor) => {
this.author = author;
return this;
};
addField = (field: EmbedField) => {
this.fields.push(field);
return this;
};
addFields = (fields: EmbedField[]) => {
this.fields.push(...fields);
return this;
};
toMessage = () => {
return new Message().addEmbed(this);
};
toJSON = () => {
return {
title: this.title,
description: this.description,
url: this.url,
timestamp: this.timestamp,
color: this.color,
footer: this.footer,
image: this.image,
thumbnail: this.thumbnail,
video: this.video,
provider: this.provider,
author: this.author,
fields: this.fields
};
};
}
export interface DiscordEmbed {
title?: string;
type?: string;
description?: string;
url?: string;
timestamp?: string;
color?: number;
footer?: EmbedFooter;
image?: EmbedImage;
thumbnail?: EmbedImage;
video?: EmbedVideo;
provider?: {
name?: string;
url?: string;
};
author?: EmbedImage & {
name?: string;
};
fields?: EmbedField[];
}
+200
View File
@@ -0,0 +1,200 @@
import { UnicordGatewayObject } from './base';
import { Trait } from './common';
// import { Emoji } from "./emoji";
// import { Member } from "./member";
// import { Role } from "./role";
export class Guild extends UnicordGatewayObject<Guild, DiscordGuild> {
id: string;
name: string;
icon: string;
icon_hash?: string;
splash: string;
discovery_splash: string;
owner?: boolean;
owner_id: string;
permissions?: string;
region?: string;
afk_channel_id: string;
afk_timeout: number;
widget_enabled?: boolean;
widget_channel_id?: string;
verification_level: number;
default_message_notifications: number;
explicit_content_filter: number;
roles: Record<string, any>[];
emojis: Record<string, any>[];
features: Record<string, any>[];
mfa_level: number;
application_id: string;
system_channel_id: string;
system_channel_flags: number;
rules_channel_id: string;
max_presences?: number;
max_members?: number;
vanity_url_code: string;
description: string;
banner: string;
premium_tier: number;
premium_subscription_count?: number;
preferred_locale: string;
public_updates_channel_id: string;
max_video_channel_users?: number;
max_stage_video_channel_users?: number;
approximate_member_count?: number;
approximate_presence_count?: number;
welcome_screen?: Record<string, any>;
nsfw_level: number;
stickers?: string;
premium_progress_bar_enabled: boolean;
safety_alerts_channel_id: string;
incidents_data: Record<string, any>;
constructor(data: DiscordGuild) {
super();
this.id = data.id;
this.name = data.name;
this.icon = data.icon;
this.icon_hash = data.icon_hash;
this.splash = data.splash;
this.discovery_splash = data.discovery_splash;
this.owner = data.owner;
this.owner_id = data.owner_id;
this.permissions = data.permissions;
this.region = data.region;
this.afk_channel_id = data.afk_channel_id;
this.afk_timeout = data.afk_timeout;
this.widget_enabled = data.widget_enabled;
this.widget_channel_id = data.widget_channel_id;
this.verification_level = data.verification_level;
this.default_message_notifications = data.default_message_notifications;
this.explicit_content_filter = data.explicit_content_filter;
this.roles = data.roles;
this.emojis = data.emojis;
this.features = data.features;
this.mfa_level = data.mfa_level;
this.application_id = data.application_id;
this.system_channel_id = data.system_channel_id;
this.system_channel_flags = data.system_channel_flags;
this.rules_channel_id = data.rules_channel_id;
this.max_presences = data.max_presences;
this.max_members = data.max_members;
this.vanity_url_code = data.vanity_url_code;
this.description = data.description;
this.banner = data.banner;
this.premium_tier = data.premium_tier;
this.premium_subscription_count = data.premium_subscription_count;
this.preferred_locale = data.preferred_locale;
this.public_updates_channel_id = data.public_updates_channel_id;
this.max_video_channel_users = data.max_video_channel_users;
this.max_stage_video_channel_users = data.max_stage_video_channel_users;
this.approximate_member_count = data.approximate_member_count;
this.approximate_presence_count = data.approximate_presence_count;
this.welcome_screen = data.welcome_screen;
this.nsfw_level = data.nsfw_level;
this.stickers = data.stickers;
this.premium_progress_bar_enabled = data.premium_progress_bar_enabled;
this.safety_alerts_channel_id = data.safety_alerts_channel_id;
this.incidents_data = data.incidents_data;
}
public static [Trait.fromDiscord]<T = Guild>(data: DiscordGuild): T {
return new Guild(data) as T;
}
}
// === Guild Structure ===
// | Field | Type | Description |
// |--------------------------------|-------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
// | id | snowflake | guild id |
// | name | string | guild name (2-100 characters, excluding trailing and leading whitespace) |
// | icon | ?string | [icon hash](/docs/reference#image-formatting) |
// | icon_hash? | ?string | [icon hash](/docs/reference#image-formatting), returned when in the template object |
// | splash | ?string | [splash hash](/docs/reference#image-formatting) |
// | discovery_splash | ?string | [discovery splash hash](/docs/reference#image-formatting); only present for guilds with the "DISCOVERABLE" feature |
// | owner? \* | boolean | true if [the user](/docs/resources/user#get-current-user-guilds) is the owner of the guild |
// | owner_id | snowflake | id of owner |
// | permissions? \* | string | total permissions for [the user](/docs/resources/user#get-current-user-guilds) in the guild (excludes overwrites and [implicit permissions](/docs/topics/permissions#implicit-permissions)) |
// | region? \*\* | ?string | [voice region](/docs/resources/voice#voice-region-object) id for the guild (deprecated) |
// | afk_channel_id | ?snowflake | id of afk channel |
// | afk_timeout | integer | afk timeout in seconds |
// | widget_enabled? | boolean | true if the server widget is enabled |
// | widget_channel_id? | ?snowflake | the channel id that the widget will generate an invite to, or `null` if set to no invite |
// | verification_level | integer | [verification level](/docs/resources/guild#guild-object-verification-level) required for the guild |
// | default_message_notifications | integer | default [message notifications level](/docs/resources/guild#guild-object-default-message-notification-level) |
// | explicit_content_filter | integer | [explicit content filter level](/docs/resources/guild#guild-object-explicit-content-filter-level) |
// | roles | array of [role](/docs/topics/permissions#role-object) objects | roles in the guild |
// | emojis | array of [emoji](/docs/resources/emoji#emoji-object) objects | custom guild emojis |
// | features | array of [guild feature](/docs/resources/guild#guild-object-guild-features) strings | enabled guild features |
// | mfa_level | integer | required [MFA level](/docs/resources/guild#guild-object-mfa-level) for the guild |
// | application_id | ?snowflake | application id of the guild creator if it is bot-created |
// | system_channel_id | ?snowflake | the id of the channel where guild notices such as welcome messages and boost events are posted |
// | system_channel_flags | integer | [system channel flags](/docs/resources/guild#guild-object-system-channel-flags) |
// | rules_channel_id | ?snowflake | the id of the channel where Community guilds can display rules and/or guidelines |
// | max_presences? | ?integer | the maximum number of presences for the guild (`null` is always returned, apart from the largest of guilds) |
// | max_members? | integer | the maximum number of members for the guild |
// | vanity_url_code | ?string | the vanity url code for the guild |
// | description | ?string | the description of a guild |
// | banner | ?string | [banner hash](/docs/reference#image-formatting) |
// | premium_tier | integer | [premium tier](/docs/resources/guild#guild-object-premium-tier) (Server Boost level) |
// | premium_subscription_count? | integer | the number of boosts this guild currently has |
// | preferred_locale | string | the preferred [locale](/docs/reference#locales) of a Community guild; used in server discovery and notices from Discord, and sent in interactions; defaults to "en-US" |
// | public_updates_channel_id | ?snowflake | the id of the channel where admins and moderators of Community guilds receive notices from Discord |
// | max_video_channel_users? | integer | the maximum amount of users in a video channel |
// | max_stage_video_channel_users? | integer | the maximum amount of users in a stage video channel |
// | approximate_member_count? | integer | approximate number of members in this guild, returned from the `GET /guilds/<id>` and `/users/@me/guilds` endpoints when `with_counts` is `true` |
// | approximate_presence_count? | integer | approximate number of non-offline members in this guild, returned from the `GET /guilds/<id>` and `/users/@me/guilds` endpoints when `with_counts` is `true` |
// | welcome_screen? | [welcome screen](/docs/resources/guild#welcome-screen-object) object | the welcome screen of a Community guild, shown to new members, returned in an [Invite](/docs/resources/invite#invite-object)'s guild object |
// | nsfw_level | integer | [guild NSFW level](/docs/resources/guild#guild-object-guild-nsfw-level) |
// | stickers? | array of [sticker](/docs/resources/sticker#sticker-object) objects | custom guild stickers |
// | premium_progress_bar_enabled | boolean | whether the guild has the boost progress bar enabled |
// | safety_alerts_channel_id | ?snowflake | the id of the channel where admins and moderators of Community guilds receive safety alerts from Discord |
// | incidents_data | ?[incidents data](/docs/resources/guild#incidents-data-object) object | the incidents data for this guild |
export interface DiscordGuild {
id: string;
name: string;
icon: string;
icon_hash?: string;
splash: string;
discovery_splash: string;
owner?: boolean;
owner_id: string;
permissions?: string;
region?: string;
afk_channel_id: string;
afk_timeout: number;
widget_enabled?: boolean;
widget_channel_id?: string;
verification_level: number;
default_message_notifications: number;
explicit_content_filter: number;
roles: Record<string, any>[];
emojis: Record<string, any>[];
features: Record<string, any>[];
mfa_level: number;
application_id: string;
system_channel_id: string;
system_channel_flags: number;
rules_channel_id: string;
max_presences?: number;
max_members?: number;
vanity_url_code: string;
description: string;
banner: string;
premium_tier: number;
premium_subscription_count?: number;
preferred_locale: string;
public_updates_channel_id: string;
max_video_channel_users?: number;
max_stage_video_channel_users?: number;
approximate_member_count?: number;
approximate_presence_count?: number;
welcome_screen?: Record<string, any>;
nsfw_level: number;
stickers?: string;
premium_progress_bar_enabled: boolean;
safety_alerts_channel_id: string;
incidents_data: Record<string, any>;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './common';
export * from './user';
export * from './channel';
export * from './guild';
export * from './message';
export * from './embed';
+281
View File
@@ -0,0 +1,281 @@
import { exists, isNil } from '../utils';
import { UnicordGatewayObject } from './base';
import type { Channel } from './channel';
import { fromDiscord, Trait, type Expectation } from './common';
import type { DiscordEmbed, Embed } from './embed';
import type { Guild } from './guild';
import { User, type DiscordUser } 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 UnicordGatewayObject<Message, DiscordMessage> {
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,
};
};
}
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;
}[];
};
+110
View File
@@ -0,0 +1,110 @@
import { UnicordGatewayObject } from './base';
import { Trait } from './common';
export class User extends UnicordGatewayObject<User, 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;
// TODO: Add classes and enums for the following properties
flags?: number;
premium_type?: number;
public_flags?: number;
avatar_decoration_data?: Record<string, any>;
collectibles?: Record<string, any>;
primary_guild?: Record<string, any>;
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: DiscordUser) {
super();
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;
this.collectibles = data.collectibles;
this.primary_guild = data.primary_guild;
}
// 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(data) as T;
}
}
// === Discord User Structure ===
// | Field | Type | Description | Required OAuth2 Scope |
// |-------------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|-----------------------|
// | 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](/docs/reference#image-formatting) | 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](/docs/reference#image-formatting) | 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](/docs/reference#locales) | identify |
// | verified? | boolean | whether the email on this account has been verified | email |
// | email? | ?string | the user's email | email |
// | flags? | integer | the [flags](/docs/resources/user#user-object-user-flags) on a user's account | identify |
// | premium_type? | integer | the [type of Nitro subscription](/docs/resources/user#user-object-premium-types) on a user's account | identify |
// | public_flags? | integer | the public [flags](/docs/resources/user#user-object-user-flags) on a user's account | identify |
// | avatar_decoration_data? | ?[avatar decoration data](/docs/resources/user#avatar-decoration-data-object) object | data for the user's avatar decoration | identify |
// | collectibles? | ?[collectibles](/docs/resources/user#collectibles) object | data for the user's collectibles | identify |
// | primary_guild? | ?[user primary guild](/docs/resources/user#user-object-user-primary-guild) object | the user's primary guild | identify |
export interface 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>;
collectibles?: Record<string, any>;
primary_guild?: Record<string, any>;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Sets top level properties when undefined on the original object — cretates a new object, does not modify the original
* @param original original object
* @param defaults defailts to set on the object
* @returns a new object with top level defaults set
*/
export function setDefaults<T>(original: Record<string, any>, defaults: Record<string, any>): T {
const final: Record<string, any> = {};
for (const prop of Object.keys(defaults)) {
final[prop] = defaults[prop];
}
for (const prop of Object.keys(original)) {
final[prop] = original[prop];
}
return final as T;
}
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().toLocaleTimeString()}]\x1b[0m`;
const prefix = `\x1b[35m[unicord]`;
console.log(`${timestamp} ${prefix} \x1b[0m`, ...args);
}
};
+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();
+25 -89
View File
@@ -1,89 +1,25 @@
import { ComponentStyle, Context, Gateway, Guilds, Message } from './lib'; // Base classes
import { Command, CommandFuntion, Intent, Presence } from './types'; export { Client, Client as unicord } from './services/client';
export { ComponentStyle, Context, Gateway, Guilds, Message, Command, CommandFuntion, Intent, Presence }; export { Intent, Trait } from './types/common';
// Consumable types
type Config = { export type { ClientConfig } from './types/common';
token: string; export type { Context } from './types/context';
application_id: string; export { Expectation } from './types/common';
prefix?: string; export { ChannelType, ChannelFlags, VideoQualityMode, SortOrderType, ForumLayoutType } from './types/channel';
intents?: Intent[]; // Consumable util classes
}; export { Guild } from './types/guild';
export { Role } from './types/role';
export class Client { export { Member } from './types/member';
readonly prefix: string; export { User } from './types/user';
readonly token: string; export { Message, type MessagePayload, ComponentStyle, ComponentType } from './types/message';
readonly application_id: string; export { Embed } from './types/embed';
private readonly gateway: Gateway; export { Emoji } from './types/emoji';
private readonly client_commands: Map<string, Command | CommandFuntion> = new Map(); // Other
readonly guilds: Guilds; export {
createCommandHandler,
constructor({ token, application_id, prefix, intents }: Config) { type InteractionData,
this.token = token; type InteractionCommpoentData,
this.application_id = application_id; type InteractionCommandData,
this.prefix = prefix ?? '!'; type InteractionHandlerFunction,
this.gateway = new Gateway({ token, intents, client: this }); } from './types/handler';
this.guilds = new Guilds({ token, gateway: this.gateway }); export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand';
}
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 = {
register: (command: Command | string, handler: CommandFuntion) => {
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 Command
? command.execute(this, context, args.slice(1))
: command(this, context, args.slice(1));
}
},
delete: (command_name: string) => {
this.client_commands.delete(command_name);
}
};
}
-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);
};
}
-337
View File
@@ -1,337 +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,
CommandFuntion,
Interaction,
InteractionFunction,
InteractionContext
} from '../types';
import { Context } from './context';
export class Gateway {
socket: WebSocket;
axios: AxiosInstance;
// Config
readonly token: string;
readonly client: Client;
private intents: number = 513;
private commandNotFound: CommandFuntion = null;
// Discord gateway data
private readonly url: string = 'wss://gateway.discord.gg/?v=10&encoding=json';
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?: CommandFuntion;
}) {
// 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 = () => {
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');
}
setInterval(() => {
this.send({
op: 1,
d: {}
});
}, this.heartbeat_interval);
};
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) => {
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);
// Is this a command?
if (payload.d.content.startsWith(this.client.prefix)) {
// Parse command
const args = payload.d.content
.slice(this.client.prefix.length)
.trim()
.split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g);
// Check if command exists
const handler = this.client.commands.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');
break;
default:
break;
}
};
private onMessage = (packet: Data): void => {
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: %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']});
}
}
-5
View File
@@ -1,5 +0,0 @@
export * from './gateway';
export * from './cache';
export * from './guilds';
export * from './context';
export * from './message';
-153
View File
@@ -1,153 +0,0 @@
export type Embed = {
}
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
}
}
}
+77
View File
@@ -0,0 +1,77 @@
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.config.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);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { EventEmitter } from 'node:events';
import type { Context } from '../types/context';
const EventBus = new EventEmitter() as Emitter;
export class Emitter extends EventEmitter {
emit(event_name: string, context: Context | null, ...args: any[]): boolean {
return super.emit(event_name, context, ...args);
}
on(event_name: string, listener: (context: Context | null, ...args: any[]) => void): this {
return super.on(event_name, listener);
}
}
export { EventBus };
+164
View File
@@ -0,0 +1,164 @@
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: LRUCache<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[],
maxCapacity: number = 10_000,
) {
this.cache = new LRUCache<string, K>(maxCapacity);
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 when it finishes
req.finally(() => 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);
}
}
}
class ListNode<K, V> {
key: K;
value: V;
prev: ListNode<K, V> | null = null;
next: ListNode<K, V> | null = null;
constructor(key: K, value: V) {
this.key = key;
this.value = value;
}
}
class LRUCache<K, V> {
private capacity: number;
private map: Map<K, ListNode<K, V>>;
private head: ListNode<K, V>;
private tail: ListNode<K, V>;
constructor(capacity: number) {
this.capacity = capacity;
this.map = new Map();
// Dummy head and tail to simplify boundary conditions
this.head = new ListNode<K, V>(null as any, null as any);
this.tail = new ListNode<K, V>(null as any, null as any);
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: K): V | undefined {
if (!this.map.has(key)) return undefined;
const node = this.map.get(key)!;
this.moveToFront(node);
return node.value;
}
has(key: K): boolean {
return this.map.has(key);
}
set(key: K, value: V): void {
if (this.map.has(key)) {
// Update existing node value and move it to front
const node = this.map.get(key)!;
node.value = value;
this.moveToFront(node);
} else {
// Create a new node
const newNode = new ListNode(key, value);
this.map.set(key, newNode);
this.addToFront(newNode);
// If over capacity, remove the least recently used (LRU) node
if (this.map.size > this.capacity) {
this.removeLRU();
}
}
}
private moveToFront(node: ListNode<K, V>): void {
this.removeNode(node);
this.addToFront(node);
}
private addToFront(node: ListNode<K, V>): void {
node.next = this.head.next;
node.prev = this.head;
this.head.next!.prev = node;
this.head.next = node;
}
private removeNode(node: ListNode<K, V>): void {
node.prev!.next = node.next;
node.next!.prev = node.prev;
}
private removeLRU(): void {
const lruNode = this.tail.prev!;
this.removeNode(lruNode);
this.map.delete(lruNode.key);
}
*[Symbol.iterator](): IterableIterator<[K, V]> {
let current = this.head.next;
while (current && current !== this.tail) {
yield [current.key, current.value];
current = current.next;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { API } from "../api";
import { Processor } from "../processor";
import { Channel } from "../../types/channel";
import { ObjectCache } from "./cache";
export class Channels extends ObjectCache<Channel, Channel> {
constructor(api: API, processor: Processor) {
super(api, processor, 'channels', Channel, ['CHANNEL_CREATE', 'CHANNEL_UPDATE']);
}
}
+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']);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { fromDiscord } from "../../types/common";
import { Message, type MessagePayload } from "../../types/message";
import type { API } from "../api";
import type { Processor } from "../processor";
import { ObjectCache } from "./cache";
export class Messages extends ObjectCache<Message, MessagePayload> {
constructor (api: API, processor: Processor) {
super(api, processor, 'messages', 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, []);
}
}
+178
View File
@@ -0,0 +1,178 @@
import { type DiscordAPIEvent, Expectation, HandlerType, Intent, type ClientConfig } from '../types/common';
import { Message, type DiscordMessage } from '../types/message';
import { exists, isNil } from '../utils/index';
import { API } from './api';
import { EventBus, type Emitter } from './bus';
import { Channels } from './caches/channels';
import { Guilds } from './caches/guilds';
import { Messages } from './caches/messages';
import { Users } from './caches/users';
import { Gateway } from './connectors/gateway';
import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator';
import { Processor } from './processor';
export class Client {
readonly config: Readonly<Required<ClientConfig>>;
// Internal services
protected bus: Emitter;
protected api: API;
protected processor: Processor;
protected gateway: Gateway;
// Caches
readonly caches: {
readonly guilds: Guilds;
readonly users: Users;
readonly messages: Messages;
readonly channels: Channels;
};
constructor(config: ClientConfig) {
// Validate the configuration
Client.validateConfig(config);
// Save the configuration
this.config = Object.freeze({
token: config.token,
application_id: config.application_id,
intents: config.intents ?? [Intent.DEFAULT],
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);
// Initialize caches
this.caches = {
guilds: new Guilds(this.api, this.processor),
users: new Users(this.api, this.processor),
messages: new Messages(this.api, this.processor),
channels: new Channels(this.api, this.processor),
};
}
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;
}
get chatCommands() {
return this.processor[HandlerType.ChatCommands];
}
get applicationCommands() {
return this.processor[HandlerType.ApplicationCommands];
}
get interactions() {
return this.processor[HandlerType.Interactions];
}
get events() {
return this.processor[HandlerType.Events];
}
/**
* 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()),
);
};
/**
* Makes a raw HTTP request to the Discord API.
* @param method HTTP method to use for the request (GET, POST, PUT, DELETE).
* @param endpoint The API endpoint to request; can start with a slash or be a full URL.
* @param body Optional body to send with the request, typically used for POST or PUT requests.
* @returns A promise that resolves to the response data of type T.
*/
rawRequest = async <T = Record<string, any>>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
endpoint: string,
body?: Record<string, any>,
): Promise<T> => {
return this.api.request<T>(method, endpoint, body);
};
// 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: DiscordAPIEvent, handler: (...args: any[]) => void) => this.bus.on(event, handler);
once = (event: DiscordAPIEvent, handler: (...args: any[]) => void) => this.bus.once(event, handler);
off = (event: DiscordAPIEvent, 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(`${HandlerType.ChatCommands}:${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);
}
}
+112
View File
@@ -0,0 +1,112 @@
import { Expectation } from "../types/common";
import { Context, type HydratedContext } from "../types/context";
import { Message, type HydratedMessage } from "../types/message";
import { Channel } from "../types/channel";
import { exists } from "../utils";
import type { API } from "./api";
import type { Client } from "./client";
export type GatewayObject = Message | Channel;
export type Hydrateable = Context | GatewayObject
export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> =
T extends Message ? HydratedMessage<K> & Omit<T, keyof HydratedMessage<K>> :
T extends Context ? HydratedContext<K> & Omit<T, keyof 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, K extends Array<Expectation>>(
data: T,
expectations: K,
client: Client,
api: API
): Promise<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.caches.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.caches.guilds.get(data.guild_id);
} else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let context = data as unknown as HydratedContext<[Expectation.Channel, ...Expectation[]]>;
context.channel = await client.caches.channels.get(data.channel_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.caches.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.caches.guilds.get(data.guild_id);
} else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let message = data as unknown as HydratedMessage<[Expectation.Channel, ...Expectation[]]>;
message.channel = await client.caches.channels.get(data.channel_id);
} else {
throw new Error(`Invalid expectation requested for Message: ${expectation}`);
}
}
}
// Create a new object that preserves all existing properties
const result = { ...data } as any;
// Ensure the result is properly typed with both old and new hydrated properties
return result as T & 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, K extends Array<Expectation>>(
data: T,
expectations: K,
client: Client,
api: API
): Promise<(data: unknown) => data is 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 Hydrated<T, K> {
return hydrated;
}
}
+193
View File
@@ -0,0 +1,193 @@
import { ApplicationCommandOptionType, ApplicationCommandType, type ApplicationCommandOption } from "../types/applicationCommand";
import { HandlerType, Trait, type EventPayload } from "../types/common";
import type { Context } from "../types/context";
import { CommandHandler, OptionConstructorMap, type ArgsFromOptions, type Handler, type InteractionData } from "../types/handler";
import type { API } from "./api";
import type { Emitter } from "./bus";
import type { Client } from "./client";
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
// TODO: There's no reason this can't use the hydrator system
// Hydrator should have a more genric subcomponent that can be used anywhere and is more dumb.
// Basically a function that accepts id's and returns values.
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.caches.users.get(userId);
} else if (typeof arg === 'object') {
// Check if the object has an ID
if (arg.id) {
validated[def.name] = await this.client.caches.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 convert from arrow to regular 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.config.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];
}
}
+278
View File
@@ -0,0 +1,278 @@
import { GatewayObject } from "./base";
import { Trait } from "./common";
import { User, type DiscordUser } from "./user";
export enum ChannelType {
GUILD_TEXT = 0,
DM = 1,
GUILD_VOICE = 2,
GROUP_DM = 3,
GUILD_CATEGORY = 4,
GUILD_ANNOUNCEMENT = 5,
ANNOUNCEMENT_THREAD = 10,
PUBLIC_THREAD = 11,
PRIVATE_THREAD = 12,
GUILD_STAGE_VOICE = 13,
GUILD_DIRECTORY = 14,
GUILD_FORUM = 15,
GUILD_MEDIA = 16
}
export enum ChannelFlags {
PINNED = 1 << 1,
REQUIRE_TAG = 1 << 4,
HIDE_MEDIA_DOWNLOAD_OPTIONS = 1 << 15
}
export enum VideoQualityMode {
AUTO = 1,
FULL = 2
}
export enum SortOrderType {
LATEST_ACTIVITY = 0,
CREATION_DATE = 1
}
export enum ForumLayoutType {
NOT_SET = 0,
LIST_VIEW = 1,
GALLERY_VIEW = 2
}
export type PermissionOverwrite = {
id: string;
type: 0 | 1; // 0 for role, 1 for member
allow: string;
deny: string;
}
export type ThreadMetadata = {
archived: boolean;
auto_archive_duration: number;
archive_timestamp: string;
locked: boolean;
invitable?: boolean;
create_timestamp?: string | null;
}
export type ThreadMember = {
id?: string;
user_id?: string;
join_timestamp: string;
flags: number;
}
export type ForumTag = {
id: string;
name: string;
moderated: boolean;
emoji_id?: string | null;
emoji_name?: string | null;
}
export type DefaultReaction = {
emoji_id?: string | null; // the id of a guild's custom emoji
emoji_name?: string | null; // the unicode character of the emoji
}
export class Channel extends GatewayObject {
id: string;
type: ChannelType;
guild_id?: string;
position?: number;
permission_overwrites?: PermissionOverwrite[];
name?: string;
topic?: string | null;
nsfw?: boolean;
last_message_id?: string | null;
bitrate?: number;
user_limit?: number;
rate_limit_per_user?: number;
recipients?: User[];
icon?: string | null;
owner_id?: string;
application_id?: string;
managed?: boolean;
parent_id?: string | null;
last_pin_timestamp?: Date | null;
rtc_region?: string | null;
video_quality_mode?: VideoQualityMode;
message_count?: number;
member_count?: number;
thread_metadata?: ThreadMetadata;
member?: ThreadMember;
default_auto_archive_duration?: number;
permissions?: string;
flags?: number;
total_message_sent?: number;
available_tags?: ForumTag[];
applied_tags?: string[];
default_reaction_emoji?: DefaultReaction;
default_thread_rate_limit_per_user?: number;
default_sort_order?: SortOrderType | null;
default_forum_layout?: ForumLayoutType;
constructor(data: Partial<Channel>) {
super();
this.id = data.id!;
this.type = data.type!;
this.guild_id = data.guild_id;
this.position = data.position;
this.permission_overwrites = data.permission_overwrites;
this.name = data.name;
this.topic = data.topic;
this.nsfw = data.nsfw;
this.last_message_id = data.last_message_id;
this.bitrate = data.bitrate;
this.user_limit = data.user_limit;
this.rate_limit_per_user = data.rate_limit_per_user;
this.recipients = data.recipients;
this.icon = data.icon;
this.owner_id = data.owner_id;
this.application_id = data.application_id;
this.managed = data.managed;
this.parent_id = data.parent_id;
this.last_pin_timestamp = data.last_pin_timestamp instanceof Date
? data.last_pin_timestamp
: data.last_pin_timestamp
? new Date(data.last_pin_timestamp)
: null;
this.rtc_region = data.rtc_region;
this.video_quality_mode = data.video_quality_mode;
this.message_count = data.message_count;
this.member_count = data.member_count;
this.thread_metadata = data.thread_metadata;
this.member = data.member;
this.default_auto_archive_duration = data.default_auto_archive_duration;
this.permissions = data.permissions;
this.flags = data.flags;
this.total_message_sent = data.total_message_sent;
this.available_tags = data.available_tags;
this.applied_tags = data.applied_tags;
this.default_reaction_emoji = data.default_reaction_emoji;
this.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user;
this.default_sort_order = data.default_sort_order;
this.default_forum_layout = data.default_forum_layout;
}
public static [Trait.fromDiscord]<T = Channel>(data: DiscordChannel): T {
return new Channel({
id: data.id,
type: data.type,
guild_id: data.guild_id,
position: data.position,
permission_overwrites: data.permission_overwrites,
name: data.name,
topic: data.topic,
nsfw: data.nsfw,
last_message_id: data.last_message_id,
bitrate: data.bitrate,
user_limit: data.user_limit,
rate_limit_per_user: data.rate_limit_per_user,
recipients: data.recipients?.map(u => User.fromDiscord(u)),
icon: data.icon,
owner_id: data.owner_id,
application_id: data.application_id,
managed: data.managed,
parent_id: data.parent_id,
last_pin_timestamp: data.last_pin_timestamp ? new Date(data.last_pin_timestamp) : null,
rtc_region: data.rtc_region,
video_quality_mode: data.video_quality_mode,
message_count: data.message_count,
member_count: data.member_count,
thread_metadata: data.thread_metadata,
member: data.member,
default_auto_archive_duration: data.default_auto_archive_duration,
permissions: data.permissions,
flags: data.flags,
total_message_sent: data.total_message_sent,
available_tags: data.available_tags,
applied_tags: data.applied_tags,
default_reaction_emoji: data.default_reaction_emoji,
default_thread_rate_limit_per_user: data.default_thread_rate_limit_per_user,
default_sort_order: data.default_sort_order,
default_forum_layout: data.default_forum_layout
}) as T;
}
}
export class DiscordChannel {
id: string;
type: number;
guild_id?: string;
position?: number;
permission_overwrites?: PermissionOverwrite[];
name?: string;
topic?: string | null;
nsfw?: boolean;
last_message_id?: string | null;
bitrate?: number;
user_limit?: number;
rate_limit_per_user?: number;
recipients?: DiscordUser[];
icon?: string | null;
owner_id?: string;
application_id?: string;
managed?: boolean;
parent_id?: string | null;
last_pin_timestamp?: string | null;
rtc_region?: string | null;
video_quality_mode?: number;
message_count?: number;
member_count?: number;
thread_metadata?: ThreadMetadata;
member?: ThreadMember;
default_auto_archive_duration?: number;
permissions?: string;
flags?: number;
total_message_sent?: number;
available_tags?: ForumTag[];
applied_tags?: string[];
default_reaction_emoji?: DefaultReaction;
default_thread_rate_limit_per_user?: number;
default_sort_order?: number | null;
default_forum_layout?: number;
constructor(data: any) {
this.id = data.id;
this.type = data.type;
this.guild_id = data.guild_id;
this.position = data.position;
this.permission_overwrites = data.permission_overwrites;
this.name = data.name;
this.topic = data.topic;
this.nsfw = data.nsfw;
this.last_message_id = data.last_message_id;
this.bitrate = data.bitrate;
this.user_limit = data.user_limit;
this.rate_limit_per_user = data.rate_limit_per_user;
this.recipients = data.recipients;
this.icon = data.icon;
this.owner_id = data.owner_id;
this.application_id = data.application_id;
this.managed = data.managed;
this.parent_id = data.parent_id;
this.last_pin_timestamp = data.last_pin_timestamp;
this.rtc_region = data.rtc_region;
this.video_quality_mode = data.video_quality_mode;
this.message_count = data.message_count;
this.member_count = data.member_count;
this.thread_metadata = data.thread_metadata;
this.member = data.member;
this.default_auto_archive_duration = data.default_auto_archive_duration;
this.permissions = data.permissions;
this.flags = data.flags;
this.total_message_sent = data.total_message_sent;
this.available_tags = data.available_tags;
this.applied_tags = data.applied_tags;
this.default_reaction_emoji = data.default_reaction_emoji;
this.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user;
this.default_sort_order = data.default_sort_order;
this.default_forum_layout = data.default_forum_layout;
}
static fromAPIResponse(data: any): DiscordChannel {
return new DiscordChannel(data);
}
}
-10
View File
@@ -1,10 +0,0 @@
import { Client } from "..";
import { Context } from "../lib";
export abstract class Command {
abstract name: string;
abstract description: string;
abstract execute: CommandFuntion;
}
export type CommandFuntion = (client: Client, context: Context, ...args: any[]) => void;
+229 -163
View File
@@ -1,79 +1,66 @@
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 {
@@ -94,110 +81,189 @@ export enum Intent {
DIRECT_MESSAGE_TYPING = 1 << 14, DIRECT_MESSAGE_TYPING = 1 << 14,
MESSAGE_CONTENT = 1 << 15, MESSAGE_CONTENT = 1 << 15,
DEFAULT = 513, DEFAULT = 513,
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_UPDATE", 'GUILD_CREATE',
"GUILD_DELETE", 'GUILD_UPDATE',
"GUILD_ROLE_CREATE", 'GUILD_DELETE',
"GUILD_ROLE_UPDATE", 'GUILD_ROLE_CREATE',
"GUILD_ROLE_DELETE", 'GUILD_ROLE_UPDATE',
"CHANNEL_CREATE", 'GUILD_ROLE_DELETE',
"CHANNEL_UPDATE", 'CHANNEL_CREATE',
"CHANNEL_DELETE", 'CHANNEL_UPDATE',
"CHANNEL_PINS_UPDATE", 'CHANNEL_DELETE',
"THREAD_CREATE", 'CHANNEL_PINS_UPDATE',
"THREAD_UPDATE", 'THREAD_CREATE',
"THREAD_DELETE", 'THREAD_UPDATE',
"THREAD_LIST_SYNC", 'THREAD_DELETE',
"THREAD_MEMBER_UPDATE", 'THREAD_LIST_SYNC',
"THREAD_MEMBERS_UPDATE *", 'THREAD_MEMBER_UPDATE',
"STAGE_INSTANCE_CREATE", 'THREAD_MEMBERS_UPDATE *',
"STAGE_INSTANCE_UPDATE", 'STAGE_INSTANCE_CREATE',
"STAGE_INSTANCE_DELETE" 'STAGE_INSTANCE_UPDATE',
], 0], 'STAGE_INSTANCE_DELETE',
],
0,
],
[Intent.GUILD_MEMBERS]: [[ [Intent.GUILD_MEMBERS]: [
"GUILD_MEMBER_ADD", ['GUILD_MEMBER_ADD', 'GUILD_MEMBER_UPDATE', 'GUILD_MEMBER_REMOVE', 'THREAD_MEMBERS_UPDATE *'],
"GUILD_MEMBER_UPDATE", 1,
"GUILD_MEMBER_REMOVE", ],
"THREAD_MEMBERS_UPDATE *",
], 1],
[Intent.GUILD_BANS]: [[ [Intent.GUILD_BANS]: [['GUILD_BAN_ADD', 'GUILD_BAN_REMOVE'], 2],
"GUILD_BAN_ADD",
"GUILD_BAN_REMOVE",
], 2],
[Intent.GUILD_EMOJIS_AND_STICKERS]: [[ [Intent.GUILD_EMOJIS_AND_STICKERS]: [['GUILD_EMOJIS_UPDATE', 'GUILD_STICKERS_UPDATE'], 3],
"GUILD_EMOJIS_UPDATE",
"GUILD_STICKERS_UPDATE",
], 3],
[Intent.GUILD_INTEGRATIONS]: [[ [Intent.GUILD_INTEGRATIONS]: [
"GUILD_INTEGRATIONS_UPDATE", ['GUILD_INTEGRATIONS_UPDATE', 'INTEGRATION_CREATE', 'INTEGRATION_UPDATE', 'INTEGRATION_DELETE'],
"INTEGRATION_CREATE", 4,
"INTEGRATION_UPDATE", ],
"INTEGRATION_DELETE",
], 4],
[Intent.GUILD_WEBHOOKS]: [[ [Intent.GUILD_WEBHOOKS]: [['WEBHOOKS_UPDATE'], 5],
"WEBHOOKS_UPDATE"
], 5],
[Intent.GUILD_INVITES]: [[ [Intent.GUILD_INVITES]: [['INVITE_CREATE', 'INVITE_DELETE'], 6],
"INVITE_CREATE",
"INVITE_DELETE",
], 6],
[Intent.GUILD_VOICE_STATES]: [[ [Intent.GUILD_VOICE_STATES]: [['VOICE_STATE_UPDATE'], 7],
"VOICE_STATE_UPDATE"
], 7],
[Intent.GUILD_PRESENCES]: [[ [Intent.GUILD_PRESENCES]: [['PRESENCE_UPDATE'], 8],
"PRESENCE_UPDATE"
], 8],
[Intent.GUILD_MESSAGES]: [[ [Intent.GUILD_MESSAGES]: [['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK'], 9],
"MESSAGE_CREATE",
"MESSAGE_UPDATE",
"MESSAGE_DELETE",
"MESSAGE_DELETE_BULK",
], 9],
[Intent.GUILD_MESSAGE_REACTIONS]: [[ [Intent.GUILD_MESSAGE_REACTIONS]: [
"MESSAGE_REACTION_ADD", [
"MESSAGE_REACTION_REMOVE", 'MESSAGE_REACTION_ADD',
"MESSAGE_REACTION_REMOVE_ALL", 'MESSAGE_REACTION_REMOVE',
"MESSAGE_REACTION_REMOVE_EMOJI", 'MESSAGE_REACTION_REMOVE_ALL',
], 10], 'MESSAGE_REACTION_REMOVE_EMOJI',
],
10,
],
[Intent.GUILD_MESSAGE_TYPING]: [[ [Intent.GUILD_MESSAGE_TYPING]: [['TYPING_START'], 11],
"TYPING_START"
], 11],
[Intent.DIRECT_MESSAGES]: [[ [Intent.DIRECT_MESSAGES]: [['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'CHANNEL_PINS_UPDATE'], 12],
"MESSAGE_CREATE",
"MESSAGE_UPDATE",
"MESSAGE_DELETE",
"CHANNEL_PINS_UPDATE",
], 12],
[Intent.DIRECT_MESSAGE_REACTIONS]: [[ [Intent.DIRECT_MESSAGE_REACTIONS]: [
"MESSAGE_REACTION_ADD", [
"MESSAGE_REACTION_REMOVE", 'MESSAGE_REACTION_ADD',
"MESSAGE_REACTION_REMOVE_ALL", 'MESSAGE_REACTION_REMOVE',
"MESSAGE_REACTION_REMOVE_EMOJI", 'MESSAGE_REACTION_REMOVE_ALL',
], 13], 'MESSAGE_REACTION_REMOVE_EMOJI',
],
13,
],
[Intent.DIRECT_MESSAGE_TYPING]: [[ [Intent.DIRECT_MESSAGE_TYPING]: [['TYPING_START'], 14],
"TYPING_START"
], 14],
[Intent.MESSAGE_CONTENT]: [[ [Intent.MESSAGE_CONTENT]: [['MESSAGE_CONTENT'], 15],
"MESSAGE_CONTENT" };
], 15]
} export type DiscordAPIEvent =
| 'READY'
| 'RESUMED'
| 'CHANNEL_CREATE'
| 'CHANNEL_UPDATE'
| 'CHANNEL_DELETE'
| 'CHANNEL_PINS_UPDATE'
| 'THREAD_CREATE'
| 'THREAD_UPDATE'
| 'THREAD_DELETE'
| 'THREAD_LIST_SYNC'
| 'THREAD_MEMBER_UPDATE'
| 'THREAD_MEMBERS_UPDATE *'
| 'STAGE_INSTANCE_CREATE'
| 'STAGE_INSTANCE_UPDATE'
| 'STAGE_INSTANCE_DELETE'
| 'GUILD_CREATE'
| 'GUILD_UPDATE'
| 'GUILD_DELETE'
| 'GUILD_ROLE_CREATE'
| 'GUILD_ROLE_UPDATE'
| 'GUILD_ROLE_DELETE'
| 'GUILD_MEMBER_ADD'
| 'GUILD_MEMBER_UPDATE'
| 'GUILD_MEMBER_REMOVE'
| 'GUILD_MEMBERS_CHUNK *'
| 'GUILD_BAN_ADD'
| 'GUILD_BAN_REMOVE'
| 'GUILD_EMOJIS_UPDATE'
| 'GUILD_STICKERS_UPDATE'
| 'GUILD_INTEGRATIONS_UPDATE'
| 'INTEGRATION_CREATE'
| 'INTEGRATION_UPDATE'
| 'INTEGRATION_DELETE'
| 'WEBHOOKS_UPDATE'
| 'INVITE_CREATE'
| 'INVITE_DELETE'
| 'VOICE_STATE_UPDATE'
| 'MESSAGE_CREATE'
| 'MESSAGE_UPDATE'
| 'MESSAGE_DELETE'
| 'MESSAGE_DELETE_BULK'
| 'MESSAGE_REACTION_ADD'
| 'MESSAGE_REACTION_REMOVE'
| 'MESSAGE_REACTION_REMOVE_ALL'
| 'MESSAGE_REACTION_REMOVE_EMOJI'
| 'TYPING_START'
| 'PRESENCE_UPDATE'
| 'APPLICATION_COMMAND_CREATE'
| 'APPLICATION_COMMAND_UPDATE'
| 'APPLICATION_COMMAND_DELETE';
export enum DiscordAPIEvents {
READY = 'READY',
RESUMED = 'RESUMED',
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',
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',
GUILD_MEMBER_ADD = 'GUILD_MEMBER_ADD',
GUILD_MEMBER_UPDATE = 'GUILD_MEMBER_UPDATE',
GUILD_MEMBER_REMOVE = 'GUILD_MEMBER_REMOVE',
GUILD_MEMBERS_CHUNK = 'GUILD_MEMBERS_CHUNK *',
GUILD_BAN_ADD = 'GUILD_BAN_ADD',
GUILD_BAN_REMOVE = 'GUILD_BAN_REMOVE',
GUILD_EMOJIS_UPDATE = 'GUILD_EMOJIS_UPDATE',
GUILD_STICKERS_UPDATE = 'GUILD_STICKERS_UPDATE',
GUILD_INTEGRATIONS_UPDATE = 'GUILD_INTEGRATIONS_UPDATE',
INTEGRATION_CREATE = 'INTEGRATION_CREATE',
INTEGRATION_UPDATE = 'INTEGRATION_UPDATE',
INTEGRATION_DELETE = 'INTEGRATION_DELETE',
WEBHOOKS_UPDATE = 'WEBHOOKS_UPDATE',
INVITE_CREATE = 'INVITE_CREATE',
INVITE_DELETE = 'INVITE_DELETE',
VOICE_STATE_UPDATE = 'VOICE_STATE_UPDATE',
MESSAGE_CREATE = 'MESSAGE_CREATE',
MESSAGE_UPDATE = 'MESSAGE_UPDATE',
MESSAGE_DELETE = 'MESSAGE_DELETE',
MESSAGE_DELETE_BULK = 'MESSAGE_DELETE_BULK',
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',
TYPING_START = 'TYPING_START',
PRESENCE_UPDATE = 'PRESENCE_UPDATE',
APPLICATION_COMMAND_CREATE = 'APPLICATION_COMMAND_CREATE',
APPLICATION_COMMAND_UPDATE = 'APPLICATION_COMMAND_UPDATE',
APPLICATION_COMMAND_DELETE = 'APPLICATION_COMMAND_DELETE',
}
+179
View File
@@ -0,0 +1,179 @@
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 { Channel } from "./channel";
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;
channel: Extract<Expectation.Channel, T[number]> extends never ? undefined : Channel;
};
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, 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);
};
}
+143
View File
@@ -0,0 +1,143 @@
import { Message } from './message';
type EmbedFooter = {
text: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedImage = {
url?: string;
proxy_url?: string;
height?: number;
width?: number;
};
type EmbedAuthor = {
name: string;
url?: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedVideo = {
url?: string;
height?: number;
width?: number;
};
type EmbedField = {
name: string;
value: string;
inline?: boolean;
};
export class Embed {
title?: string;
description?: string;
url?: string;
timestamp?: string;
color?: number;
footer?: EmbedFooter;
image?: EmbedImage;
thumbnail?: EmbedImage;
video?: EmbedVideo;
provider?: {
name?: string;
url?: string;
};
author?: EmbedImage & {
name?: string;
};
fields: EmbedField[];
constructor() {
this.fields = [];
}
setTitle = (title: string) => {
this.title = title;
return this;
};
setDescription = (description: string) => {
this.description = description;
return this;
};
setUrl = (url: string) => {
this.url = url;
return this;
};
setTimestamp = (timestamp: string) => {
this.timestamp = timestamp;
return this;
};
setColor = (color: number | string) => {
this.color = typeof color === 'string' ? parseInt(`0x${color.replace('#', '')}`) : color;
return this;
};
setFooter = (footer: EmbedFooter) => {
this.footer = footer;
return this;
};
setImage = (image: EmbedImage) => {
this.image = image;
return this;
};
setThumbnail = (thumbnail: EmbedImage) => {
this.thumbnail = thumbnail;
return this;
};
setVideo = (video: EmbedVideo) => {
this.video = video;
return this;
};
setProvider = (provider: { name?: string; url?: string }) => {
this.provider = provider;
return this;
};
setAuthor = (author: EmbedAuthor) => {
this.author = author;
return this;
};
addField = (field: EmbedField) => {
this.fields.push(field);
return this;
};
addFields = (fields: EmbedField[]) => {
this.fields.push(...fields);
return this;
};
toMessage = () => {
return new Message().addEmbed(this);
};
toJSON = () => {
return {
title: this.title,
description: this.description,
url: this.url,
timestamp: this.timestamp,
color: this.color,
footer: this.footer,
image: this.image,
thumbnail: this.thumbnail,
video: this.video,
provider: this.provider,
author: this.author,
fields: this.fields
};
};
}
+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[]>;
-4
View File
@@ -1,4 +0,0 @@
export * from './common';
export * from './command';
export * from './handler';
export * from './interaction';
-76
View File
@@ -1,76 +0,0 @@
import { Client, Message } from '..';
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
}
export class InteractionContext {
public client: Client;
public interaction: Record<string, any>;
constructor({ client, interaction }: { client: Client; interaction: Record<string, any> }) {
this.client = client;
this.interaction = interaction;
}
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);
}
return this.ack(InteractionResponseType.UpdateMessage, {
...message
});
};
editOriginal = (message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
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 });
};
}
+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);
}
}
+283
View File
@@ -0,0 +1,283 @@
import { exists, isNil } from '../utils';
import { GatewayObject } from './base';
import type { Channel } from './channel';
import { fromDiscord, Trait, type Expectation } from './common';
import type { Embed } from './embed';
import type { Guild } from './guild';
import { User, type DiscordUser } 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 DefaultReaction = {
emoji_id?: string | null; // the id of a guild's custom emoji
emoji_name?: string | null; // the unicode character of the emoji
};
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);
}
}
+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);
}
};
-147
View File
@@ -1,147 +0,0 @@
import * as dotenv from 'dotenv';
dotenv.config();
import { Client, 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.register('ping', (client, context, args) => {
context.reply('Pong!', true);
});
bot.commands.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.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.editOriginal(`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.editOriginal(
new Message().addComponent(
Message.button({
label: 'Click me!',
style: ComponentStyle.Primary,
custom_id: '1'
})
)
);
}, 1000);
});
bot.connect();
+241
View File
@@ -0,0 +1,241 @@
import { ApplicationCommandType, ChannelType, 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,
Intent.DIRECT_MESSAGES,
Intent.DIRECT_MESSAGE_REACTIONS
],
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 { channel } = await context.hydrate(message, [Expectation.Channel]);
const hydrate = await context.hydrator(context, [Expectation.Guild]);
const hasGuild = hydrate(context);
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 ${context.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 in ${channel.type === ChannelType.DM ? 'our DM\'s 😘' : 'a Group 👀'}`, 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();
+103
View File
@@ -0,0 +1,103 @@
// Here we are defining what we want the interface to look like so that there is a goal to work towards
// Things here are not necessarily implemented in the actual code but show what we want it to look like to use the library
import { Expectation, Unicord, UnicordArgumentType } from '../rewrite/index.ts';
const DISCORD_APPLICATION_ID = process.env.APPLICATION_ID!;
const DISCORD_CLIENT_TOKEN = process.env.BOT_TOKEN!;
// Create a new client instance
// Most usage will be via chaining methods off the client instance
const client = new Unicord({
application_id: DISCORD_APPLICATION_ID,
token: DISCORD_CLIENT_TOKEN,
})
.registerChatCommand(
'ping',
async (context, args) => {
let response = 'Pong!';
response += ` Hello, ${args.name}!`;
if (!!args.age && args.age > 0) {
response += ` Happy to hear you are ${args.age} years old.`;
}
await context.reply(response);
},
{
description: 'Say hello',
args: [
{
name: 'name',
description: 'Your name',
// Maybe we can use t.String() here but only certain types are valid for command options
// But in theory we could parse the raw value and attempt convert it to requested type here
type: UnicordArgumentType.String,
required: true,
},
{
name: 'age',
description: 'Your age',
type: UnicordArgumentType.Number,
required: false,
},
],
},
)
.registerChatCommand(
'channel',
async (context) => {
const hydrated = await context.hydrate(context, [Expectation.Channel]);
await context.reply(hydrated.channel.name ?? 'Unnamed Channel');
},
{
description: 'Get info about a channel',
args: [],
},
)
.registerApplicationCommand(
'echo',
async (context, args) => {
await context.reply('Echo! ' + args.text);
},
{
description: 'Echo command',
args: [
{
name: 'text',
description: 'Text to echo',
type: UnicordArgumentType.String,
required: true,
},
],
},
)
.onEvent('READY', async (payload) => {
console.log(`Logged in as ${payload.user.username}`);
});
// Allow use of "child" instances inside parent instance
const etc = new Unicord().registerChatCommand(
'zing',
async (context, args) => {
await context.reply(`Zing! You got served!` + args.fav_amimal ? ` One ${args.fav_amimal} please!` : '');
},
{
args: [
{
name: 'fav_amimal',
description: 'Your favorite animal',
type: UnicordArgumentType.String,
required: false,
choices: [
{ name: 'Dog', value: 'dog' },
{ name: 'Cat', value: 'cat' },
{ name: 'Hamster', value: 'hamster' },
{ name: 'Dolphin', value: 'dolphin' },
],
},
],
},
);
client.use(etc);
client.login();
+30 -9
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
}
} }
+13
View File
@@ -0,0 +1,13 @@
{
"folders": [
{
"name": "unicord",
"path": "."
},
{
"name": "quotes-bot",
"path": "../quotes-bot"
}
],
"settings": {}
}
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'export'
};
export default nextConfig;
+2057
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "unicord-website",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"monaco-editor": "^0.52.2",
"monaco-editor-webpack-plugin": "^7.1.0",
"next": "15.1.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-syntax-highlighter": "^15.4.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+31
View File
@@ -0,0 +1,31 @@
:root {
--background: #252525;
--foreground: #ededed;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
color-scheme: dark;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { Space_Grotesk, Space_Mono } from "next/font/google";
import "./globals.css";
const spaceGrotesk = Space_Grotesk({
variable: "--font-space-grotesk",
subsets: ["latin"]
});
const spaceMono = Space_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400"]
});
export const metadata: Metadata = {
title: 'Unicord',
description: 'Unicord is a simple, type-safe, and modular framework for building Discord bots in TypeScript.',
openGraph: {
title: 'Unicord',
description: 'Unicord is a simple, type-safe, and modular framework for building Discord bots in TypeScript.',
images: [
{
url: 'https://unicord.hat.fish/unicord.png',
width: 1024,
height: 1024,
alt: 'Unicord logo'
}
]
}
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${spaceGrotesk.variable} ${spaceMono.variable}`}>
{children}
</body>
</html>
);
}
+495
View File
@@ -0,0 +1,495 @@
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
font-family: var(--font-space-grotesk);
}
/* page with header needs margin atop */
.pageWithHeader {
margin-top: 4rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.7rem;
/* glassy background */
background: transparent;
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--gray-alpha-200);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
.nav {
display: flex;
gap: 2rem;
margin-right: 2rem;
}
.navLink {
color: var(--color-text);
text-decoration: none;
font-size: 1rem;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.navLink:hover {
opacity: 1;
}
.header img {
width: 40px;
height: 40px;
margin-right: 8px;
}
.logoMark {
display: flex;
align-items: center;
gap: 0px;
font-size: 32px;
font-weight: 600;
}
.logoMarkSpan {
color: #ff0080;
}
.main {
overflow-y: auto;
overflow-x: hidden;
/* padding: 4rem 0em; */
}
@keyframes dotPattern {
0% {
background-position: top right;
}
100% {
background-position: bottom left;
}
}
.hero {
padding: 0 5em 0;
height: calc(100vh - 4rem);
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
position: relative;
overflow: hidden;
gap: 2rem;
border-bottom: 1px solid var(--gray-alpha-200);
}
.hero::before {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-image: radial-gradient(circle, #ff0080 1px, transparent 1px);
background-size: 20px 20px;
animation: dotPattern 30s linear infinite;
opacity: 0.5;
pointer-events: none;
}
.hero::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(to bottom, transparent, var(--gray-alpha-100));
pointer-events: none;
}
.codeExample {
flex: 1;
max-width: 60%;
height: 75%;
max-height: 500px;
}
.code {
font-family: var(--font-mono);
font-size: 1em;
}
.codeBlock {
background: var(--gray-alpha-100);
padding: 1em;
border-radius: 0.5em;
}
.codeBlock pre {
margin: 0;
}
.codeBlock code {
font-size: 1em;
}
.codeBlock code::before {
content: "";
}
.codeBlock code::after {
content: "";
}
.codeBlock code:hover {
background: var(--gray-alpha-200);
}
.codeBlock code:active {
background: var(--gray-alpha-200);
}
.codeBlock code:focus {
background: var(--gray-alpha-200);
}
.codeBlock code:focus-visible {
background: var(--gray-alpha-200);
}
.codeBlock code:focus-within {
background: var(--gray-alpha-200);
}
.pillars {
padding: 6rem 12em;
background: var(--gray-alpha-100);
min-height: 50vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.pillarContainer {
display: flex;
gap: 4rem;
justify-content: center;
width: 100%;
max-width: 1200px;
}
.pillar {
flex: 1;
text-align: center;
padding: 2rem;
background: var(--gray-alpha-100);
border-radius: 12px;
border: 1px solid var(--gray-alpha-200);
transition: transform 0.2s ease-in-out;
}
.pillar:hover {
transform: translateY(-5px);
}
.pillarIcon {
font-size: 3rem;
margin-bottom: 1rem;
}
.pillar h2 {
color: #ff0080;
margin: 0.5rem 0;
font-size: 1.5rem;
}
.pillar p {
color: #fff;
opacity: 0.8;
margin: 0;
font-size: 1rem;
line-height: 1.5;
}
.hydrationSection {
padding: 6rem 12em;
min-height: 50vh;
display: flex;
align-items: space;
justify-content: center;
position: relative;
border-top: 1px solid var(--gray-alpha-200);
}
.hydrationContainer {
display: flex;
gap: 4rem;
justify-content: space-between;
align-items: center;
width: 100%;
}
.hydrationCode {
flex: 2;
width: 100%;
max-width: 780px;
}
.hydrationText {
flex: 1;
/* padding: 2rem; */
}
.hydrationText h2 {
color: #ff0080;
margin: 0 0 1.5rem 0;
font-size: 2rem;
}
.hydrationText p {
color: #fff;
opacity: 0.8;
margin: 1rem 0;
font-size: 1.1rem;
line-height: 1.6;
}
.typeSection {
padding: 6rem 12em;
min-height: 50vh;
display: flex;
align-items: space;
justify-content: center;
position: relative;
background: var(--gray-alpha-100);
border-top: 1px solid var(--gray-alpha-200);
}
.typeContainer {
display: flex;
gap: 4rem;
justify-content: space-between;
align-items: center;
width: 100%;
}
.typeText {
flex: 1;
}
.typeCode {
flex: 2;
width: 100%;
max-width: 780px;
}
.typeText h2 {
color: #ff0080;
margin: 0 0 1.5rem 0;
font-size: 2rem;
}
.typeText p {
color: #fff;
opacity: 0.8;
margin: 1rem 0;
font-size: 1.1rem;
line-height: 1.6;
}
.typeText p code, .hydrationText p code {
font-family: var(--font-mono);
font-size: 0.85em;
background-color: var(--gray-alpha-100);
border-radius: 0.2em;
padding: 0.1em 0.2em;
color: var(--foreground);
border: 1px solid var(--gray-alpha-200);
}
.installSection {
padding: 6rem 12em;
min-height: 30vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-top: 1px solid var(--gray-alpha-200);
}
.installContainer {
width: 100%;
max-width: 600px;
text-align: center;
}
.installContainer h2 {
color: #fff;
margin: 0 0 2rem 0;
font-size: 2rem;
}
.packageManagerTabs {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tabButton {
background: transparent;
border: 1px solid var(--gray-alpha-200);
color: #fff;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.tabButton:hover {
background: var(--gray-alpha-200);
}
.activeTab {
background: var(--gray-alpha-200);
border-color: #ff0080;
}
.installCommandWrapper {
display: flex;
align-items: center;
background: var(--gray-alpha-100);
border: 1px solid var(--gray-alpha-200);
border-radius: 8px;
padding: 1rem;
gap: 1rem;
}
.installCommand {
flex: 1;
display: block;
text-align: left;
font-family: var(--font-mono);
font-size: 0.9rem;
color: #fff;
}
.copyButton {
background: transparent;
border: 1px solid var(--gray-alpha-200);
color: #fff;
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
white-space: nowrap;
}
.copyButton:hover {
background: var(--gray-alpha-200);
}
@media (max-width: 1200px) {
.hero {
padding: 0 2rem;
height: calc(100vh - 4rem);
flex-direction: column;
align-items: center;
justify-content: center;
}
.hero .codeExample, .hero::after {
display: none;
}
.pillars {
padding: 4rem 2rem;
}
.pillarContainer {
flex-direction: column;
gap: 2rem;
}
.pillar {
max-width: 400px;
margin: 0 auto;
}
.hydrationSection {
padding: 4rem 2rem;
}
.hydrationContainer {
flex-direction: column-reverse;
gap: 2rem;
}
.hydrationCode {
width: 100%;
}
.hydrationText {
width: 100%;
padding: 1rem;
}
.typeSection {
padding: 4rem 2rem;
}
.typeContainer {
flex-direction: column;
gap: 2rem;
}
.typeCode {
width: 100%;
}
.typeText {
width: 100%;
padding: 1rem;
}
.installSection {
padding: 4rem 2rem;
}
}
@media (max-width: 600px) {
.packageManagerTabs {
flex-wrap: wrap;
}
.tabButton {
flex: 1;
min-width: 80px;
}
.nav {
gap: 1rem;
margin-right: 1rem;
}
.navLink {
font-size: 0.9rem;
}
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import styles from "./page.module.css";
import dynamic from 'next/dynamic';
const CodeExample = dynamic(
() => import('../components/CodeExample')
);
import { simpleExample, hydrationExample, typeCoercionExample } from '../components/CodeExample';
export default function Home() {
const [activeTab, setActiveTab] = useState('npm');
const [windowWidth, setWindowWidth] = useState(1200);
useEffect(() => {
setWindowWidth(window.innerWidth);
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<div className={[styles.page, styles.pageWithHeader].join(" ")}>
<header className={styles.header}>
<div className={styles.logoMark}>
<img src="/unicord.png" alt="Unicord logo"></img>
<div><span className={styles.logoMarkSpan}>uni</span>cord</div>
</div>
<nav className={styles.nav}>
{/* <a href="/docs" className={styles.navLink}>Docs</a> */}
<a href="https://github.com/jaronpate/unicord" className={styles.navLink}>Source</a>
<a href="https://discord.gg/6kFfqa5bde" target='_blank' className={styles.navLink}>Discord</a>
</nav>
</header>
<main className={styles.main}>
<div className={styles.hero}>
<div>
<h1 className={styles.title}>
Welcome to <span className={styles.logoMarkSpan}>uni</span>cord
</h1>
<p className={styles.description}>
Build good bots fast.
</p>
</div>
<div className={styles.codeExample}>
<CodeExample code={simpleExample} noScroll={false} />
</div>
</div>
<div className={styles.pillars}>
<div className={styles.pillarContainer}>
<div className={styles.pillar}>
<div className={styles.pillarIcon}>⚡</div>
<h2>Simple</h2>
<p>Build Discord bots with minimal boilerplate and intuitive APIs</p>
</div>
<div className={styles.pillar}>
<div className={styles.pillarIcon}>🔧</div>
<h2>Maintainable</h2>
<p>Type-safe, modular architecture that scales with your project</p>
</div>
<div className={styles.pillar}>
<div className={styles.pillarIcon}>📈</div>
<h2>Scalable</h2>
<p>Efficient caching and event handling for bots of any size</p>
</div>
</div>
</div>
<div className={styles.hydrationSection}>
<div className={styles.hydrationContainer}>
<div className={styles.hydrationCode}>
<CodeExample code={hydrationExample} height={260} noScroll={windowWidth < 1200 ? false : true} />
</div>
<div className={styles.hydrationText}>
<h2>Smart Hydration</h2>
<p>
Unicord's hydration system automatically manages Discord object lifecycles.
The <code>context.hydrate</code> method intelligently fetches and caches Discord objects
like messages, users, and guilds, ensuring your bot stays efficient while
keeping your code clean and predictable.
</p>
</div>
</div>
</div>
<div className={styles.typeSection}>
<div className={styles.typeContainer}>
<div className={styles.typeText}>
<h2>Type Coercion</h2>
<p>
Unicord automatically handles type coercion for command arguments,
ensuring you always get the correct types in your handlers. Define your
command structure once, and get full type safety and autocompletion
throughout your codebase.
</p>
<p>
In this example, the <code>user</code> argument is automatically typed
as a <code>User</code> object, providing access to all user properties
with full TypeScript support.
</p>
</div>
<div className={styles.typeCode}>
<CodeExample code={typeCoercionExample} height={370} noScroll={windowWidth < 1200 ? false : true} />
</div>
</div>
</div>
<div className={styles.installSection}>
<div className={styles.installContainer}>
<h2>Install <span className={styles.logoMarkSpan}>uni</span>cord</h2>
<div className={styles.packageManagerTabs}>
<button
className={`${styles.tabButton} ${activeTab === 'npm' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('npm')}
>
npm
</button>
<button
className={`${styles.tabButton} ${activeTab === 'yarn' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('yarn')}
>
yarn
</button>
<button
className={`${styles.tabButton} ${activeTab === 'pnpm' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('pnpm')}
>
pnpm
</button>
<button
className={`${styles.tabButton} ${activeTab === 'bun' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('bun')}
>
bun
</button>
</div>
<div className={styles.installCommandWrapper}>
<code className={styles.installCommand}>
{activeTab === 'npm' && 'npm install unicord'}
{activeTab === 'yarn' && 'yarn add unicord'}
{activeTab === 'pnpm' && 'pnpm add unicord'}
{activeTab === 'bun' && 'bun add unicord'}
</code>
<button
className={styles.copyButton}
onClick={() => {
const commands = {
npm: 'npm install unicord',
yarn: 'yarn add unicord',
pnpm: 'pnpm add unicord',
bun: 'bun add unicord'
};
navigator.clipboard.writeText(commands[activeTab as keyof typeof commands]);
}}
>
Copy
</button>
</div>
</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,13 @@
.editorWrapper {
position: relative;
width: 100%;
height: 100%;
}
.editorContainer {
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
border: 1px solid #2d2d2d;
}
+154
View File
@@ -0,0 +1,154 @@
'use client';
import Editor, { Monaco } from "@monaco-editor/react";
import styles from './CodeExample.module.css';
import { useEffect, useState } from "react";
type CodeExampleProps = {
code: string;
height?: string | number;
noScroll?: boolean;
}
export default function CodeExample({ code, height = "100%", noScroll = false }: CodeExampleProps) {
// const [unicordLib, setUnicordLib] = useState("");
// useEffect(() => {
// setUnicordLib(`
// declare module 'unicord' {
// export enum Intent {
// GUILDS = 1 << 0,
// }
// }
// `);
// }, []);
// const handleEditorDidMount = (monaco: Monaco) => {
// monaco.languages.typescript.typescriptDefaults.addExtraLib(unicordLib);
// monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
// noSemanticValidation: false,
// noSyntaxValidation: false,
// noSuggestionDiagnostics: false,
// });
// monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
// target: monaco.languages.typescript.ScriptTarget.ESNext,
// module: monaco.languages.typescript.ModuleKind.ESNext,
// allowJs: true,
// checkJs: true,
// allowNonTsExtensions: true,
// strict: true,
// noEmit: true,
// lib: [
// "ESNext",
// "DOM",
// "DOM.Iterable",
// "ESNext.AsyncIterable",
// "ESNext.BigInt"
// ],
// types: ["node", "unicord"],
// baseUrl: "./",
// paths: {
// "index": ["./src/index.ts"],
// "unicord": ["./node_modules/unicord/dist/index.d.ts"]
// }
// });
// };
// useEffect(() => {
// fetch("https://www.unpkg.com/unicord@0.0.4/out/types/common.d.ts")
// .then((response) => response.text())
// .then((text) => {
// setUnicordLib(text);
// console.log("Fetched extra lib:", text);
// })
// .catch((error) => {
// console.error("Error fetching extra lib:", error);
// });
// }, []);
return (
<div className={styles.editorWrapper}>
<div className={styles.editorContainer}>
<Editor
height={height}
defaultLanguage="typescript"
defaultValue={code}
theme="vs-dark"
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
roundedSelection: false,
padding: { top: 16, bottom: 16 },
automaticLayout: true,
scrollbar: {
alwaysConsumeMouseWheel: !noScroll,
}
}}
// beforeMount={handleEditorDidMount}
/>
</div>
</div>
);
}
// Export the example code snippets for reuse
export const simpleExample = `\
import { Client, type Context, Intent } from 'unicord';
// Initialize and configure
const client = new Client({
token: '<token>',
application_id: '<application_id>',
intents: [
Intent.GUILDS,
Intent.GUILD_MESSAGES,
Intent.GUILD_MESSAGE_REACTIONS,
Intent.MESSAGE_CONTENT
],
prefix: '??'
});
// Register a command
client.chatCommands.register('ping', (context: Context, args) => {
context.reply('Pong!', true);
});
// Connect
client.connect();`;
export const hydrationExample = `\
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 = Date.now() - message.timestamp.getTime();
// Edit the message to include the time
await context.editMessage(response, \`Pong! Latency: \${time}ms\`);
});`;
export const typeCoercionExample = `\
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);
}
});`;
+27
View File
@@ -0,0 +1,27 @@
:root {
--color-primary: #ff0080;
--color-background: #000;
--color-text: #fff;
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 2rem;
--spacing-lg: 4rem;
--spacing-xl: 6rem;
--border-radius-sm: 6px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--font-size-sm: 0.9rem;
--font-size-md: 1.1rem;
--font-size-lg: 1.5rem;
--font-size-xl: 2rem;
--section-padding-desktop: 6rem 12em;
--section-padding-mobile: 4rem 2rem;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
-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==