diff --git a/bun.lockb b/bun.lockb index 75023fb..bc892e0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/rewrite/caches/cache.ts b/rewrite/caches/cache.ts new file mode 100644 index 0000000..285d40a --- /dev/null +++ b/rewrite/caches/cache.ts @@ -0,0 +1,163 @@ +import type { Unicord } from '..'; +import { Trait } from '../types/common'; + +export class UnicordObjectCache { + cache: LRUCache; + requests = new Map>(); + 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(maxCapacity); + this.type = type; + for (const event of events) { + this.self.onEvent(event, async (_context, e) => { + this.cache.set(e.id, factory[Trait.fromDiscord](e)); + }); + } + } + + set(object_id: string, object: Entity): void { + this.cache.set(object_id, object); + } + + // update(object_id: string, object: Partial): 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 { + 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 { + if (this.cache.has(object_id)) { + return await Promise.resolve(this.cache.get(object_id)!); + } else { + return this.fetch(object_id); + } + } +} + +class ListNode { + key: K; + value: V; + prev: ListNode | null = null; + next: ListNode | null = null; + + constructor(key: K, value: V) { + this.key = key; + this.value = value; + } +} + +class LRUCache { + private capacity: number; + private map: Map>; + private head: ListNode; + private tail: ListNode; + + constructor(capacity: number) { + this.capacity = capacity; + this.map = new Map(); + + // Dummy head and tail to simplify boundary conditions + this.head = new ListNode(null as any, null as any); + this.tail = new ListNode(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): void { + this.removeNode(node); + this.addToFront(node); + } + + private addToFront(node: ListNode): void { + node.next = this.head.next; + node.prev = this.head; + this.head.next!.prev = node; + this.head.next = node; + } + + private removeNode(node: ListNode): 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; + } + } +} diff --git a/rewrite/caches/channels.ts b/rewrite/caches/channels.ts new file mode 100644 index 0000000..da80285 --- /dev/null +++ b/rewrite/caches/channels.ts @@ -0,0 +1,10 @@ + +import type { Unicord } from ".."; +import { Channel } from "../types/channel"; +import { UnicordObjectCache } from "./cache"; + +export class UnicordChannelsCache extends UnicordObjectCache { + constructor(self: Unicord) { + super(self, 'channels', Channel, ['CHANNEL_CREATE', 'CHANNEL_UPDATE']); + } +} diff --git a/rewrite/caches/guilds.ts b/rewrite/caches/guilds.ts new file mode 100644 index 0000000..7b38946 --- /dev/null +++ b/rewrite/caches/guilds.ts @@ -0,0 +1,9 @@ +import type { Unicord } from ".."; +import { Guild } from "../types/guild"; +import { UnicordObjectCache } from "./cache"; + +export class UnicordGuildsCache extends UnicordObjectCache { + constructor(self: Unicord) { + super(self, 'guilds', Guild, ['GUILD_CREATE', 'GUILD_UPDATE']); + } +} diff --git a/rewrite/caches/index.ts b/rewrite/caches/index.ts new file mode 100644 index 0000000..775a932 --- /dev/null +++ b/rewrite/caches/index.ts @@ -0,0 +1,3 @@ +export * from './users'; +export * from './channels'; +export * from './guilds'; \ No newline at end of file diff --git a/rewrite/caches/users.ts b/rewrite/caches/users.ts new file mode 100644 index 0000000..35b57ec --- /dev/null +++ b/rewrite/caches/users.ts @@ -0,0 +1,9 @@ +import type { Unicord } from '..'; +import { User } from '../types/user'; +import { UnicordObjectCache } from './cache'; + +export class UnicordUsersCache extends UnicordObjectCache { + constructor(self: Unicord) { + super(self, 'users', User, []); + } +} diff --git a/rewrite/context.ts b/rewrite/context.ts new file mode 100644 index 0000000..7de7f2f --- /dev/null +++ b/rewrite/context.ts @@ -0,0 +1,102 @@ +import type { Unicord } from '.'; +import { hydrate } from './hydrator'; +import type { MessagePayload } from './types'; +import { Message } from './types'; +import { exists, isNil } from './utils'; + +export type UnicordCommandContextInput = + | { + message: MessagePayload; + channel_id?: string; + message_id?: string; + guild_id?: string; + } + | { + channel_id: string; + message_id: string; + guild_id?: string; + message?: MessagePayload; + }; + +/** + * Represents the context of a message in a Discord channel. + * Provides methods to interact with the message and the channel it was sent in. + * + * @class Context + * + * @property {string} message_id - The ID of the message that this context is associated with. + * @property {string} channel_id - The ID of the channel where the message was sent. + * @property {string} [guild_id] - The ID of the guild where the message was sent, if applicable. + * + * @constructor + * @param {Unicord} self - The client instance. + * @param {UnicordCommandContextInput} data - The payload data for the context. + * + * @throws {Error} If the message ID is not provided. + * @throws {Error} If the channel ID is not provided. + */ +export class UnicordCommandContext { + // Public properties + message_id: string; + channel_id: string; + guild_id?: string; + + message?: Message; + + constructor(private self: Unicord, data: UnicordCommandContextInput) { + if (isNil(data.message)) { + if (isNil(data.message_id)) { + throw new Error('Message ID is required to create a context'); + } + + if (isNil(data.channel_id)) { + throw new Error('Channel ID is required to create a context'); + } + + this.message_id = data.message_id; + this.channel_id = data.channel_id; + this.guild_id = data.guild_id; + } else { + this.message = data.message; + this.message_id = data.message.id; + this.channel_id = data.message.channel_id; + this.guild_id = data.message.guild_id; + } + } + + /** + * Reply to the message that this command + * @param message The message to send to the channel + * @param reference If true the message will be a reference the the message that triggered the command + * @returns The message that was sent + */ + public reply = async (message: Message | string, reference: boolean = true): Promise => { + 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); + } +} diff --git a/rewrite/discordAPI.ts b/rewrite/discordAPI.ts new file mode 100644 index 0000000..542681e --- /dev/null +++ b/rewrite/discordAPI.ts @@ -0,0 +1,78 @@ +import type { Unicord } from '.'; +import { exists, log } from './utils'; + +export class UnicordDiscordAPI { + private readonly base_url: string = 'https://discord.com/api'; + private readonly version: number = 10; + + constructor(private self: Unicord) {} + + async request(method: string, path: string, data?: any): Promise { + 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: ${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(path: string): Promise { + return this.request('GET', path); + } + + async post(path: string, data: any): Promise { + return this.request('POST', path, data); + } + + async patch(path: string, data: any): Promise { + return this.request('PATCH', path, data); + } + + async delete(path: string): Promise { + return this.request('DELETE', path); + } +} diff --git a/rewrite/eventProcessor.ts b/rewrite/eventProcessor.ts new file mode 100644 index 0000000..ec0c1f1 --- /dev/null +++ b/rewrite/eventProcessor.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from 'node:events'; +import type { Unicord } from '.'; +import type { + UnicordCommandHandler, + UnicordCommandOptions, + UnicordEventContext, + ArgumentTypeFromOptions, + UnicordArgumentDefinition, + UnicordHandler, +} from './types/common'; +import { UnicordArgumentType } from './types/common'; +import type { UnicordCommandContext } from './context'; + +export const OptionConstructorMap = { + [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 UnicordEventProcessor extends EventEmitter { + constructor(private readonly self: Unicord) { + super(); + } + + private validateAndResolveArgs = async ( + args: any[], + definitions: Options['args'], + ): Promise> => { + // Zip the args and definition together + const zipped: [UnicordArgumentDefinition, any][] = args.map((arg, i) => [definitions[i], arg]); + // Validate the args + const validated: Record = {}; + // TODO: Attempt some type coresion/resolution here (mostly for text commands) + for (const [def, arg] of zipped) { + if (false) { + } else { + validated[def.name] = arg; + } + } + // TODO: Fix types so we don't need to cast here + return Promise.resolve(validated as ArgumentTypeFromOptions); + }; + + register( + event: string, + handler: UnicordHandler, + options: Options, + ) { + // Generate a closure to wrap the handler + const wrappedHandler = async (context: UnicordCommandContext | UnicordEventContext, args: any[]) => { + const resolvedArgs = await this.validateAndResolveArgs(args, options.args); + // TODO: Fix context type here + // @ts-expect-error will fix later + return handler(context, resolvedArgs); + }; + return super.on(event, wrappedHandler); + } + + emit(event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]): boolean { + return super.emit(event, context, args); + } +} diff --git a/rewrite/gatewayManager.ts b/rewrite/gatewayManager.ts new file mode 100644 index 0000000..f1dd486 --- /dev/null +++ b/rewrite/gatewayManager.ts @@ -0,0 +1,208 @@ +import { WebSocket, type Data } from 'ws'; +import { Unicord } from '.'; +import { UnicordCommandContext } from './context'; +import type { DiscordMessage, Payload } from './types'; +import { fromDiscord, Message, UnicordEventContext, UnicordEventType, User } from './types'; +import { exists, isNil, isObject, log } from './utils'; + +export class UnicordGatewayManager { + private socket: WebSocket | null = null; + private heartbeat_interval: number | null = null; + private heartbeat_timeout: Timer | null = null; + private sequence: number | null = null; + + // Internal state + user: User | null = null; + + constructor(private readonly self: Unicord) {} + + private onOpen = () => { + log('Connected to gateway'); + }; + + private onMessage = async (packet: Data) => { + // Allocate a variable to store the parsed payload + let data: Payload | null = null; + + try { + // Attempt to parse the packet data + data = JSON.parse(packet.toString()); + } catch (e) { + console.error(e); + } + + if (exists(data)) { + // NOTE: Uncomment for debugging recieved event data + // log('='.repeat(25)); + // log('received: ', data.op); + // log('received event: ', data.t ?? 'no event'); + // log('received data: ', exists(data.d) ? Object.keys(data.d) : 'no data'); + // log('='.repeat(25)); + // If the data is successfully parsed check the opcode + const code = data.op; + if (code === 0) { + // Event received + await this.parseEvent(data); + } else if (code === 1) { + // Heartbeat requested + this.send({ op: 1, d: this.sequence }); + } else if (code === 9) { + // Reconnect requested + } else if (code === 10) { + // Hello + if (!isObject(data.d)) { + throw new Error('Invalid HELLO payload received'); + } + // Store the heartbeat interval + this.heartbeat_interval = data.d?.heartbeat_interval; + // Kick off the heartbeat loop + this.heartbeat(); + // Identify with the gateway + this.indentify(); + } else if (code === 11) { + // Heartbeat ACK + log('Heartbeat ACK'); + } + } + }; + + private heartbeat() { + if (exists(this.heartbeat_interval)) { + // If a previous heartbeat timeout exists, clear it + if (exists(this.heartbeat_timeout)) { + clearTimeout(this.heartbeat_timeout); + } + // Send the heartbeat payload + this.send({ op: 1, d: this.sequence }); + // Set a timeout to ensure the heartbeat is sent within the interval + this.heartbeat_timeout = setTimeout(() => { + this.heartbeat(); + }, this.heartbeat_interval * Math.random()); + } else { + throw new Error('Heartbeat interval not set'); + } + } + + protected send = (payload: Partial) => { + if (isNil(this.socket)) { + throw new Error('Socket is not connected'); + } + + // log('='.repeat(25)); + // log('sending: ', JSON.stringify(payload, null, 4)); + // log('='.repeat(25)); + + this.socket.send(JSON.stringify(payload)); + }; + + private indentify = () => { + this.send({ + op: 2, + d: { + token: this.self.config.token, + intents: this.self.config.intents.reduce((acc, intent) => acc | intent, 0), + properties: { + os: process.platform, + browser: 'unicord', + device: 'unicord', + }, + }, + }); + }; + + private async parseEvent(payload: Payload) { + if (isNil(payload.t)) { + throw new Error('Event name is required'); + } + + // log('Event:', payload.t); + + // Extract the event name and data + const event_name = payload.t; + const event_data = payload.d; + + // Simple validation on the event data + if (!isObject(event_data)) { + throw new Error('Invalid event data received'); + } + + if (event_name === 'READY') { + this.user = User[fromDiscord](event_data.user); + } else if (event_name === 'INTERACTION_CREATE') { + // await this.handleInteractionCreate(payload); + } else if (event_name === 'MESSAGE_CREATE') { + await this.handleMessageCreate(payload); + } else if (event_name === 'MESSAGE_UPDATE') { + // TODO: setting to call commands again here on message edit + // await this.handleMessageUpdate(payload); + } + + // TODO: Make a real UnicordEventContext class + this.self.emit(UnicordEventType.SystemEvent, event_name, event_data as UnicordEventContext, []); + } + + private handleMessageCreate = async (payload: Payload) => { + if (isNil(this.user)) { + throw new Error('Cannot handle MESSAGE_CREATE event without a user'); + } + + if (!isObject(payload.d)) { + throw new Error('Invalid MESSAGE_CREATE payload received'); + } + + // Ignore bots + if (payload.d.author.bot) { + return; + } + // Ignore self + if (payload.d.author.id === this.user.id) { + return; + } + // Extract the content + const content = payload.d.content.trim(); + const hasPrefixInConfig = exists(this.self.config.prefix) && this.self.config.prefix.length > 0; + const userMentionPrefix = `<@${this.user.id}>`; + // Is this a command? + if ( + (hasPrefixInConfig && content.startsWith(this.self.config.prefix)) || + content.startsWith(userMentionPrefix) + ) { + // Determine the prefix + const prefix = + hasPrefixInConfig && content.startsWith(this.self.config.prefix) + ? this.self.config.prefix! + : userMentionPrefix; + // Parse command + const args = content + .slice(prefix.length) + .trim() + .split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g); + // Ignore empty commands + if (args.length === 0) { + return; + } + // Extract the command name + // TODO: Case insensitive config? + const command = args.shift()!; + // Generate context + const message = Message.fromDiscord(payload.d as DiscordMessage); + const context: UnicordCommandContext = new UnicordCommandContext(this.self, { message }); + // Dispatch event for the command + this.self.emit(UnicordEventType.ChatCommands, command, context, args); + } + }; + + async connect() { + // Fetch the Discord gateway URL + const response = await this.self.discordAPI.request('GET', '/gateway/bot'); + // Append the query parameters to the URL + const url = new URL(response.url); + url.searchParams.append('v', '10'); + url.searchParams.append('encoding', 'json'); + // Open a new WebSocket connection to the Discord gateway + this.socket = new WebSocket(url); + // Register event listeners + this.socket.on('open', this.onOpen); + this.socket.on('message', this.onMessage); + } +} diff --git a/rewrite/hydrator.ts b/rewrite/hydrator.ts new file mode 100644 index 0000000..90043f4 --- /dev/null +++ b/rewrite/hydrator.ts @@ -0,0 +1,70 @@ +import type { Expects, Unicord } from '.'; +import { Expectation } from '.'; +import { UnicordCommandContext } from './context'; +import type { Channel, Guild, Message } from './types'; +import { exists } from './utils'; + +export type Hydrateable = UnicordCommandContext; + +export type UnicordHydratedObject> = Object & { + channel: Expects; + guild: Expects; + message: Expects; +}; + +export type Hydrated> = UnicordHydratedObject< + Object, + Expectations +>; + +// export type Hydrated> = T extends UnicordCommandContext +// ? HydratedCommandContext +// : never; + +// NOTE: Dont forget to update the typedoc comment in src/index.ts and src/context.ts when making changes here +/** + * Hydrates a given data object based on the provided expectations. + * + * @param self - The client instance used to fetch additional data required for hydration. + * @param data - The data object to be hydrated. Can be of type `Context` or `Message`. + * @param expectations - An array of expectations that specify what properties should be hydrated. + * + * @returns A promise that resolves to the hydrated data object. + * + * @throws Will throw an error if an expectation cannot be resolved. + */ +export async function hydrate>( + self: Unicord, + data: T, + expectations: K, +): Promise> { + // TODO: add clone trait. Make it a requirement for hydrateable objects. + // Then clone the object and return the hydrated object. + let hydrated: UnicordHydratedObject; + + if (data instanceof UnicordCommandContext) { + for (const expectation of expectations) { + // if (expectation === Expectation.Message && exists(data.message_id)) { + // let context = data as unknown as UnicordHydratedObject<[Expectation.Message, ...Expectation[]]>; + // context.message = await self.caches.messages.get(data.channel_id, data.message_id); + // } else if (expectation === Expectation.Guild && exists(data.guild_id)) { + if (expectation === Expectation.Guild && exists(data.guild_id)) { + let context = data as unknown as UnicordHydratedObject; + 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; + context.channel = await self.caches.channels.get(data.channel_id); + } else { + throw new Error(`Invalid expectation requested for Context: ${expectation}`); + } + } + } else { + throw new Error(`Hydration for the provided type is not implemented.`); + } + + // Create a new object that preserves all existing properties + const result = { ...data } as any; + + // Ensure the result is properly typed with both old and new hydrated properties + return result as T & Hydrated; +} diff --git a/rewrite/index.ts b/rewrite/index.ts new file mode 100644 index 0000000..1a9ae5f --- /dev/null +++ b/rewrite/index.ts @@ -0,0 +1,123 @@ +import { UnicordChannelsCache, UnicordGuildsCache, UnicordUsersCache } from './caches'; +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 { defaults } 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; + readonly caches: { + readonly users: UnicordUsersCache; + readonly channels: UnicordChannelsCache; + readonly guilds: UnicordGuildsCache; + }; + + constructor(userConfig: UnicordConfig = {}) { + this.config = defaults(userConfig, UNICORD_CONFIG_DEFAULTS) as UnicordConfigWithDefaults; + this.discordAPI = new UnicordDiscordAPI(this); + this.eventProcessor = new UnicordEventProcessor(this); + this.gatewayManager = new UnicordGatewayManager(this); + 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 registerEvent( + type: UnicordEventType, + event: string, + handler: UnicordHandler, + options: Options = { args: [] } as unknown as Options, + ) { + return this.eventProcessor.register(`${type}:${event}`, handler, options); + } + + emit(type: UnicordEventType, event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]) { + this.eventProcessor.emit(`${type}:${event}`, context, args); + } + + registerChatCommand( + event: string, + handler: UnicordCommandHandler, + options: Options = { args: [] } as unknown as Options, + ): this { + this.registerEvent(UnicordEventType.ChatCommands, event, handler, options); + return this; + } + + registerApplicationCommand( + event: string, + handler: UnicordCommandHandler, + options: Options = { args: [] } as unknown as Options, + ): this { + // TODO: Register with Discord API + this.registerEvent(UnicordEventType.ApplicationCommands, event, handler, options); + return this; + } + + // TODO: should have a different signature for events that are not commands + onEvent(event: string, handler: UnicordEventHandler): this { + this.registerEvent(UnicordEventType.SystemEvent, event, handler); + return this; + } + + sendMessage = async (channel_id: string, message: Message | string) => { + if (typeof message === 'string') { + message = new Message().setContent(message); + } + + return Message.fromDiscord( + await this.discordAPI.post(`/channels/${channel_id}/messages`, message.toJSON()), + ); + }; + + login(token: string | undefined = this.config.token) { + 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(); + } +} + +export * from './types/common'; diff --git a/rewrite/types/base.ts b/rewrite/types/base.ts new file mode 100644 index 0000000..2d29ef0 --- /dev/null +++ b/rewrite/types/base.ts @@ -0,0 +1,23 @@ +import { Trait } from './common'; + +export abstract class UnicordGatewayObject { + constructor() {} + + // TODO: Add a clone method to subclasses + // public abstract [Trait.clone](this: new (data: any) => T): T; + + // public get clone() { + // return this[Trait.clone]; + // } + + static [Trait.fromDiscord]>( + 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]; + } +} diff --git a/rewrite/types/channel.ts b/rewrite/types/channel.ts new file mode 100644 index 0000000..8892fe6 --- /dev/null +++ b/rewrite/types/channel.ts @@ -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 { + 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](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; +} diff --git a/rewrite/types/common.ts b/rewrite/types/common.ts new file mode 100644 index 0000000..06e9e3b --- /dev/null +++ b/rewrite/types/common.ts @@ -0,0 +1,358 @@ +import { UnicordCommandContext } from "../context"; + +export type Primitive = string | number | boolean | null; + +export interface UnicordConfig { + token?: string; + prefix?: string; + intents?: Intent[]; +} + +export interface UnicordConfigWithDefaults extends UnicordConfig { + intents: Intent[]; +} + +export type Brand = T & { __brand: K }; + +export type EventName = Brand; + +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 interface UnicordArgumentDefinition { + name: string; + description: string; + type: UnicordArgumentType; + required: boolean; +} + +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 { + type: UnicordEventType; +} + +export interface UnicordEventContext extends UnicordBaseContext {} + +export type OptionTypeMap = { + [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 = { + [Option in T[number] as Option['name']]: Option['type'] extends keyof OptionTypeMap + ? Option['required'] extends true // If required, keep as is; otherwise, make optional + ? OptionTypeMap[Option['type']] + : OptionTypeMap[Option['type']] | undefined + : unknown; +}; + +export type UnicordCommandHandler = ( + context: UnicordCommandContext, + args: ArgumentTypeFromOptions, +) => any; + +export type UnicordEventHandler = (context: UnicordEventContext, payload: any) => any; + +export type UnicordHandler = + | UnicordCommandHandler + | 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, K extends Expectation, R = true> = Extract 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} 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 | 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', +} diff --git a/rewrite/types/guild.ts b/rewrite/types/guild.ts new file mode 100644 index 0000000..d3130cf --- /dev/null +++ b/rewrite/types/guild.ts @@ -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 { + 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[]; + emojis: Record[]; + features: Record[]; + 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; + nsfw_level: number; + stickers?: string; + premium_progress_bar_enabled: boolean; + safety_alerts_channel_id: string; + incidents_data: Record; + + 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](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/` 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/` 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[]; + emojis: Record[]; + features: Record[]; + 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; + nsfw_level: number; + stickers?: string; + premium_progress_bar_enabled: boolean; + safety_alerts_channel_id: string; + incidents_data: Record; +} diff --git a/rewrite/types/index.ts b/rewrite/types/index.ts new file mode 100644 index 0000000..f9510d6 --- /dev/null +++ b/rewrite/types/index.ts @@ -0,0 +1,5 @@ +export * from './common'; +export * from './user'; +export * from './channel'; +export * from './guild'; +export * from './message'; \ No newline at end of file diff --git a/rewrite/types/message.ts b/rewrite/types/message.ts new file mode 100644 index 0000000..cd6c1ed --- /dev/null +++ b/rewrite/types/message.ts @@ -0,0 +1,282 @@ +import { exists, isNil } from '../utils'; +import { UnicordGatewayObject } from './base'; +import type { Channel } from './channel'; +import { fromDiscord, Trait, type Expectation } from './common'; +// import type { Embed } from './embed'; +import type { Guild } from './guild'; +import { User, type DiscordUser } from './user'; + +export type Component = Partial