mirror of
https://github.com/jaronpate/unicord.git
synced 2026-10-18 21:47:40 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8318e6399 | |||
| ce45eacb3b |
@@ -16,8 +16,8 @@ export class UnicordObjectCache<Entity> {
|
|||||||
this.cache = new LRUCache<string, Entity>(maxCapacity);
|
this.cache = new LRUCache<string, Entity>(maxCapacity);
|
||||||
this.type = type;
|
this.type = type;
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
this.self.onEvent(event, async (_context, e) => {
|
this.self.onSystemEvent(event, async (payload) => {
|
||||||
this.cache.set(e.id, factory[Trait.fromDiscord](e));
|
this.cache.set(payload.id, factory[Trait.fromDiscord](payload));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-12
@@ -4,13 +4,11 @@ import type {
|
|||||||
UnicordArgumentDefinition,
|
UnicordArgumentDefinition,
|
||||||
UnicordCommandHandler,
|
UnicordCommandHandler,
|
||||||
UnicordCommandOptions,
|
UnicordCommandOptions,
|
||||||
UnicordHandler,
|
|
||||||
} from '.';
|
} from '.';
|
||||||
import { UnicordCommandContext } from './context';
|
import { UnicordCommandContext } from './context';
|
||||||
import type { UnicordEventProcessor } from './eventProcessor';
|
import type { UnicordEventProcessor } from './eventProcessor';
|
||||||
import { Embed } from './types';
|
import { Embed } from './types';
|
||||||
import { UnicordArgumentType, UnicordEventType } from './types/common';
|
import { UnicordArgumentType, UnicordEventType } from './types/common';
|
||||||
import { log } from './utils';
|
|
||||||
|
|
||||||
type UnicordCommandDefinition<Options extends UnicordCommandOptions> = {
|
type UnicordCommandDefinition<Options extends UnicordCommandOptions> = {
|
||||||
options: Options;
|
options: Options;
|
||||||
@@ -56,19 +54,59 @@ export class UnicordCommandManager {
|
|||||||
args: any[],
|
args: any[],
|
||||||
definitions: Options['args'],
|
definitions: Options['args'],
|
||||||
): Promise<ArgumentTypeFromOptions<Options['args']>> {
|
): Promise<ArgumentTypeFromOptions<Options['args']>> {
|
||||||
// Zip the args and definition together
|
|
||||||
const zipped: [UnicordArgumentDefinition, any][] = args.map((arg, i) => [definitions[i], arg]);
|
|
||||||
// Validate the args
|
|
||||||
const validated: Record<string, any> = {};
|
const validated: Record<string, any> = {};
|
||||||
// TODO: Attempt some type coresion/resolution here (mostly for text commands)
|
|
||||||
for (const [def, arg] of zipped) {
|
if (args.length > definitions.length) {
|
||||||
if (false) {
|
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 {
|
} else {
|
||||||
validated[def.name] = arg;
|
throw new Error(`Argument ${def.name} must be a boolean`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO: Fix types so we don't need to cast here
|
|
||||||
return Promise.resolve(validated as ArgumentTypeFromOptions<Options['args']>);
|
if (def.choices && !def.choices.some((choice) => choice.value === coerced)) {
|
||||||
|
throw new Error(`Invalid choice for argument ${def.name}: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return coerced;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerDefaultHelpCommand() {
|
registerDefaultHelpCommand() {
|
||||||
@@ -142,13 +180,35 @@ export class UnicordCommandManager {
|
|||||||
return this.self;
|
return this.self;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Register application commands with the DiscordAPI
|
|
||||||
register<const Options extends UnicordCommandOptions>(
|
register<const Options extends UnicordCommandOptions>(
|
||||||
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
|
type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands,
|
||||||
event: string,
|
event: string,
|
||||||
handler: UnicordCommandHandler<Options>,
|
handler: UnicordCommandHandler<Options>,
|
||||||
options: Options = { args: [] } as unknown as 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)) {
|
if (this.has(type, event)) {
|
||||||
this.unregister(type, event);
|
this.unregister(type, event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type UnicordCommandContextInput =
|
|||||||
message?: MessagePayload;
|
message?: MessagePayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the context of a message in a Discord channel.
|
* Represents the context of a message in a Discord channel.
|
||||||
* Provides methods to interact with the message and the channel it was sent in.
|
* Provides methods to interact with the message and the channel it was sent in.
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export class UnicordDiscordAPI {
|
|||||||
return this.request('POST', path, data);
|
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> {
|
async patch<T = any>(path: string, data: any): Promise<T> {
|
||||||
return this.request('PATCH', path, data);
|
return this.request('PATCH', path, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { Unicord } from '.';
|
import type { Unicord } from '.';
|
||||||
import type { UnicordCommandContext } from './context';
|
import type { UnicordCommandContext } from './context';
|
||||||
import type { UnicordCommandOptions, UnicordEventContext, UnicordHandler } from './types/common';
|
import type { UnicordCommandOptions, UnicordEventHandler, UnicordHandler } from './types/common';
|
||||||
|
import { UnicordEventType } from './types/common';
|
||||||
|
|
||||||
export class UnicordEventProcessor extends EventEmitter {
|
export class UnicordEventProcessor extends EventEmitter {
|
||||||
constructor(private readonly self: Unicord) {
|
constructor(private readonly self: Unicord) {
|
||||||
@@ -16,7 +17,16 @@ export class UnicordEventProcessor extends EventEmitter {
|
|||||||
return super.off(event, handler);
|
return super.off(event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]): boolean {
|
emitCommand(
|
||||||
return super.emit(event, context, args);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-35
@@ -1,15 +1,22 @@
|
|||||||
import { WebSocket, type Data } from 'ws';
|
import { WebSocket, type Data } from 'ws';
|
||||||
import { Unicord } from '.';
|
import { Unicord } from '.';
|
||||||
import { UnicordCommandContext } from './context';
|
import { UnicordCommandContext } from './context';
|
||||||
import type { DiscordMessage, Payload } from './types';
|
import type { DiscordMessage, Payload, UnicordEventContext } from './types';
|
||||||
import { fromDiscord, Message, UnicordEventContext, UnicordEventType, User } from './types';
|
import { fromDiscord, Message, UnicordEventType, User } from './types';
|
||||||
import { exists, isNil, isObject, log } from './utils';
|
import { exists, isNil, isObject, log } from './utils';
|
||||||
|
|
||||||
export class UnicordGatewayManager {
|
export class UnicordGatewayManager {
|
||||||
private socket: WebSocket | null = null;
|
private socket: WebSocket | null = null;
|
||||||
private heartbeat_interval: number | null = null;
|
private heartbeat_interval: number | null = null;
|
||||||
private heartbeat_timeout: Timer | null = null;
|
private heartbeat_timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private sequence: number | 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
|
// Internal state
|
||||||
user: User | null = null;
|
user: User | null = null;
|
||||||
@@ -18,6 +25,25 @@ export class UnicordGatewayManager {
|
|||||||
|
|
||||||
private onOpen = () => {
|
private onOpen = () => {
|
||||||
log('Connected to gateway');
|
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) => {
|
private onMessage = async (packet: Data) => {
|
||||||
@@ -32,6 +58,10 @@ export class UnicordGatewayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (exists(data)) {
|
if (exists(data)) {
|
||||||
|
if (exists(data.s)) {
|
||||||
|
this.sequence = data.s;
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Uncomment for debugging recieved event data
|
// NOTE: Uncomment for debugging recieved event data
|
||||||
// log('='.repeat(25));
|
// log('='.repeat(25));
|
||||||
// log('received: ', data.op);
|
// log('received: ', data.op);
|
||||||
@@ -45,9 +75,19 @@ export class UnicordGatewayManager {
|
|||||||
await this.parseEvent(data);
|
await this.parseEvent(data);
|
||||||
} else if (code === 1) {
|
} else if (code === 1) {
|
||||||
// Heartbeat requested
|
// Heartbeat requested
|
||||||
this.send({ op: 1, d: this.sequence });
|
this.sendHeartbeat();
|
||||||
} else if (code === 9) {
|
} else if (code === 7) {
|
||||||
// Reconnect requested
|
// 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) {
|
} else if (code === 10) {
|
||||||
// Hello
|
// Hello
|
||||||
if (!isObject(data.d)) {
|
if (!isObject(data.d)) {
|
||||||
@@ -55,36 +95,68 @@ export class UnicordGatewayManager {
|
|||||||
}
|
}
|
||||||
// Store the heartbeat interval
|
// Store the heartbeat interval
|
||||||
this.heartbeat_interval = data.d?.heartbeat_interval;
|
this.heartbeat_interval = data.d?.heartbeat_interval;
|
||||||
// Kick off the heartbeat loop
|
// Kick off the heartbeat loop with initial jitter
|
||||||
this.heartbeat();
|
this.startHeartbeat();
|
||||||
// Identify with the gateway
|
// Resume or identify with the gateway
|
||||||
this.indentify();
|
if (this.should_resume && exists(this.session_id)) {
|
||||||
|
this.resume();
|
||||||
|
} else {
|
||||||
|
this.identify();
|
||||||
|
}
|
||||||
} else if (code === 11) {
|
} else if (code === 11) {
|
||||||
// Heartbeat ACK
|
// Heartbeat ACK
|
||||||
// log('Heartbeat ACK');
|
this.awaiting_heartbeat_ack = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private heartbeat() {
|
private startHeartbeat() {
|
||||||
if (exists(this.heartbeat_interval)) {
|
if (!exists(this.heartbeat_interval)) {
|
||||||
// If a previous heartbeat timeout exists, clear it
|
|
||||||
if (exists(this.heartbeat_timeout)) {
|
|
||||||
clearTimeout(this.heartbeat_timeout);
|
|
||||||
}
|
|
||||||
// Send the heartbeat payload
|
|
||||||
this.send({ op: 1, d: this.sequence });
|
|
||||||
// Set a timeout to ensure the heartbeat is sent within the interval
|
|
||||||
this.heartbeat_timeout = setTimeout(() => {
|
|
||||||
this.heartbeat();
|
|
||||||
}, this.heartbeat_interval * Math.random());
|
|
||||||
} else {
|
|
||||||
throw new Error('Heartbeat interval not set');
|
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>) => {
|
protected send = (payload: Partial<Payload>) => {
|
||||||
if (isNil(this.socket)) {
|
if (isNil(this.socket) || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
throw new Error('Socket is not connected');
|
throw new Error('Socket is not connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +167,8 @@ export class UnicordGatewayManager {
|
|||||||
this.socket.send(JSON.stringify(payload));
|
this.socket.send(JSON.stringify(payload));
|
||||||
};
|
};
|
||||||
|
|
||||||
private indentify = () => {
|
private identify = () => {
|
||||||
|
this.should_resume = false;
|
||||||
this.send({
|
this.send({
|
||||||
op: 2,
|
op: 2,
|
||||||
d: {
|
d: {
|
||||||
@@ -110,6 +183,17 @@ export class UnicordGatewayManager {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
private async parseEvent(payload: Payload) {
|
||||||
if (isNil(payload.t)) {
|
if (isNil(payload.t)) {
|
||||||
throw new Error('Event name is required');
|
throw new Error('Event name is required');
|
||||||
@@ -128,8 +212,13 @@ export class UnicordGatewayManager {
|
|||||||
|
|
||||||
if (event_name === 'READY') {
|
if (event_name === 'READY') {
|
||||||
this.user = User[fromDiscord](event_data.user);
|
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') {
|
} else if (event_name === 'INTERACTION_CREATE') {
|
||||||
// await this.handleInteractionCreate(payload);
|
// TODO: Application commands/interactions need a redesigned context/hydration model before implementation.
|
||||||
} else if (event_name === 'MESSAGE_CREATE') {
|
} else if (event_name === 'MESSAGE_CREATE') {
|
||||||
await this.handleMessageCreate(payload);
|
await this.handleMessageCreate(payload);
|
||||||
} else if (event_name === 'MESSAGE_UPDATE') {
|
} else if (event_name === 'MESSAGE_UPDATE') {
|
||||||
@@ -138,7 +227,7 @@ export class UnicordGatewayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Make a real UnicordEventContext class
|
// TODO: Make a real UnicordEventContext class
|
||||||
this.self.emit(UnicordEventType.SystemEvent, event_name, event_data as UnicordEventContext, []);
|
this.self.emitSystem(event_name, event_data as UnicordEventContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessageCreate = async (payload: Payload) => {
|
private handleMessageCreate = async (payload: Payload) => {
|
||||||
@@ -158,18 +247,23 @@ export class UnicordGatewayManager {
|
|||||||
if (payload.d.author.id === this.user.id) {
|
if (payload.d.author.id === this.user.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (typeof payload.d.content !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract the content
|
// Extract the content
|
||||||
const content = payload.d.content.trim();
|
const content = payload.d.content.trim();
|
||||||
const hasPrefixInConfig = exists(this.self.config.prefix) && this.self.config.prefix.length > 0;
|
const hasPrefixInConfig = exists(this.self.config.prefix) && this.self.config.prefix.length > 0;
|
||||||
const userMentionPrefix = `<@${this.user.id}>`;
|
const userMentionPrefix = `<@${this.user.id}>`;
|
||||||
|
|
||||||
// Is this a command?
|
// Is this a command?
|
||||||
if (
|
if (
|
||||||
(hasPrefixInConfig && content.startsWith(this.self.config.prefix)) ||
|
(hasPrefixInConfig && content.startsWith(this.self.config.prefix!)) ||
|
||||||
content.startsWith(userMentionPrefix)
|
content.startsWith(userMentionPrefix)
|
||||||
) {
|
) {
|
||||||
// Determine the prefix
|
// Determine the prefix
|
||||||
const prefix =
|
const prefix =
|
||||||
hasPrefixInConfig && content.startsWith(this.self.config.prefix)
|
hasPrefixInConfig && content.startsWith(this.self.config.prefix!)
|
||||||
? this.self.config.prefix!
|
? this.self.config.prefix!
|
||||||
: userMentionPrefix;
|
: userMentionPrefix;
|
||||||
// Parse command
|
// Parse command
|
||||||
@@ -177,6 +271,7 @@ export class UnicordGatewayManager {
|
|||||||
.slice(prefix.length)
|
.slice(prefix.length)
|
||||||
.trim()
|
.trim()
|
||||||
.split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g);
|
.split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g);
|
||||||
|
|
||||||
// Ignore empty commands
|
// Ignore empty commands
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -188,21 +283,67 @@ export class UnicordGatewayManager {
|
|||||||
const message = Message.fromDiscord(payload.d as DiscordMessage);
|
const message = Message.fromDiscord(payload.d as DiscordMessage);
|
||||||
const context: UnicordCommandContext = new UnicordCommandContext(this.self, { message });
|
const context: UnicordCommandContext = new UnicordCommandContext(this.self, { message });
|
||||||
// Dispatch event for the command
|
// Dispatch event for the command
|
||||||
this.self.emit(UnicordEventType.ChatCommands, command, context, args);
|
this.self.emitCommand(UnicordEventType.ChatCommands, command, context, args);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async connect() {
|
private reconnect(resume: boolean) {
|
||||||
// Fetch the Discord gateway URL
|
if (this.reconnecting) {
|
||||||
const response = await this.self.discordAPI.request('GET', '/gateway/bot');
|
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
|
// Append the query parameters to the URL
|
||||||
const url = new URL(response.url);
|
const url = new URL(response.url);
|
||||||
url.searchParams.append('v', '10');
|
url.searchParams.set('v', '10');
|
||||||
url.searchParams.append('encoding', 'json');
|
url.searchParams.set('encoding', 'json');
|
||||||
// Open a new WebSocket connection to the Discord gateway
|
// Open a new WebSocket connection to the Discord gateway
|
||||||
this.socket = new WebSocket(url);
|
this.socket = new WebSocket(url);
|
||||||
// Register event listeners
|
// Register event listeners
|
||||||
this.socket.on('open', this.onOpen);
|
this.socket.on('open', this.onOpen);
|
||||||
this.socket.on('message', this.onMessage);
|
this.socket.on('message', this.onMessage);
|
||||||
|
this.socket.on('close', this.onClose);
|
||||||
|
this.socket.on('error', this.onError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-18
@@ -4,7 +4,7 @@ import { UnicordCommandContext } from './context';
|
|||||||
import type { Channel, Guild, Message } from './types';
|
import type { Channel, Guild, Message } from './types';
|
||||||
import { exists } from './utils';
|
import { exists } from './utils';
|
||||||
|
|
||||||
export type Hydrateable = UnicordCommandContext;
|
export type UnicordHydrateable = UnicordCommandContext;
|
||||||
|
|
||||||
export type UnicordHydratedObject<Object, Expectations extends Array<Expectation>> = Object & {
|
export type UnicordHydratedObject<Object, Expectations extends Array<Expectation>> = Object & {
|
||||||
channel: Expects<Expectations, Expectation.Channel, Channel>;
|
channel: Expects<Expectations, Expectation.Channel, Channel>;
|
||||||
@@ -12,15 +12,6 @@ export type UnicordHydratedObject<Object, Expectations extends Array<Expectation
|
|||||||
message: Expects<Expectations, Expectation.Message, Message>;
|
message: Expects<Expectations, Expectation.Message, Message>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Hydrated<Object extends Hydrateable, Expectations extends Array<Expectation>> = UnicordHydratedObject<
|
|
||||||
Object,
|
|
||||||
Expectations
|
|
||||||
>;
|
|
||||||
|
|
||||||
// export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> = T extends UnicordCommandContext
|
|
||||||
// ? HydratedCommandContext<K>
|
|
||||||
// : never;
|
|
||||||
|
|
||||||
// NOTE: Dont forget to update the typedoc comment in src/index.ts and src/context.ts when making changes here
|
// 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.
|
* Hydrates a given data object based on the provided expectations.
|
||||||
@@ -33,7 +24,7 @@ export type Hydrated<Object extends Hydrateable, Expectations extends Array<Expe
|
|||||||
*
|
*
|
||||||
* @throws Will throw an error if an expectation cannot be resolved.
|
* @throws Will throw an error if an expectation cannot be resolved.
|
||||||
*/
|
*/
|
||||||
export async function hydrate<T extends Hydrateable, K extends Array<Expectation>>(
|
export async function hydrate<T extends UnicordHydrateable, K extends Array<Expectation>>(
|
||||||
self: Unicord,
|
self: Unicord,
|
||||||
data: T,
|
data: T,
|
||||||
expectations: K,
|
expectations: K,
|
||||||
@@ -45,15 +36,18 @@ export async function hydrate<T extends Hydrateable, K extends Array<Expectation
|
|||||||
if (data instanceof UnicordCommandContext) {
|
if (data instanceof UnicordCommandContext) {
|
||||||
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 UnicordHydratedObject<[Expectation.Message, ...Expectation[]]>;
|
// let context = data as unknown as UnicordHydratedObject<T, [Expectation.Message]>;
|
||||||
// context.message = await self.caches.messages.get(data.channel_id, data.message_id);
|
// context.message = await self.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)) {
|
||||||
if (expectation === Expectation.Guild && exists(data.guild_id)) {
|
if (expectation === Expectation.Guild && exists(data.guild_id)) {
|
||||||
let context = data as unknown as UnicordHydratedObject<UnicordCommandContext, [Expectation.Guild]>;
|
let context = data as unknown as UnicordHydratedObject<T, [Expectation.Guild]>;
|
||||||
context.guild = await self.caches.guilds.get(data.guild_id);
|
context.guild = await self.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 UnicordHydratedObject<UnicordCommandContext, [Expectation.Channel]>;
|
let context = data as unknown as UnicordHydratedObject<T, [Expectation.Channel]>;
|
||||||
context.channel = await self.caches.channels.get(data.channel_id);
|
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 {
|
} else {
|
||||||
throw new Error(`Invalid expectation requested for Context: ${expectation}`);
|
throw new Error(`Invalid expectation requested for Context: ${expectation}`);
|
||||||
}
|
}
|
||||||
@@ -62,9 +56,6 @@ export async function hydrate<T extends Hydrateable, K extends Array<Expectation
|
|||||||
throw new Error(`Hydration for the provided type is not implemented.`);
|
throw new Error(`Hydration for the provided type is not implemented.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new object that preserves all existing properties
|
|
||||||
const result = { ...data } as any;
|
|
||||||
|
|
||||||
// Ensure the result is properly typed with both old and new hydrated properties
|
// Ensure the result is properly typed with both old and new hydrated properties
|
||||||
return result as T & Hydrated<T, K>;
|
return data as T & UnicordHydratedObject<T, K>;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -70,14 +70,24 @@ export class Unicord {
|
|||||||
return this.eventProcessor.register(`${type}:${event}`, handler);
|
return this.eventProcessor.register(`${type}:${event}`, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
use(client: Unicord): this {
|
// This is kinda dumb maybe just remove
|
||||||
this.commandManager.merge(client.commandManager);
|
// use(client: Unicord): this {
|
||||||
// TODO: inherit event listeners too
|
// this.commandManager.merge(client.commandManager);
|
||||||
return this;
|
// // TODO: inherit event listeners too
|
||||||
|
// return this;
|
||||||
|
// }
|
||||||
|
|
||||||
|
emitSystem(event: string, payload: UnicordEventContext) {
|
||||||
|
return this.eventProcessor.emitSystem(event, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(type: UnicordEventType, event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]) {
|
emitCommand(
|
||||||
this.eventProcessor.emit(`${type}:${event}`, context, args);
|
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?
|
// TODO: can we automate defining these methods with some TS magic?
|
||||||
@@ -104,6 +114,10 @@ export class Unicord {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSystemEvent(event: string, handler: UnicordEventHandler): void {
|
||||||
|
this.__onEvent(UnicordEventType.SystemEvent, event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
sendMessage = async (channel_id: string, message: Message | string) => {
|
sendMessage = async (channel_id: string, message: Message | string) => {
|
||||||
if (typeof message === 'string') {
|
if (typeof message === 'string') {
|
||||||
message = new Message().setContent(message);
|
message = new Message().setContent(message);
|
||||||
|
|||||||
@@ -133,12 +133,9 @@ export type UnicordCommandHandler<Options extends UnicordCommandOptions> = (
|
|||||||
args: ArgumentTypeFromOptions<Options['args']>,
|
args: ArgumentTypeFromOptions<Options['args']>,
|
||||||
) => any;
|
) => any;
|
||||||
|
|
||||||
// TODO: Fill out signature
|
export type UnicordEventHandler = (payload: EventPayload) => any;
|
||||||
export type UnicordEventHandler = (context: UnicordEventContext, payload: any) => any;
|
|
||||||
|
|
||||||
export type UnicordHandler<Options extends UnicordCommandOptions> =
|
export type UnicordHandler<Options extends UnicordCommandOptions> = UnicordCommandHandler<Options> | UnicordEventHandler;
|
||||||
| UnicordCommandHandler<Options>
|
|
||||||
| UnicordEventHandler;
|
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
token: string;
|
token: string;
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export function setDefaults<T>(original: Record<string, any>, defaults: Record<s
|
|||||||
export const isNil = (value: any): value is null | undefined => value === null || value === undefined;
|
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 exists = <T>(value: T | null | undefined): value is NonNullable<T> => !isNil(value);
|
||||||
export const isObject = (value: any): value is Record<string, any> =>
|
export const isObject = (value: any): value is Record<string, any> =>
|
||||||
typeof value === 'object' && !Array.isArray(value);
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
export const log = (...args: any[]) => {
|
export const log = (...args: any[]) => {
|
||||||
const LOG_LEVEL = process.env.UNICORD_LOG_LEVEL?.toLocaleLowerCase();
|
const LOG_LEVEL = process.env.UNICORD_LOG_LEVEL?.toLocaleLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Here we are defining what we want the interface to look like so that there is a goal to work towards
|
// 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
|
// Things here are not necessarily implemented in the actual code but show what we want it to look like to use the library
|
||||||
|
|
||||||
import { Expectation, Unicord, UnicordArgumentType } from '../rewrite/index.ts';
|
import { Expectation, Unicord, UnicordArgumentType, Intent } from '../rewrite/index.ts';
|
||||||
|
|
||||||
const DISCORD_APPLICATION_ID = process.env.APPLICATION_ID!;
|
const DISCORD_APPLICATION_ID = process.env.APPLICATION_ID!;
|
||||||
const DISCORD_CLIENT_TOKEN = process.env.BOT_TOKEN!;
|
const DISCORD_CLIENT_TOKEN = process.env.BOT_TOKEN!;
|
||||||
@@ -9,8 +9,17 @@ const DISCORD_CLIENT_TOKEN = process.env.BOT_TOKEN!;
|
|||||||
// Create a new client instance
|
// Create a new client instance
|
||||||
// Most usage will be via chaining methods off the client instance
|
// Most usage will be via chaining methods off the client instance
|
||||||
const client = new Unicord({
|
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,
|
application_id: DISCORD_APPLICATION_ID,
|
||||||
token: DISCORD_CLIENT_TOKEN,
|
token: DISCORD_CLIENT_TOKEN,
|
||||||
|
prefix: '!!',
|
||||||
})
|
})
|
||||||
.registerChatCommand(
|
.registerChatCommand(
|
||||||
'ping',
|
'ping',
|
||||||
@@ -98,6 +107,6 @@ const etc = new Unicord().registerChatCommand(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
client.use(etc);
|
// client.use(etc);
|
||||||
|
|
||||||
client.login();
|
client.login();
|
||||||
|
|||||||
Reference in New Issue
Block a user