14 Commits

30 changed files with 2897 additions and 63 deletions
+1
View File
@@ -0,0 +1 @@
src/services/processor.ts
+2 -2
View File
@@ -112,9 +112,9 @@ You can also generate a hydration function that returns a type narrowing boolean
```typescript ```typescript
client.chatCommands.register('me', async (context: Context, _args: any[]) => { client.chatCommands.register('me', async (context: Context, _args: any[]) => {
// Hydrate the message id from the context // 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 // Check if the object is hydrated
const hasGuild = hydrate(context); const hasGuild = hydrate(hydrated);
if (hasGuild) { if (hasGuild) {
// Send a message stating the user that they are in a guild // Send a message stating the user that they are in a guild
BIN
View File
Binary file not shown.
+163
View File
@@ -0,0 +1,163 @@
import type { Unicord } from '..';
import { Trait } from '../types/common';
export class UnicordObjectCache<Entity> {
cache: LRUCache<string, Entity>;
requests = new Map<string, Promise<Entity>>();
readonly type: string;
constructor(
private readonly self: Unicord,
type: string,
private readonly factory: { [Trait.fromDiscord]: (data: any) => Entity } & (new (data: any) => Entity),
events: string[],
maxCapacity: number = 10_000,
) {
this.cache = new LRUCache<string, Entity>(maxCapacity);
this.type = type;
for (const event of events) {
this.self.onSystemEvent(event, async (payload) => {
this.cache.set(payload.id, factory[Trait.fromDiscord](payload));
});
}
}
set(object_id: string, object: Entity): void {
this.cache.set(object_id, object);
}
// update(object_id: string, object: Partial<T>): void {
// const existing = this.cache.get(object_id);
// if (exists(existing)) {
// // TODO: Idk wtf happens if this is called on two classes
// this.cache.set(object_id, { ...existing, ...object });
// } else {
// this.cache.set(object_id, object as T);
// }
// }
async fetch(object_id: string, ..._args: any[]): Promise<Entity> {
if (this.requests.has(object_id)) {
return this.requests.get(object_id)!;
// } else if (this.cache.has(object_id)) {
// return Promise.resolve(this.cache.get(object_id)!);
} else {
// Dispatch the request
const req = this.self.discordAPI.get(`/${this.type}/${object_id}`).then((data) => {
// Create the object
const object = this.factory[Trait.fromDiscord](data);
// Save the object
this.cache.set(object_id, object);
// Return the object
return object;
});
// Save the request
this.requests.set(object_id, req);
// Remove the request from the cache when it finishes
req.finally(() => this.requests.delete(object_id));
// Return the request
return req;
}
}
async get(object_id: string, ..._args: any[]): Promise<Entity> {
if (this.cache.has(object_id)) {
return await Promise.resolve(this.cache.get(object_id)!);
} else {
return this.fetch(object_id);
}
}
}
class ListNode<K, V> {
key: K;
value: V;
prev: ListNode<K, V> | null = null;
next: ListNode<K, V> | null = null;
constructor(key: K, value: V) {
this.key = key;
this.value = value;
}
}
class LRUCache<K, V> {
private capacity: number;
private map: Map<K, ListNode<K, V>>;
private head: ListNode<K, V>;
private tail: ListNode<K, V>;
constructor(capacity: number) {
this.capacity = capacity;
this.map = new Map();
// Dummy head and tail to simplify boundary conditions
this.head = new ListNode<K, V>(null as any, null as any);
this.tail = new ListNode<K, V>(null as any, null as any);
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: K): V | undefined {
if (!this.map.has(key)) return undefined;
const node = this.map.get(key)!;
this.moveToFront(node);
return node.value;
}
has(key: K): boolean {
return this.map.has(key);
}
set(key: K, value: V): void {
if (this.map.has(key)) {
// Update existing node value and move it to front
const node = this.map.get(key)!;
node.value = value;
this.moveToFront(node);
} else {
// Create a new node
const newNode = new ListNode(key, value);
this.map.set(key, newNode);
this.addToFront(newNode);
// If over capacity, remove the least recently used (LRU) node
if (this.map.size > this.capacity) {
this.removeLRU();
}
}
}
private moveToFront(node: ListNode<K, V>): void {
this.removeNode(node);
this.addToFront(node);
}
private addToFront(node: ListNode<K, V>): void {
node.next = this.head.next;
node.prev = this.head;
this.head.next!.prev = node;
this.head.next = node;
}
private removeNode(node: ListNode<K, V>): void {
node.prev!.next = node.next;
node.next!.prev = node.prev;
}
private removeLRU(): void {
const lruNode = this.tail.prev!;
this.removeNode(lruNode);
this.map.delete(lruNode.key);
}
*[Symbol.iterator](): IterableIterator<[K, V]> {
let current = this.head.next;
while (current && current !== this.tail) {
yield [current.key, current.value];
current = current.next;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Unicord } from "..";
import { Channel } from "../types/channel";
import { UnicordObjectCache } from "./cache";
export class UnicordChannelsCache extends UnicordObjectCache<Channel> {
constructor(self: Unicord) {
super(self, 'channels', Channel, ['CHANNEL_CREATE', 'CHANNEL_UPDATE']);
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { Unicord } from "..";
import { Guild } from "../types/guild";
import { UnicordObjectCache } from "./cache";
export class UnicordGuildsCache extends UnicordObjectCache<Guild> {
constructor(self: Unicord) {
super(self, 'guilds', Guild, ['GUILD_CREATE', 'GUILD_UPDATE']);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './users';
export * from './channels';
export * from './guilds';
+9
View File
@@ -0,0 +1,9 @@
import type { Unicord } from '..';
import { User } from '../types/user';
import { UnicordObjectCache } from './cache';
export class UnicordUsersCache extends UnicordObjectCache<User> {
constructor(self: Unicord) {
super(self, 'users', User, []);
}
}
+230
View File
@@ -0,0 +1,230 @@
import type {
ArgumentTypeFromOptions,
Unicord,
UnicordArgumentDefinition,
UnicordCommandHandler,
UnicordCommandOptions,
} from '.';
import { UnicordCommandContext } from './context';
import type { UnicordEventProcessor } from './eventProcessor';
import { Embed } from './types';
import { UnicordArgumentType, UnicordEventType } from './types/common';
type UnicordCommandDefinition<Options extends UnicordCommandOptions> = {
options: Options;
handler: UnicordCommandHandler<Options>;
};
export const ArgumentConstructorMap = {
[UnicordArgumentType.String]: String,
[UnicordArgumentType.Integer]: Number,
[UnicordArgumentType.Boolean]: Boolean,
[UnicordArgumentType.Number]: Number,
[UnicordArgumentType.User]: String, // Could be a user ID or object depending on your use case
[UnicordArgumentType.Channel]: String, // Could be a channel ID or object
[UnicordArgumentType.Role]: String, // Could be a role ID or object
[UnicordArgumentType.Mentionable]: String, // Could be a mentionable ID or object
[UnicordArgumentType.Attachment]: String, // Could represent attachment IDs
[UnicordArgumentType.SubCommand]: String, // Handled by `options`
[UnicordArgumentType.SubCommandGroup]: String, // Handled by `options`
};
export class UnicordCommandManager {
private handlers = {
[UnicordEventType.ChatCommands]: new Map<
string,
UnicordCommandDefinition<any> & { wrappedHandler: UnicordCommandHandler<any> }
>(),
[UnicordEventType.ApplicationCommands]: new Map<
string,
UnicordCommandDefinition<any> & { wrappedHandler: UnicordCommandHandler<any> }
>(),
};
constructor(private readonly self: Unicord, private readonly eventProcessor: UnicordEventProcessor) {
this.handlers = {
[UnicordEventType.ChatCommands]: new Map(),
[UnicordEventType.ApplicationCommands]: new Map(),
};
this.registerDefaultHelpCommand();
}
private async validateAndResolveArgs<const Options extends UnicordCommandOptions>(
args: any[],
definitions: Options['args'],
): Promise<ArgumentTypeFromOptions<Options['args']>> {
const validated: Record<string, any> = {};
if (args.length > definitions.length) {
throw new Error(`Too many arguments. Expected ${definitions.length}, received ${args.length}`);
}
for (const [index, def] of definitions.entries()) {
const arg = args[index];
if (arg === undefined || arg === null || arg === '') {
if (def.required) {
throw new Error(`Missing required argument: ${def.name}`);
}
validated[def.name] = undefined;
continue;
}
validated[def.name] = this.coerceArgument(def, arg);
}
return Promise.resolve(validated as ArgumentTypeFromOptions<Options['args']>);
}
private coerceArgument(def: UnicordArgumentDefinition, value: any) {
let coerced = value;
if (def.type === UnicordArgumentType.Integer) {
coerced = typeof value === 'number' ? value : parseInt(value, 10);
if (!Number.isInteger(coerced)) {
throw new Error(`Argument ${def.name} must be an integer`);
}
} else if (def.type === UnicordArgumentType.Number) {
coerced = typeof value === 'number' ? value : Number(value);
if (Number.isNaN(coerced)) {
throw new Error(`Argument ${def.name} must be a number`);
}
} else if (def.type === UnicordArgumentType.Boolean) {
if (typeof value === 'boolean') {
coerced = value;
} else if (value === 'true') {
coerced = true;
} else if (value === 'false') {
coerced = false;
} else {
throw new Error(`Argument ${def.name} must be a boolean`);
}
}
if (def.choices && !def.choices.some((choice) => choice.value === coerced)) {
throw new Error(`Invalid choice for argument ${def.name}: ${value}`);
}
return coerced;
}
registerDefaultHelpCommand() {
this.registerChatCommand(
'help',
async (context) => {
// Just list chat commands for now
const reply = new Embed().setTitle('Available Commands');
for (const [command, def] of this.handlers[UnicordEventType.ChatCommands]) {
let fieldValue = def.options.description ?? 'No description';
fieldValue += '\n';
if (def.options.args.length > 0) {
fieldValue += `\n\`${command}`;
for (const arg of def.options.args) {
fieldValue += arg.required ? ` <${arg.name}>` : ` <?${arg.name}>`;
}
fieldValue += '`\n';
for (const arg of def.options.args) {
fieldValue += `- \`${arg.name}\` (${UnicordArgumentType[arg.type]}${
arg.required ? ', required' : ''
}): ${arg.description ?? 'No description'}\n`;
}
}
fieldValue += '\n';
reply.fields.push({
name: command,
value: fieldValue,
});
}
await context.reply(reply.toMessage());
},
{
description: 'Lists all available commands',
args: [],
},
);
}
all() {
return this.handlers;
}
has(type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, event: string) {
return this.handlers[type].has(event);
}
merge(other: UnicordCommandManager) {
const handlers = other.all();
for (const type in handlers) {
const typeKey = type as UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands;
for (const [event, definition] of handlers[typeKey]) {
this.register(typeKey, event, definition.handler, definition.options);
}
}
this.registerDefaultHelpCommand();
}
unregister(type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, event: string) {
const handler = this.handlers[type].get(event);
if (handler) {
this.eventProcessor.unregister(`${type}:${event}`, handler.wrappedHandler);
this.handlers[type].delete(event);
}
return this.self;
}
register<const Options extends UnicordCommandOptions>(
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
event: string,
handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options,
) {
if (type === UnicordEventType.ApplicationCommands) {
const commandNameRegex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
if (!commandNameRegex.test(event)) {
throw new Error(
`${event} is not a valid application command name (1-32 characters, only letters, numbers, hyphens, and underscores)`,
);
}
if (!options.description || options.description.length === 0) {
throw new Error('Application commands require a description');
}
if (options.args && options.args.length > 25) {
throw new Error('Application commands cannot have more than 25 arguments');
}
for (const arg of options.args) {
if (arg.choices && arg.choices.length > 25) {
throw new Error(`Application command argument ${arg.name} cannot have more than 25 choices`);
}
}
}
if (this.has(type, event)) {
this.unregister(type, event);
}
const wrappedHandler = (async (context: UnicordCommandContext, args: any[]) => {
const resolvedArgs = await this.validateAndResolveArgs<Options>(args, options.args);
return handler(context, resolvedArgs);
}) as UnicordCommandHandler<Options>;
this.handlers[type].set(event, { options, handler, wrappedHandler });
this.eventProcessor.register(`${type}:${event}`, wrappedHandler);
return this.self;
}
registerChatCommand = this.register.bind(this, UnicordEventType.ChatCommands);
registerApplicationCommand = this.register.bind(this, UnicordEventType.ApplicationCommands);
}
+103
View File
@@ -0,0 +1,103 @@
import type { Unicord } from '.';
import { hydrate } from './hydrator';
import type { MessagePayload } from './types';
import { Message } from './types';
import { exists, isNil } from './utils';
export type UnicordCommandContextInput =
| {
message: MessagePayload;
channel_id?: string;
message_id?: string;
guild_id?: string;
}
| {
channel_id: string;
message_id: string;
guild_id?: string;
message?: MessagePayload;
};
/**
* Represents the context of a message in a Discord channel.
* Provides methods to interact with the message and the channel it was sent in.
*
* @class Context
*
* @property {string} message_id - The ID of the message that this context is associated with.
* @property {string} channel_id - The ID of the channel where the message was sent.
* @property {string} [guild_id] - The ID of the guild where the message was sent, if applicable.
*
* @constructor
* @param {Unicord} self - The client instance.
* @param {UnicordCommandContextInput} data - The payload data for the context.
*
* @throws {Error} If the message ID is not provided.
* @throws {Error} If the channel ID is not provided.
*/
export class UnicordCommandContext {
// Public properties
message_id: string;
channel_id: string;
guild_id?: string;
message?: Message;
constructor(private self: Unicord, data: UnicordCommandContextInput) {
if (isNil(data.message)) {
if (isNil(data.message_id)) {
throw new Error('Message ID is required to create a context');
}
if (isNil(data.channel_id)) {
throw new Error('Channel ID is required to create a context');
}
this.message_id = data.message_id;
this.channel_id = data.channel_id;
this.guild_id = data.guild_id;
} else {
this.message = data.message;
this.message_id = data.message.id;
this.channel_id = data.message.channel_id;
this.guild_id = data.message.guild_id;
}
}
/**
* Reply to the message that this command
* @param message The message to send to the channel
* @param reference If true the message will be a reference the the message that triggered the command
* @returns The message that was sent
*/
public reply = async (message: Message | string, reference: boolean = true): Promise<Message> => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
if (exists(this.message)) {
if (reference) {
message.setReference(this.message);
}
return this.self.sendMessage(this.channel_id, message);
} else {
throw new Error('Cannot reply without a message payload');
}
};
/**
* Hydrates a given data object based on the provided expectations.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
*
* @returns A promise that resolves to the hydrated data object.
*
* @throws Will throw an error if an expectation cannot be resolved.
*/
get hydrate() {
return hydrate.bind(null, this.self);
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { Unicord } from '.';
import { exists, log } from './utils';
export class UnicordDiscordAPI {
private readonly base_url: string = 'https://discord.com/api';
private readonly version: number = 10;
constructor(private self: Unicord) {}
async request<T = any>(method: string, path: string, data?: any): Promise<T> {
const url = `${this.base_url}/v${this.version.toString()}${path.startsWith('/') ? '' : '/'}${path}`;
const headers = {
Authorization: `Bot ${this.self.config.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
const response = await fetch(url, {
method,
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
// console.error(`Failed to fetch: ${response.statusText} ${response.url} ${response.status}`);
console.log('Failed Request Response: ', await response.text());
throw new Error(`Failed to fetch: ${response.statusText}`);
}
const body = await response.json();
// console.log(`API Request: ${method} ${url}`);
// console.log(`API Body: ${JSON.stringify(data, null, 4)}`);
// console.log('API Response:', JSON.stringify(body, null, 4));
// Extract rate limit headers
const remaining = response.headers.get('X-RateLimit-Remaining');
const limit = response.headers.get('X-RateLimit-Limit');
const reset = response.headers.get('X-RateLimit-Reset');
const resetSeconds = response.headers.get('X-RateLimit-Reset-After');
const limitBucket = response.headers.get('X-RateLimit-Bucket');
// Log rate limit info for debugging
log(`Rate Limit: ${remaining}/${limit} - Reset: ${resetSeconds}s - Bucket: ${limitBucket}`);
// If status is 429, we are being rate limited
if (response.status === 429) {
// Look for the retry-after header
const retryAfter = response.headers.get('Retry-After');
if (exists(retryAfter)) {
// Wait for the retry-after time
await new Promise((resolve) => setTimeout(resolve, parseInt(retryAfter) * 1000));
} else {
// TODO: Handle this case better
// Maybe wait some arbitrary time??
throw new Error('Rate limited without Retry-After header');
}
}
return body;
}
async get<T = any>(path: string): Promise<T> {
return this.request('GET', path);
}
async post<T = any>(path: string, data: any): Promise<T> {
return this.request('POST', path, data);
}
async put<T = any>(path: string, data: any): Promise<T> {
return this.request('PUT', path, data);
}
async patch<T = any>(path: string, data: any): Promise<T> {
return this.request('PATCH', path, data);
}
async delete<T = any>(path: string): Promise<T> {
return this.request('DELETE', path);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { EventEmitter } from 'node:events';
import type { Unicord } from '.';
import type { UnicordCommandContext } from './context';
import type { UnicordCommandOptions, UnicordEventHandler, UnicordHandler } from './types/common';
import { UnicordEventType } from './types/common';
export class UnicordEventProcessor extends EventEmitter {
constructor(private readonly self: Unicord) {
super();
}
register<const Options extends UnicordCommandOptions>(event: string, handler: UnicordHandler<Options>) {
return super.on(event, handler);
}
unregister<const Options extends UnicordCommandOptions>(event: string, handler: UnicordHandler<Options>) {
return super.off(event, handler);
}
emitCommand(
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
event: string,
context: UnicordCommandContext,
args: any[],
): boolean {
return super.emit(`${type}:${event}`, context, args);
}
emitSystem(event: string, payload: Parameters<UnicordEventHandler>[0]): boolean {
return super.emit(`${UnicordEventType.SystemEvent}:${event}`, payload);
}
}
+349
View File
@@ -0,0 +1,349 @@
import { WebSocket, type Data } from 'ws';
import { Unicord } from '.';
import { UnicordCommandContext } from './context';
import type { DiscordMessage, Payload, UnicordEventContext } from './types';
import { fromDiscord, Message, UnicordEventType, User } from './types';
import { exists, isNil, isObject, log } from './utils';
export class UnicordGatewayManager {
private socket: WebSocket | null = null;
private heartbeat_interval: number | null = null;
private heartbeat_timeout: ReturnType<typeof setTimeout> | null = null;
private sequence: number | null = null;
private awaiting_heartbeat_ack = false;
private session_id: string | null = null;
private resume_gateway_url: string | null = null;
private should_resume = false;
private reconnecting = false;
private reconnect_attempts = 0;
// Internal state
user: User | null = null;
constructor(private readonly self: Unicord) {}
private onOpen = () => {
log('Connected to gateway');
this.reconnecting = false;
this.reconnect_attempts = 0;
};
private onClose = (code: number, reason: Buffer) => {
log('Gateway closed:', code, reason.toString());
this.clearHeartbeat();
if (!this.reconnecting) {
this.reconnect(this.canResumeFromCloseCode(code));
}
};
private onError = (error: Error) => {
log('Gateway error:', error);
if (!this.reconnecting) {
this.reconnect(true);
}
};
private onMessage = async (packet: Data) => {
// Allocate a variable to store the parsed payload
let data: Payload | null = null;
try {
// Attempt to parse the packet data
data = JSON.parse(packet.toString());
} catch (e) {
console.error(e);
}
if (exists(data)) {
if (exists(data.s)) {
this.sequence = data.s;
}
// NOTE: Uncomment for debugging recieved event data
// log('='.repeat(25));
// log('received: ', data.op);
// log('received event: ', data.t ?? 'no event');
// log('received data: ', exists(data.d) ? Object.keys(data.d) : 'no data');
// log('='.repeat(25));
// If the data is successfully parsed check the opcode
const code = data.op;
if (code === 0) {
// Event received
await this.parseEvent(data);
} else if (code === 1) {
// Heartbeat requested
this.sendHeartbeat();
} else if (code === 7) {
// Reconnect requested
this.reconnect(true);
} else if (code === 9) {
// Invalid session. Discord sends a boolean indicating whether resume is allowed.
const canResume = data.d === true;
if (!canResume) {
this.session_id = null;
this.sequence = null;
this.should_resume = false;
}
this.reconnect(canResume);
} else if (code === 10) {
// Hello
if (!isObject(data.d)) {
throw new Error('Invalid HELLO payload received');
}
// Store the heartbeat interval
this.heartbeat_interval = data.d?.heartbeat_interval;
// Kick off the heartbeat loop with initial jitter
this.startHeartbeat();
// Resume or identify with the gateway
if (this.should_resume && exists(this.session_id)) {
this.resume();
} else {
this.identify();
}
} else if (code === 11) {
// Heartbeat ACK
this.awaiting_heartbeat_ack = false;
}
}
};
private startHeartbeat() {
if (!exists(this.heartbeat_interval)) {
throw new Error('Heartbeat interval not set');
}
this.clearHeartbeat();
const firstHeartbeatDelay = this.heartbeat_interval * Math.random();
this.heartbeat_timeout = setTimeout(() => {
this.heartbeat();
}, firstHeartbeatDelay);
}
private heartbeat() {
if (!exists(this.heartbeat_interval)) {
throw new Error('Heartbeat interval not set');
}
if (this.awaiting_heartbeat_ack) {
log('Missed heartbeat ACK; reconnecting gateway');
this.reconnect(true);
return;
}
this.sendHeartbeat();
this.heartbeat_timeout = setTimeout(() => {
this.heartbeat();
}, this.heartbeat_interval);
}
private sendHeartbeat() {
this.awaiting_heartbeat_ack = true;
this.send({ op: 1, d: this.sequence });
}
private clearHeartbeat() {
if (exists(this.heartbeat_timeout)) {
clearTimeout(this.heartbeat_timeout);
this.heartbeat_timeout = null;
}
this.awaiting_heartbeat_ack = false;
}
protected send = (payload: Partial<Payload>) => {
if (isNil(this.socket) || this.socket.readyState !== WebSocket.OPEN) {
throw new Error('Socket is not connected');
}
// log('='.repeat(25));
// log('sending: ', JSON.stringify(payload, null, 4));
// log('='.repeat(25));
this.socket.send(JSON.stringify(payload));
};
private identify = () => {
this.should_resume = false;
this.send({
op: 2,
d: {
token: this.self.config.token,
intents: this.self.config.intents.reduce((acc, intent) => acc | intent, 0),
properties: {
os: process.platform,
browser: 'unicord',
device: 'unicord',
},
},
});
};
private resume = () => {
this.send({
op: 6,
d: {
token: this.self.config.token,
session_id: this.session_id,
seq: this.sequence,
},
});
};
private async parseEvent(payload: Payload) {
if (isNil(payload.t)) {
throw new Error('Event name is required');
}
// log('Event:', payload.t);
// Extract the event name and data
const event_name = payload.t;
const event_data = payload.d;
// Simple validation on the event data
if (!isObject(event_data)) {
throw new Error('Invalid event data received');
}
if (event_name === 'READY') {
this.user = User[fromDiscord](event_data.user);
this.session_id = event_data.session_id;
this.resume_gateway_url = event_data.resume_gateway_url;
this.should_resume = true;
} else if (event_name === 'RESUMED') {
this.should_resume = true;
} else if (event_name === 'INTERACTION_CREATE') {
// TODO: Application commands/interactions need a redesigned context/hydration model before implementation.
} else if (event_name === 'MESSAGE_CREATE') {
await this.handleMessageCreate(payload);
} else if (event_name === 'MESSAGE_UPDATE') {
// TODO: setting to call commands again here on message edit
// await this.handleMessageUpdate(payload);
}
// TODO: Make a real UnicordEventContext class
this.self.emitSystem(event_name, event_data as UnicordEventContext);
}
private handleMessageCreate = async (payload: Payload) => {
if (isNil(this.user)) {
throw new Error('Cannot handle MESSAGE_CREATE event without a user');
}
if (!isObject(payload.d)) {
throw new Error('Invalid MESSAGE_CREATE payload received');
}
// Ignore bots
if (payload.d.author.bot) {
return;
}
// Ignore self
if (payload.d.author.id === this.user.id) {
return;
}
if (typeof payload.d.content !== 'string') {
return;
}
// Extract the content
const content = payload.d.content.trim();
const hasPrefixInConfig = exists(this.self.config.prefix) && this.self.config.prefix.length > 0;
const userMentionPrefix = `<@${this.user.id}>`;
// Is this a command?
if (
(hasPrefixInConfig && content.startsWith(this.self.config.prefix!)) ||
content.startsWith(userMentionPrefix)
) {
// Determine the prefix
const prefix =
hasPrefixInConfig && content.startsWith(this.self.config.prefix!)
? this.self.config.prefix!
: userMentionPrefix;
// Parse command
const args = content
.slice(prefix.length)
.trim()
.split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g);
// Ignore empty commands
if (args.length === 0) {
return;
}
// Extract the command name
// TODO: Case insensitive config?
const command = args.shift()!;
// Generate context
const message = Message.fromDiscord(payload.d as DiscordMessage);
const context: UnicordCommandContext = new UnicordCommandContext(this.self, { message });
// Dispatch event for the command
this.self.emitCommand(UnicordEventType.ChatCommands, command, context, args);
}
};
private reconnect(resume: boolean) {
if (this.reconnecting) {
return;
}
this.reconnecting = true;
this.should_resume = resume && exists(this.session_id);
this.clearHeartbeat();
if (exists(this.socket)) {
this.socket.removeAllListeners();
this.socket.terminate();
this.socket = null;
}
const delay = this.getReconnectDelay();
log(`Reconnecting gateway in ${delay}ms${this.should_resume ? ' with resume' : ''}`);
setTimeout(() => {
const gatewayUrl =
this.should_resume && exists(this.resume_gateway_url) ? this.resume_gateway_url : undefined;
this.connect(gatewayUrl).catch((error) => {
log('Gateway reconnect failed:', error);
this.reconnecting = false;
this.reconnect(this.should_resume);
});
}, delay);
}
private getReconnectDelay() {
const baseDelay = 1_000;
const maxDelay = 60_000;
const delay = Math.min(maxDelay, baseDelay * 2 ** this.reconnect_attempts);
this.reconnect_attempts += 1;
return delay + Math.floor(Math.random() * 1_000);
}
private canResumeFromCloseCode(code: number) {
// Discord documents these close codes as non-resumable.
return ![4004, 4010, 4011, 4012, 4013, 4014].includes(code);
}
async connect(gatewayUrl?: string) {
// Fetch the Discord gateway URL when no explicit URL was provided.
const response = gatewayUrl
? { url: gatewayUrl }
: await this.self.discordAPI.request<{ url: string }>('GET', '/gateway/bot');
// Append the query parameters to the URL
const url = new URL(response.url);
url.searchParams.set('v', '10');
url.searchParams.set('encoding', 'json');
// Open a new WebSocket connection to the Discord gateway
this.socket = new WebSocket(url);
// Register event listeners
this.socket.on('open', this.onOpen);
this.socket.on('message', this.onMessage);
this.socket.on('close', this.onClose);
this.socket.on('error', this.onError);
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { Expects, Unicord } from '.';
import { Expectation } from '.';
import { UnicordCommandContext } from './context';
import type { Channel, Guild, Message } from './types';
import { exists } from './utils';
export type UnicordHydrateable = UnicordCommandContext;
export type UnicordHydratedObject<Object, Expectations extends Array<Expectation>> = Object & {
channel: Expects<Expectations, Expectation.Channel, Channel>;
guild: Expects<Expectations, Expectation.Guild, Guild>;
message: Expects<Expectations, Expectation.Message, Message>;
};
// NOTE: Dont forget to update the typedoc comment in src/index.ts and src/context.ts when making changes here
/**
* Hydrates a given data object based on the provided expectations.
*
* @param self - The client instance used to fetch additional data required for hydration.
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
*
* @returns A promise that resolves to the hydrated data object.
*
* @throws Will throw an error if an expectation cannot be resolved.
*/
export async function hydrate<T extends UnicordHydrateable, K extends Array<Expectation>>(
self: Unicord,
data: T,
expectations: K,
): Promise<UnicordHydratedObject<T, K>> {
// TODO: add clone trait. Make it a requirement for hydrateable objects.
// Then clone the object and return the hydrated object.
let hydrated: UnicordHydratedObject<T, K>;
if (data instanceof UnicordCommandContext) {
for (const expectation of expectations) {
// if (expectation === Expectation.Message && exists(data.message_id)) {
// let context = data as unknown as UnicordHydratedObject<T, [Expectation.Message]>;
// context.message = await self.caches.messages.get(data.channel_id, data.message_id);
// } else if (expectation === Expectation.Guild && exists(data.guild_id)) {
if (expectation === Expectation.Guild && exists(data.guild_id)) {
let context = data as unknown as UnicordHydratedObject<T, [Expectation.Guild]>;
context.guild = await self.caches.guilds.get(data.guild_id);
} else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let context = data as unknown as UnicordHydratedObject<T, [Expectation.Channel]>;
context.channel = await self.caches.channels.get(data.channel_id);
} else if (expectation === Expectation.Message && exists(data.message)) {
let context = data as unknown as UnicordHydratedObject<T, [Expectation.Message]>;
context.message = data.message;
} else {
throw new Error(`Invalid expectation requested for Context: ${expectation}`);
}
}
} else {
throw new Error(`Hydration for the provided type is not implemented.`);
}
// Ensure the result is properly typed with both old and new hydrated properties
return data as T & UnicordHydratedObject<T, K>;
}
+149
View File
@@ -0,0 +1,149 @@
import { UnicordChannelsCache, UnicordGuildsCache, UnicordUsersCache } from './caches';
import { UnicordCommandManager } from './commandManager';
import type { UnicordCommandContext } from './context';
import { UnicordDiscordAPI } from './discordAPI';
import { UnicordEventProcessor } from './eventProcessor';
import { UnicordGatewayManager } from './gatewayManager';
import { hydrate } from './hydrator';
import type {
UnicordCommandHandler,
UnicordCommandOptions,
UnicordConfig,
UnicordConfigWithDefaults,
UnicordEventContext,
UnicordEventHandler,
UnicordHandler,
} from './types/common';
import { Intent, UnicordEventType } from './types/common';
import type { DiscordMessage } from './types/message';
import { Message } from './types/message';
import { setDefaults } from './utils';
const UNICORD_CONFIG_DEFAULTS = {
intents: [Intent.DEFAULT],
};
export class Unicord {
readonly config: UnicordConfigWithDefaults;
readonly discordAPI: UnicordDiscordAPI;
private readonly eventProcessor: UnicordEventProcessor;
private readonly gatewayManager: UnicordGatewayManager;
private readonly commandManager: UnicordCommandManager;
readonly caches: {
readonly users: UnicordUsersCache;
readonly channels: UnicordChannelsCache;
readonly guilds: UnicordGuildsCache;
};
constructor(userConfig: UnicordConfig = {}) {
this.config = setDefaults<UnicordConfigWithDefaults>(userConfig, UNICORD_CONFIG_DEFAULTS);
this.discordAPI = new UnicordDiscordAPI(this);
this.eventProcessor = new UnicordEventProcessor(this);
this.gatewayManager = new UnicordGatewayManager(this);
this.commandManager = new UnicordCommandManager(this, this.eventProcessor);
this.caches = {
users: new UnicordUsersCache(this),
channels: new UnicordChannelsCache(this),
guilds: new UnicordGuildsCache(this),
};
}
/**
* Hydrates a given data object based on the provided expectations.
*
* @param data - The data object to be hydrated. Can be of type `Context` or `Message`.
* @param expectations - An array of expectations that specify what properties should be hydrated.
*
* @returns A promise that resolves to the hydrated data object.
*
* @throws Will throw an error if an expectation cannot be resolved.
*/
get hydrate() {
return hydrate.bind(null, this);
}
private __onEvent<Options extends UnicordCommandOptions>(
type: UnicordEventType,
event: string,
handler: UnicordHandler<Options>,
) {
return this.eventProcessor.register(`${type}:${event}`, handler);
}
// This is kinda dumb maybe just remove
// use(client: Unicord): this {
// this.commandManager.merge(client.commandManager);
// // TODO: inherit event listeners too
// return this;
// }
emitSystem(event: string, payload: UnicordEventContext) {
return this.eventProcessor.emitSystem(event, payload);
}
emitCommand(
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
event: string,
context: UnicordCommandContext,
args: any[],
) {
return this.eventProcessor.emitCommand(type, event, context, args);
}
// TODO: can we automate defining these methods with some TS magic?
registerChatCommand<const Options extends UnicordCommandOptions>(
event: string,
handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options,
): this {
this.commandManager.registerChatCommand(event, handler, options);
return this;
}
registerApplicationCommand<const Options extends UnicordCommandOptions>(
event: string,
handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options,
): this {
this.commandManager.registerApplicationCommand(event, handler, options);
return this;
}
onEvent(event: string, handler: UnicordEventHandler): this {
this.__onEvent(UnicordEventType.SystemEvent, event, handler);
return this;
}
onSystemEvent(event: string, handler: UnicordEventHandler): void {
this.__onEvent(UnicordEventType.SystemEvent, event, handler);
}
sendMessage = async (channel_id: string, message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return Message.fromDiscord(
await this.discordAPI.post<DiscordMessage>(`/channels/${channel_id}/messages`, message.toJSON()),
);
};
login(token: string | undefined = this.config.token): this {
if (token === null || token === undefined) {
throw new Error(
'You must provide a token to login. Either in the Unicord config or when calling `login()`',
);
}
this.config.token = token;
this.gatewayManager.connect();
return this;
}
}
export * from './types/common';
// export * from './types/message';
// export * from './types/channel';
// export * from './types/guild';
// export * from './types/embed';
+23
View File
@@ -0,0 +1,23 @@
import { Trait } from './common';
export abstract class UnicordGatewayObject<UnicordClass, DiscordObject> {
constructor() {}
// TODO: Add a clone method to subclasses
// public abstract [Trait.clone]<T extends GatewayObject>(this: new (data: any) => T): T;
// public get clone() {
// return this[Trait.clone];
// }
static [Trait.fromDiscord]<T extends UnicordGatewayObject<any, any>>(
this: new (...args: any[]) => T,
data: any,
): T {
throw new Error('Method should be overridden - you should not see this. If you do, please report it.');
}
static get fromDiscord() {
return this[Trait.fromDiscord];
}
}
+272
View File
@@ -0,0 +1,272 @@
import { UnicordGatewayObject } from './base';
import { Trait } from './common';
import { User, type DiscordUser } from './user';
export enum ChannelType {
GUILD_TEXT = 0,
DM = 1,
GUILD_VOICE = 2,
GROUP_DM = 3,
GUILD_CATEGORY = 4,
GUILD_ANNOUNCEMENT = 5,
ANNOUNCEMENT_THREAD = 10,
PUBLIC_THREAD = 11,
PRIVATE_THREAD = 12,
GUILD_STAGE_VOICE = 13,
GUILD_DIRECTORY = 14,
GUILD_FORUM = 15,
GUILD_MEDIA = 16,
}
export enum ChannelFlags {
PINNED = 1 << 1,
REQUIRE_TAG = 1 << 4,
HIDE_MEDIA_DOWNLOAD_OPTIONS = 1 << 15,
}
export enum VideoQualityMode {
AUTO = 1,
FULL = 2,
}
export enum SortOrderType {
LATEST_ACTIVITY = 0,
CREATION_DATE = 1,
}
export enum ForumLayoutType {
NOT_SET = 0,
LIST_VIEW = 1,
GALLERY_VIEW = 2,
}
export type PermissionOverwrite = {
id: string;
type: 0 | 1; // 0 for role, 1 for member
allow: string;
deny: string;
};
export type ThreadMetadata = {
archived: boolean;
auto_archive_duration: number;
archive_timestamp: string;
locked: boolean;
invitable?: boolean;
create_timestamp?: string | null;
};
export type ThreadMember = {
id?: string;
user_id?: string;
join_timestamp: string;
flags: number;
};
export type ForumTag = {
id: string;
name: string;
moderated: boolean;
emoji_id?: string | null;
emoji_name?: string | null;
};
export type DefaultReaction = {
emoji_id?: string | null; // the id of a guild's custom emoji
emoji_name?: string | null; // the unicode character of the emoji
};
export class Channel extends UnicordGatewayObject<Channel, DiscordChannel> {
id: string;
type: ChannelType;
guild_id?: string;
position?: number;
permission_overwrites?: PermissionOverwrite[];
name?: string;
topic?: string | null;
nsfw?: boolean;
last_message_id?: string | null;
bitrate?: number;
user_limit?: number;
rate_limit_per_user?: number;
recipients?: User[];
icon?: string | null;
owner_id?: string;
application_id?: string;
managed?: boolean;
parent_id?: string | null;
last_pin_timestamp?: Date | null;
rtc_region?: string | null;
video_quality_mode?: VideoQualityMode;
message_count?: number;
member_count?: number;
thread_metadata?: ThreadMetadata;
member?: ThreadMember;
default_auto_archive_duration?: number;
permissions?: string;
flags?: number;
total_message_sent?: number;
available_tags?: ForumTag[];
applied_tags?: string[];
default_reaction_emoji?: DefaultReaction;
default_thread_rate_limit_per_user?: number;
default_sort_order?: SortOrderType | null;
default_forum_layout?: ForumLayoutType;
constructor(data: DiscordChannel) {
super();
this.id = data.id!;
this.type = data.type!;
this.guild_id = data.guild_id;
this.position = data.position;
this.permission_overwrites = data.permission_overwrites;
this.name = data.name;
this.topic = data.topic;
this.nsfw = data.nsfw;
this.last_message_id = data.last_message_id;
this.bitrate = data.bitrate;
this.user_limit = data.user_limit;
this.rate_limit_per_user = data.rate_limit_per_user;
this.recipients = data.recipients?.map((u) => User.fromDiscord(u));
this.icon = data.icon;
this.owner_id = data.owner_id;
this.application_id = data.application_id;
this.managed = data.managed;
this.parent_id = data.parent_id;
this.last_pin_timestamp = data.last_pin_timestamp ? new Date(data.last_pin_timestamp) : null;
this.rtc_region = data.rtc_region;
this.video_quality_mode = data.video_quality_mode;
this.message_count = data.message_count;
this.member_count = data.member_count;
this.thread_metadata = data.thread_metadata;
this.member = data.member;
this.default_auto_archive_duration = data.default_auto_archive_duration;
this.permissions = data.permissions;
this.flags = data.flags;
this.total_message_sent = data.total_message_sent;
this.available_tags = data.available_tags;
this.applied_tags = data.applied_tags;
this.default_reaction_emoji = data.default_reaction_emoji;
this.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user;
this.default_sort_order = data.default_sort_order;
this.default_forum_layout = data.default_forum_layout;
}
public static [Trait.fromDiscord]<T = Channel>(data: DiscordChannel): T {
return new Channel({
id: data.id,
type: data.type,
guild_id: data.guild_id,
position: data.position,
permission_overwrites: data.permission_overwrites,
name: data.name,
topic: data.topic,
nsfw: data.nsfw,
last_message_id: data.last_message_id,
bitrate: data.bitrate,
user_limit: data.user_limit,
rate_limit_per_user: data.rate_limit_per_user,
recipients: data.recipients,
icon: data.icon,
owner_id: data.owner_id,
application_id: data.application_id,
managed: data.managed,
parent_id: data.parent_id,
last_pin_timestamp: data.last_pin_timestamp,
rtc_region: data.rtc_region,
video_quality_mode: data.video_quality_mode,
message_count: data.message_count,
member_count: data.member_count,
thread_metadata: data.thread_metadata,
member: data.member,
default_auto_archive_duration: data.default_auto_archive_duration,
permissions: data.permissions,
flags: data.flags,
total_message_sent: data.total_message_sent,
available_tags: data.available_tags,
applied_tags: data.applied_tags,
default_reaction_emoji: data.default_reaction_emoji,
default_thread_rate_limit_per_user: data.default_thread_rate_limit_per_user,
default_sort_order: data.default_sort_order,
default_forum_layout: data.default_forum_layout,
}) as T;
}
}
// === Channel Structure ===
// | Field | Type | Description |
// |-------------------------------------|-----------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
// | id | snowflake | the id of this channel |
// | type | integer | the [type of channel](/docs/resources/channel#channel-object-channel-types) |
// | guild_id? | snowflake | the id of the guild (may be missing for some channel objects received over gateway guild dispatches) |
// | position? | integer | sorting position of the channel (channels with the same position are sorted by id) |
// | permission_overwrites? | array of [overwrite](/docs/resources/channel#overwrite-object) objects | explicit permission overwrites for members and roles |
// | name? | ?string | the name of the channel (1-100 characters) |
// | topic? | ?string | the channel topic (0-4096 characters for `GUILD_FORUM` and `GUILD_MEDIA` channels, 0-1024 characters for all others) |
// | nsfw? | boolean | whether the channel is nsfw |
// | last_message_id? | ?snowflake | the id of the last message sent in this channel (or thread for `GUILD_FORUM` or `GUILD_MEDIA` channels) (may not point to an existing or valid message or thread) |
// | bitrate? | integer | the bitrate (in bits) of the voice channel |
// | user_limit? | integer | the user limit of the voice channel |
// | rate_limit_per_user?\* | integer | amount of seconds a user has to wait before sending another message (0-21600); bots, as well as users with the permission `manage_messages` or `manage_channel`, are unaffected |
// | recipients? | array of [user](/docs/resources/user#user-object) objects | the recipients of the DM |
// | icon? | ?string | icon hash of the group DM |
// | owner_id? | snowflake | id of the creator of the group DM or thread |
// | application_id? | snowflake | application id of the group DM creator if it is bot-created |
// | managed? | boolean | for group DM channels: whether the channel is managed by an application via the `gdm.join` OAuth2 scope |
// | parent_id? | ?snowflake | for guild channels: id of the parent category for a channel (each parent category can contain up to 50 channels), for threads: id of the text channel this thread was created |
// | last_pin_timestamp? | ?ISO8601 timestamp | when the last pinned message was pinned. This may be `null` in events such as `GUILD_CREATE` when a message is not pinned. |
// | rtc_region? | ?string | [voice region](/docs/resources/voice#voice-region-object) id for the voice channel, automatic when set to null |
// | video_quality_mode? | integer | the camera [video quality mode](/docs/resources/channel#channel-object-video-quality-modes) of the voice channel, 1 when not present |
// | message_count?\*\* | integer | number of messages (not including the initial message or deleted messages) in a thread. |
// | member_count? | integer | an approximate count of users in a thread, stops counting at 50 |
// | thread_metadata? | a [thread metadata](/docs/resources/channel#thread-metadata-object) object | thread-specific fields not needed by other channels |
// | member? | a [thread member](/docs/resources/channel#thread-member-object) object | thread member object for the current user, if they have joined the thread, only included on certain API endpoints |
// | default_auto_archive_duration? | integer | default duration, copied onto newly created threads, in minutes, threads will stop showing in the channel list after the specified period of inactivity, can be set to: 60, 1440, 4320, 10080 |
// | permissions? | string | computed permissions for the invoking user in the channel, including overwrites, only included when part of the `resolved` data received on an interaction. This does not include [implicit permissions](/docs/topics/permissions#implicit-permissions), which may need to be checked separately |
// | flags? | integer | [channel flags](/docs/resources/channel#channel-object-channel-flags) combined as a [bitfield](https://en.wikipedia.org/wiki/Bit_field) |
// | total_message_sent? | integer | number of messages ever sent in a thread, it's similar to `message_count` on message creation, but will not decrement the number when a message is deleted |
// | available_tags? | array of [tag](/docs/resources/channel#forum-tag-object) objects | the set of tags that can be used in a `GUILD_FORUM` or a `GUILD_MEDIA` channel |
// | applied_tags? | array of snowflakes | the IDs of the set of tags that have been applied to a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel |
// | default_reaction_emoji? | ?[default reaction](/docs/resources/channel#default-reaction-object) object | the emoji to show in the add reaction button on a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel |
// | default_thread_rate_limit_per_user? | integer | the initial `rate_limit_per_user` to set on newly created threads in a channel. this field is copied to the thread at creation time and does not live update. |
// | default_sort_order? | ?integer | the [default sort order type](/docs/resources/channel#channel-object-sort-order-types) used to order posts in `GUILD_FORUM` and `GUILD_MEDIA` channels. Defaults to `null`, which indicates a preferred sort order hasn't been set by a channel admin |
// | default_forum_layout? | integer | the [default forum layout view](/docs/resources/channel#channel-object-forum-layout-types) used to display posts in `GUILD_FORUM` channels. Defaults to `0`, which indicates a layout view has not been set by a channel admin |
export interface DiscordChannel {
id: string;
type: number;
guild_id?: string;
position?: number;
permission_overwrites?: PermissionOverwrite[];
name?: string;
topic?: string | null;
nsfw?: boolean;
last_message_id?: string | null;
bitrate?: number;
user_limit?: number;
rate_limit_per_user?: number;
recipients?: DiscordUser[];
icon?: string | null;
owner_id?: string;
application_id?: string;
managed?: boolean;
parent_id?: string | null;
last_pin_timestamp?: string | null;
rtc_region?: string | null;
video_quality_mode?: number;
message_count?: number;
member_count?: number;
thread_metadata?: ThreadMetadata;
member?: ThreadMember;
default_auto_archive_duration?: number;
permissions?: string;
flags?: number;
total_message_sent?: number;
available_tags?: ForumTag[];
applied_tags?: string[];
default_reaction_emoji?: DefaultReaction;
default_thread_rate_limit_per_user?: number;
default_sort_order?: number | null;
default_forum_layout?: number;
}
+406
View File
@@ -0,0 +1,406 @@
import { UnicordCommandContext } from '../context';
export type Primitive = string | number | boolean | null;
export interface UnicordConfig {
application_id?: string;
token?: string;
prefix?: string;
intents?: Intent[];
}
export interface UnicordConfigWithDefaults extends UnicordConfig {
application_id: string;
token: string;
intents: Intent[];
}
export type Brand<T, K> = T & { __brand: K };
export type EventName = Brand<string, 'event-name'>;
export enum UnicordApplicationCommandType {
Chat = 1,
User = 2,
Message = 3,
PrimaryEntryPoint = 4,
}
export enum UnicordArgumentType {
SubCommand = 1,
SubCommandGroup = 2,
String = 3,
Integer = 4,
Boolean = 5,
User = 6,
Channel = 7,
Role = 8,
Mentionable = 9,
Number = 10,
Attachment = 11,
}
export enum UnicordChannelType {
GuildText = 0,
DM = 1,
GuildVoice = 2,
GroupDm = 3,
GuildCategory = 4,
GuildNews = 5,
GuildStore = 6,
GuildNewsThread = 10,
GuildPublicThread = 11,
GuildPrivateThread = 12,
GuildStageVoice = 13,
GuildDirectory = 14,
GuildForum = 15,
GuildMedia = 16,
}
// Old type
// export type ApplicationCommandOption = {
// type: ApplicationCommandOptionType;
// name: string;
// id: string;
// name_localizations?: Record<string, string> | null;
// description: string;
// description_localizations?: Record<string, string> | null;
// required?: boolean;
// choices?: Array<{ name: string; value: string | number }>;
// options?: ApplicationCommandOption[]; // Recursive structure for subcommands
// channel_types?: ChannelType[];
// min_value?: number;
// max_value?: number;
// min_length?: number;
// max_length?: number;
// autocomplete?: boolean;
// };
export interface UnicordArgumentDefinition {
name: string;
description: string;
type: UnicordArgumentType;
required: boolean;
choices?: Array<{ name: string; value: string | number }>;
}
export interface UnicordCommandOptions {
description?: string;
args: UnicordArgumentDefinition[];
}
export enum UnicordEventType {
SystemEvent = 'system',
ChatCommands = 'chat_commands',
ApplicationCommands = 'application_commands',
Interactions = 'interactions',
Custom = 'custom',
}
export interface UnicordBaseContext extends Record<string, any> {
type: UnicordEventType;
}
export interface UnicordEventContext extends UnicordBaseContext {}
export type DefinitionTypeMap = {
[UnicordArgumentType.String]: string;
[UnicordArgumentType.Integer]: number;
[UnicordArgumentType.Boolean]: boolean;
[UnicordArgumentType.Number]: number;
// [UnicordArgumentType.User]: User, // Could be a user ID or object depending on your use case
[UnicordArgumentType.Channel]: string; // Could be a channel ID or object
[UnicordArgumentType.Role]: string; // Could be a role ID or object
[UnicordArgumentType.Mentionable]: string; // Could be a mentionable ID or object
[UnicordArgumentType.Attachment]: string; // Could represent attachment IDs
[UnicordArgumentType.SubCommand]: string; // Handled by `options`
[UnicordArgumentType.SubCommandGroup]: string; // Handled by `options`
};
export type ArgumentTypeFromOptions<T extends readonly UnicordArgumentDefinition[]> = {
[Definition in T[number] as Definition['name']]: Definition['type'] extends keyof DefinitionTypeMap
? Definition['choices'] extends readonly { value: infer U }[]
? Definition['required'] extends true // If required, keep as is; otherwise, make optional
? U
: U | undefined
: Definition['required'] extends true // If required, keep as is; otherwise, make optional
? DefinitionTypeMap[Definition['type']]
: DefinitionTypeMap[Definition['type']] | undefined
: unknown;
};
export type UnicordCommandHandler<Options extends UnicordCommandOptions> = (
context: UnicordCommandContext,
args: ArgumentTypeFromOptions<Options['args']>,
) => any;
export type UnicordEventHandler = (payload: EventPayload) => any;
export type UnicordHandler<Options extends UnicordCommandOptions> = UnicordCommandHandler<Options> | UnicordEventHandler;
export type ClientConfig = {
token: string;
application_id: string;
prefix?: string;
intents?: Intent[];
};
export enum Expectation {
Guild = 'guild',
Channel = 'channel',
User = 'user',
Message = 'message',
}
export enum HandlerType {
Events = 'events',
ChatCommands = 'chat_commands',
ApplicationCommands = 'application_commands',
Interactions = 'interactions',
}
export type Expects<T extends Array<Expectation>, K extends Expectation, R = true> = Extract<K, T[number]> extends never
? undefined
: R;
export const clone: unique symbol = Symbol('clone');
export const fromDiscord: unique symbol = Symbol('fromDiscord');
export const execute: unique symbol = Symbol('execute');
export const Trait: {
clone: typeof clone;
fromDiscord: typeof fromDiscord;
execute: typeof execute;
} = {
clone,
fromDiscord,
execute,
};
export type EventPayload = any;
/**
* Represents a payload structure used in the Discord API.
*
* @typedef {Object} Payload
* @property {number} op - The opcode of the payload.
* @property {Record<string, any>} d - The data associated with the payload.
* @property {number | null} s - The sequence number, or null if not applicable.
* @property {string | null} t - The event name, or null if not applicable.
*/
export type Payload = {
op: number;
d: Record<string, any> | Primitive | null;
s: number | null;
t: string | null;
};
export enum InteractionResponseType {
Pong = 1,
Message = 4,
DeferredMessage = 5,
}
export enum Intent {
GUILDS = 1 << 0,
GUILD_MEMBERS = 1 << 1,
GUILD_BANS = 1 << 2,
GUILD_EMOJIS_AND_STICKERS = 1 << 3,
GUILD_INTEGRATIONS = 1 << 4,
GUILD_WEBHOOKS = 1 << 5,
GUILD_INVITES = 1 << 6,
GUILD_VOICE_STATES = 1 << 7,
GUILD_PRESENCES = 1 << 8,
GUILD_MESSAGES = 1 << 9,
GUILD_MESSAGE_REACTIONS = 1 << 10,
GUILD_MESSAGE_TYPING = 1 << 11,
DIRECT_MESSAGES = 1 << 12,
DIRECT_MESSAGE_REACTIONS = 1 << 13,
DIRECT_MESSAGE_TYPING = 1 << 14,
MESSAGE_CONTENT = 1 << 15,
DEFAULT = 513,
ALL = 32767,
}
export const IntentGroup: { [key in Intent]?: [string[], number] } = {
[Intent.GUILDS]: [
[
'GUILD_CREATE',
'GUILD_UPDATE',
'GUILD_DELETE',
'GUILD_ROLE_CREATE',
'GUILD_ROLE_UPDATE',
'GUILD_ROLE_DELETE',
'CHANNEL_CREATE',
'CHANNEL_UPDATE',
'CHANNEL_DELETE',
'CHANNEL_PINS_UPDATE',
'THREAD_CREATE',
'THREAD_UPDATE',
'THREAD_DELETE',
'THREAD_LIST_SYNC',
'THREAD_MEMBER_UPDATE',
'THREAD_MEMBERS_UPDATE *',
'STAGE_INSTANCE_CREATE',
'STAGE_INSTANCE_UPDATE',
'STAGE_INSTANCE_DELETE',
],
0,
],
[Intent.GUILD_MEMBERS]: [
['GUILD_MEMBER_ADD', 'GUILD_MEMBER_UPDATE', 'GUILD_MEMBER_REMOVE', 'THREAD_MEMBERS_UPDATE *'],
1,
],
[Intent.GUILD_BANS]: [['GUILD_BAN_ADD', 'GUILD_BAN_REMOVE'], 2],
[Intent.GUILD_EMOJIS_AND_STICKERS]: [['GUILD_EMOJIS_UPDATE', 'GUILD_STICKERS_UPDATE'], 3],
[Intent.GUILD_INTEGRATIONS]: [
['GUILD_INTEGRATIONS_UPDATE', 'INTEGRATION_CREATE', 'INTEGRATION_UPDATE', 'INTEGRATION_DELETE'],
4,
],
[Intent.GUILD_WEBHOOKS]: [['WEBHOOKS_UPDATE'], 5],
[Intent.GUILD_INVITES]: [['INVITE_CREATE', 'INVITE_DELETE'], 6],
[Intent.GUILD_VOICE_STATES]: [['VOICE_STATE_UPDATE'], 7],
[Intent.GUILD_PRESENCES]: [['PRESENCE_UPDATE'], 8],
[Intent.GUILD_MESSAGES]: [['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK'], 9],
[Intent.GUILD_MESSAGE_REACTIONS]: [
[
'MESSAGE_REACTION_ADD',
'MESSAGE_REACTION_REMOVE',
'MESSAGE_REACTION_REMOVE_ALL',
'MESSAGE_REACTION_REMOVE_EMOJI',
],
10,
],
[Intent.GUILD_MESSAGE_TYPING]: [['TYPING_START'], 11],
[Intent.DIRECT_MESSAGES]: [['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'CHANNEL_PINS_UPDATE'], 12],
[Intent.DIRECT_MESSAGE_REACTIONS]: [
[
'MESSAGE_REACTION_ADD',
'MESSAGE_REACTION_REMOVE',
'MESSAGE_REACTION_REMOVE_ALL',
'MESSAGE_REACTION_REMOVE_EMOJI',
],
13,
],
[Intent.DIRECT_MESSAGE_TYPING]: [['TYPING_START'], 14],
[Intent.MESSAGE_CONTENT]: [['MESSAGE_CONTENT'], 15],
};
export type DiscordAPIEvent =
| 'READY'
| 'RESUMED'
| 'CHANNEL_CREATE'
| 'CHANNEL_UPDATE'
| 'CHANNEL_DELETE'
| 'CHANNEL_PINS_UPDATE'
| 'THREAD_CREATE'
| 'THREAD_UPDATE'
| 'THREAD_DELETE'
| 'THREAD_LIST_SYNC'
| 'THREAD_MEMBER_UPDATE'
| 'THREAD_MEMBERS_UPDATE *'
| 'STAGE_INSTANCE_CREATE'
| 'STAGE_INSTANCE_UPDATE'
| 'STAGE_INSTANCE_DELETE'
| 'GUILD_CREATE'
| 'GUILD_UPDATE'
| 'GUILD_DELETE'
| 'GUILD_ROLE_CREATE'
| 'GUILD_ROLE_UPDATE'
| 'GUILD_ROLE_DELETE'
| 'GUILD_MEMBER_ADD'
| 'GUILD_MEMBER_UPDATE'
| 'GUILD_MEMBER_REMOVE'
| 'GUILD_MEMBERS_CHUNK *'
| 'GUILD_BAN_ADD'
| 'GUILD_BAN_REMOVE'
| 'GUILD_EMOJIS_UPDATE'
| 'GUILD_STICKERS_UPDATE'
| 'GUILD_INTEGRATIONS_UPDATE'
| 'INTEGRATION_CREATE'
| 'INTEGRATION_UPDATE'
| 'INTEGRATION_DELETE'
| 'WEBHOOKS_UPDATE'
| 'INVITE_CREATE'
| 'INVITE_DELETE'
| 'VOICE_STATE_UPDATE'
| 'MESSAGE_CREATE'
| 'MESSAGE_UPDATE'
| 'MESSAGE_DELETE'
| 'MESSAGE_DELETE_BULK'
| 'MESSAGE_REACTION_ADD'
| 'MESSAGE_REACTION_REMOVE'
| 'MESSAGE_REACTION_REMOVE_ALL'
| 'MESSAGE_REACTION_REMOVE_EMOJI'
| 'TYPING_START'
| 'PRESENCE_UPDATE'
| 'APPLICATION_COMMAND_CREATE'
| 'APPLICATION_COMMAND_UPDATE'
| 'APPLICATION_COMMAND_DELETE';
export enum DiscordAPIEvents {
READY = 'READY',
RESUMED = 'RESUMED',
CHANNEL_CREATE = 'CHANNEL_CREATE',
CHANNEL_UPDATE = 'CHANNEL_UPDATE',
CHANNEL_DELETE = 'CHANNEL_DELETE',
CHANNEL_PINS_UPDATE = 'CHANNEL_PINS_UPDATE',
THREAD_CREATE = 'THREAD_CREATE',
THREAD_UPDATE = 'THREAD_UPDATE',
THREAD_DELETE = 'THREAD_DELETE',
THREAD_LIST_SYNC = 'THREAD_LIST_SYNC',
THREAD_MEMBER_UPDATE = 'THREAD_MEMBER_UPDATE',
THREAD_MEMBERS_UPDATE = 'THREAD_MEMBERS_UPDATE *',
STAGE_INSTANCE_CREATE = 'STAGE_INSTANCE_CREATE',
STAGE_INSTANCE_UPDATE = 'STAGE_INSTANCE_UPDATE',
STAGE_INSTANCE_DELETE = 'STAGE_INSTANCE_DELETE',
GUILD_CREATE = 'GUILD_CREATE',
GUILD_UPDATE = 'GUILD_UPDATE',
GUILD_DELETE = 'GUILD_DELETE',
GUILD_ROLE_CREATE = 'GUILD_ROLE_CREATE',
GUILD_ROLE_UPDATE = 'GUILD_ROLE_UPDATE',
GUILD_ROLE_DELETE = 'GUILD_ROLE_DELETE',
GUILD_MEMBER_ADD = 'GUILD_MEMBER_ADD',
GUILD_MEMBER_UPDATE = 'GUILD_MEMBER_UPDATE',
GUILD_MEMBER_REMOVE = 'GUILD_MEMBER_REMOVE',
GUILD_MEMBERS_CHUNK = 'GUILD_MEMBERS_CHUNK *',
GUILD_BAN_ADD = 'GUILD_BAN_ADD',
GUILD_BAN_REMOVE = 'GUILD_BAN_REMOVE',
GUILD_EMOJIS_UPDATE = 'GUILD_EMOJIS_UPDATE',
GUILD_STICKERS_UPDATE = 'GUILD_STICKERS_UPDATE',
GUILD_INTEGRATIONS_UPDATE = 'GUILD_INTEGRATIONS_UPDATE',
INTEGRATION_CREATE = 'INTEGRATION_CREATE',
INTEGRATION_UPDATE = 'INTEGRATION_UPDATE',
INTEGRATION_DELETE = 'INTEGRATION_DELETE',
WEBHOOKS_UPDATE = 'WEBHOOKS_UPDATE',
INVITE_CREATE = 'INVITE_CREATE',
INVITE_DELETE = 'INVITE_DELETE',
VOICE_STATE_UPDATE = 'VOICE_STATE_UPDATE',
MESSAGE_CREATE = 'MESSAGE_CREATE',
MESSAGE_UPDATE = 'MESSAGE_UPDATE',
MESSAGE_DELETE = 'MESSAGE_DELETE',
MESSAGE_DELETE_BULK = 'MESSAGE_DELETE_BULK',
MESSAGE_REACTION_ADD = 'MESSAGE_REACTION_ADD',
MESSAGE_REACTION_REMOVE = 'MESSAGE_REACTION_REMOVE',
MESSAGE_REACTION_REMOVE_ALL = 'MESSAGE_REACTION_REMOVE_ALL',
MESSAGE_REACTION_REMOVE_EMOJI = 'MESSAGE_REACTION_REMOVE_EMOJI',
TYPING_START = 'TYPING_START',
PRESENCE_UPDATE = 'PRESENCE_UPDATE',
APPLICATION_COMMAND_CREATE = 'APPLICATION_COMMAND_CREATE',
APPLICATION_COMMAND_UPDATE = 'APPLICATION_COMMAND_UPDATE',
APPLICATION_COMMAND_DELETE = 'APPLICATION_COMMAND_DELETE',
}
+182
View File
@@ -0,0 +1,182 @@
import { Message } from './message';
type EmbedFooter = {
text: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedImage = {
url?: string;
proxy_url?: string;
height?: number;
width?: number;
};
type EmbedAuthor = {
name: string;
url?: string;
icon_url?: string;
proxy_icon_url?: string;
};
type EmbedVideo = {
url?: string;
height?: number;
width?: number;
};
type EmbedField = {
name: string;
value: string;
inline?: boolean;
};
// === Embed Structure ===
// | Field | Type | Description |
// |--------------|--------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|
// | title? | string | title of embed |
// | type? | string | [type of embed](/docs/resources/message#embed-object-embed-types) (always "rich" for webhook embeds) |
// | description? | string | description of embed |
// | url? | string | url of embed |
// | timestamp? | ISO8601 timestamp | timestamp of embed content |
// | color? | integer | color code of the embed |
// | footer? | [embed footer](/docs/resources/message#embed-object-embed-footer-structure) object | footer information |
// | image? | [embed image](/docs/resources/message#embed-object-embed-image-structure) object | image information |
// | thumbnail? | [embed thumbnail](/docs/resources/message#embed-object-embed-thumbnail-structure) object | thumbnail information |
// | video? | [embed video](/docs/resources/message#embed-object-embed-video-structure) object | video information |
// | provider? | [embed provider](/docs/resources/message#embed-object-embed-provider-structure) object | provider information |
// | author? | [embed author](/docs/resources/message#embed-object-embed-author-structure) object | author information |
// | fields? | array of [embed field](/docs/resources/message#embed-object-embed-field-structure) objects | fields information, max of 25 |
export class Embed {
title?: string;
description?: string;
url?: string;
timestamp?: string;
color?: number;
footer?: EmbedFooter;
image?: EmbedImage;
thumbnail?: EmbedImage;
video?: EmbedVideo;
provider?: {
name?: string;
url?: string;
};
author?: EmbedImage & {
name?: string;
};
fields: EmbedField[];
constructor() {
this.fields = [];
}
setTitle = (title: string) => {
this.title = title;
return this;
};
setDescription = (description: string) => {
this.description = description;
return this;
};
setUrl = (url: string) => {
this.url = url;
return this;
};
setTimestamp = (timestamp: string) => {
this.timestamp = timestamp;
return this;
};
setColor = (color: number | string) => {
this.color = typeof color === 'string' ? parseInt(`0x${color.replace('#', '')}`) : color;
return this;
};
setFooter = (footer: EmbedFooter) => {
this.footer = footer;
return this;
};
setImage = (image: EmbedImage) => {
this.image = image;
return this;
};
setThumbnail = (thumbnail: EmbedImage) => {
this.thumbnail = thumbnail;
return this;
};
setVideo = (video: EmbedVideo) => {
this.video = video;
return this;
};
setProvider = (provider: { name?: string; url?: string }) => {
this.provider = provider;
return this;
};
setAuthor = (author: EmbedAuthor) => {
this.author = author;
return this;
};
addField = (field: EmbedField) => {
this.fields.push(field);
return this;
};
addFields = (fields: EmbedField[]) => {
this.fields.push(...fields);
return this;
};
toMessage = () => {
return new Message().addEmbed(this);
};
toJSON = () => {
return {
title: this.title,
description: this.description,
url: this.url,
timestamp: this.timestamp,
color: this.color,
footer: this.footer,
image: this.image,
thumbnail: this.thumbnail,
video: this.video,
provider: this.provider,
author: this.author,
fields: this.fields
};
};
}
export interface DiscordEmbed {
title?: string;
type?: string;
description?: string;
url?: string;
timestamp?: string;
color?: number;
footer?: EmbedFooter;
image?: EmbedImage;
thumbnail?: EmbedImage;
video?: EmbedVideo;
provider?: {
name?: string;
url?: string;
};
author?: EmbedImage & {
name?: string;
};
fields?: EmbedField[];
}
+200
View File
@@ -0,0 +1,200 @@
import { UnicordGatewayObject } from './base';
import { Trait } from './common';
// import { Emoji } from "./emoji";
// import { Member } from "./member";
// import { Role } from "./role";
export class Guild extends UnicordGatewayObject<Guild, DiscordGuild> {
id: string;
name: string;
icon: string;
icon_hash?: string;
splash: string;
discovery_splash: string;
owner?: boolean;
owner_id: string;
permissions?: string;
region?: string;
afk_channel_id: string;
afk_timeout: number;
widget_enabled?: boolean;
widget_channel_id?: string;
verification_level: number;
default_message_notifications: number;
explicit_content_filter: number;
roles: Record<string, any>[];
emojis: Record<string, any>[];
features: Record<string, any>[];
mfa_level: number;
application_id: string;
system_channel_id: string;
system_channel_flags: number;
rules_channel_id: string;
max_presences?: number;
max_members?: number;
vanity_url_code: string;
description: string;
banner: string;
premium_tier: number;
premium_subscription_count?: number;
preferred_locale: string;
public_updates_channel_id: string;
max_video_channel_users?: number;
max_stage_video_channel_users?: number;
approximate_member_count?: number;
approximate_presence_count?: number;
welcome_screen?: Record<string, any>;
nsfw_level: number;
stickers?: string;
premium_progress_bar_enabled: boolean;
safety_alerts_channel_id: string;
incidents_data: Record<string, any>;
constructor(data: DiscordGuild) {
super();
this.id = data.id;
this.name = data.name;
this.icon = data.icon;
this.icon_hash = data.icon_hash;
this.splash = data.splash;
this.discovery_splash = data.discovery_splash;
this.owner = data.owner;
this.owner_id = data.owner_id;
this.permissions = data.permissions;
this.region = data.region;
this.afk_channel_id = data.afk_channel_id;
this.afk_timeout = data.afk_timeout;
this.widget_enabled = data.widget_enabled;
this.widget_channel_id = data.widget_channel_id;
this.verification_level = data.verification_level;
this.default_message_notifications = data.default_message_notifications;
this.explicit_content_filter = data.explicit_content_filter;
this.roles = data.roles;
this.emojis = data.emojis;
this.features = data.features;
this.mfa_level = data.mfa_level;
this.application_id = data.application_id;
this.system_channel_id = data.system_channel_id;
this.system_channel_flags = data.system_channel_flags;
this.rules_channel_id = data.rules_channel_id;
this.max_presences = data.max_presences;
this.max_members = data.max_members;
this.vanity_url_code = data.vanity_url_code;
this.description = data.description;
this.banner = data.banner;
this.premium_tier = data.premium_tier;
this.premium_subscription_count = data.premium_subscription_count;
this.preferred_locale = data.preferred_locale;
this.public_updates_channel_id = data.public_updates_channel_id;
this.max_video_channel_users = data.max_video_channel_users;
this.max_stage_video_channel_users = data.max_stage_video_channel_users;
this.approximate_member_count = data.approximate_member_count;
this.approximate_presence_count = data.approximate_presence_count;
this.welcome_screen = data.welcome_screen;
this.nsfw_level = data.nsfw_level;
this.stickers = data.stickers;
this.premium_progress_bar_enabled = data.premium_progress_bar_enabled;
this.safety_alerts_channel_id = data.safety_alerts_channel_id;
this.incidents_data = data.incidents_data;
}
public static [Trait.fromDiscord]<T = Guild>(data: DiscordGuild): T {
return new Guild(data) as T;
}
}
// === Guild Structure ===
// | Field | Type | Description |
// |--------------------------------|-------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
// | id | snowflake | guild id |
// | name | string | guild name (2-100 characters, excluding trailing and leading whitespace) |
// | icon | ?string | [icon hash](/docs/reference#image-formatting) |
// | icon_hash? | ?string | [icon hash](/docs/reference#image-formatting), returned when in the template object |
// | splash | ?string | [splash hash](/docs/reference#image-formatting) |
// | discovery_splash | ?string | [discovery splash hash](/docs/reference#image-formatting); only present for guilds with the "DISCOVERABLE" feature |
// | owner? \* | boolean | true if [the user](/docs/resources/user#get-current-user-guilds) is the owner of the guild |
// | owner_id | snowflake | id of owner |
// | permissions? \* | string | total permissions for [the user](/docs/resources/user#get-current-user-guilds) in the guild (excludes overwrites and [implicit permissions](/docs/topics/permissions#implicit-permissions)) |
// | region? \*\* | ?string | [voice region](/docs/resources/voice#voice-region-object) id for the guild (deprecated) |
// | afk_channel_id | ?snowflake | id of afk channel |
// | afk_timeout | integer | afk timeout in seconds |
// | widget_enabled? | boolean | true if the server widget is enabled |
// | widget_channel_id? | ?snowflake | the channel id that the widget will generate an invite to, or `null` if set to no invite |
// | verification_level | integer | [verification level](/docs/resources/guild#guild-object-verification-level) required for the guild |
// | default_message_notifications | integer | default [message notifications level](/docs/resources/guild#guild-object-default-message-notification-level) |
// | explicit_content_filter | integer | [explicit content filter level](/docs/resources/guild#guild-object-explicit-content-filter-level) |
// | roles | array of [role](/docs/topics/permissions#role-object) objects | roles in the guild |
// | emojis | array of [emoji](/docs/resources/emoji#emoji-object) objects | custom guild emojis |
// | features | array of [guild feature](/docs/resources/guild#guild-object-guild-features) strings | enabled guild features |
// | mfa_level | integer | required [MFA level](/docs/resources/guild#guild-object-mfa-level) for the guild |
// | application_id | ?snowflake | application id of the guild creator if it is bot-created |
// | system_channel_id | ?snowflake | the id of the channel where guild notices such as welcome messages and boost events are posted |
// | system_channel_flags | integer | [system channel flags](/docs/resources/guild#guild-object-system-channel-flags) |
// | rules_channel_id | ?snowflake | the id of the channel where Community guilds can display rules and/or guidelines |
// | max_presences? | ?integer | the maximum number of presences for the guild (`null` is always returned, apart from the largest of guilds) |
// | max_members? | integer | the maximum number of members for the guild |
// | vanity_url_code | ?string | the vanity url code for the guild |
// | description | ?string | the description of a guild |
// | banner | ?string | [banner hash](/docs/reference#image-formatting) |
// | premium_tier | integer | [premium tier](/docs/resources/guild#guild-object-premium-tier) (Server Boost level) |
// | premium_subscription_count? | integer | the number of boosts this guild currently has |
// | preferred_locale | string | the preferred [locale](/docs/reference#locales) of a Community guild; used in server discovery and notices from Discord, and sent in interactions; defaults to "en-US" |
// | public_updates_channel_id | ?snowflake | the id of the channel where admins and moderators of Community guilds receive notices from Discord |
// | max_video_channel_users? | integer | the maximum amount of users in a video channel |
// | max_stage_video_channel_users? | integer | the maximum amount of users in a stage video channel |
// | approximate_member_count? | integer | approximate number of members in this guild, returned from the `GET /guilds/<id>` and `/users/@me/guilds` endpoints when `with_counts` is `true` |
// | approximate_presence_count? | integer | approximate number of non-offline members in this guild, returned from the `GET /guilds/<id>` and `/users/@me/guilds` endpoints when `with_counts` is `true` |
// | welcome_screen? | [welcome screen](/docs/resources/guild#welcome-screen-object) object | the welcome screen of a Community guild, shown to new members, returned in an [Invite](/docs/resources/invite#invite-object)'s guild object |
// | nsfw_level | integer | [guild NSFW level](/docs/resources/guild#guild-object-guild-nsfw-level) |
// | stickers? | array of [sticker](/docs/resources/sticker#sticker-object) objects | custom guild stickers |
// | premium_progress_bar_enabled | boolean | whether the guild has the boost progress bar enabled |
// | safety_alerts_channel_id | ?snowflake | the id of the channel where admins and moderators of Community guilds receive safety alerts from Discord |
// | incidents_data | ?[incidents data](/docs/resources/guild#incidents-data-object) object | the incidents data for this guild |
export interface DiscordGuild {
id: string;
name: string;
icon: string;
icon_hash?: string;
splash: string;
discovery_splash: string;
owner?: boolean;
owner_id: string;
permissions?: string;
region?: string;
afk_channel_id: string;
afk_timeout: number;
widget_enabled?: boolean;
widget_channel_id?: string;
verification_level: number;
default_message_notifications: number;
explicit_content_filter: number;
roles: Record<string, any>[];
emojis: Record<string, any>[];
features: Record<string, any>[];
mfa_level: number;
application_id: string;
system_channel_id: string;
system_channel_flags: number;
rules_channel_id: string;
max_presences?: number;
max_members?: number;
vanity_url_code: string;
description: string;
banner: string;
premium_tier: number;
premium_subscription_count?: number;
preferred_locale: string;
public_updates_channel_id: string;
max_video_channel_users?: number;
max_stage_video_channel_users?: number;
approximate_member_count?: number;
approximate_presence_count?: number;
welcome_screen?: Record<string, any>;
nsfw_level: number;
stickers?: string;
premium_progress_bar_enabled: boolean;
safety_alerts_channel_id: string;
incidents_data: Record<string, any>;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './common';
export * from './user';
export * from './channel';
export * from './guild';
export * from './message';
export * from './embed';
+281
View File
@@ -0,0 +1,281 @@
import { exists, isNil } from '../utils';
import { UnicordGatewayObject } from './base';
import type { Channel } from './channel';
import { fromDiscord, Trait, type Expectation } from './common';
import type { DiscordEmbed, Embed } from './embed';
import type { Guild } from './guild';
import { User, type DiscordUser } from './user';
export type Component = Partial<Button | SelectMenu> & {
type: ComponentType;
content?: string;
components?: Component[];
};
// {
// content?: string;
// type?: ComponentType;
// description?: string;
// label?: string;
// value?: string;
// style?: ComponentStyle;
// custom_id?: string;
// url?: string;
// disabled?: boolean;
// required?: boolean;
// emoji?: {
// name: string;
// id?: string;
// animated?: boolean;
// }
// options?: Component[];
// placeholder?: string;
// min_values?: number;
// max_values?: number;
// components?: Component[];
// }
export type Emoji = {
name: string | null;
id: string | null;
animated?: boolean;
};
export type Button = {
style: ComponentStyle;
label: string;
emoji?: Emoji;
custom_id?: string;
url?: string;
disabled?: boolean;
};
export type SelectMenu = {
custom_id: string;
options: {
label: string;
value: string;
description?: string;
emoji?: Emoji;
default?: boolean;
}[];
placeholder?: string;
min_values?: number;
max_values?: number;
};
export enum ComponentStyle {
Primary = 1,
Secondary,
Success,
Danger,
Link,
}
export enum ComponentType {
ActionRow = 1,
Button,
SelectMenu,
TextInput,
UserSelect,
RoleSelect,
MentionableSelect,
ChannelSelect,
}
export type HydratedMessage<T extends Array<Expectation>> = MessagePayload & {
channel: Extract<Expectation.Channel, T[number]> extends never ? undefined : Channel;
reference: Extract<Expectation.Message, T[number]> extends never ? undefined : MessagePayload;
guild: Extract<Expectation.Guild, T[number]> extends never ? undefined : Guild;
};
export type MessagePayload = Message & {
id: string;
author: User;
channel_id: string;
content: string;
embeds: Embed[];
components: Component[];
message_reference?: {
id: string;
channel_id: string;
guild_id?: string;
};
timestamp: Date;
};
export class Message extends UnicordGatewayObject<Message, DiscordMessage> {
id?: string;
author?: User;
channel_id?: string;
guild_id?: string;
public content: string;
public embeds: Embed[] = [];
public components: Component[] = [];
public message_reference?: {
id: string;
channel_id: string;
guild_id?: string;
};
timestamp?: Date;
constructor(data?: Partial<Message>) {
super();
this.id = data?.id;
this.author = data?.author;
this.channel_id = data?.channel_id;
this.guild_id = data?.guild_id;
this.content = data?.content ?? '';
this.message_reference = data?.message_reference;
this.timestamp = data?.timestamp;
}
// NOTE: Overriden here because the `fromDiscord` method returns a `MessagePayload` object
// and the GatewayObject getter overrides the return type to `Message`
// TODO: Fix type in GatewayObject so this override is not needed
public static get fromDiscord() {
return this[Trait.fromDiscord];
}
public static [Trait.fromDiscord]<T = MessagePayload>(data: DiscordMessage): T {
return new Message({
id: data.id,
author: User[fromDiscord](data.author),
channel_id: data.channel_id,
guild_id: data.guild_id,
content: data.content,
// TODO: Add embeds
// embeds: data.embeds.map(embed => Embed.fromDiscord(embed)),
// TODO: Add components
// components: data.components,
message_reference: exists(data.message_reference)
? {
id: data.message_reference.message_id,
channel_id: data.message_reference.channel_id,
guild_id: data.message_reference.guild_id,
}
: undefined,
timestamp: new Date(data.timestamp),
}) as T;
}
public static button(config: Button): Component {
return {
type: ComponentType.Button,
...config,
};
}
public static selectMenu(config: SelectMenu): Component {
return {
type: ComponentType.SelectMenu,
...config,
};
}
public setContent = (content: string) => {
this.content = content;
return this;
};
public addEmbed = (embed: Embed) => {
this.embeds.push(embed);
return this;
};
public addComponent = (content: Component | string, ...components: Component[]) => {
if (typeof content === 'string') {
this.components.push({
type: ComponentType.ActionRow,
content,
components,
});
} else {
this.components.push({
type: ComponentType.ActionRow,
components: [content, ...components],
});
}
return this;
};
public setReference = (message: Message | Partial<Message>) => {
if (isNil(message.id) || isNil(message.channel_id)) {
throw new Error('Message ID and Channel ID are required');
}
let ref: Message;
if (!(message instanceof Message)) {
ref = new Message(message);
} else {
ref = message;
}
this.message_reference = {
id: ref.id!,
channel_id: ref.channel_id!,
guild_id: ref.guild_id,
};
return this;
};
public toJSON = () => {
return {
content: this.content,
embeds: this.embeds,
components: this.components,
message_reference: this.message_reference
? {
message_id: this.message_reference.id,
channel_id: this.message_reference.channel_id,
guild_id: this.message_reference.guild_id,
}
: undefined,
};
};
}
export type DiscordMessage = {
id: string;
channel_id: string;
guild_id?: string;
author: DiscordUser;
// member?: DiscordGuildMember;
content: string;
// ISO8601 timestamp
timestamp: string;
edited_timestamp: string | null;
tts: boolean;
mention_everyone: boolean;
mentions: DiscordUser[];
mention_roles: string[];
mention_channels?: { id: string; guild_id: string; type: number; name: string }[];
attachments: { id: string; filename: string; url: string; proxy_url: string; size: number }[];
embeds: DiscordEmbed[];
reactions?: { count: number; me: boolean; emoji: Emoji }[];
nonce?: string | number;
pinned: boolean;
webhook_id?: string;
type: number;
activity?: { type: number; party_id?: string };
application?: { id: string; cover_image?: string; description: string; icon?: string; name: string };
application_id?: string;
message_reference?: { message_id: string; channel_id: string; guild_id?: string; fail_if_not_exists?: boolean };
flags?: number;
referenced_message?: DiscordMessage | null;
interaction?: { id: string; type: number; name: string; user: DiscordUser };
thread?: { id: string; name: string; type: number };
components?: any[];
sticker_items?: { id: string; name: string; format_type: number }[];
stickers?: {
id: string;
pack_id?: string;
name: string;
description?: string;
tags: string;
asset: string;
preview_asset?: string;
format_type: number;
}[];
};
+110
View File
@@ -0,0 +1,110 @@
import { UnicordGatewayObject } from './base';
import { Trait } from './common';
export class User extends UnicordGatewayObject<User, DiscordUser> {
id: string;
username: string;
discriminator: string;
global_name: string;
avatar: string;
bot?: boolean;
system?: boolean;
mfa_enabled?: boolean;
banner?: string;
accent_color?: number;
locale?: string;
verified?: boolean;
email?: string;
// TODO: Add classes and enums for the following properties
flags?: number;
premium_type?: number;
public_flags?: number;
avatar_decoration_data?: Record<string, any>;
collectibles?: Record<string, any>;
primary_guild?: Record<string, any>;
get avatarURL() {
// Check if avatar is an animated gif
if (this.avatar?.startsWith('a_')) {
return `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.gif`;
} else {
return `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.png`;
}
}
constructor(data: DiscordUser) {
super();
this.id = data.id;
this.username = data.username;
this.discriminator = data.discriminator;
this.global_name = data.global_name;
this.avatar = data.avatar;
this.bot = data.bot;
this.system = data.system;
this.mfa_enabled = data.mfa_enabled;
this.banner = data.banner;
this.accent_color = data.accent_color;
this.locale = data.locale;
this.verified = data.verified;
this.email = data.email;
this.flags = data.flags;
this.premium_type = data.premium_type;
this.public_flags = data.public_flags;
this.avatar_decoration_data = data.avatar_decoration_data;
this.collectibles = data.collectibles;
this.primary_guild = data.primary_guild;
}
// TODO: Add a clone method to subclasses
// public [Trait.clone]<T = User>(data: DiscordUser): T { ... };
public static [Trait.fromDiscord]<T = User>(data: DiscordUser): T {
return new User(data) as T;
}
}
// === Discord User Structure ===
// | Field | Type | Description | Required OAuth2 Scope |
// |-------------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|-----------------------|
// | id | snowflake | the user's id | identify |
// | username | string | the user's username, not unique across the platform | identify |
// | discriminator | string | the user's Discord-tag | identify |
// | global_name | ?string | the user's display name, if it is set. For bots, this is the application name | identify |
// | avatar | ?string | the user's [avatar hash](/docs/reference#image-formatting) | identify |
// | bot? | boolean | whether the user belongs to an OAuth2 application | identify |
// | system? | boolean | whether the user is an Official Discord System user (part of the urgent message system) | identify |
// | mfa_enabled? | boolean | whether the user has two factor enabled on their account | identify |
// | banner? | ?string | the user's [banner hash](/docs/reference#image-formatting) | identify |
// | accent_color? | ?integer | the user's banner color encoded as an integer representation of hexadecimal color code | identify |
// | locale? | string | the user's chosen [language option](/docs/reference#locales) | identify |
// | verified? | boolean | whether the email on this account has been verified | email |
// | email? | ?string | the user's email | email |
// | flags? | integer | the [flags](/docs/resources/user#user-object-user-flags) on a user's account | identify |
// | premium_type? | integer | the [type of Nitro subscription](/docs/resources/user#user-object-premium-types) on a user's account | identify |
// | public_flags? | integer | the public [flags](/docs/resources/user#user-object-user-flags) on a user's account | identify |
// | avatar_decoration_data? | ?[avatar decoration data](/docs/resources/user#avatar-decoration-data-object) object | data for the user's avatar decoration | identify |
// | collectibles? | ?[collectibles](/docs/resources/user#collectibles) object | data for the user's collectibles | identify |
// | primary_guild? | ?[user primary guild](/docs/resources/user#user-object-user-primary-guild) object | the user's primary guild | identify |
export interface DiscordUser {
id: string;
username: string;
discriminator: string;
global_name: string;
avatar: string;
bot?: boolean;
system?: boolean;
mfa_enabled?: boolean;
banner?: string;
accent_color?: number;
locale?: string;
verified?: boolean;
email?: string;
flags?: number;
premium_type?: number;
public_flags?: number;
avatar_decoration_data?: Record<string, any>;
collectibles?: Record<string, any>;
primary_guild?: Record<string, any>;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Sets top level properties when undefined on the original object — cretates a new object, does not modify the original
* @param original original object
* @param defaults defailts to set on the object
* @returns a new object with top level defaults set
*/
export function setDefaults<T>(original: Record<string, any>, defaults: Record<string, any>): T {
const final: Record<string, any> = {};
for (const prop of Object.keys(defaults)) {
final[prop] = defaults[prop];
}
for (const prop of Object.keys(original)) {
final[prop] = original[prop];
}
return final as T;
}
export const isNil = (value: any): value is null | undefined => value === null || value === undefined;
export const exists = <T>(value: T | null | undefined): value is NonNullable<T> => !isNil(value);
export const isObject = (value: any): value is Record<string, any> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const log = (...args: any[]) => {
const LOG_LEVEL = process.env.UNICORD_LOG_LEVEL?.toLocaleLowerCase();
if (LOG_LEVEL === 'debug') {
const timestamp = `\x1b[37m[${new Date().toLocaleTimeString()}]\x1b[0m`;
const prefix = `\x1b[35m[unicord]`;
console.log(`${timestamp} ${prefix} \x1b[0m`, ...args);
}
};
+6 -7
View File
@@ -1,5 +1,5 @@
import { exists, log } from "../utils"; import { exists, log } from '../utils';
import type { Client } from "./client"; import type { Client } from './client';
export class API { export class API {
private readonly base_url: string = 'https://discord.com/api/v10'; 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> { async request<T = any>(method: string, path: string, data?: any): Promise<T> {
const url = `${this.base_url}${path.startsWith('/') ? '' : '/'}${path}`; const url = `${this.base_url}${path.startsWith('/') ? '' : '/'}${path}`;
const headers = { const headers = {
Authorization: `Bot ${this.client.token}`, Authorization: `Bot ${this.client.config.token}`,
'Accept': 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}; };
const response = await fetch(url, { const response = await fetch(url, {
@@ -28,7 +28,6 @@ export class API {
const body = await response.json(); const body = await response.json();
// console.log(`API Request: ${method} ${url}`); // console.log(`API Request: ${method} ${url}`);
// console.log(`API Body: ${JSON.stringify(data, null, 4)}`); // console.log(`API Body: ${JSON.stringify(data, null, 4)}`);
// console.log('API Response:', JSON.stringify(body, 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'); const retryAfter = response.headers.get('Retry-After');
if (exists(retryAfter)) { if (exists(retryAfter)) {
// Wait for the retry-after time // 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 { } else {
// TODO: Handle this case better // TODO: Handle this case better
// Maybe wait some arbitrary time?? // Maybe wait some arbitrary time??
+37 -23
View File
@@ -12,44 +12,42 @@ import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator';
import { Processor } from './processor'; import { Processor } from './processor';
export class Client { export class Client {
readonly token: string; readonly config: Readonly<Required<ClientConfig>>;
readonly application_id: string;
readonly intents: Intent[] = [Intent.DEFAULT];
prefix: string;
// Internal services // Internal services
protected bus: Emitter; protected bus: Emitter;
protected api: API; protected api: API;
protected processor: Processor; protected processor: Processor;
protected gateway: Gateway; protected gateway: Gateway;
guilds: Guilds; // Caches
users: Users; readonly caches: {
messages: Messages; readonly guilds: Guilds;
channels: Channels; readonly users: Users;
chatCommands: Processor[HandlerType.ChatCommands]; readonly messages: Messages;
applicationCommands: Processor[HandlerType.ApplicationCommands]; readonly channels: Channels;
interactions: Processor[HandlerType.Interactions]; };
constructor(config: ClientConfig) { constructor(config: ClientConfig) {
// Validate the configuration // Validate the configuration
Client.validateConfig(config); Client.validateConfig(config);
// Save the configuration // Save the configuration
this.token = config.token; this.config = Object.freeze({
this.application_id = config.application_id; token: config.token,
this.intents = config.intents ?? this.intents; application_id: config.application_id,
this.prefix = config.prefix ?? '!'; intents: config.intents ?? [Intent.DEFAULT],
prefix: config.prefix ?? '!',
});
// Initialize internal services // Initialize internal services
this.bus = EventBus; this.bus = EventBus;
this.api = new API(this); this.api = new API(this);
this.processor = new Processor(this, this.api, this.bus); this.processor = new Processor(this, this.api, this.bus);
this.gateway = new Gateway(this, this.processor, this.api, this.bus); this.gateway = new Gateway(this, this.processor, this.api, this.bus);
this.guilds = new Guilds(this.api, this.processor); // Initialize caches
this.users = new Users(this.api, this.processor); this.caches = {
this.messages = new Messages(this.api, this.processor); guilds: new Guilds(this.api, this.processor),
this.channels = new Channels(this.api, this.processor); users: new Users(this.api, this.processor),
// Save references to command and event handlers messages: new Messages(this.api, this.processor),
this.chatCommands = this.processor[HandlerType.ChatCommands]; channels: new Channels(this.api, this.processor),
this.applicationCommands = this.processor[HandlerType.ApplicationCommands]; };
this.interactions = this.processor[HandlerType.Interactions];
} }
private static validateConfig(config: ClientConfig) { private static validateConfig(config: ClientConfig) {
@@ -82,6 +80,22 @@ export class Client {
return this.gateway.user; 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. * Hydrates a given data object based on the provided expectations.
* *
+6 -6
View File
@@ -41,13 +41,13 @@ export async function hydrate<T extends Hydrateable, K extends Array<Expectation
for (const expectation of expectations) { for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_id)) { if (expectation === Expectation.Message && exists(data.message_id)) {
let context = data as unknown as HydratedContext<[Expectation.Message, ...Expectation[]]>; 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)) { } else if (expectation === Expectation.Guild && exists(data.guild_id)) {
let context = data as unknown as HydratedContext<[Expectation.Guild, ...Expectation[]]>; 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)) { } else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let context = data as unknown as HydratedContext<[Expectation.Channel, ...Expectation[]]>; 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 { } else {
throw new Error(`Invalid expectation requested for Context: ${expectation}`); 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) { for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_reference?.id)) { if (expectation === Expectation.Message && exists(data.message_reference?.id)) {
let message = data as unknown as HydratedMessage<[Expectation.Message, ...Expectation[]]>; 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)) { } else if (expectation === Expectation.Guild && exists(data.guild_id)) {
let message = data as unknown as HydratedMessage<[Expectation.Guild, ...Expectation[]]>; 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)) { } else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let message = data as unknown as HydratedMessage<[Expectation.Channel, ...Expectation[]]>; 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 { } else {
throw new Error(`Invalid expectation requested for Message: ${expectation}`); throw new Error(`Invalid expectation requested for Message: ${expectation}`);
} }
+12 -12
View File
@@ -1,10 +1,10 @@
import type { Client } from "./client";
import type { Context } from "../types/context";
import { HandlerType, Trait, type EventPayload } from "../types/common";
import { CommandHandler, type Handler, type ArgsFromOptions, OptionConstructorMap, type InteractionData } from "../types/handler";
import type { API } from "./api";
import { ApplicationCommandOptionType, ApplicationCommandType, type ApplicationCommandOption } from "../types/applicationCommand"; import { 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 { Emitter } from "./bus";
import type { Client } from "./client";
export class Processor { export class Processor {
private handlers = { private handlers = {
@@ -102,11 +102,11 @@ export class Processor {
if (!userId) { if (!userId) {
throw new Error(`Invalid user for ${def.name}: expected a user mention or snowflake id, got ${arg}`); 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') { } else if (typeof arg === 'object') {
// Check if the object has an ID // Check if the object has an ID
if (arg.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 { } else {
throw new Error(`Invalid user for ${def.name}: expected a user object with an ID, got ${arg}`); 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), register: (event: string | string[], handler: Handler) => this.register(HandlerType.ChatCommands, event, handler),
unregister: (event: string | string[], handler?: Handler) => this.unregister(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), 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] = { [HandlerType.ApplicationCommands] = {
@@ -154,7 +154,7 @@ export class Processor {
// Ensure the command name is valid // Ensure the command name is valid
// @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming // @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 commandNameRegex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
const commandNameTest = commandNameRegex.test.bind(commandNameRegex) const commandNameTest = commandNameRegex.test.bind(commandNameRegex)
if (!names.every(commandNameTest)) { if (!names.every(commandNameTest)) {
@@ -166,21 +166,21 @@ export class Processor {
const command: Record<string, any> = handler.toJSON(); const command: Record<string, any> = handler.toJSON();
command.name = n; command.name = n;
command.type = type; 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) this.register(HandlerType.ApplicationCommands, names, handler)
}, },
unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.ApplicationCommands, event, 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), 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] = { [HandlerType.Interactions] = {
register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler), register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler),
unregister: (event: string | string[], handler?: Handler) => this.unregister(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), 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[]> => { static isCommandHandler = (handler: Handler): handler is CommandHandler<ApplicationCommandOption[]> => {
+112
View File
@@ -0,0 +1,112 @@
// Here we are defining what we want the interface to look like so that there is a goal to work towards
// Things here are not necessarily implemented in the actual code but show what we want it to look like to use the library
import { Expectation, Unicord, UnicordArgumentType, Intent } from '../rewrite/index.ts';
const DISCORD_APPLICATION_ID = process.env.APPLICATION_ID!;
const DISCORD_CLIENT_TOKEN = process.env.BOT_TOKEN!;
// Create a new client instance
// Most usage will be via chaining methods off the client instance
const client = new Unicord({
intents: [
Intent.GUILDS,
Intent.GUILD_MESSAGES,
Intent.GUILD_MESSAGE_REACTIONS,
Intent.MESSAGE_CONTENT,
Intent.DIRECT_MESSAGES,
Intent.DIRECT_MESSAGE_REACTIONS,
],
application_id: DISCORD_APPLICATION_ID,
token: DISCORD_CLIENT_TOKEN,
prefix: '!!',
})
.registerChatCommand(
'ping',
async (context, args) => {
let response = 'Pong!';
response += ` Hello, ${args.name}!`;
if (!!args.age && args.age > 0) {
response += ` Happy to hear you are ${args.age} years old.`;
}
await context.reply(response);
},
{
description: 'Say hello',
args: [
{
name: 'name',
description: 'Your name',
// Maybe we can use t.String() here but only certain types are valid for command options
// But in theory we could parse the raw value and attempt convert it to requested type here
type: UnicordArgumentType.String,
required: true,
},
{
name: 'age',
description: 'Your age',
type: UnicordArgumentType.Number,
required: false,
},
],
},
)
.registerChatCommand(
'channel',
async (context) => {
const hydrated = await context.hydrate(context, [Expectation.Channel]);
await context.reply(hydrated.channel.name ?? 'Unnamed Channel');
},
{
description: 'Get info about a channel',
args: [],
},
)
.registerApplicationCommand(
'echo',
async (context, args) => {
await context.reply('Echo! ' + args.text);
},
{
description: 'Echo command',
args: [
{
name: 'text',
description: 'Text to echo',
type: UnicordArgumentType.String,
required: true,
},
],
},
)
.onEvent('READY', async (payload) => {
console.log(`Logged in as ${payload.user.username}`);
});
// Allow use of "child" instances inside parent instance
const etc = new Unicord().registerChatCommand(
'zing',
async (context, args) => {
await context.reply(`Zing! You got served!` + args.fav_amimal ? ` One ${args.fav_amimal} please!` : '');
},
{
args: [
{
name: 'fav_amimal',
description: 'Your favorite animal',
type: UnicordArgumentType.String,
required: false,
choices: [
{ name: 'Dog', value: 'dog' },
{ name: 'Cat', value: 'cat' },
{ name: 'Hamster', value: 'hamster' },
{ name: 'Dolphin', value: 'dolphin' },
],
},
],
},
);
// client.use(etc);
client.login();
+4 -8
View File
@@ -175,7 +175,7 @@
} }
.pillars { .pillars {
padding: 6rem 6em; padding: 6rem 12em;
background: var(--gray-alpha-100); background: var(--gray-alpha-100);
min-height: 50vh; min-height: 50vh;
display: flex; display: flex;
@@ -226,7 +226,7 @@
} }
.hydrationSection { .hydrationSection {
padding: 6rem 6em; padding: 6rem 12em;
min-height: 50vh; min-height: 50vh;
display: flex; display: flex;
align-items: space; align-items: space;
@@ -252,14 +252,12 @@
.hydrationText { .hydrationText {
flex: 1; flex: 1;
/* padding: 2rem; */ /* padding: 2rem; */
min-width: 400px;
} }
.hydrationText h2 { .hydrationText h2 {
color: #ff0080; color: #ff0080;
margin: 0 0 1.5rem 0; margin: 0 0 1.5rem 0;
font-size: 2rem; font-size: 2rem;
min-width: 400px;
} }
.hydrationText p { .hydrationText p {
@@ -268,11 +266,10 @@
margin: 1rem 0; margin: 1rem 0;
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.6; line-height: 1.6;
min-width: 400px;
} }
.typeSection { .typeSection {
padding: 6rem 6em; padding: 6rem 12em;
min-height: 50vh; min-height: 50vh;
display: flex; display: flex;
align-items: space; align-items: space;
@@ -292,7 +289,6 @@
.typeText { .typeText {
flex: 1; flex: 1;
min-width: 400px;
} }
.typeCode { .typeCode {
@@ -326,7 +322,7 @@
} }
.installSection { .installSection {
padding: 6rem 6em; padding: 6rem 12em;
min-height: 30vh; min-height: 30vh;
display: flex; display: flex;
align-items: center; align-items: center;