78 Commits

Author SHA1 Message Date
jaron f8318e6399 remove extra console.logs 2026-05-17 22:53:16 -05:00
jaron ce45eacb3b refactor: decouple system events from command dispatch and add explicit system event handlers and improve gateway handling 2026-05-17 22:27:25 -05:00
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
61 changed files with 6756 additions and 278 deletions
@@ -1,4 +1,4 @@
name: CI
name: CI (template)
on: workflow_dispatch
jobs:
build:
+11 -10
View File
@@ -4,14 +4,15 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: 14
registry-url: https://registry.npmjs.org/
- run: yarn
- run: yarn build
# - run: yarn test
- run: yarn publish --access public
# Pull repo
- uses: actions/checkout@v2
# Install bun
- uses: oven-sh/setup-bun@v2
# Run tests
# TODO: Add tests
# Build
- run: bun build
# Publish
- run: bun publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/_authToken=${{ secrets.NPM_TOKEN }}
+1
View File
@@ -173,3 +173,4 @@ dist
# Finder (MacOS) folder config
.DS_Store
.aider*
+1
View File
@@ -0,0 +1 @@
src/services/processor.ts
+44 -1
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="./assets/unicord.png" alt="unicord" width="200"/>
<img src="https://raw.githubusercontent.com/jaronpate/unicord/master/assets/unicord.png" width="150" height="150">
<h2>unicord</h2>
<p><em>Build good bots fast</em></p>
</div>
@@ -181,3 +181,46 @@ 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.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
View File
Binary file not shown.
+10 -3
View File
@@ -1,7 +1,7 @@
{
"name": "unicord",
"description": "A simple discord library primarily for TypeScript. REST like and easy to use.",
"version": "0.0.1",
"description": "A simple discord library primarily for TypeScript. Web server like and easy to use.",
"version": "0.0.4",
"module": "src/index.ts",
"type": "module",
"main": "out/index.js",
@@ -9,6 +9,7 @@
"files": [
"out"
],
"repository": "github:jaronpate/unicord",
"scripts": {
"test": "bun run testing/demo.test.ts",
"test:inspect": "bun --inspect testing/demo.test.ts",
@@ -17,7 +18,7 @@
"build:packages": "bun run build:types && bun run build:core -- --packages external",
"build:nocheck": "bun run build:types:nocheck && bun run build:core",
"build:core": "bun build src/index.ts --outdir out --drop=console --drop=debugger",
"build: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",
@@ -34,5 +35,11 @@
},
"dependencies": {
"ws": "^8.18.0"
},
"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.onSystemEvent(event, async (payload) => {
this.cache.set(payload.id, factory[Trait.fromDiscord](payload));
});
}
}
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, []);
}
}
+230
View File
@@ -0,0 +1,230 @@
import type {
ArgumentTypeFromOptions,
Unicord,
UnicordArgumentDefinition,
UnicordCommandHandler,
UnicordCommandOptions,
} from '.';
import { UnicordCommandContext } from './context';
import type { UnicordEventProcessor } from './eventProcessor';
import { Embed } from './types';
import { UnicordArgumentType, UnicordEventType } from './types/common';
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']>> {
const validated: Record<string, any> = {};
if (args.length > definitions.length) {
throw new Error(`Too many arguments. Expected ${definitions.length}, received ${args.length}`);
}
for (const [index, def] of definitions.entries()) {
const arg = args[index];
if (arg === undefined || arg === null || arg === '') {
if (def.required) {
throw new Error(`Missing required argument: ${def.name}`);
}
validated[def.name] = undefined;
continue;
}
validated[def.name] = this.coerceArgument(def, arg);
}
return Promise.resolve(validated as ArgumentTypeFromOptions<Options['args']>);
}
private coerceArgument(def: UnicordArgumentDefinition, value: any) {
let coerced = value;
if (def.type === UnicordArgumentType.Integer) {
coerced = typeof value === 'number' ? value : parseInt(value, 10);
if (!Number.isInteger(coerced)) {
throw new Error(`Argument ${def.name} must be an integer`);
}
} else if (def.type === UnicordArgumentType.Number) {
coerced = typeof value === 'number' ? value : Number(value);
if (Number.isNaN(coerced)) {
throw new Error(`Argument ${def.name} must be a number`);
}
} else if (def.type === UnicordArgumentType.Boolean) {
if (typeof value === 'boolean') {
coerced = value;
} else if (value === 'true') {
coerced = true;
} else if (value === 'false') {
coerced = false;
} else {
throw new Error(`Argument ${def.name} must be a boolean`);
}
}
if (def.choices && !def.choices.some((choice) => choice.value === coerced)) {
throw new Error(`Invalid choice for argument ${def.name}: ${value}`);
}
return coerced;
}
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;
}
register<const Options extends UnicordCommandOptions>(
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
event: string,
handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options,
) {
if (type === UnicordEventType.ApplicationCommands) {
const commandNameRegex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
if (!commandNameRegex.test(event)) {
throw new Error(
`${event} is not a valid application command name (1-32 characters, only letters, numbers, hyphens, and underscores)`,
);
}
if (!options.description || options.description.length === 0) {
throw new Error('Application commands require a description');
}
if (options.args && options.args.length > 25) {
throw new Error('Application commands cannot have more than 25 arguments');
}
for (const arg of options.args) {
if (arg.choices && arg.choices.length > 25) {
throw new Error(`Application command argument ${arg.name} cannot have more than 25 choices`);
}
}
}
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);
}
+103
View File
@@ -0,0 +1,103 @@
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);
}
}
+82
View File
@@ -0,0 +1,82 @@
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 put<T = any>(path: string, data: any): Promise<T> {
return this.request('PUT', 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);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { EventEmitter } from 'node:events';
import type { Unicord } from '.';
import type { UnicordCommandContext } from './context';
import type { UnicordCommandOptions, UnicordEventHandler, UnicordHandler } from './types/common';
import { UnicordEventType } 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);
}
emitCommand(
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
event: string,
context: UnicordCommandContext,
args: any[],
): boolean {
return super.emit(`${type}:${event}`, context, args);
}
emitSystem(event: string, payload: Parameters<UnicordEventHandler>[0]): boolean {
return super.emit(`${UnicordEventType.SystemEvent}:${event}`, payload);
}
}
+349
View File
@@ -0,0 +1,349 @@
import { WebSocket, type Data } from 'ws';
import { Unicord } from '.';
import { UnicordCommandContext } from './context';
import type { DiscordMessage, Payload, UnicordEventContext } from './types';
import { fromDiscord, Message, 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: ReturnType<typeof setTimeout> | null = null;
private sequence: number | null = null;
private awaiting_heartbeat_ack = false;
private session_id: string | null = null;
private resume_gateway_url: string | null = null;
private should_resume = false;
private reconnecting = false;
private reconnect_attempts = 0;
// Internal state
user: User | null = null;
constructor(private readonly self: Unicord) {}
private onOpen = () => {
log('Connected to gateway');
this.reconnecting = false;
this.reconnect_attempts = 0;
};
private onClose = (code: number, reason: Buffer) => {
log('Gateway closed:', code, reason.toString());
this.clearHeartbeat();
if (!this.reconnecting) {
this.reconnect(this.canResumeFromCloseCode(code));
}
};
private onError = (error: Error) => {
log('Gateway error:', error);
if (!this.reconnecting) {
this.reconnect(true);
}
};
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)) {
if (exists(data.s)) {
this.sequence = data.s;
}
// 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.sendHeartbeat();
} else if (code === 7) {
// Reconnect requested
this.reconnect(true);
} else if (code === 9) {
// Invalid session. Discord sends a boolean indicating whether resume is allowed.
const canResume = data.d === true;
if (!canResume) {
this.session_id = null;
this.sequence = null;
this.should_resume = false;
}
this.reconnect(canResume);
} 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 with initial jitter
this.startHeartbeat();
// Resume or identify with the gateway
if (this.should_resume && exists(this.session_id)) {
this.resume();
} else {
this.identify();
}
} else if (code === 11) {
// Heartbeat ACK
this.awaiting_heartbeat_ack = false;
}
}
};
private startHeartbeat() {
if (!exists(this.heartbeat_interval)) {
throw new Error('Heartbeat interval not set');
}
this.clearHeartbeat();
const firstHeartbeatDelay = this.heartbeat_interval * Math.random();
this.heartbeat_timeout = setTimeout(() => {
this.heartbeat();
}, firstHeartbeatDelay);
}
private heartbeat() {
if (!exists(this.heartbeat_interval)) {
throw new Error('Heartbeat interval not set');
}
if (this.awaiting_heartbeat_ack) {
log('Missed heartbeat ACK; reconnecting gateway');
this.reconnect(true);
return;
}
this.sendHeartbeat();
this.heartbeat_timeout = setTimeout(() => {
this.heartbeat();
}, this.heartbeat_interval);
}
private sendHeartbeat() {
this.awaiting_heartbeat_ack = true;
this.send({ op: 1, d: this.sequence });
}
private clearHeartbeat() {
if (exists(this.heartbeat_timeout)) {
clearTimeout(this.heartbeat_timeout);
this.heartbeat_timeout = null;
}
this.awaiting_heartbeat_ack = false;
}
protected send = (payload: Partial<Payload>) => {
if (isNil(this.socket) || this.socket.readyState !== WebSocket.OPEN) {
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 identify = () => {
this.should_resume = false;
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 resume = () => {
this.send({
op: 6,
d: {
token: this.self.config.token,
session_id: this.session_id,
seq: this.sequence,
},
});
};
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);
this.session_id = event_data.session_id;
this.resume_gateway_url = event_data.resume_gateway_url;
this.should_resume = true;
} else if (event_name === 'RESUMED') {
this.should_resume = true;
} else if (event_name === 'INTERACTION_CREATE') {
// TODO: Application commands/interactions need a redesigned context/hydration model before implementation.
} 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.emitSystem(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;
}
if (typeof payload.d.content !== 'string') {
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.emitCommand(UnicordEventType.ChatCommands, command, context, args);
}
};
private reconnect(resume: boolean) {
if (this.reconnecting) {
return;
}
this.reconnecting = true;
this.should_resume = resume && exists(this.session_id);
this.clearHeartbeat();
if (exists(this.socket)) {
this.socket.removeAllListeners();
this.socket.terminate();
this.socket = null;
}
const delay = this.getReconnectDelay();
log(`Reconnecting gateway in ${delay}ms${this.should_resume ? ' with resume' : ''}`);
setTimeout(() => {
const gatewayUrl =
this.should_resume && exists(this.resume_gateway_url) ? this.resume_gateway_url : undefined;
this.connect(gatewayUrl).catch((error) => {
log('Gateway reconnect failed:', error);
this.reconnecting = false;
this.reconnect(this.should_resume);
});
}, delay);
}
private getReconnectDelay() {
const baseDelay = 1_000;
const maxDelay = 60_000;
const delay = Math.min(maxDelay, baseDelay * 2 ** this.reconnect_attempts);
this.reconnect_attempts += 1;
return delay + Math.floor(Math.random() * 1_000);
}
private canResumeFromCloseCode(code: number) {
// Discord documents these close codes as non-resumable.
return ![4004, 4010, 4011, 4012, 4013, 4014].includes(code);
}
async connect(gatewayUrl?: string) {
// Fetch the Discord gateway URL when no explicit URL was provided.
const response = gatewayUrl
? { url: gatewayUrl }
: await this.self.discordAPI.request<{ url: string }>('GET', '/gateway/bot');
// Append the query parameters to the URL
const url = new URL(response.url);
url.searchParams.set('v', '10');
url.searchParams.set('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);
this.socket.on('close', this.onClose);
this.socket.on('error', this.onError);
}
}
+61
View File
@@ -0,0 +1,61 @@
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 UnicordHydrateable = 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>;
};
// 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 UnicordHydrateable, 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<T, [Expectation.Message]>;
// 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<T, [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<T, [Expectation.Channel]>;
context.channel = await self.caches.channels.get(data.channel_id);
} else if (expectation === Expectation.Message && exists(data.message)) {
let context = data as unknown as UnicordHydratedObject<T, [Expectation.Message]>;
context.message = data.message;
} else {
throw new Error(`Invalid expectation requested for Context: ${expectation}`);
}
}
} else {
throw new Error(`Hydration for the provided type is not implemented.`);
}
// Ensure the result is properly typed with both old and new hydrated properties
return data as T & UnicordHydratedObject<T, K>;
}
+149
View File
@@ -0,0 +1,149 @@
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);
}
// This is kinda dumb maybe just remove
// use(client: Unicord): this {
// this.commandManager.merge(client.commandManager);
// // TODO: inherit event listeners too
// return this;
// }
emitSystem(event: string, payload: UnicordEventContext) {
return this.eventProcessor.emitSystem(event, payload);
}
emitCommand(
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
event: string,
context: UnicordCommandContext,
args: any[],
) {
return this.eventProcessor.emitCommand(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;
}
onSystemEvent(event: string, handler: UnicordEventHandler): void {
this.__onEvent(UnicordEventType.SystemEvent, event, handler);
}
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;
}
+406
View File
@@ -0,0 +1,406 @@
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;
export type UnicordEventHandler = (payload: EventPayload) => 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> =>
value !== null && 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);
}
};
+9 -2
View File
@@ -1,10 +1,11 @@
// Base classes
export { Client } from './services/client';
export { Client, Client as unicord } from './services/client';
export { Intent, Trait } from './types/common';
// Consumable types
export type { ClientConfig } from './types/common';
export type { Context } from './types/context';
export { Expectation } from './types/common';
export { ChannelType, ChannelFlags, VideoQualityMode, SortOrderType, ForumLayoutType } from './types/channel';
// Consumable util classes
export { Guild } from './types/guild';
export { Role } from './types/role';
@@ -14,5 +15,11 @@ export { Message, type MessagePayload, ComponentStyle, ComponentType } from './t
export { Embed } from './types/embed';
export { Emoji } from './types/emoji';
// Other
export { createCommandHandler, type InteractionData, type InteractionCommpoentData, type InteractionCommandData, type InteractionHandlerFunction } from './types/handler';
export {
createCommandHandler,
type InteractionData,
type InteractionCommpoentData,
type InteractionCommandData,
type InteractionHandlerFunction,
} from './types/handler';
export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand';
+6 -7
View File
@@ -1,5 +1,5 @@
import { exists, log } from "../utils";
import type { Client } from "./client";
import { exists, log } from '../utils';
import type { Client } from './client';
export class API {
private readonly base_url: string = 'https://discord.com/api/v10';
@@ -9,9 +9,9 @@ export class API {
async request<T = any>(method: string, path: string, data?: any): Promise<T> {
const url = `${this.base_url}${path.startsWith('/') ? '' : '/'}${path}`;
const headers = {
Authorization: `Bot ${this.client.token}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
Authorization: `Bot ${this.client.config.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
const response = await fetch(url, {
@@ -28,7 +28,6 @@ export class API {
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));
@@ -49,7 +48,7 @@ export class API {
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));
await new Promise((resolve) => setTimeout(resolve, parseInt(retryAfter) * 1000));
} else {
// TODO: Handle this case better
// Maybe wait some arbitrary time??
+7 -2
View File
@@ -3,8 +3,13 @@ import type { Context } from '../types/context';
const EventBus = new EventEmitter() as Emitter;
export type Emitter = EventEmitter & {
emit: (event_name: string, context: Context | null, ...args: any[]) => boolean
export 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 };
+106 -13
View File
@@ -1,15 +1,22 @@
import { fromDiscord } from "../../types/common";
import type { Context } from "../../types/context";
import type { API } from "../api";
import type { Processor } from "../processor";
import { fromDiscord } from '../../types/common';
import type { Context } from '../../types/context';
import type { API } from '../api';
import type { Processor } from '../processor';
export class ObjectCache<T, K extends T> {
cache = new Map<string, K>();
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[]) {
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));
@@ -48,10 +55,8 @@ export class ObjectCache<T, K extends T> {
});
// Save the request
this.requests.set(object_id, req);
// Remove the request from the cache if it succeeds
req.then(() => this.requests.delete(object_id));
// Remove the request from the cache if it fails
req.catch(() => this.requests.delete(object_id));
// Remove the request from the cache when it finishes
req.finally(() => this.requests.delete(object_id));
// Return the request
return req;
}
@@ -64,8 +69,96 @@ export class ObjectCache<T, K extends T> {
return this.fetch(object_id);
}
}
}
list(): Map<string, Promise<T> | T> {
return this.cache;
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']);
}
}
+2 -3
View File
@@ -1,14 +1,13 @@
import { fromDiscord } from "../../types/common";
import { Message, type MessagePayload } from "../../types/message";
import type { API } from "../api";
import type { Client } from "../client";
import type { Processor } from "../processor";
import { ObjectCache } from "./cache";
export class Messages extends ObjectCache<Message, MessagePayload> {
constructor (protected client: Client, api: API, processor: Processor) {
super(api, processor, 'message', Message, ['MESSAGE_CREATE', 'MESSAGE_UPDATE']);
constructor (api: API, processor: Processor) {
super(api, processor, 'messages', Message, ['MESSAGE_CREATE', 'MESSAGE_UPDATE']);
}
async fetch(channel_id: string, message_id: string): Promise<MessagePayload> {
+73 -36
View File
@@ -1,52 +1,53 @@
import { API } from './api';
import { Gateway } from './connectors/gateway';
import { Processor } from './processor';
import { Expectation, HandlerType, Intent, type ClientConfig } from '../types/common';
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 { type DiscordMessage, Message } from '../types/message';
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 { EventBus, type Emitter } from './bus';
import { Processor } from './processor';
export class Client {
readonly token: string;
readonly application_id: string;
readonly intents: Intent[] = [Intent.DEFAULT];
prefix: string;
readonly config: Readonly<Required<ClientConfig>>;
// Internal services
protected bus: Emitter;
protected api: API;
protected processor: Processor;
protected gateway: Gateway;
guilds: Guilds;
users: Users;
messages: Messages;
chatCommands: Processor[HandlerType.ChatCommands];
applicationCommands: Processor[HandlerType.ApplicationCommands];
interactions: Processor[HandlerType.Interactions];
// 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.token = config.token;
this.application_id = config.application_id;
this.intents = config.intents ?? this.intents;
this.prefix = config.prefix ?? '!';
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);
this.guilds = new Guilds(this.api, this.processor);
this.users = new Users(this.api, this.processor);
this.messages = new Messages(this, this.api, this.processor);
// Save references to command and event handlers
this.chatCommands = this.processor[HandlerType.ChatCommands];
this.applicationCommands = this.processor[HandlerType.ApplicationCommands];
this.interactions = this.processor[HandlerType.Interactions];
// 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) {
@@ -79,6 +80,22 @@ export class Client {
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.
*
@@ -92,9 +109,12 @@ export class Client {
*
* @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) => {
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.
@@ -107,17 +127,34 @@ export class Client {
*
* @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) => {
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()));
}
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),
@@ -131,11 +168,11 @@ export class Client {
// has: (name: string) => this.processor.application_commands.has(name)
// }
on = (event: string, handler: (...args: any[]) => void) => this.bus.on(event, handler);
on = (event: DiscordAPIEvent, handler: (...args: any[]) => void) => this.bus.on(event, handler);
once = (event: string, handler: (...args: any[]) => void) => this.bus.once(event, handler);
once = (event: DiscordAPIEvent, handler: (...args: any[]) => void) => this.bus.once(event, handler);
off = (event: string, handler: (...args: any[]) => void) => this.bus.off(event, handler);
off = (event: DiscordAPIEvent, handler: (...args: any[]) => void) => this.bus.off(event, handler);
connect = () => this.gateway.connect();
}
+7 -7
View File
@@ -205,15 +205,15 @@ export class Gateway {
// Generate context
const context = new Context(this.client, this.api, { message });
// Dispatch event for the command
this.bus.emit(`command:${command}`, context, args);
// 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
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}`);
// }
}
}
};
+24 -13
View File
@@ -1,15 +1,16 @@
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;
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> :
T extends Context ? HydratedContext<K> : never;
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.
@@ -26,12 +27,12 @@ export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> =
*
* @throws Will throw an error if an expectation cannot be resolved.
*/
export async function hydrate<T extends Hydrateable | Hydrated<any, any>, K extends Array<Expectation>>(
export async function hydrate<T extends Hydrateable, K extends Array<Expectation>>(
data: T,
expectations: K,
client: Client,
api: API
): Promise<T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>> {
): 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>;
@@ -40,10 +41,13 @@ export async function hydrate<T extends Hydrateable | Hydrated<any, any>, K exte
for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_id)) {
let context = data as unknown as HydratedContext<[Expectation.Message, ...Expectation[]]>;
context.message = await client.messages.get(data.channel_id, data.message_id);
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.guilds.get(data.guild_id);
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}`);
}
@@ -52,17 +56,24 @@ export async function hydrate<T extends Hydrateable | Hydrated<any, any>, K exte
for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_reference?.id)) {
let message = data as unknown as HydratedMessage<[Expectation.Message, ...Expectation[]]>;
message.reference = await client.messages.get(data.message_reference.channel_id, data.message_reference.id);
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.guilds.get(data.guild_id);
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}`);
}
}
}
return data as unknown as (T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>);
// 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>;
}
/**
@@ -78,12 +89,12 @@ export async function hydrate<T extends Hydrateable | Hydrated<any, any>, K exte
*
* @returns A promise that resolves to a type guard function. The type guard function returns `true` if the data object was successfully hydrated, otherwise `false`.
*/
export async function hydrator<T extends Hydrateable | Hydrated<any, any>, K extends Array<Expectation>>(
export async function hydrator<T extends Hydrateable, K extends Array<Expectation>>(
data: T,
expectations: K,
client: Client,
api: API
): Promise<(data: unknown) => data is T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>> {
): Promise<(data: unknown) => data is Hydrated<T, K>> {
let hydrated = true;
try {
@@ -95,7 +106,7 @@ export async function hydrator<T extends Hydrateable | Hydrated<any, any>, K ext
}
// BUG: Resolves to never after hydrations on an already hydrated object
return function (data: unknown): data is T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K> {
return function (data: unknown): data is Hydrated<T, K> {
return hydrated;
}
}
+16 -13
View File
@@ -1,10 +1,10 @@
import type { Client } from "./client";
import type { Context } from "../types/context";
import { HandlerType, Trait, type EventPayload } from "../types/common";
import { CommandHandler, type Handler, type ArgsFromOptions, OptionConstructorMap, type InteractionData } from "../types/handler";
import type { API } from "./api";
import { ApplicationCommandOptionType, ApplicationCommandType, type ApplicationCommandOption } from "../types/applicationCommand";
import { 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 = {
@@ -14,7 +14,7 @@ export class Processor {
[HandlerType.Interactions]: new Map<string, Handler[]>()
};
constructor(private client: Client, private api: API, private bus: Emitter) {};
constructor(private client: Client, private api: API, private bus: Emitter) { };
public register(type: HandlerType, event: string | string[], handler: Handler) {
// Normalize events into an array
@@ -64,6 +64,9 @@ export class Processor {
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));
@@ -99,11 +102,11 @@ export class Processor {
if (!userId) {
throw new Error(`Invalid user for ${def.name}: expected a user mention or snowflake id, got ${arg}`);
}
validated[def.name] = await this.client.users.get(userId);
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.users.get(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}`);
}
@@ -135,7 +138,7 @@ export class Processor {
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
has: (event: string) => this.handlers[HandlerType.ChatCommands].has(event) && this.handlers[HandlerType.ChatCommands].get(event)?.length! > 0
};
[HandlerType.ApplicationCommands] = {
@@ -151,7 +154,7 @@ export class Processor {
// Ensure the command name is valid
// @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming
// TODO: can this sus bind be removed? Is the only way to kill the arrow function?
// 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)) {
@@ -163,21 +166,21 @@ export class Processor {
const command: Record<string, any> = handler.toJSON();
command.name = n;
command.type = type;
this.api.post(`/applications/${this.client.application_id}/commands`, command);
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,
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
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[]> => {
+267 -28
View File
@@ -1,39 +1,278 @@
export class Channel {
version: number;
type: number;
position: number;
permission_overwrites: any[];
name: string;
id: string;
flags: number;
topic?: string | null;
rate_limit_per_user?: number;
parent_id?: string;
last_message_id?: string | null;
user_limit?: number;
rtc_region?: string | null;
bitrate?: number;
last_pin_timestamp?: string;
import { GatewayObject } from "./base";
import { Trait } from "./common";
import { User, type DiscordUser } from "./user";
constructor(data: any) {
this.version = data.version;
this.type = data.type;
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.id = data.id;
this.flags = data.flags;
this.topic = data.topic;
this.rate_limit_per_user = data.rate_limit_per_user;
this.parent_id = data.parent_id;
this.nsfw = data.nsfw;
this.last_message_id = data.last_message_id;
this.user_limit = data.user_limit;
this.rtc_region = data.rtc_region;
this.bitrate = data.bitrate;
this.last_pin_timestamp = data.last_pin_timestamp;
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;
}
static fromAPIResponse(data: any): Channel {
return new Channel(data);
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);
}
}
+181 -100
View File
@@ -3,7 +3,7 @@ export type ClientConfig = {
application_id: string;
prefix?: string;
intents?: Intent[];
}
};
export enum Expectation {
Guild = 'guild',
@@ -16,24 +16,26 @@ export enum HandlerType {
Events = 'events',
ChatCommands = 'chat_commands',
ApplicationCommands = 'application_commands',
Interactions = 'interactions'
Interactions = 'interactions',
}
export type Expects<T extends Array<Expectation>, K extends Expectation> = Extract<K, T[number]> extends never ? false : true;
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 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: typeof clone;
fromDiscord: typeof fromDiscord;
execute: typeof execute;
} = {
clone,
fromDiscord,
execute
}
execute,
};
export type EventPayload = any;
@@ -58,7 +60,7 @@ export type Payload = {
export enum InteractionResponseType {
Pong = 1,
Message = 4,
DeferredMessage = 5
DeferredMessage = 5,
}
export enum Intent {
@@ -79,110 +81,189 @@ export enum Intent {
DIRECT_MESSAGE_TYPING = 1 << 14,
MESSAGE_CONTENT = 1 << 15,
DEFAULT = 513,
ALL = 32767
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.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_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_BANS]: [['GUILD_BAN_ADD', 'GUILD_BAN_REMOVE'], 2],
[Intent.GUILD_EMOJIS_AND_STICKERS]: [[
"GUILD_EMOJIS_UPDATE",
"GUILD_STICKERS_UPDATE",
], 3],
[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_INTEGRATIONS]: [
['GUILD_INTEGRATIONS_UPDATE', 'INTEGRATION_CREATE', 'INTEGRATION_UPDATE', 'INTEGRATION_DELETE'],
4,
],
[Intent.GUILD_WEBHOOKS]: [[
"WEBHOOKS_UPDATE"
], 5],
[Intent.GUILD_WEBHOOKS]: [['WEBHOOKS_UPDATE'], 5],
[Intent.GUILD_INVITES]: [[
"INVITE_CREATE",
"INVITE_DELETE",
], 6],
[Intent.GUILD_INVITES]: [['INVITE_CREATE', 'INVITE_DELETE'], 6],
[Intent.GUILD_VOICE_STATES]: [[
"VOICE_STATE_UPDATE"
], 7],
[Intent.GUILD_VOICE_STATES]: [['VOICE_STATE_UPDATE'], 7],
[Intent.GUILD_PRESENCES]: [[
"PRESENCE_UPDATE"
], 8],
[Intent.GUILD_PRESENCES]: [['PRESENCE_UPDATE'], 8],
[Intent.GUILD_MESSAGES]: [[
"MESSAGE_CREATE",
"MESSAGE_UPDATE",
"MESSAGE_DELETE",
"MESSAGE_DELETE_BULK",
], 9],
[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_REACTIONS]: [
[
'MESSAGE_REACTION_ADD',
'MESSAGE_REACTION_REMOVE',
'MESSAGE_REACTION_REMOVE_ALL',
'MESSAGE_REACTION_REMOVE_EMOJI',
],
10,
],
[Intent.GUILD_MESSAGE_TYPING]: [[
"TYPING_START"
], 11],
[Intent.GUILD_MESSAGE_TYPING]: [['TYPING_START'], 11],
[Intent.DIRECT_MESSAGES]: [[
"MESSAGE_CREATE",
"MESSAGE_UPDATE",
"MESSAGE_DELETE",
"CHANNEL_PINS_UPDATE",
], 12],
[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_REACTIONS]: [
[
'MESSAGE_REACTION_ADD',
'MESSAGE_REACTION_REMOVE',
'MESSAGE_REACTION_REMOVE_ALL',
'MESSAGE_REACTION_REMOVE_EMOJI',
],
13,
],
[Intent.DIRECT_MESSAGE_TYPING]: [[
"TYPING_START"
], 14],
[Intent.DIRECT_MESSAGE_TYPING]: [['TYPING_START'], 14],
[Intent.MESSAGE_CONTENT]: [[
"MESSAGE_CONTENT"
], 15]
[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',
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { hydrate, hydrator, type Hydrateable, type Hydrated } from "../services/
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";
/**
@@ -28,6 +29,7 @@ import { type DiscordMessage, Message, type MessagePayload } from "./message";
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 =
@@ -92,7 +94,7 @@ export class Context<D extends ContextData = ContextData> {
*
* @returns A promise that resolves to a type guard function. The type guard function returns `true` if the data object was successfully hydrated, otherwise `false`.
*/
public hydrator = async <T extends Hydrateable | Hydrated<Hydrateable, Array<Expectation>>, K extends Array<Expectation>>(data: T, expectations: K) => {
public hydrator = async <T extends Hydrateable, K extends Array<Expectation>>(data: T, expectations: K) => {
return hydrator<T, K>(data, expectations, this.client, this.api);
}
+9 -3
View File
@@ -1,9 +1,10 @@
import { exists, isNil } from '../utils';
import { GatewayObject } from './base';
import { fromDiscord, Trait, type Expectation, type Expects } from './common';
import type { Channel } from './channel';
import { fromDiscord, Trait, type Expectation } from './common';
import type { Embed } from './embed';
import type { Guild } from './guild';
import { type DiscordUser, User } from './user';
import { User, type DiscordUser } from './user';
export type Component = Partial<Button | SelectMenu> & {
type: ComponentType;
@@ -34,6 +35,11 @@ export type Component = Partial<Button | SelectMenu> & {
// 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;
@@ -83,7 +89,7 @@ export enum ComponentType {
}
export type HydratedMessage<T extends Array<Expectation>> = MessagePayload & {
// channel: Extract<Expectation.Channel, T[number]> extends never ? undefined : Channel;
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;
}
+10 -12
View File
@@ -1,4 +1,4 @@
import { ApplicationCommandType, Client, CommandOptionType, ComponentStyle, type Context, createCommandHandler, Expectation, Intent, InteractionCommpoentData, type InteractionData, Message, type MessagePayload } from '../src/index';
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!,
@@ -7,7 +7,9 @@ const client = new Client({
Intent.GUILDS,
Intent.GUILD_MESSAGES,
Intent.GUILD_MESSAGE_REACTIONS,
Intent.MESSAGE_CONTENT
Intent.MESSAGE_CONTENT,
Intent.DIRECT_MESSAGES,
Intent.DIRECT_MESSAGE_REACTIONS
],
prefix: '!!'
});
@@ -76,23 +78,19 @@ Nice to meet you, I'm ${context.self.username}!`
// Repeated hydration
client.chatCommands.register('me', async (context: Context, _args: any[]) => {
// Note: Testing repeated hydration
const { message } = await context.hydrate(context, [Expectation.Message])
const hydrated = await context.hydrate(message, [Expectation.Channel])
const rehydrated = await context.hydrate(hydrated, [Expectation.Guild]);
rehydrated.guild.name
const { message } = await context.hydrate(context, [Expectation.Message]);
const { channel } = await context.hydrate(message, [Expectation.Channel]);
const hydrate = await context.hydrator(hydrated, [Expectation.Guild]);
const hasGuild = hydrate(hydrated);
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 ${hydrated.guild.name}`, true);
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 not in a server`, true);
await context.reply(`You are ${message.author.username} and we are in ${channel.type === ChannelType.DM ? 'our DM\'s 😘' : 'a Group 👀'}`, true);
}
// await context.reply(`You are ${message.author.username} ${hasGuild ? message.guild.name : ''}`, true);
});
// Hydrator - hydration function generator
+112
View File
@@ -0,0 +1,112 @@
// 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, Intent } 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({
intents: [
Intent.GUILDS,
Intent.GUILD_MESSAGES,
Intent.GUILD_MESSAGE_REACTIONS,
Intent.MESSAGE_CONTENT,
Intent.DIRECT_MESSAGES,
Intent.DIRECT_MESSAGE_REACTIONS,
],
application_id: DISCORD_APPLICATION_ID,
token: DISCORD_CLIENT_TOKEN,
prefix: '!!',
})
.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();
+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"]
}