mirror of
https://github.com/jaronpate/unicord.git
synced 2026-10-18 21:47:40 +00:00
Compare commits
14 Commits
master
..
f8318e6399
| Author | SHA1 | Date | |
|---|---|---|---|
| f8318e6399 | |||
| ce45eacb3b | |||
| 8b1eecd823 | |||
| 5beda9328b | |||
| 898ceec96d | |||
| 40d76aae80 | |||
| f0f526e5c5 | |||
| 2dae0b066f | |||
| ebaf7d711c | |||
| d10a22424e | |||
| e711473b7f | |||
| 6c59334c27 | |||
| 444faea651 | |||
| 215b10e608 |
@@ -0,0 +1 @@
|
||||
src/services/processor.ts
|
||||
@@ -112,9 +112,9 @@ You can also generate a hydration function that returns a type narrowing boolean
|
||||
```typescript
|
||||
client.chatCommands.register('me', async (context: Context, _args: any[]) => {
|
||||
// Hydrate the message id from the context
|
||||
const hydrate = await context.hydrator(context, [Expectation.Guild]);
|
||||
const hydrate = await context.hydrator(hydrated, [Expectation.Guild]);
|
||||
// Check if the object is hydrated
|
||||
const hasGuild = hydrate(context);
|
||||
const hasGuild = hydrate(hydrated);
|
||||
|
||||
if (hasGuild) {
|
||||
// Send a message stating the user that they are in a guild
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './users';
|
||||
export * from './channels';
|
||||
export * from './guilds';
|
||||
@@ -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, []);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './common';
|
||||
export * from './user';
|
||||
export * from './channel';
|
||||
export * from './guild';
|
||||
export * from './message';
|
||||
export * from './embed';
|
||||
@@ -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;
|
||||
}[];
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
+6
-7
@@ -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??
|
||||
|
||||
+37
-23
@@ -12,44 +12,42 @@ import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator';
|
||||
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;
|
||||
channels: Channels;
|
||||
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.api, this.processor);
|
||||
this.channels = new Channels(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) {
|
||||
@@ -82,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.
|
||||
*
|
||||
|
||||
@@ -41,13 +41,13 @@ export async function hydrate<T extends Hydrateable, K extends Array<Expectation
|
||||
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.channels.get(data.channel_id);
|
||||
context.channel = await client.caches.channels.get(data.channel_id);
|
||||
} else {
|
||||
throw new Error(`Invalid expectation requested for Context: ${expectation}`);
|
||||
}
|
||||
@@ -56,13 +56,13 @@ export async function hydrate<T extends Hydrateable, K extends Array<Expectation
|
||||
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.channels.get(data.channel_id);
|
||||
message.channel = await client.caches.channels.get(data.channel_id);
|
||||
} else {
|
||||
throw new Error(`Invalid expectation requested for Message: ${expectation}`);
|
||||
}
|
||||
|
||||
+13
-13
@@ -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
|
||||
@@ -102,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}`);
|
||||
}
|
||||
@@ -138,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] = {
|
||||
@@ -154,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)) {
|
||||
@@ -166,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[]> => {
|
||||
|
||||
@@ -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();
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
|
||||
.pillars {
|
||||
padding: 6rem 6em;
|
||||
padding: 6rem 12em;
|
||||
background: var(--gray-alpha-100);
|
||||
min-height: 50vh;
|
||||
display: flex;
|
||||
@@ -226,7 +226,7 @@
|
||||
}
|
||||
|
||||
.hydrationSection {
|
||||
padding: 6rem 6em;
|
||||
padding: 6rem 12em;
|
||||
min-height: 50vh;
|
||||
display: flex;
|
||||
align-items: space;
|
||||
@@ -252,14 +252,12 @@
|
||||
.hydrationText {
|
||||
flex: 1;
|
||||
/* padding: 2rem; */
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.hydrationText h2 {
|
||||
color: #ff0080;
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 2rem;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.hydrationText p {
|
||||
@@ -268,11 +266,10 @@
|
||||
margin: 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.typeSection {
|
||||
padding: 6rem 6em;
|
||||
padding: 6rem 12em;
|
||||
min-height: 50vh;
|
||||
display: flex;
|
||||
align-items: space;
|
||||
@@ -292,7 +289,6 @@
|
||||
|
||||
.typeText {
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.typeCode {
|
||||
@@ -326,7 +322,7 @@
|
||||
}
|
||||
|
||||
.installSection {
|
||||
padding: 6rem 6em;
|
||||
padding: 6rem 12em;
|
||||
min-height: 30vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user