diff --git a/.gitignore b/.gitignore index 9b1ee42..65137a5 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,4 @@ dist # Finder (MacOS) folder config .DS_Store +.aider* diff --git a/src/index.ts b/src/index.ts index 0bfe8f1..f6de7a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,13 @@ export { Intent, Trait } from './types/common'; export type { ClientConfig } from './types/common'; export type { Context } from './types/context'; export { Expectation } from './types/common'; +export { + ChannelType, + ChannelFlags, + VideoQualityMode, + SortOrderType, + ForumLayoutType +} from './types/channel'; // Consumable util classes export { Guild } from './types/guild'; export { Role } from './types/role'; @@ -15,4 +22,4 @@ export { Embed } from './types/embed'; export { Emoji } from './types/emoji'; // Other export { createCommandHandler, type InteractionData, type InteractionCommpoentData, type InteractionCommandData, type InteractionHandlerFunction } from './types/handler'; -export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand'; \ No newline at end of file +export { ApplicationCommandType, ApplicationCommandOptionType as CommandOptionType } from './types/applicationCommand'; diff --git a/src/services/caches/channels.ts b/src/services/caches/channels.ts index e48118d..bd731c0 100644 --- a/src/services/caches/channels.ts +++ b/src/services/caches/channels.ts @@ -1,10 +1,10 @@ +import { API } from "../api"; +import { Processor } from "../processor"; import { Channel } from "../../types/channel"; -import type { API } from "../api"; -import type { Processor } from "../processor"; import { ObjectCache } from "./cache"; export class Channels extends ObjectCache { - constructor (api: API, processor: Processor) { + constructor(api: API, processor: Processor) { super(api, processor, 'channels', Channel, ['CHANNEL_CREATE', 'CHANNEL_UPDATE']); } -} \ No newline at end of file +} diff --git a/src/services/client.ts b/src/services/client.ts index 3031b97..a841b19 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,15 +1,15 @@ -import { API } from './api'; -import { Gateway } from './connectors/gateway'; -import { Processor } from './processor'; import { Expectation, HandlerType, Intent, type ClientConfig } from '../types/common'; +import { Message, type DiscordMessage } from '../types/message'; import { exists, isNil } from '../utils/index'; -import { type DiscordMessage, Message } from '../types/message'; +import { API } from './api'; +import { EventBus, type Emitter } from './bus'; +import { Channels } from './caches/channels'; import { Guilds } from './caches/guilds'; import { Messages } from './caches/messages'; import { Users } from './caches/users'; -import { Channels } from './caches/channels'; +import { Gateway } from './connectors/gateway'; import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator'; -import { EventBus, type Emitter } from './bus'; +import { Processor } from './processor'; export class Client { readonly token: string; @@ -141,4 +141,4 @@ export class Client { off = (event: string, handler: (...args: any[]) => void) => this.bus.off(event, handler); connect = () => this.gateway.connect(); -} \ No newline at end of file +} diff --git a/src/services/hydrator.ts b/src/services/hydrator.ts index 03278d1..1e50c1b 100644 --- a/src/services/hydrator.ts +++ b/src/services/hydrator.ts @@ -1,15 +1,16 @@ import { Expectation } from "../types/common"; import { Context, type HydratedContext } from "../types/context"; import { Message, type HydratedMessage } from "../types/message"; +import { Channel } from "../types/channel"; import { exists } from "../utils"; import type { API } from "./api"; import type { Client } from "./client"; -export type GatewayObject = Message; +export type GatewayObject = Message | Channel; export type Hydrateable = Context | GatewayObject export type Hydrated> = - T extends Message ? HydratedMessage : - T extends Context ? HydratedContext : never; + T extends Message ? HydratedMessage & Omit> : + T extends Context ? HydratedContext & Omit> : never; /** * Hydrates a given data object based on the provided expectations. @@ -26,12 +27,12 @@ export type Hydrated> = * * @throws Will throw an error if an expectation cannot be resolved. */ -export async function hydrate, K extends Array>( +export async function hydrate>( data: T, expectations: K, client: Client, api: API -): Promise ? Hydrated : Hydrated> { +): Promise> { // TODO: add clone trait. Make it a requirement for hydrateable objects. // Then clone the object and return the hydrated object. let hydrated: Hydrated; @@ -44,6 +45,9 @@ export async function hydrate, K exte } else if (expectation === Expectation.Guild && exists(data.guild_id)) { let context = data as unknown as HydratedContext<[Expectation.Guild, ...Expectation[]]>; context.guild = await client.guilds.get(data.guild_id); + } else if (expectation === Expectation.Channel && exists(data.channel_id)) { + let context = data as unknown as HydratedContext<[Expectation.Channel, ...Expectation[]]>; + context.channel = await client.channels.get(data.channel_id); } else { throw new Error(`Invalid expectation requested for Context: ${expectation}`); } @@ -65,7 +69,11 @@ export async function hydrate, K exte } } - return data as unknown as (T extends Hydrated ? Hydrated : Hydrated); + // 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; } /** @@ -81,12 +89,12 @@ export async function hydrate, K exte * * @returns A promise that resolves to a type guard function. The type guard function returns `true` if the data object was successfully hydrated, otherwise `false`. */ -export async function hydrator, K extends Array>( +export async function hydrator>( data: T, expectations: K, client: Client, api: API -): Promise<(data: unknown) => data is T extends Hydrated ? Hydrated : Hydrated> { +): Promise<(data: unknown) => data is Hydrated> { let hydrated = true; try { @@ -98,7 +106,7 @@ export async function hydrator, K ext } // BUG: Resolves to never after hydrations on an already hydrated object - return function (data: unknown): data is T extends Hydrated ? Hydrated : Hydrated { + return function (data: unknown): data is Hydrated { return hydrated; } -} \ No newline at end of file +} diff --git a/src/types/channel.ts b/src/types/channel.ts index aadaa12..48202b9 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -1,79 +1,278 @@ +import { GatewayObject } from "./base"; import { Trait } from "./common"; +import { User, type DiscordUser } from "./user"; -export class Channel { - version: number; - type: number; - position: number; - permission_overwrites: any[]; - name: string; +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; - flags: number; - topic?: string | null; - rate_limit_per_user?: number; - parent_id?: string; - last_message_id?: string | null; - user_limit?: number; - rtc_region?: string | null; - bitrate?: number; - last_pin_timestamp?: string; + type: 0 | 1; // 0 for role, 1 for member + allow: string; + deny: string; +} - constructor(data: any) { - this.version = data.version; - this.type = data.type; +export type ThreadMetadata = { + archived: boolean; + auto_archive_duration: number; + archive_timestamp: string; + locked: boolean; + invitable?: boolean; + create_timestamp?: string | null; +} + +export type ThreadMember = { + id?: string; + user_id?: string; + join_timestamp: string; + flags: number; +} + +export type ForumTag = { + id: string; + name: string; + moderated: boolean; + emoji_id?: string | null; + emoji_name?: string | null; +} + +export type DefaultReaction = { + emoji_id?: string | null; // the id of a guild's custom emoji + emoji_name?: string | null; // the unicode character of the emoji +} + +export class Channel extends GatewayObject { + id: string; + type: ChannelType; + guild_id?: string; + position?: number; + permission_overwrites?: PermissionOverwrite[]; + name?: string; + topic?: string | null; + nsfw?: boolean; + last_message_id?: string | null; + bitrate?: number; + user_limit?: number; + rate_limit_per_user?: number; + recipients?: User[]; + icon?: string | null; + owner_id?: string; + application_id?: string; + managed?: boolean; + parent_id?: string | null; + last_pin_timestamp?: Date | null; + rtc_region?: string | null; + video_quality_mode?: VideoQualityMode; + message_count?: number; + member_count?: number; + thread_metadata?: ThreadMetadata; + member?: ThreadMember; + default_auto_archive_duration?: number; + permissions?: string; + flags?: number; + total_message_sent?: number; + available_tags?: ForumTag[]; + applied_tags?: string[]; + default_reaction_emoji?: DefaultReaction; + default_thread_rate_limit_per_user?: number; + default_sort_order?: SortOrderType | null; + default_forum_layout?: ForumLayoutType; + + constructor(data: Partial) { + super(); + this.id = data.id!; + this.type = data.type!; + this.guild_id = data.guild_id; this.position = data.position; this.permission_overwrites = data.permission_overwrites; this.name = data.name; - this.id = data.id; - this.flags = data.flags; this.topic = data.topic; - this.rate_limit_per_user = data.rate_limit_per_user; - this.parent_id = data.parent_id; + this.nsfw = data.nsfw; this.last_message_id = data.last_message_id; - this.user_limit = data.user_limit; - this.rtc_region = data.rtc_region; this.bitrate = data.bitrate; - this.last_pin_timestamp = data.last_pin_timestamp; + this.user_limit = data.user_limit; + this.rate_limit_per_user = data.rate_limit_per_user; + this.recipients = data.recipients; + this.icon = data.icon; + this.owner_id = data.owner_id; + this.application_id = data.application_id; + this.managed = data.managed; + this.parent_id = data.parent_id; + this.last_pin_timestamp = data.last_pin_timestamp instanceof Date + ? data.last_pin_timestamp + : data.last_pin_timestamp + ? new Date(data.last_pin_timestamp) + : null; + this.rtc_region = data.rtc_region; + this.video_quality_mode = data.video_quality_mode; + this.message_count = data.message_count; + this.member_count = data.member_count; + this.thread_metadata = data.thread_metadata; + this.member = data.member; + this.default_auto_archive_duration = data.default_auto_archive_duration; + this.permissions = data.permissions; + this.flags = data.flags; + this.total_message_sent = data.total_message_sent; + this.available_tags = data.available_tags; + this.applied_tags = data.applied_tags; + this.default_reaction_emoji = data.default_reaction_emoji; + this.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user; + this.default_sort_order = data.default_sort_order; + this.default_forum_layout = data.default_forum_layout; } - static [Trait.fromDiscord](data: DiscordChannel): Channel { + public static [Trait.fromDiscord](data: DiscordChannel): T { return new Channel({ - version: data.version, + id: data.id, type: data.type, + guild_id: data.guild_id, position: data.position, permission_overwrites: data.permission_overwrites, name: data.name, - id: data.id, - flags: data.flags, topic: data.topic, - rate_limit_per_user: data.rate_limit_per_user, - parent_id: data.parent_id, + nsfw: data.nsfw, last_message_id: data.last_message_id, - user_limit: data.user_limit, - rtc_region: data.rtc_region, bitrate: data.bitrate, - last_pin_timestamp: data.last_pin_timestamp - }); - } - - static fromAPIResponse(data: any): Channel { - return new Channel(data); + user_limit: data.user_limit, + rate_limit_per_user: data.rate_limit_per_user, + recipients: data.recipients?.map(u => User.fromDiscord(u)), + icon: data.icon, + owner_id: data.owner_id, + application_id: data.application_id, + managed: data.managed, + parent_id: data.parent_id, + last_pin_timestamp: data.last_pin_timestamp ? new Date(data.last_pin_timestamp) : null, + rtc_region: data.rtc_region, + video_quality_mode: data.video_quality_mode, + message_count: data.message_count, + member_count: data.member_count, + thread_metadata: data.thread_metadata, + member: data.member, + default_auto_archive_duration: data.default_auto_archive_duration, + permissions: data.permissions, + flags: data.flags, + total_message_sent: data.total_message_sent, + available_tags: data.available_tags, + applied_tags: data.applied_tags, + default_reaction_emoji: data.default_reaction_emoji, + default_thread_rate_limit_per_user: data.default_thread_rate_limit_per_user, + default_sort_order: data.default_sort_order, + default_forum_layout: data.default_forum_layout + }) as T; } } -export type DiscordChannel = { - version: number; - type: number; - position: number; - permission_overwrites: any[]; - name: string; +export class DiscordChannel { id: string; - flags: number; + type: number; + guild_id?: string; + position?: number; + permission_overwrites?: PermissionOverwrite[]; + name?: string; topic?: string | null; - rate_limit_per_user?: number; - parent_id?: string; + nsfw?: boolean; last_message_id?: string | null; - user_limit?: number; - rtc_region?: string | null; bitrate?: number; - last_pin_timestamp?: string; -} \ No newline at end of file + user_limit?: number; + rate_limit_per_user?: number; + recipients?: DiscordUser[]; + icon?: string | null; + owner_id?: string; + application_id?: string; + managed?: boolean; + parent_id?: string | null; + last_pin_timestamp?: string | null; + rtc_region?: string | null; + video_quality_mode?: number; + message_count?: number; + member_count?: number; + thread_metadata?: ThreadMetadata; + member?: ThreadMember; + default_auto_archive_duration?: number; + permissions?: string; + flags?: number; + total_message_sent?: number; + available_tags?: ForumTag[]; + applied_tags?: string[]; + default_reaction_emoji?: DefaultReaction; + default_thread_rate_limit_per_user?: number; + default_sort_order?: number | null; + default_forum_layout?: number; + + constructor(data: any) { + this.id = data.id; + this.type = data.type; + this.guild_id = data.guild_id; + this.position = data.position; + this.permission_overwrites = data.permission_overwrites; + this.name = data.name; + this.topic = data.topic; + this.nsfw = data.nsfw; + this.last_message_id = data.last_message_id; + this.bitrate = data.bitrate; + this.user_limit = data.user_limit; + this.rate_limit_per_user = data.rate_limit_per_user; + this.recipients = data.recipients; + this.icon = data.icon; + this.owner_id = data.owner_id; + this.application_id = data.application_id; + this.managed = data.managed; + this.parent_id = data.parent_id; + this.last_pin_timestamp = data.last_pin_timestamp; + this.rtc_region = data.rtc_region; + this.video_quality_mode = data.video_quality_mode; + this.message_count = data.message_count; + this.member_count = data.member_count; + this.thread_metadata = data.thread_metadata; + this.member = data.member; + this.default_auto_archive_duration = data.default_auto_archive_duration; + this.permissions = data.permissions; + this.flags = data.flags; + this.total_message_sent = data.total_message_sent; + this.available_tags = data.available_tags; + this.applied_tags = data.applied_tags; + this.default_reaction_emoji = data.default_reaction_emoji; + this.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user; + this.default_sort_order = data.default_sort_order; + this.default_forum_layout = data.default_forum_layout; + } + + static fromAPIResponse(data: any): DiscordChannel { + return new DiscordChannel(data); + } +} diff --git a/src/types/context.ts b/src/types/context.ts index b427e75..a4c048e 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -4,6 +4,7 @@ import { hydrate, hydrator, type Hydrateable, type Hydrated } from "../services/ import { isNil, exists } from "../utils"; import { fromDiscord, InteractionResponseType, type Expectation } from "./common"; import type { Guild } from "./guild"; +import { Channel } from "./channel"; import type { InteractionPayload } from "./handler"; import { type DiscordMessage, Message, type MessagePayload } from "./message"; /** @@ -28,6 +29,7 @@ import { type DiscordMessage, Message, type MessagePayload } from "./message"; export type HydratedContext> = Context & { guild: Extract extends never ? undefined : Guild; message: Extract extends never ? undefined : Message; + channel: Extract extends never ? undefined : Channel; }; export type ContextData = @@ -92,7 +94,7 @@ export class Context { * * @returns A promise that resolves to a type guard function. The type guard function returns `true` if the data object was successfully hydrated, otherwise `false`. */ - public hydrator = async >, K extends Array>(data: T, expectations: K) => { + public hydrator = async >(data: T, expectations: K) => { return hydrator(data, expectations, this.client, this.api); } @@ -174,4 +176,4 @@ export class Context { public defer = async () => { return this.ack(InteractionResponseType.DeferredMessage); }; -} \ No newline at end of file +} diff --git a/src/types/message.ts b/src/types/message.ts index 43cbe68..c82d501 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -35,6 +35,11 @@ export type Component = Partial