Merge branch 'master' of github.com:jaronpate/unicord into ft/lru-cache

This commit is contained in:
2025-02-15 17:47:27 -06:00
9 changed files with 306 additions and 86 deletions
+1
View File
@@ -173,3 +173,4 @@ dist
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
.aider*
+7
View File
@@ -5,6 +5,13 @@ export { Intent, Trait } from './types/common';
export type { ClientConfig } from './types/common'; export type { ClientConfig } from './types/common';
export type { Context } from './types/context'; export type { Context } from './types/context';
export { Expectation } from './types/common'; export { Expectation } from './types/common';
export {
ChannelType,
ChannelFlags,
VideoQualityMode,
SortOrderType,
ForumLayoutType
} from './types/channel';
// Consumable util classes // Consumable util classes
export { Guild } from './types/guild'; export { Guild } from './types/guild';
export { Role } from './types/role'; export { Role } from './types/role';
+2 -2
View File
@@ -1,6 +1,6 @@
import { API } from "../api";
import { Processor } from "../processor";
import { Channel } from "../../types/channel"; import { Channel } from "../../types/channel";
import type { API } from "../api";
import type { Processor } from "../processor";
import { ObjectCache } from "./cache"; import { ObjectCache } from "./cache";
export class Channels extends ObjectCache<Channel, Channel> { export class Channels extends ObjectCache<Channel, Channel> {
+6 -6
View File
@@ -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 { Expectation, HandlerType, Intent, type ClientConfig } from '../types/common';
import { Message, type DiscordMessage } from '../types/message';
import { exists, isNil } from '../utils/index'; 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 { Guilds } from './caches/guilds';
import { Messages } from './caches/messages'; import { Messages } from './caches/messages';
import { Users } from './caches/users'; 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 { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator';
import { EventBus, type Emitter } from './bus'; import { Processor } from './processor';
export class Client { export class Client {
readonly token: string; readonly token: string;
+17 -9
View File
@@ -1,15 +1,16 @@
import { Expectation } from "../types/common"; import { Expectation } from "../types/common";
import { Context, type HydratedContext } from "../types/context"; import { Context, type HydratedContext } from "../types/context";
import { Message, type HydratedMessage } from "../types/message"; import { Message, type HydratedMessage } from "../types/message";
import { Channel } from "../types/channel";
import { exists } from "../utils"; import { exists } from "../utils";
import type { API } from "./api"; import type { API } from "./api";
import type { Client } from "./client"; import type { Client } from "./client";
export type GatewayObject = Message; export type GatewayObject = Message | Channel;
export type Hydrateable = Context | GatewayObject export type Hydrateable = Context | GatewayObject
export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> = export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> =
T extends Message ? HydratedMessage<K> : T extends Message ? HydratedMessage<K> & Omit<T, keyof HydratedMessage<K>> :
T extends Context ? HydratedContext<K> : never; T extends Context ? HydratedContext<K> & Omit<T, keyof HydratedContext<K>> : never;
/** /**
* Hydrates a given data object based on the provided expectations. * Hydrates a given data object based on the provided expectations.
@@ -26,12 +27,12 @@ export type Hydrated<T extends Hydrateable, K extends Array<Expectation>> =
* *
* @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 | Hydrated<any, any>, K extends Array<Expectation>>( export async function hydrate<T extends Hydrateable, K extends Array<Expectation>>(
data: T, data: T,
expectations: K, expectations: K,
client: Client, client: Client,
api: API api: API
): Promise<T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>> { ): Promise<Hydrated<T, K>> {
// TODO: add clone trait. Make it a requirement for hydrateable objects. // TODO: add clone trait. Make it a requirement for hydrateable objects.
// Then clone the object and return the hydrated object. // Then clone the object and return the hydrated object.
let hydrated: Hydrated<T, K>; let hydrated: Hydrated<T, K>;
@@ -44,6 +45,9 @@ export async function hydrate<T extends Hydrateable | Hydrated<any, any>, K exte
} else if (expectation === Expectation.Guild && exists(data.guild_id)) { } else if (expectation === Expectation.Guild && exists(data.guild_id)) {
let context = data as unknown as HydratedContext<[Expectation.Guild, ...Expectation[]]>; let context = data as unknown as HydratedContext<[Expectation.Guild, ...Expectation[]]>;
context.guild = await client.guilds.get(data.guild_id); context.guild = await client.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 { } else {
throw new Error(`Invalid expectation requested for Context: ${expectation}`); throw new Error(`Invalid expectation requested for Context: ${expectation}`);
} }
@@ -65,7 +69,11 @@ export async function hydrate<T extends Hydrateable | Hydrated<any, any>, K exte
} }
} }
return data as unknown as (T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>); // 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<T, K>;
} }
/** /**
@@ -81,12 +89,12 @@ export async function hydrate<T extends Hydrateable | Hydrated<any, any>, 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`. * @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<T extends Hydrateable | Hydrated<any, any>, K extends Array<Expectation>>( export async function hydrator<T extends Hydrateable, K extends Array<Expectation>>(
data: T, data: T,
expectations: K, expectations: K,
client: Client, client: Client,
api: API api: API
): Promise<(data: unknown) => data is T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K>> { ): Promise<(data: unknown) => data is Hydrated<T, K>> {
let hydrated = true; let hydrated = true;
try { try {
@@ -98,7 +106,7 @@ export async function hydrator<T extends Hydrateable | Hydrated<any, any>, K ext
} }
// BUG: Resolves to never after hydrations on an already hydrated object // BUG: Resolves to never after hydrations on an already hydrated object
return function (data: unknown): data is T extends Hydrated<infer U, infer H> ? Hydrated<T & U, [...H, ...K]> : Hydrated<T, K> { return function (data: unknown): data is Hydrated<T, K> {
return hydrated; return hydrated;
} }
} }
+250 -51
View File
@@ -1,79 +1,278 @@
import { GatewayObject } from "./base";
import { Trait } from "./common"; import { Trait } from "./common";
import { User, type DiscordUser } from "./user";
export class Channel { export enum ChannelType {
version: number; GUILD_TEXT = 0,
type: number; DM = 1,
position: number; GUILD_VOICE = 2,
permission_overwrites: any[]; GROUP_DM = 3,
name: string; 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; id: string;
flags: number; type: 0 | 1; // 0 for role, 1 for member
topic?: string | null; allow: string;
rate_limit_per_user?: number; deny: string;
parent_id?: string; }
last_message_id?: string | null;
user_limit?: number;
rtc_region?: string | null;
bitrate?: number;
last_pin_timestamp?: string;
constructor(data: any) { export type ThreadMetadata = {
this.version = data.version; archived: boolean;
this.type = data.type; 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<Channel>) {
super();
this.id = data.id!;
this.type = data.type!;
this.guild_id = data.guild_id;
this.position = data.position; this.position = data.position;
this.permission_overwrites = data.permission_overwrites; this.permission_overwrites = data.permission_overwrites;
this.name = data.name; this.name = data.name;
this.id = data.id;
this.flags = data.flags;
this.topic = data.topic; this.topic = data.topic;
this.rate_limit_per_user = data.rate_limit_per_user; this.nsfw = data.nsfw;
this.parent_id = data.parent_id;
this.last_message_id = data.last_message_id; 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.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]<T = Channel>(data: DiscordChannel): T {
return new Channel({ return new Channel({
version: data.version, id: data.id,
type: data.type, type: data.type,
guild_id: data.guild_id,
position: data.position, position: data.position,
permission_overwrites: data.permission_overwrites, permission_overwrites: data.permission_overwrites,
name: data.name, name: data.name,
id: data.id,
flags: data.flags,
topic: data.topic, topic: data.topic,
rate_limit_per_user: data.rate_limit_per_user, nsfw: data.nsfw,
parent_id: data.parent_id,
last_message_id: data.last_message_id, last_message_id: data.last_message_id,
user_limit: data.user_limit,
rtc_region: data.rtc_region,
bitrate: data.bitrate, bitrate: data.bitrate,
last_pin_timestamp: data.last_pin_timestamp 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,
static fromAPIResponse(data: any): Channel { owner_id: data.owner_id,
return new Channel(data); 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 = { export class DiscordChannel {
version: number;
type: number;
position: number;
permission_overwrites: any[];
name: string;
id: string; id: string;
flags: number; type: number;
guild_id?: string;
position?: number;
permission_overwrites?: PermissionOverwrite[];
name?: string;
topic?: string | null; topic?: string | null;
rate_limit_per_user?: number; nsfw?: boolean;
parent_id?: string;
last_message_id?: string | null; last_message_id?: string | null;
user_limit?: number;
rtc_region?: string | null;
bitrate?: number; bitrate?: number;
last_pin_timestamp?: string; 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);
}
} }
+3 -1
View File
@@ -4,6 +4,7 @@ import { hydrate, hydrator, type Hydrateable, type Hydrated } from "../services/
import { isNil, exists } from "../utils"; import { isNil, exists } from "../utils";
import { fromDiscord, InteractionResponseType, type Expectation } from "./common"; import { fromDiscord, InteractionResponseType, type Expectation } from "./common";
import type { Guild } from "./guild"; import type { Guild } from "./guild";
import { Channel } from "./channel";
import type { InteractionPayload } from "./handler"; import type { InteractionPayload } from "./handler";
import { type DiscordMessage, Message, type MessagePayload } from "./message"; import { type DiscordMessage, Message, type MessagePayload } from "./message";
/** /**
@@ -28,6 +29,7 @@ import { type DiscordMessage, Message, type MessagePayload } from "./message";
export type HydratedContext<T extends Array<Expectation>> = Context & { export type HydratedContext<T extends Array<Expectation>> = Context & {
guild: Extract<Expectation.Guild, T[number]> extends never ? undefined : Guild; guild: Extract<Expectation.Guild, T[number]> extends never ? undefined : Guild;
message: Extract<Expectation.Message, T[number]> extends never ? undefined : Message; message: Extract<Expectation.Message, T[number]> extends never ? undefined : Message;
channel: Extract<Expectation.Channel, T[number]> extends never ? undefined : Channel;
}; };
export type ContextData = export type ContextData =
@@ -92,7 +94,7 @@ export class Context<D extends ContextData = ContextData> {
* *
* @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`. * @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 <T extends Hydrateable | Hydrated<Hydrateable, Array<Expectation>>, K extends Array<Expectation>>(data: T, expectations: K) => { public hydrator = async <T extends Hydrateable, K extends Array<Expectation>>(data: T, expectations: K) => {
return hydrator<T, K>(data, expectations, this.client, this.api); return hydrator<T, K>(data, expectations, this.client, this.api);
} }
+5
View File
@@ -35,6 +35,11 @@ export type Component = Partial<Button | SelectMenu> & {
// components?: Component[]; // components?: Component[];
// } // }
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 type Emoji = { export type Emoji = {
name: string | null; name: string | null;
id: string | null; id: string | null;
+7 -9
View File
@@ -1,4 +1,4 @@
import { ApplicationCommandType, Client, CommandOptionType, ComponentStyle, type Context, createCommandHandler, Expectation, Intent, InteractionCommpoentData, type InteractionData, Message, type MessagePayload } from '../src/index'; import { ApplicationCommandType, ChannelType, Client, CommandOptionType, ComponentStyle, type Context, createCommandHandler, Expectation, Intent, InteractionCommpoentData, type InteractionData, Message, type MessagePayload } from '../src/index';
const client = new Client({ const client = new Client({
token: process.env.BOT_TOKEN!, token: process.env.BOT_TOKEN!,
@@ -78,20 +78,18 @@ Nice to meet you, I'm ${context.self.username}!`
// Repeated hydration // Repeated hydration
client.chatCommands.register('me', async (context: Context, _args: any[]) => { client.chatCommands.register('me', async (context: Context, _args: any[]) => {
// Note: Testing repeated hydration // Note: Testing repeated hydration
const { message } = await context.hydrate(context, [Expectation.Message]) const { message } = await context.hydrate(context, [Expectation.Message]);
const hydrated = await context.hydrate(message, [Expectation.Channel]) const { channel } = await context.hydrate(message, [Expectation.Channel]);
// const rehydrated = await context.hydrate(hydrated, [Expectation.Guild]);
// rehydrated.guild.name
const hydrate = await context.hydrator(hydrated, [Expectation.Guild]); const hydrate = await context.hydrator(context, [Expectation.Guild]);
const hasGuild = hydrate(hydrated); const hasGuild = hydrate(context);
if (hasGuild) { if (hasGuild) {
// Send a message stating the user that they are in a guild // Send a message stating the user that they are in a guild
await context.reply(`You are ${message.author.username} and this is ${hydrated.guild.name}`, true); await context.reply(`You are ${message.author.username} and this is ${context.guild.name}`, true);
} else { } else {
// Send a message stating the user that they are not in a guild // Send a message stating the user that they are not in a guild
await context.reply(`You are ${message.author.username} and we are in ${hydrated.channel.type === 1 ? 'our DM\'s <3' : 'a Group DM 👀'}`, true); await context.reply(`You are ${message.author.username} and we are in ${channel.type === ChannelType.DM ? 'our DM\'s 😘' : 'a Group 👀'}`, true);
} }
}); });