From 9cd9d307c535236df84ceb927dc481eb6d82d4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Susa=C3=B1a?= <66887817+socram03@users.noreply.github.com> Date: Fri, 8 Jul 2022 18:18:57 -0400 Subject: [PATCH] USER_UPDATE & TYPING_START (#37) * USER_UPDATE * TYPING_START * fix: fmt --- packages/biscuit/Actions.ts | 26 +- packages/biscuit/Util.ts | 7 +- packages/biscuit/structures/Member.ts | 2 +- packages/biscuit/structures/channels.ts | 1384 +++++++++-------- .../structures/components/ComponentFactory.ts | 2 +- 5 files changed, 719 insertions(+), 702 deletions(-) diff --git a/packages/biscuit/Actions.ts b/packages/biscuit/Actions.ts index 6f3a0fd..7c109c1 100644 --- a/packages/biscuit/Actions.ts +++ b/packages/biscuit/Actions.ts @@ -14,6 +14,7 @@ import type { DiscordIntegration, DiscordIntegrationDelete, DiscordInteraction, + DiscordMemberWithUser, DiscordMessage, DiscordMessageDelete, DiscordMessageReactionAdd, @@ -25,6 +26,7 @@ import type { // DiscordThreadMemberUpdate, // DiscordThreadMembersUpdate, DiscordThreadListSync, + DiscordTypingStart, DiscordUser, DiscordWebhookUpdate, } from "../discordeno/mod.ts"; @@ -34,11 +36,7 @@ import type { Session } from "./Session.ts"; import type { Channel } from "./structures/channels.ts"; import type { Interaction } from "./structures/interactions/InteractionFactory.ts"; -import { - ChannelFactory, - GuildChannel, - ThreadChannel, -} from "./structures/channels.ts"; +import { ChannelFactory, GuildChannel, ThreadChannel } from "./structures/channels.ts"; import ThreadMember from "./structures/ThreadMember.ts"; import Member from "./structures/Member.ts"; @@ -132,6 +130,18 @@ export const GUILD_ROLE_DELETE: RawHandler = (session, _ session.emit("guildRoleDelete", { guildId: data.guild_id, roleId: data.role_id }); }; +export const TYPING_START: RawHandler = (session, _shardId, payload) => { + session.emit("typingStart", { + channelId: payload.channel_id, + guildId: payload.guild_id ? payload.guild_id : undefined, + userId: payload.user_id, + timestamp: payload.timestamp, + member: payload.guild_id + ? new Member(session, payload.member as DiscordMemberWithUser, payload.guild_id) + : undefined, + }); +}; + export const INTERACTION_CREATE: RawHandler = (session, _shardId, interaction) => { session.emit("interactionCreate", InteractionFactory.from(session, interaction)); }; @@ -185,6 +195,10 @@ export const CHANNEL_PINS_UPDATE: RawHandler = (sessio }); }; +export const USER_UPDATE: RawHandler = (session, _shardId, payload) => { + session.emit("userUpdate", new User(session, payload)); +}; + export const WEBHOOKS_UPDATE: RawHandler = (session, _shardId, webhook) => { session.emit("webhooksUpdate", { guildId: webhook.guild_id, channelId: webhook.channel_id }); }; @@ -269,6 +283,7 @@ export interface Events { "guildRoleCreate": Handler<[{ guildId: Snowflake, role: DiscordRole }]>; "guildRoleUpdate": Handler<[{ guildId: Snowflake, role: DiscordRole }]>; "guildRoleDelete": Handler<[{ guildId: Snowflake, roleId: Snowflake }]>; + "typingStart": Handler<[{channelId: Snowflake, guildId?: Snowflake, userId: Snowflake, timestamp: number, member?: Member}]> "channelCreate": Handler<[Channel]>; "channelUpdate": Handler<[Channel]>; "channelDelete": Handler<[GuildChannel]>; @@ -283,4 +298,5 @@ export interface Events { "integrationDelete": Handler<[{ id: Snowflake, guildId?: Snowflake, applicationId?: Snowflake }]>; "raw": Handler<[unknown, number]>; "webhooksUpdate": Handler<[{ guildId: Snowflake, channelId: Snowflake }]>; + "userUpdate": Handler<[User]>; } diff --git a/packages/biscuit/Util.ts b/packages/biscuit/Util.ts index f98a292..37c865d 100644 --- a/packages/biscuit/Util.ts +++ b/packages/biscuit/Util.ts @@ -33,17 +33,16 @@ export type ComponentBuilder = /*** * Utility type - * */ + */ export type ComponentEmoji = { id: Snowflake; name: string; animated?: boolean; }; - /** * Utility type - * */ + */ export interface PermissionsOverwrites { id: Snowflake; type: 0 | 1; @@ -63,7 +62,7 @@ export type ImageSize = 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096; /** * Utility functions - * */ + */ export class Util { static formatImageURL(url: string, size: ImageSize = 128, format?: ImageFormat) { return `${url}.${format || (url.includes("/a_") ? "gif" : "jpg")}?size=${size}`; diff --git a/packages/biscuit/structures/Member.ts b/packages/biscuit/structures/Member.ts index 4612319..3b711e9 100644 --- a/packages/biscuit/structures/Member.ts +++ b/packages/biscuit/structures/Member.ts @@ -1,7 +1,7 @@ import type { Model } from "./Base.ts"; import type { Snowflake } from "../Snowflake.ts"; import type { Session } from "../Session.ts"; -import type { DiscordMemberWithUser } from "../../discordeno/mod.ts" +import type { DiscordMemberWithUser } from "../../discordeno/mod.ts"; import type { ImageFormat, ImageSize } from "../Util.ts"; import type { CreateGuildBan, ModifyGuildMember } from "./guilds/Guild.ts"; import Util from "../Util.ts"; diff --git a/packages/biscuit/structures/channels.ts b/packages/biscuit/structures/channels.ts index ee4bad8..8c29c20 100644 --- a/packages/biscuit/structures/channels.ts +++ b/packages/biscuit/structures/channels.ts @@ -1,691 +1,693 @@ -/** Types */ -import type { Model } from "./Base.ts"; -import type { Snowflake } from "../Snowflake.ts"; -import type { Session } from "../Session.ts"; -import type { PermissionsOverwrites } from "../Util.ts"; - -/** External from vendor */ -import { - DiscordChannel, - VideoQualityModes, - ChannelTypes, - GatewayOpcodes, - DiscordInvite, - DiscordMessage, - DiscordWebhook, - TargetTypes, - DiscordInviteMetadata, - DiscordThreadMember, - DiscordListArchivedThreads -} from "../../discordeno/mod.ts"; - -/** Functions and others */ -import { calculateShardId } from "../../discordeno/gateway/calculateShardId.ts"; -import { urlToBase64 } from "../util/urlToBase64.ts"; - -/** Classes and routes */ -import * as Routes from "../Routes.ts"; -import Message, { CreateMessage, EditMessage, ReactionResolvable } from "./Message.ts"; -import Invite from "./Invite.ts"; -import Webhook from "./Webhook.ts"; -import User from "./User.ts"; -import ThreadMember from "./ThreadMember.ts"; - -export abstract class BaseChannel implements Model { - constructor(session: Session, data: DiscordChannel) { - this.id = data.id; - this.session = session; - this.name = data.name; - this.type = data.type; - } - readonly id: Snowflake; - readonly session: Session; - - name?: string; - type: ChannelTypes; - - isText(): this is TextChannel { - return textBasedChannels.includes(this.type); - } - - isVoice(): this is VoiceChannel { - return this.type === ChannelTypes.GuildVoice; - } - - isDM(): this is DMChannel { - return this.type === ChannelTypes.DM; - } - - isNews(): this is NewsChannel { - return this.type === ChannelTypes.GuildNews; - } - - isThread(): this is ThreadChannel { - return this.type === ChannelTypes.GuildPublicThread || this.type === ChannelTypes.GuildPrivateThread; - } - - isStage(): this is StageChannel { - return this.type === ChannelTypes.GuildStageVoice; - } - - toString(): string { - return `<#${this.id}>`; - } -} - -/** TextChannel */ -/** - * Represents the options object to create an invitation - * @link https://discord.com/developers/docs/resources/channel#create-channel-invite-json-params - */ - export interface DiscordInviteOptions { - maxAge?: number; - maxUses?: number; - unique?: boolean; - temporary: boolean; - reason?: string; - targetType?: TargetTypes; - targetUserId?: Snowflake; - targetApplicationId?: Snowflake; -} - -export interface CreateWebhook { - name: string; - avatar?: string; - reason?: string; -} - -export const textBasedChannels = [ - ChannelTypes.DM, - ChannelTypes.GroupDm, - ChannelTypes.GuildPrivateThread, - ChannelTypes.GuildPublicThread, - ChannelTypes.GuildNews, - ChannelTypes.GuildVoice, - ChannelTypes.GuildText, -]; - -export type TextBasedChannels = - | ChannelTypes.DM - | ChannelTypes.GroupDm - | ChannelTypes.GuildPrivateThread - | ChannelTypes.GuildPublicThread - | ChannelTypes.GuildNews - | ChannelTypes.GuildVoice - | ChannelTypes.GuildText; - -export class TextChannel { - constructor(session: Session, data: DiscordChannel) { - this.session = session; - this.id = data.id; - this.name = data.name; - this.type = data.type as number; - this.rateLimitPerUser = data.rate_limit_per_user ?? 0; - this.nsfw = !!data.nsfw ?? false; - - if (data.last_message_id) { - this.lastMessageId = data.last_message_id; - } - - if (data.last_pin_timestamp) { - this.lastPinTimestamp = data.last_pin_timestamp; - } - } - - readonly session: Session; - readonly id: Snowflake; - name?: string; - type: TextBasedChannels; - lastMessageId?: Snowflake; - lastPinTimestamp?: string; - rateLimitPerUser: number; - nsfw: boolean; - - /** - * Mixin - */ - // deno-lint-ignore ban-types - static applyTo(klass: Function, ignore: Array = []) { - const methods: Array = [ - "fetchPins", - "createInvite", - "fetchMessages", - "sendTyping", - "pinMessage", - "unpinMessage", - "addReaction", - "removeReaction", - "nukeReactions", - "fetchPins", - "sendMessage", - "editMessage", - "createWebhook", - ]; - - for (const method of methods) { - if (ignore.includes(method)) continue; - - klass.prototype[method] = TextChannel.prototype[method]; - } - } - - async fetchPins(): Promise { - const messages = await this.session.rest.runMethod( - this.session.rest, - "GET", - Routes.CHANNEL_PINS(this.id), - ); - return messages[0] ? messages.map((x: DiscordMessage) => new Message(this.session, x)) : []; - } - - async createInvite(options?: DiscordInviteOptions) { - const invite = await this.session.rest.runMethod( - this.session.rest, - "POST", - Routes.CHANNEL_INVITES(this.id), - options - ? { - max_age: options.maxAge, - max_uses: options.maxUses, - temporary: options.temporary, - unique: options.unique, - target_type: options.targetType, - target_user_id: options.targetUserId, - target_application_id: options.targetApplicationId, - } - : {}, - ); - - return new Invite(this.session, invite); - } - - async fetchMessages(options?: Routes.GetMessagesOptions): Promise { - if (options?.limit! > 100) throw Error("Values must be between 0-100"); - const messages = await this.session.rest.runMethod( - this.session.rest, - "GET", - Routes.CHANNEL_MESSAGES(this.id, options), - ); - return messages[0] ? messages.map((x) => new Message(this.session, x)) : []; - } - - async sendTyping() { - await this.session.rest.runMethod( - this.session.rest, - "POST", - Routes.CHANNEL_TYPING(this.id), - ); - } - - async pinMessage(messageId: Snowflake) { - await Message.prototype.pin.call({ id: messageId, channelId: this.id, session: this.session }); - } - - async unpinMessage(messageId: Snowflake) { - await Message.prototype.unpin.call({ id: messageId, channelId: this.id, session: this.session }); - } - - async addReaction(messageId: Snowflake, reaction: ReactionResolvable) { - await Message.prototype.addReaction.call( - { channelId: this.id, id: messageId, session: this.session }, - reaction, - ); - } - - async removeReaction(messageId: Snowflake, reaction: ReactionResolvable, options?: { userId: Snowflake }) { - await Message.prototype.removeReaction.call( - { channelId: this.id, id: messageId, session: this.session }, - reaction, - options, - ); - } - - async removeReactionEmoji(messageId: Snowflake, reaction: ReactionResolvable) { - await Message.prototype.removeReactionEmoji.call( - { channelId: this.id, id: messageId, session: this.session }, - reaction, - ); - } - - async nukeReactions(messageId: Snowflake) { - await Message.prototype.nukeReactions.call({ channelId: this.id, id: messageId }); - } - - async fetchReactions(messageId: Snowflake, reaction: ReactionResolvable, options?: Routes.GetReactions) { - const users = await Message.prototype.fetchReactions.call( - { channelId: this.id, id: messageId, session: this.session }, - reaction, - options, - ); - - return users; - } - - sendMessage(options: CreateMessage) { - return Message.prototype.reply.call({ channelId: this.id, session: this.session }, options); - } - - editMessage(messageId: Snowflake, options: EditMessage) { - return Message.prototype.edit.call({ channelId: this.id, id: messageId, session: this.session }, options); - } - - async createWebhook(options: CreateWebhook) { - const webhook = await this.session.rest.runMethod( - this.session.rest, - "POST", - Routes.CHANNEL_WEBHOOKS(this.id), - { - name: options.name, - avatar: options.avatar ? urlToBase64(options.avatar) : undefined, - reason: options.reason, - }, - ); - - return new Webhook(this.session, webhook); - } -} - -/** GuildChannel */ -/** - * Represent the options object to create a thread channel - * @link https://discord.com/developers/docs/resources/channel#start-thread-without-message - */ - export interface ThreadCreateOptions { - name: string; - autoArchiveDuration?: 60 | 1440 | 4320 | 10080; - type: 10 | 11 | 12; - invitable?: boolean; - rateLimitPerUser?: number; - reason?: string; -} - -/** - * Representations of the objects to edit a guild channel - * @link https://discord.com/developers/docs/resources/channel#modify-channel-json-params-guild-channel - */ -export interface EditGuildChannelOptions { - name?: string; - position?: number; - permissionOverwrites?: PermissionsOverwrites[]; -} - -export interface EditNewsChannelOptions extends EditGuildChannelOptions { - type?: ChannelTypes.GuildNews | ChannelTypes.GuildText; - topic?: string | null; - nfsw?: boolean | null; - parentId?: Snowflake | null; - defaultAutoArchiveDuration?: number | null; -} - -export interface EditGuildTextChannelOptions extends EditNewsChannelOptions { - rateLimitPerUser?: number | null; -} - -export interface EditStageChannelOptions extends EditGuildChannelOptions { - bitrate?: number | null; - rtcRegion?: Snowflake | null; -} - -export interface EditVoiceChannelOptions extends EditStageChannelOptions { - nsfw?: boolean | null; - userLimit?: number | null; - parentId?: Snowflake | null; - videoQualityMode?: VideoQualityModes | null; -} - -/** - * Represents the option object to create a thread channel from a message - * @link https://discord.com/developers/docs/resources/channel#start-thread-from-message - */ -export interface ThreadCreateOptions { - name: string; - autoArchiveDuration?: 60 | 1440 | 4320 | 10080; - rateLimitPerUser?: number; - messageId: Snowflake; -} - -export class GuildChannel extends BaseChannel implements Model { - constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { - super(session, data); - this.type = data.type as number; - this.guildId = guildId; - this.position = data.position; - data.topic ? this.topic = data.topic : null; - data.parent_id ? this.parentId = data.parent_id : undefined; - } - - override type: Exclude; - guildId: Snowflake; - topic?: string; - position?: number; - parentId?: Snowflake; - - async fetchInvites(): Promise { - const invites = await this.session.rest.runMethod( - this.session.rest, - "GET", - Routes.CHANNEL_INVITES(this.id), - ); - - return invites.map((invite) => new Invite(this.session, invite)); - } - - async edit(options: EditNewsChannelOptions): Promise; - async edit(options: EditStageChannelOptions): Promise; - async edit(options: EditVoiceChannelOptions): Promise; - async edit( - options: EditGuildTextChannelOptions | EditNewsChannelOptions | EditVoiceChannelOptions, - ): Promise { - const channel = await this.session.rest.runMethod( - this.session.rest, - "PATCH", - Routes.CHANNEL(this.id), - { - name: options.name, - type: "type" in options ? options.type : undefined, - position: options.position, - topic: "topic" in options ? options.topic : undefined, - nsfw: "nfsw" in options ? options.nfsw : undefined, - rate_limit_per_user: "rateLimitPerUser" in options ? options.rateLimitPerUser : undefined, - bitrate: "bitrate" in options ? options.bitrate : undefined, - user_limit: "userLimit" in options ? options.userLimit : undefined, - permissions_overwrites: options.permissionOverwrites, - parent_id: "parentId" in options ? options.parentId : undefined, - rtc_region: "rtcRegion" in options ? options.rtcRegion : undefined, - video_quality_mode: "videoQualityMode" in options ? options.videoQualityMode : undefined, - default_auto_archive_duration: "defaultAutoArchiveDuration" in options - ? options.defaultAutoArchiveDuration - : undefined, - }, - ); - return ChannelFactory.from(this.session, channel); - } - - async getArchivedThreads(options: Routes.ListArchivedThreads & { type: "public" | "private" | "privateJoinedThreads" }) { - let func: (channelId: Snowflake, options: Routes.ListArchivedThreads) => string; - - switch (options.type) { - case "public": - func = Routes.THREAD_ARCHIVED_PUBLIC; - break; - case "private": - func = Routes.THREAD_START_PRIVATE; - break; - case "privateJoinedThreads": - func = Routes.THREAD_ARCHIVED_PRIVATE_JOINED; - break; - } - - const { threads, members, has_more } = await this.session.rest.runMethod( - this.session.rest, - "GET", - func(this.id, options), - ); - - return { - threads: Object.fromEntries( - threads.map((thread) => [thread.id, new ThreadChannel(this.session, thread, this.id)]), - ) as Record, - members: Object.fromEntries( - members.map((threadMember) => [threadMember.id, new ThreadMember(this.session, threadMember)]), - ) as Record, - hasMore: has_more, - }; - } - - async createThread(options: ThreadCreateOptions): Promise { - const thread = await this.session.rest.runMethod( - this.session.rest, - "POST", - "messageId" in options - ? Routes.THREAD_START_PUBLIC(this.id, options.messageId) - : Routes.THREAD_START_PRIVATE(this.id), - { - name: options.name, - auto_archive_duration: options.autoArchiveDuration, - }, - ); - - return new ThreadChannel(this.session, thread, thread.guild_id ?? this.guildId); - } -} - -/** BaseVoiceChannel */ -/** - * @link https://discord.com/developers/docs/topics/gateway#update-voice-state - */ - export interface UpdateVoiceState { - guildId: string; - channelId?: string; - selfMute: boolean; - selfDeaf: boolean; -} - -export abstract class BaseVoiceChannel extends GuildChannel { - constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { - super(session, data, guildId); - this.bitRate = data.bitrate; - this.userLimit = data.user_limit ?? 0; - this.videoQuality = data.video_quality_mode; - this.nsfw = !!data.nsfw; - this.type = data.type as number; - - if (data.rtc_region) { - this.rtcRegion = data.rtc_region; - } - } - override type: ChannelTypes.GuildVoice | ChannelTypes.GuildStageVoice; - bitRate?: number; - userLimit: number; - rtcRegion?: Snowflake; - - videoQuality?: VideoQualityModes; - nsfw: boolean; - - /** - * This function was gathered from Discordeno it may not work - */ - async connect(options?: UpdateVoiceState) { - const shardId = calculateShardId(this.session.gateway, BigInt(super.guildId)); - const shard = this.session.gateway.manager.shards.get(shardId); - - if (!shard) { - throw new Error(`Shard (id: ${shardId} not found`); - } - - await shard.send({ - op: GatewayOpcodes.VoiceStateUpdate, - d: { - guild_id: super.guildId, - channel_id: super.id, - self_mute: Boolean(options?.selfMute), - self_deaf: options?.selfDeaf ?? true, - }, - }); - } -} - -/** DMChannel */ -export class DMChannel extends BaseChannel implements Model { - constructor(session: Session, data: DiscordChannel) { - super(session, data); - this.user = new User(this.session, data.recipents!.find((r) => r.id !== this.session.botId)!); - this.type = data.type as ChannelTypes.DM | ChannelTypes.GroupDm; - if (data.last_message_id) { - this.lastMessageId = data.last_message_id; - } - } - - override type: ChannelTypes.DM | ChannelTypes.GroupDm; - user: User; - lastMessageId?: Snowflake; - - async close() { - const channel = await this.session.rest.runMethod( - this.session.rest, - "DELETE", - Routes.CHANNEL(this.id), - ); - - return new DMChannel(this.session, channel); - } -} - -TextChannel.applyTo(DMChannel); - -export interface DMChannel extends Omit, Omit {} - -/** VoiceChannel */ -export class VoiceChannel extends BaseVoiceChannel { - constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { - super(session, data, guildId); - this.type = data.type as number; - } - override type: ChannelTypes.GuildVoice; -} - -export interface VoiceChannel extends TextChannel, BaseVoiceChannel {} - -TextChannel.applyTo(VoiceChannel); - -/** NewsChannel */ -export class NewsChannel extends GuildChannel { - constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { - super(session, data, guildId); - this.type = data.type as ChannelTypes.GuildNews; - this.defaultAutoArchiveDuration = data.default_auto_archive_duration; - } - - override type: ChannelTypes.GuildNews; - defaultAutoArchiveDuration?: number; - - crosspostMessage(messageId: Snowflake): Promise { - return Message.prototype.crosspost.call({ id: messageId, channelId: this.id, session: this.session }); - } - - get publishMessage() { - return this.crosspostMessage; - } -} - -TextChannel.applyTo(NewsChannel); - -export interface NewsChannel extends TextChannel, GuildChannel {} - -/** StageChannel */ -export class StageChannel extends BaseVoiceChannel { - constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { - super(session, data, guildId); - this.type = data.type as number; - this.topic = data.topic ? data.topic : undefined; - } - override type: ChannelTypes.GuildStageVoice; - topic?: string; -} - -/** ThreadChannel */ -export class ThreadChannel extends GuildChannel implements Model { - constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { - super(session, data, guildId); - this.type = data.type as number; - this.archived = !!data.thread_metadata?.archived; - this.archiveTimestamp = data.thread_metadata?.archive_timestamp; - this.autoArchiveDuration = data.thread_metadata?.auto_archive_duration; - this.locked = !!data.thread_metadata?.locked; - this.messageCount = data.message_count; - this.memberCount = data.member_count; - this.ownerId = data.owner_id; - - if (data.member) { - this.member = new ThreadMember(session, data.member); - } - } - - override type: ChannelTypes.GuildNewsThread | ChannelTypes.GuildPrivateThread | ChannelTypes.GuildPublicThread; - archived?: boolean; - archiveTimestamp?: string; - autoArchiveDuration?: number; - locked?: boolean; - messageCount?: number; - memberCount?: number; - member?: ThreadMember; - ownerId?: Snowflake; - - async joinThread() { - await this.session.rest.runMethod( - this.session.rest, - "PUT", - Routes.THREAD_ME(this.id), - ); - } - - async addToThread(guildMemberId: Snowflake) { - await this.session.rest.runMethod( - this.session.rest, - "PUT", - Routes.THREAD_USER(this.id, guildMemberId), - ); - } - - async leaveToThread(guildMemberId: Snowflake) { - await this.session.rest.runMethod( - this.session.rest, - "DELETE", - Routes.THREAD_USER(this.id, guildMemberId), - ); - } - - removeMember(memberId: Snowflake = this.session.botId) { - return ThreadMember.prototype.quitThread.call({ id: this.id, session: this.session }, memberId); - } - - fetchMember(memberId: Snowflake = this.session.botId) { - return ThreadMember.prototype.fetchMember.call({ id: this.id, session: this.session }, memberId); - } - - async fetchMembers(): Promise { - const members = await this.session.rest.runMethod( - this.session.rest, - "GET", - Routes.THREAD_MEMBERS(this.id), - ); - - return members.map((threadMember) => new ThreadMember(this.session, threadMember)); - } -} - -TextChannel.applyTo(ThreadChannel); - -export interface ThreadChannel extends Omit, Omit {} - -/** ChannelFactory */ -export type Channel = - | TextChannel - | VoiceChannel - | DMChannel - | NewsChannel - | ThreadChannel - | StageChannel; - -export class ChannelFactory { - static from(session: Session, channel: DiscordChannel): Channel { - switch (channel.type) { - case ChannelTypes.GuildPublicThread: - case ChannelTypes.GuildPrivateThread: - return new ThreadChannel(session, channel, channel.guild_id!); - case ChannelTypes.GuildNews: - return new NewsChannel(session, channel, channel.guild_id!); - case ChannelTypes.DM: - return new DMChannel(session, channel); - case ChannelTypes.GuildVoice: - return new VoiceChannel(session, channel, channel.guild_id!); - case ChannelTypes.GuildStageVoice: - return new StageChannel(session, channel, channel.guild_id!); - default: - if (textBasedChannels.includes(channel.type)) { - return new TextChannel(session, channel); - } - throw new Error("Channel was not implemented"); - } - } -} +/** Types */ +import type { Model } from "./Base.ts"; +import type { Snowflake } from "../Snowflake.ts"; +import type { Session } from "../Session.ts"; +import type { PermissionsOverwrites } from "../Util.ts"; + +/** External from vendor */ +import { + ChannelTypes, + DiscordChannel, + DiscordInvite, + DiscordInviteMetadata, + DiscordListArchivedThreads, + DiscordMessage, + DiscordThreadMember, + DiscordWebhook, + GatewayOpcodes, + TargetTypes, + VideoQualityModes, +} from "../../discordeno/mod.ts"; + +/** Functions and others */ +import { calculateShardId } from "../../discordeno/gateway/calculateShardId.ts"; +import { urlToBase64 } from "../util/urlToBase64.ts"; + +/** Classes and routes */ +import * as Routes from "../Routes.ts"; +import Message, { CreateMessage, EditMessage, ReactionResolvable } from "./Message.ts"; +import Invite from "./Invite.ts"; +import Webhook from "./Webhook.ts"; +import User from "./User.ts"; +import ThreadMember from "./ThreadMember.ts"; + +export abstract class BaseChannel implements Model { + constructor(session: Session, data: DiscordChannel) { + this.id = data.id; + this.session = session; + this.name = data.name; + this.type = data.type; + } + readonly id: Snowflake; + readonly session: Session; + + name?: string; + type: ChannelTypes; + + isText(): this is TextChannel { + return textBasedChannels.includes(this.type); + } + + isVoice(): this is VoiceChannel { + return this.type === ChannelTypes.GuildVoice; + } + + isDM(): this is DMChannel { + return this.type === ChannelTypes.DM; + } + + isNews(): this is NewsChannel { + return this.type === ChannelTypes.GuildNews; + } + + isThread(): this is ThreadChannel { + return this.type === ChannelTypes.GuildPublicThread || this.type === ChannelTypes.GuildPrivateThread; + } + + isStage(): this is StageChannel { + return this.type === ChannelTypes.GuildStageVoice; + } + + toString(): string { + return `<#${this.id}>`; + } +} + +/** TextChannel */ +/** + * Represents the options object to create an invitation + * @link https://discord.com/developers/docs/resources/channel#create-channel-invite-json-params + */ +export interface DiscordInviteOptions { + maxAge?: number; + maxUses?: number; + unique?: boolean; + temporary: boolean; + reason?: string; + targetType?: TargetTypes; + targetUserId?: Snowflake; + targetApplicationId?: Snowflake; +} + +export interface CreateWebhook { + name: string; + avatar?: string; + reason?: string; +} + +export const textBasedChannels = [ + ChannelTypes.DM, + ChannelTypes.GroupDm, + ChannelTypes.GuildPrivateThread, + ChannelTypes.GuildPublicThread, + ChannelTypes.GuildNews, + ChannelTypes.GuildVoice, + ChannelTypes.GuildText, +]; + +export type TextBasedChannels = + | ChannelTypes.DM + | ChannelTypes.GroupDm + | ChannelTypes.GuildPrivateThread + | ChannelTypes.GuildPublicThread + | ChannelTypes.GuildNews + | ChannelTypes.GuildVoice + | ChannelTypes.GuildText; + +export class TextChannel { + constructor(session: Session, data: DiscordChannel) { + this.session = session; + this.id = data.id; + this.name = data.name; + this.type = data.type as number; + this.rateLimitPerUser = data.rate_limit_per_user ?? 0; + this.nsfw = !!data.nsfw ?? false; + + if (data.last_message_id) { + this.lastMessageId = data.last_message_id; + } + + if (data.last_pin_timestamp) { + this.lastPinTimestamp = data.last_pin_timestamp; + } + } + + readonly session: Session; + readonly id: Snowflake; + name?: string; + type: TextBasedChannels; + lastMessageId?: Snowflake; + lastPinTimestamp?: string; + rateLimitPerUser: number; + nsfw: boolean; + + /** + * Mixin + */ + // deno-lint-ignore ban-types + static applyTo(klass: Function, ignore: Array = []) { + const methods: Array = [ + "fetchPins", + "createInvite", + "fetchMessages", + "sendTyping", + "pinMessage", + "unpinMessage", + "addReaction", + "removeReaction", + "nukeReactions", + "fetchPins", + "sendMessage", + "editMessage", + "createWebhook", + ]; + + for (const method of methods) { + if (ignore.includes(method)) continue; + + klass.prototype[method] = TextChannel.prototype[method]; + } + } + + async fetchPins(): Promise { + const messages = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.CHANNEL_PINS(this.id), + ); + return messages[0] ? messages.map((x: DiscordMessage) => new Message(this.session, x)) : []; + } + + async createInvite(options?: DiscordInviteOptions) { + const invite = await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.CHANNEL_INVITES(this.id), + options + ? { + max_age: options.maxAge, + max_uses: options.maxUses, + temporary: options.temporary, + unique: options.unique, + target_type: options.targetType, + target_user_id: options.targetUserId, + target_application_id: options.targetApplicationId, + } + : {}, + ); + + return new Invite(this.session, invite); + } + + async fetchMessages(options?: Routes.GetMessagesOptions): Promise { + if (options?.limit! > 100) throw Error("Values must be between 0-100"); + const messages = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.CHANNEL_MESSAGES(this.id, options), + ); + return messages[0] ? messages.map((x) => new Message(this.session, x)) : []; + } + + async sendTyping() { + await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.CHANNEL_TYPING(this.id), + ); + } + + async pinMessage(messageId: Snowflake) { + await Message.prototype.pin.call({ id: messageId, channelId: this.id, session: this.session }); + } + + async unpinMessage(messageId: Snowflake) { + await Message.prototype.unpin.call({ id: messageId, channelId: this.id, session: this.session }); + } + + async addReaction(messageId: Snowflake, reaction: ReactionResolvable) { + await Message.prototype.addReaction.call( + { channelId: this.id, id: messageId, session: this.session }, + reaction, + ); + } + + async removeReaction(messageId: Snowflake, reaction: ReactionResolvable, options?: { userId: Snowflake }) { + await Message.prototype.removeReaction.call( + { channelId: this.id, id: messageId, session: this.session }, + reaction, + options, + ); + } + + async removeReactionEmoji(messageId: Snowflake, reaction: ReactionResolvable) { + await Message.prototype.removeReactionEmoji.call( + { channelId: this.id, id: messageId, session: this.session }, + reaction, + ); + } + + async nukeReactions(messageId: Snowflake) { + await Message.prototype.nukeReactions.call({ channelId: this.id, id: messageId }); + } + + async fetchReactions(messageId: Snowflake, reaction: ReactionResolvable, options?: Routes.GetReactions) { + const users = await Message.prototype.fetchReactions.call( + { channelId: this.id, id: messageId, session: this.session }, + reaction, + options, + ); + + return users; + } + + sendMessage(options: CreateMessage) { + return Message.prototype.reply.call({ channelId: this.id, session: this.session }, options); + } + + editMessage(messageId: Snowflake, options: EditMessage) { + return Message.prototype.edit.call({ channelId: this.id, id: messageId, session: this.session }, options); + } + + async createWebhook(options: CreateWebhook) { + const webhook = await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.CHANNEL_WEBHOOKS(this.id), + { + name: options.name, + avatar: options.avatar ? urlToBase64(options.avatar) : undefined, + reason: options.reason, + }, + ); + + return new Webhook(this.session, webhook); + } +} + +/** GuildChannel */ +/** + * Represent the options object to create a thread channel + * @link https://discord.com/developers/docs/resources/channel#start-thread-without-message + */ +export interface ThreadCreateOptions { + name: string; + autoArchiveDuration?: 60 | 1440 | 4320 | 10080; + type: 10 | 11 | 12; + invitable?: boolean; + rateLimitPerUser?: number; + reason?: string; +} + +/** + * Representations of the objects to edit a guild channel + * @link https://discord.com/developers/docs/resources/channel#modify-channel-json-params-guild-channel + */ +export interface EditGuildChannelOptions { + name?: string; + position?: number; + permissionOverwrites?: PermissionsOverwrites[]; +} + +export interface EditNewsChannelOptions extends EditGuildChannelOptions { + type?: ChannelTypes.GuildNews | ChannelTypes.GuildText; + topic?: string | null; + nfsw?: boolean | null; + parentId?: Snowflake | null; + defaultAutoArchiveDuration?: number | null; +} + +export interface EditGuildTextChannelOptions extends EditNewsChannelOptions { + rateLimitPerUser?: number | null; +} + +export interface EditStageChannelOptions extends EditGuildChannelOptions { + bitrate?: number | null; + rtcRegion?: Snowflake | null; +} + +export interface EditVoiceChannelOptions extends EditStageChannelOptions { + nsfw?: boolean | null; + userLimit?: number | null; + parentId?: Snowflake | null; + videoQualityMode?: VideoQualityModes | null; +} + +/** + * Represents the option object to create a thread channel from a message + * @link https://discord.com/developers/docs/resources/channel#start-thread-from-message + */ +export interface ThreadCreateOptions { + name: string; + autoArchiveDuration?: 60 | 1440 | 4320 | 10080; + rateLimitPerUser?: number; + messageId: Snowflake; +} + +export class GuildChannel extends BaseChannel implements Model { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { + super(session, data); + this.type = data.type as number; + this.guildId = guildId; + this.position = data.position; + data.topic ? this.topic = data.topic : null; + data.parent_id ? this.parentId = data.parent_id : undefined; + } + + override type: Exclude; + guildId: Snowflake; + topic?: string; + position?: number; + parentId?: Snowflake; + + async fetchInvites(): Promise { + const invites = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.CHANNEL_INVITES(this.id), + ); + + return invites.map((invite) => new Invite(this.session, invite)); + } + + async edit(options: EditNewsChannelOptions): Promise; + async edit(options: EditStageChannelOptions): Promise; + async edit(options: EditVoiceChannelOptions): Promise; + async edit( + options: EditGuildTextChannelOptions | EditNewsChannelOptions | EditVoiceChannelOptions, + ): Promise { + const channel = await this.session.rest.runMethod( + this.session.rest, + "PATCH", + Routes.CHANNEL(this.id), + { + name: options.name, + type: "type" in options ? options.type : undefined, + position: options.position, + topic: "topic" in options ? options.topic : undefined, + nsfw: "nfsw" in options ? options.nfsw : undefined, + rate_limit_per_user: "rateLimitPerUser" in options ? options.rateLimitPerUser : undefined, + bitrate: "bitrate" in options ? options.bitrate : undefined, + user_limit: "userLimit" in options ? options.userLimit : undefined, + permissions_overwrites: options.permissionOverwrites, + parent_id: "parentId" in options ? options.parentId : undefined, + rtc_region: "rtcRegion" in options ? options.rtcRegion : undefined, + video_quality_mode: "videoQualityMode" in options ? options.videoQualityMode : undefined, + default_auto_archive_duration: "defaultAutoArchiveDuration" in options + ? options.defaultAutoArchiveDuration + : undefined, + }, + ); + return ChannelFactory.from(this.session, channel); + } + + async getArchivedThreads( + options: Routes.ListArchivedThreads & { type: "public" | "private" | "privateJoinedThreads" }, + ) { + let func: (channelId: Snowflake, options: Routes.ListArchivedThreads) => string; + + switch (options.type) { + case "public": + func = Routes.THREAD_ARCHIVED_PUBLIC; + break; + case "private": + func = Routes.THREAD_START_PRIVATE; + break; + case "privateJoinedThreads": + func = Routes.THREAD_ARCHIVED_PRIVATE_JOINED; + break; + } + + const { threads, members, has_more } = await this.session.rest.runMethod( + this.session.rest, + "GET", + func(this.id, options), + ); + + return { + threads: Object.fromEntries( + threads.map((thread) => [thread.id, new ThreadChannel(this.session, thread, this.id)]), + ) as Record, + members: Object.fromEntries( + members.map((threadMember) => [threadMember.id, new ThreadMember(this.session, threadMember)]), + ) as Record, + hasMore: has_more, + }; + } + + async createThread(options: ThreadCreateOptions): Promise { + const thread = await this.session.rest.runMethod( + this.session.rest, + "POST", + "messageId" in options + ? Routes.THREAD_START_PUBLIC(this.id, options.messageId) + : Routes.THREAD_START_PRIVATE(this.id), + { + name: options.name, + auto_archive_duration: options.autoArchiveDuration, + }, + ); + + return new ThreadChannel(this.session, thread, thread.guild_id ?? this.guildId); + } +} + +/** BaseVoiceChannel */ +/** + * @link https://discord.com/developers/docs/topics/gateway#update-voice-state + */ +export interface UpdateVoiceState { + guildId: string; + channelId?: string; + selfMute: boolean; + selfDeaf: boolean; +} + +export abstract class BaseVoiceChannel extends GuildChannel { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { + super(session, data, guildId); + this.bitRate = data.bitrate; + this.userLimit = data.user_limit ?? 0; + this.videoQuality = data.video_quality_mode; + this.nsfw = !!data.nsfw; + this.type = data.type as number; + + if (data.rtc_region) { + this.rtcRegion = data.rtc_region; + } + } + override type: ChannelTypes.GuildVoice | ChannelTypes.GuildStageVoice; + bitRate?: number; + userLimit: number; + rtcRegion?: Snowflake; + + videoQuality?: VideoQualityModes; + nsfw: boolean; + + /** + * This function was gathered from Discordeno it may not work + */ + async connect(options?: UpdateVoiceState) { + const shardId = calculateShardId(this.session.gateway, BigInt(super.guildId)); + const shard = this.session.gateway.manager.shards.get(shardId); + + if (!shard) { + throw new Error(`Shard (id: ${shardId} not found`); + } + + await shard.send({ + op: GatewayOpcodes.VoiceStateUpdate, + d: { + guild_id: super.guildId, + channel_id: super.id, + self_mute: Boolean(options?.selfMute), + self_deaf: options?.selfDeaf ?? true, + }, + }); + } +} + +/** DMChannel */ +export class DMChannel extends BaseChannel implements Model { + constructor(session: Session, data: DiscordChannel) { + super(session, data); + this.user = new User(this.session, data.recipents!.find((r) => r.id !== this.session.botId)!); + this.type = data.type as ChannelTypes.DM | ChannelTypes.GroupDm; + if (data.last_message_id) { + this.lastMessageId = data.last_message_id; + } + } + + override type: ChannelTypes.DM | ChannelTypes.GroupDm; + user: User; + lastMessageId?: Snowflake; + + async close() { + const channel = await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.CHANNEL(this.id), + ); + + return new DMChannel(this.session, channel); + } +} + +TextChannel.applyTo(DMChannel); + +export interface DMChannel extends Omit, Omit {} + +/** VoiceChannel */ +export class VoiceChannel extends BaseVoiceChannel { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { + super(session, data, guildId); + this.type = data.type as number; + } + override type: ChannelTypes.GuildVoice; +} + +export interface VoiceChannel extends TextChannel, BaseVoiceChannel {} + +TextChannel.applyTo(VoiceChannel); + +/** NewsChannel */ +export class NewsChannel extends GuildChannel { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { + super(session, data, guildId); + this.type = data.type as ChannelTypes.GuildNews; + this.defaultAutoArchiveDuration = data.default_auto_archive_duration; + } + + override type: ChannelTypes.GuildNews; + defaultAutoArchiveDuration?: number; + + crosspostMessage(messageId: Snowflake): Promise { + return Message.prototype.crosspost.call({ id: messageId, channelId: this.id, session: this.session }); + } + + get publishMessage() { + return this.crosspostMessage; + } +} + +TextChannel.applyTo(NewsChannel); + +export interface NewsChannel extends TextChannel, GuildChannel {} + +/** StageChannel */ +export class StageChannel extends BaseVoiceChannel { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { + super(session, data, guildId); + this.type = data.type as number; + this.topic = data.topic ? data.topic : undefined; + } + override type: ChannelTypes.GuildStageVoice; + topic?: string; +} + +/** ThreadChannel */ +export class ThreadChannel extends GuildChannel implements Model { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { + super(session, data, guildId); + this.type = data.type as number; + this.archived = !!data.thread_metadata?.archived; + this.archiveTimestamp = data.thread_metadata?.archive_timestamp; + this.autoArchiveDuration = data.thread_metadata?.auto_archive_duration; + this.locked = !!data.thread_metadata?.locked; + this.messageCount = data.message_count; + this.memberCount = data.member_count; + this.ownerId = data.owner_id; + + if (data.member) { + this.member = new ThreadMember(session, data.member); + } + } + + override type: ChannelTypes.GuildNewsThread | ChannelTypes.GuildPrivateThread | ChannelTypes.GuildPublicThread; + archived?: boolean; + archiveTimestamp?: string; + autoArchiveDuration?: number; + locked?: boolean; + messageCount?: number; + memberCount?: number; + member?: ThreadMember; + ownerId?: Snowflake; + + async joinThread() { + await this.session.rest.runMethod( + this.session.rest, + "PUT", + Routes.THREAD_ME(this.id), + ); + } + + async addToThread(guildMemberId: Snowflake) { + await this.session.rest.runMethod( + this.session.rest, + "PUT", + Routes.THREAD_USER(this.id, guildMemberId), + ); + } + + async leaveToThread(guildMemberId: Snowflake) { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.THREAD_USER(this.id, guildMemberId), + ); + } + + removeMember(memberId: Snowflake = this.session.botId) { + return ThreadMember.prototype.quitThread.call({ id: this.id, session: this.session }, memberId); + } + + fetchMember(memberId: Snowflake = this.session.botId) { + return ThreadMember.prototype.fetchMember.call({ id: this.id, session: this.session }, memberId); + } + + async fetchMembers(): Promise { + const members = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.THREAD_MEMBERS(this.id), + ); + + return members.map((threadMember) => new ThreadMember(this.session, threadMember)); + } +} + +TextChannel.applyTo(ThreadChannel); + +export interface ThreadChannel extends Omit, Omit {} + +/** ChannelFactory */ +export type Channel = + | TextChannel + | VoiceChannel + | DMChannel + | NewsChannel + | ThreadChannel + | StageChannel; + +export class ChannelFactory { + static from(session: Session, channel: DiscordChannel): Channel { + switch (channel.type) { + case ChannelTypes.GuildPublicThread: + case ChannelTypes.GuildPrivateThread: + return new ThreadChannel(session, channel, channel.guild_id!); + case ChannelTypes.GuildNews: + return new NewsChannel(session, channel, channel.guild_id!); + case ChannelTypes.DM: + return new DMChannel(session, channel); + case ChannelTypes.GuildVoice: + return new VoiceChannel(session, channel, channel.guild_id!); + case ChannelTypes.GuildStageVoice: + return new StageChannel(session, channel, channel.guild_id!); + default: + if (textBasedChannels.includes(channel.type)) { + return new TextChannel(session, channel); + } + throw new Error("Channel was not implemented"); + } + } +} diff --git a/packages/biscuit/structures/components/ComponentFactory.ts b/packages/biscuit/structures/components/ComponentFactory.ts index 9b4c51e..395f453 100644 --- a/packages/biscuit/structures/components/ComponentFactory.ts +++ b/packages/biscuit/structures/components/ComponentFactory.ts @@ -1,5 +1,5 @@ import type { Session } from "../../Session.ts"; -import type { DiscordComponent } from "../../../discordeno/mod.ts" +import type { DiscordComponent } from "../../../discordeno/mod.ts"; import type { Component } from "./Component.ts"; import { ButtonStyles, MessageComponentTypes } from "../../../discordeno/mod.ts"; import ActionRow from "./ActionRowComponent.ts";