diff --git a/structures/channels/BaseChannel.ts b/structures/channels/BaseChannel.ts new file mode 100644 index 0000000..fc88e18 --- /dev/null +++ b/structures/channels/BaseChannel.ts @@ -0,0 +1,51 @@ +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordChannel } from "../../vendor/external.ts"; +import type TextChannel from "./TextChannel.ts"; +import type VoiceChannel from "./VoiceChannel.ts"; +import type DMChannel from "./DMChannel.ts"; +import type NewsChannel from "./NewsChannel.ts"; +import type ThreadChannel from "./ThreadChannel.ts"; +import { ChannelTypes } from "../../vendor/external.ts"; +import { textBasedChannels } from "./TextChannel.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; + } + + toString(): string { + return `<#${this.id}>`; + } +} + +export default BaseChannel; diff --git a/structures/channels/ChannelFactory.ts b/structures/channels/ChannelFactory.ts new file mode 100644 index 0000000..2603514 --- /dev/null +++ b/structures/channels/ChannelFactory.ts @@ -0,0 +1,39 @@ +import type { Session } from "../../session/Session.ts"; +import type { DiscordChannel } from "../../vendor/external.ts"; +import { ChannelTypes } from "../../vendor/external.ts"; +import { textBasedChannels } from "./TextChannel.ts"; +import TextChannel from "./TextChannel.ts"; +import VoiceChannel from "./VoiceChannel.ts"; +import DMChannel from "./DMChannel.ts"; +import NewsChannel from "./NewsChannel.ts"; +import ThreadChannel from "./ThreadChannel.ts"; + +export type Channel = + | TextChannel + | VoiceChannel + | DMChannel + | NewsChannel + | ThreadChannel; + +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!); + default: + if (textBasedChannels.includes(channel.type)) { + return new TextChannel(session, channel); + } + throw new Error("Channel was not implemented"); + } + } +} + +export default ChannelFactory; diff --git a/structures/channels/DMChannel.ts b/structures/channels/DMChannel.ts new file mode 100644 index 0000000..ad94a05 --- /dev/null +++ b/structures/channels/DMChannel.ts @@ -0,0 +1,39 @@ +import type { Model } from "../Base.ts"; +import type { Session } from "../../session/Session.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { ChannelTypes, DiscordChannel } from "../../vendor/external.ts"; +import TextChannel from "./TextChannel.ts"; +import BaseChannel from "./BaseChannel.ts"; +import User from "../User.ts"; +import * as Routes from "../../util/Routes.ts"; + +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 {} + +export default DMChannel; diff --git a/structures/channels/GuildChannel.ts b/structures/channels/GuildChannel.ts new file mode 100644 index 0000000..aaa4e53 --- /dev/null +++ b/structures/channels/GuildChannel.ts @@ -0,0 +1,70 @@ +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { ChannelTypes, DiscordChannel, DiscordInviteMetadata } from "../../vendor/external.ts"; +import BaseChannel from "./BaseChannel.ts"; +import Invite from "../Invite.ts"; +import * as Routes from "../../util/Routes.ts"; + +/** + * 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; + reason?: string; +} + +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 createThread(options: ThreadCreateOptions): Promise { + const thread = await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.CHANNEL_CREATE_THREAD(this.id), + options, + ); + return new ThreadChannel(this.session, thread, this.guildId); + }*/ + + async delete(reason?: string) { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.CHANNEL(this.id), + { + reason, + }, + ); + } +} + +export default GuildChannel; diff --git a/structures/channels/NewsChannel.ts b/structures/channels/NewsChannel.ts new file mode 100644 index 0000000..a21ae61 --- /dev/null +++ b/structures/channels/NewsChannel.ts @@ -0,0 +1,31 @@ +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { ChannelTypes, DiscordChannel } from "../../vendor/external.ts"; +import GuildChannel from "./GuildChannel.ts"; +import Message from "../Message.ts"; +import TextChannel from "./TextChannel.ts"; + +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 {} + +export default NewsChannel; diff --git a/structures/channels/TextChannel.ts b/structures/channels/TextChannel.ts new file mode 100644 index 0000000..6f5e324 --- /dev/null +++ b/structures/channels/TextChannel.ts @@ -0,0 +1,221 @@ +// deno-lint-ignore-file ban-types +import type { Session } from "../../session/Session.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { GetMessagesOptions, GetReactions } from "../../util/Routes.ts"; +import type { + DiscordChannel, + DiscordInvite, + DiscordMessage, + DiscordWebhook, + TargetTypes, +} from "../../vendor/external.ts"; +import type { CreateMessage, EditMessage, ReactionResolvable } from "../Message.ts"; +import { ChannelTypes } from "../../vendor/external.ts"; +import { urlToBase64 } from "../../util/urlToBase64.ts"; +import Message from "../Message.ts"; +import Invite from "../Invite.ts"; +import Webhook from "../Webhook.ts"; +import * as Routes from "../../util/Routes.ts"; + +/** + * 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.GuildText, +]; + +export type TextBasedChannels = + | ChannelTypes.DM + | ChannelTypes.GroupDm + | ChannelTypes.GuildPrivateThread + | ChannelTypes.GuildPublicThread + | ChannelTypes.GuildNews + | 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 + */ + static applyTo(klass: Function) { + klass.prototype.fetchPins = TextChannel.prototype.fetchPins; + klass.prototype.createInvite = TextChannel.prototype.createInvite; + klass.prototype.fetchMessages = TextChannel.prototype.fetchMessages; + klass.prototype.sendTyping = TextChannel.prototype.sendTyping; + klass.prototype.pinMessage = TextChannel.prototype.pinMessage; + klass.prototype.unpinMessage = TextChannel.prototype.unpinMessage; + klass.prototype.addReaction = TextChannel.prototype.addReaction; + klass.prototype.removeReaction = TextChannel.prototype.removeReaction; + klass.prototype.removeReactionEmoji = TextChannel.prototype.removeReactionEmoji; + klass.prototype.nukeReactions = TextChannel.prototype.nukeReactions; + klass.prototype.fetchReactions = TextChannel.prototype.fetchReactions; + klass.prototype.sendMessage = TextChannel.prototype.sendMessage; + klass.prototype.editMessage = TextChannel.prototype.editMessage; + klass.prototype.createWebhook = TextChannel.prototype.createWebhook; + } + + 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?: 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?: 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); + } +} + +export default TextChannel; diff --git a/structures/channels/ThreadChannel.ts b/structures/channels/ThreadChannel.ts new file mode 100644 index 0000000..459ba6e --- /dev/null +++ b/structures/channels/ThreadChannel.ts @@ -0,0 +1,35 @@ +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { ChannelTypes, DiscordChannel } from "../../vendor/external.ts"; +import GuildChannel from "./GuildChannel.ts"; +import TextChannel from "./TextChannel.ts"; + +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; + } + + override type: ChannelTypes.GuildNewsThread | ChannelTypes.GuildPrivateThread | ChannelTypes.GuildPublicThread; + archived?: boolean; + archiveTimestamp?: string; + autoArchiveDuration?: number; + locked?: boolean; + messageCount?: number; + memberCount?: number; + ownerId?: Snowflake; +} + +TextChannel.applyTo(ThreadChannel); + +export interface ThreadChannel extends Omit, Omit {} + +export default ThreadChannel; diff --git a/structures/channels/VoiceChannel.ts b/structures/channels/VoiceChannel.ts new file mode 100644 index 0000000..00da784 --- /dev/null +++ b/structures/channels/VoiceChannel.ts @@ -0,0 +1,60 @@ +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordChannel, VideoQualityModes } from "../../vendor/external.ts"; +import { GatewayOpcodes } from "../../vendor/external.ts"; +import { calculateShardId } from "../../vendor/gateway/calculateShardId.ts"; +import GuildChannel from "./GuildChannel.ts"; + +/** + * @link https://discord.com/developers/docs/topics/gateway#update-voice-state + */ +export interface UpdateVoiceState { + guildId: string; + channelId?: string; + selfMute: boolean; + selfDeaf: boolean; +} + +export class VoiceChannel 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; + + if (data.rtc_region) { + this.rtcRegion = data.rtc_region; + } + } + 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, + }, + }); + } +} + +export default VoiceChannel; diff --git a/structures/guilds/AnonymousGuild.ts b/structures/guilds/AnonymousGuild.ts new file mode 100644 index 0000000..c53308e --- /dev/null +++ b/structures/guilds/AnonymousGuild.ts @@ -0,0 +1,55 @@ +import type { Model } from "../Base.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordGuild, GuildNsfwLevel, VerificationLevels } from "../../vendor/external.ts"; +import type { ImageFormat, ImageSize } from "../../util/shared/images.ts"; +import { iconBigintToHash, iconHashToBigInt } from "../../util/hash.ts"; +import { formatImageUrl } from "../../util/shared/images.ts"; +import BaseGuild from "./BaseGuild.ts"; +import * as Routes from "../../util/Routes.ts"; + +export class AnonymousGuild extends BaseGuild implements Model { + constructor(session: Session, data: Partial); // TODO: Improve this type (name and id are required) + constructor(session: Session, data: DiscordGuild) { + super(session, data); + + this.splashHash = data.splash ? iconHashToBigInt(data.splash) : undefined; + this.bannerHash = data.banner ? iconHashToBigInt(data.banner) : undefined; + + this.verificationLevel = data.verification_level; + this.vanityUrlCode = data.vanity_url_code ? data.vanity_url_code : undefined; + this.nsfwLevel = data.nsfw_level; + this.description = data.description ? data.description : undefined; + this.premiumSubscriptionCount = data.premium_subscription_count; + } + + splashHash?: bigint; + bannerHash?: bigint; + + verificationLevel: VerificationLevels; + vanityUrlCode?: string; + nsfwLevel: GuildNsfwLevel; + description?: string; + premiumSubscriptionCount?: number; + + splashUrl(options: { size?: ImageSize; format?: ImageFormat } = { size: 128 }) { + if (this.splashHash) { + return formatImageUrl( + Routes.GUILD_SPLASH(this.id, iconBigintToHash(this.splashHash)), + options.size, + options.format, + ); + } + } + + bannerUrl(options: { size?: ImageSize; format?: ImageFormat } = { size: 128 }) { + if (this.bannerHash) { + return formatImageUrl( + Routes.GUILD_BANNER(this.id, iconBigintToHash(this.bannerHash)), + options.size, + options.format, + ); + } + } +} + +export default AnonymousGuild; diff --git a/structures/guilds/BaseGuild.ts b/structures/guilds/BaseGuild.ts new file mode 100644 index 0000000..0b50c61 --- /dev/null +++ b/structures/guilds/BaseGuild.ts @@ -0,0 +1,41 @@ +import type { Model } from "../Base.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordGuild, GuildFeatures } from "../../vendor/external.ts"; +import { Snowflake } from "../../util/Snowflake.ts"; +import { iconHashToBigInt } from "../../util/hash.ts"; + +/** + * Class for {@link Guild} and {@link AnonymousGuild} + */ +export abstract class BaseGuild implements Model { + constructor(session: Session, data: DiscordGuild) { + this.session = session; + this.id = data.id; + + this.name = data.name; + this.iconHash = data.icon ? iconHashToBigInt(data.icon) : undefined; + + this.features = data.features; + } + + readonly session: Session; + readonly id: Snowflake; + + name: string; + iconHash?: bigint; + features: GuildFeatures[]; + + get createdTimestamp() { + return Snowflake.snowflakeToTimestamp(this.id); + } + + get createdAt() { + return new Date(this.createdTimestamp); + } + + toString() { + return this.name; + } +} + +export default BaseGuild; diff --git a/structures/guilds/Guild.ts b/structures/guilds/Guild.ts new file mode 100644 index 0000000..fe39151 --- /dev/null +++ b/structures/guilds/Guild.ts @@ -0,0 +1,367 @@ +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { + DiscordEmoji, + DiscordGuild, + DiscordInviteMetadata, + DiscordMemberWithUser, + DiscordRole, +} from "../../vendor/external.ts"; +import type { GetInvite } from "../../util/Routes.ts"; +import { + DefaultMessageNotificationLevels, + ExplicitContentFilterLevels, + VerificationLevels, +} from "../../vendor/external.ts"; +import { iconBigintToHash, iconHashToBigInt } from "../../util/hash.ts"; +import { urlToBase64 } from "../../util/urlToBase64.ts"; +import Member from "../Member.ts"; +import BaseGuild from "./BaseGuild.ts"; +import Role from "../Role.ts"; +import GuildEmoji from "../GuildEmoji.ts"; +import Invite from "../Invite.ts"; +import * as Routes from "../../util/Routes.ts"; + +export interface CreateRole { + name?: string; + color?: number; + iconHash?: string | bigint; + unicodeEmoji?: string; + hoist?: boolean; + mentionable?: boolean; +} + +export interface ModifyGuildRole { + name?: string; + color?: number; + hoist?: boolean; + mentionable?: boolean; + unicodeEmoji?: string; +} + +export interface CreateGuildEmoji { + name: string; + image: string; + roles?: Snowflake[]; + reason?: string; +} + +export interface ModifyGuildEmoji { + name?: string; + roles?: Snowflake[]; +} + +/** + * @link https://discord.com/developers/docs/resources/guild#create-guild-ban + */ +export interface CreateGuildBan { + deleteMessageDays?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; + reason?: string; +} + +/** + * @link https://discord.com/developers/docs/resources/guild#modify-guild-member + */ +export interface ModifyGuildMember { + nick?: string; + roles?: Snowflake[]; + mute?: boolean; + deaf?: boolean; + channelId?: Snowflake; + communicationDisabledUntil?: number; +} + +/** + * @link https://discord.com/developers/docs/resources/guild#begin-guild-prune + */ +export interface BeginGuildPrune { + days?: number; + computePruneCount?: boolean; + includeRoles?: Snowflake[]; +} + +export interface ModifyRolePositions { + id: Snowflake; + position?: number | null; +} + +/** + * Represents a guild + * @link https://discord.com/developers/docs/resources/guild#guild-object + */ +export class Guild extends BaseGuild implements Model { + constructor(session: Session, data: DiscordGuild) { + super(session, data); + + this.splashHash = data.splash ? iconHashToBigInt(data.splash) : undefined; + this.discoverySplashHash = data.discovery_splash ? iconHashToBigInt(data.discovery_splash) : undefined; + this.ownerId = data.owner_id; + this.widgetEnabled = !!data.widget_enabled; + this.widgetChannelId = data.widget_channel_id ? data.widget_channel_id : undefined; + this.vefificationLevel = data.verification_level; + this.defaultMessageNotificationLevel = data.default_message_notifications; + this.explicitContentFilterLevel = data.explicit_content_filter; + this.members = data.members?.map((member) => new Member(session, { ...member, user: member.user! }, data.id)) ?? + []; + this.roles = data.roles.map((role) => new Role(session, role, data.id)); + this.emojis = data.emojis.map((guildEmoji) => new GuildEmoji(session, guildEmoji, data.id)); + } + + splashHash?: bigint; + discoverySplashHash?: bigint; + ownerId: Snowflake; + widgetEnabled: boolean; + widgetChannelId?: Snowflake; + vefificationLevel: VerificationLevels; + defaultMessageNotificationLevel: DefaultMessageNotificationLevels; + explicitContentFilterLevel: ExplicitContentFilterLevels; + members: Member[]; + roles: Role[]; + emojis: GuildEmoji[]; + + /** + * 'null' would reset the nickname + */ + async editBotNickname(options: { nick: string | null; reason?: string }) { + const result = await this.session.rest.runMethod<{ nick?: string } | undefined>( + this.session.rest, + "PATCH", + Routes.USER_NICK(this.id), + options, + ); + + return result?.nick; + } + + async createEmoji(options: CreateGuildEmoji): Promise { + if (options.image && !options.image.startsWith("data:image/")) { + options.image = await urlToBase64(options.image); + } + + const emoji = await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.GUILD_EMOJIS(this.id), + options, + ); + + return new GuildEmoji(this.session, emoji, this.id); + } + + async deleteEmoji(id: Snowflake, { reason }: { reason?: string } = {}): Promise { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.GUILD_EMOJI(this.id, id), + { reason }, + ); + } + + async editEmoji(id: Snowflake, options: ModifyGuildEmoji): Promise { + const emoji = await this.session.rest.runMethod( + this.session.rest, + "PATCH", + Routes.GUILD_EMOJI(this.id, id), + options, + ); + + return new GuildEmoji(this.session, emoji, this.id); + } + + async createRole(options: CreateRole): Promise { + let icon: string | undefined; + + if (options.iconHash) { + if (typeof options.iconHash === "string") { + icon = options.iconHash; + } else { + icon = iconBigintToHash(options.iconHash); + } + } + + const role = await this.session.rest.runMethod( + this.session.rest, + "PUT", + Routes.GUILD_ROLES(this.id), + { + name: options.name, + color: options.color, + icon, + unicode_emoji: options.unicodeEmoji, + hoist: options.hoist, + mentionable: options.mentionable, + }, + ); + + return new Role(this.session, role, this.id); + } + + async deleteRole(roleId: Snowflake): Promise { + await this.session.rest.runMethod(this.session.rest, "DELETE", Routes.GUILD_ROLE(this.id, roleId)); + } + + async editRole(roleId: Snowflake, options: ModifyGuildRole): Promise { + const role = await this.session.rest.runMethod( + this.session.rest, + "PATCH", + Routes.GUILD_ROLE(this.id, roleId), + { + name: options.name, + color: options.color, + hoist: options.hoist, + mentionable: options.mentionable, + }, + ); + + return new Role(this.session, role, this.id); + } + + async addRole(memberId: Snowflake, roleId: Snowflake, { reason }: { reason?: string } = {}) { + await this.session.rest.runMethod( + this.session.rest, + "PUT", + Routes.GUILD_MEMBER_ROLE(this.id, memberId, roleId), + { reason }, + ); + } + + async removeRole(memberId: Snowflake, roleId: Snowflake, { reason }: { reason?: string } = {}) { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.GUILD_MEMBER_ROLE(this.id, memberId, roleId), + { reason }, + ); + } + + /** + * Returns the roles moved + */ + async moveRoles(options: ModifyRolePositions[]) { + const roles = await this.session.rest.runMethod( + this.session.rest, + "PATCH", + Routes.GUILD_ROLES(this.id), + options, + ); + + return roles.map((role) => new Role(this.session, role, this.id)); + } + + async deleteInvite(inviteCode: string): Promise { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.INVITE(inviteCode), + {}, + ); + } + + async fetchInvite(inviteCode: string, options: GetInvite): Promise { + const inviteMetadata = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.INVITE(inviteCode, options), + ); + + return new Invite(this.session, inviteMetadata); + } + + async fetchInvites(): Promise { + const invites = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.GUILD_INVITES(this.id), + ); + + return invites.map((invite) => new Invite(this.session, invite)); + } + + /** + * Bans the member + */ + async banMember(memberId: Snowflake, options: CreateGuildBan) { + await this.session.rest.runMethod( + this.session.rest, + "PUT", + Routes.GUILD_BAN(this.id, memberId), + options + ? { + delete_message_days: options.deleteMessageDays, + reason: options.reason, + } + : {}, + ); + } + + /** + * Kicks the member + */ + async kickMember(memberId: Snowflake, { reason }: { reason?: string }) { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.GUILD_MEMBER(this.id, memberId), + { reason }, + ); + } + + /* + * Unbans the member + * */ + async unbanMember(memberId: Snowflake) { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.GUILD_BAN(this.id, memberId), + ); + } + + async editMember(memberId: Snowflake, options: ModifyGuildMember) { + const member = await this.session.rest.runMethod( + this.session.rest, + "PATCH", + Routes.GUILD_MEMBER(this.id, memberId), + { + nick: options.nick, + roles: options.roles, + mute: options.mute, + deaf: options.deaf, + channel_id: options.channelId, + communication_disabled_until: options.communicationDisabledUntil + ? new Date(options.communicationDisabledUntil).toISOString() + : undefined, + }, + ); + + return new Member(this.session, member, this.id); + } + + async pruneMembers(options: BeginGuildPrune): Promise { + const result = await this.session.rest.runMethod<{ pruned: number }>( + this.session.rest, + "POST", + Routes.GUILD_PRUNE(this.id), + { + days: options.days, + compute_prune_count: options.computePruneCount, + include_roles: options.includeRoles, + }, + ); + + return result.pruned; + } + + async getPruneCount(): Promise { + const result = await this.session.rest.runMethod<{ pruned: number }>( + this.session.rest, + "GET", + Routes.GUILD_PRUNE(this.id), + ); + + return result.pruned; + } +} + +export default Guild; diff --git a/structures/guilds/InviteGuild.ts b/structures/guilds/InviteGuild.ts new file mode 100644 index 0000000..85e3d50 --- /dev/null +++ b/structures/guilds/InviteGuild.ts @@ -0,0 +1,19 @@ +import type { Model } from "../Base.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordGuild } from "../../vendor/external.ts"; +import AnonymousGuild from "./AnonymousGuild.ts"; +import WelcomeScreen from "../WelcomeScreen.ts"; + +export class InviteGuild extends AnonymousGuild implements Model { + constructor(session: Session, data: Partial) { + super(session, data); + + if (data.welcome_screen) { + this.welcomeScreen = new WelcomeScreen(session, data.welcome_screen); + } + } + + welcomeScreen?: WelcomeScreen; +} + +export default InviteGuild; diff --git a/structures/interactions/Interaction.ts b/structures/interactions/Interaction.ts new file mode 100644 index 0000000..5ed2b94 --- /dev/null +++ b/structures/interactions/Interaction.ts @@ -0,0 +1,145 @@ +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { + DiscordInteraction, + DiscordMessage, + FileContent, + InteractionResponseTypes, + InteractionTypes, +} from "../../vendor/external.ts"; +import type { MessageFlags } from "../../util/shared/flags.ts"; +import type { AllowedMentions } from "../Message.ts"; +import User from "../User.ts"; +import Message from "../Message.ts"; +import Member from "../Member.ts"; +import * as Routes from "../../util/Routes.ts"; + +export interface InteractionResponse { + type: InteractionResponseTypes; + data?: InteractionApplicationCommandCallbackData; +} + +export interface InteractionApplicationCommandCallbackData { + content?: string; + tts?: boolean; + allowedMentions?: AllowedMentions; + files?: FileContent[]; + customId?: string; + title?: string; + // components?: Component[]; + flags?: MessageFlags; + choices?: ApplicationCommandOptionChoice[]; +} + +/** https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoptionchoice */ +export interface ApplicationCommandOptionChoice { + name: string; + value: string | number; +} + +// TODO: abstract Interaction, CommandInteraction, ComponentInteraction, PingInteraction, etc + +export class Interaction implements Model { + constructor(session: Session, data: DiscordInteraction) { + this.session = session; + this.id = data.id; + this.token = data.token; + this.type = data.type; + this.guildId = data.guild_id; + this.channelId = data.channel_id; + this.applicationId = data.application_id; + this.locale = data.locale; + this.data = data.data; + + if (!data.guild_id) { + this.user = new User(session, data.user!); + } else { + this.member = new Member(session, data.member!, data.guild_id); + } + } + + readonly session: Session; + readonly id: Snowflake; + readonly token: string; + + type: InteractionTypes; + guildId?: Snowflake; + channelId?: Snowflake; + applicationId?: Snowflake; + locale?: string; + // deno-lint-ignore no-explicit-any + data: any; + user?: User; + member?: Member; + + async respond({ type, data }: InteractionResponse) { + const toSend = { + tts: data?.tts, + title: data?.title, + flags: data?.flags, + content: data?.content, + choices: data?.choices, + custom_id: data?.customId, + allowed_mentions: data?.allowedMentions + ? { + users: data.allowedMentions.users, + roles: data.allowedMentions.roles, + parse: data.allowedMentions.parse, + replied_user: data.allowedMentions.repliedUser, + } + : { parse: [] }, + }; + + if (this.session.unrepliedInteractions.delete(BigInt(this.id))) { + await this.session.rest.sendRequest( + this.session.rest, + { + url: Routes.INTERACTION_ID_TOKEN(this.id, this.token), + method: "POST", + payload: this.session.rest.createRequestBody(this.session.rest, { + method: "POST", + body: { + type: type, + data: toSend, + file: data?.files, + }, + headers: { + // remove authorization header + Authorization: "", + }, + }), + }, + ); + + return; + } + + const result = await this.session.rest.sendRequest( + this.session.rest, + { + url: Routes.WEBHOOK(this.session.applicationId ?? this.session.botId, this.token), + method: "POST", + payload: this.session.rest.createRequestBody(this.session.rest, { + method: "POST", + body: { + ...toSend, + file: data?.files, + }, + headers: { + // remove authorization header + Authorization: "", + }, + }), + }, + ); + + return new Message(this.session, result); + } + + inGuild(): this is Interaction & { user: undefined; guildId: Snowflake; member: Member } { + return !!this.guildId; + } +} + +export default Interaction;