diff --git a/handlers/Actions.ts b/handlers/Actions.ts index e9ec833..032853c 100644 --- a/handlers/Actions.ts +++ b/handlers/Actions.ts @@ -1,39 +1,78 @@ -import type { DiscordMessage, DiscordReady } from "../vendor/external.ts"; +import type { + DiscordGuildMemberAdd, + DiscordGuildMemberRemove, + DiscordGuildMemberUpdate, + DiscordInteraction, + DiscordMessage, + DiscordMessageDelete, + DiscordReady, +} from "../vendor/external.ts"; import type { Snowflake } from "../util/Snowflake.ts"; import type { Session } from "../session/Session.ts"; +import Member from "../structures/Member.ts"; import Message from "../structures/Message.ts"; +import User from "../structures/User.ts"; +import Interaction from "../structures/Interaction.ts"; -export type RawHandler = (...args: [Session, number, ...T]) => void; +export type RawHandler = (...args: [Session, number, T]) => void; export type Handler = (...args: T) => unknown; -export type Ready = [DiscordReady]; -export const READY: RawHandler = (session, shardId, payload) => { - session.emit("ready", payload, shardId); +export const READY: RawHandler = (session, shardId, payload) => { + session.applicationId = payload.application.id; + session.botId = payload.user.id; + session.emit("ready", { ...payload, user: new User(session, payload.user) }, shardId); }; -export type MessageCreate = [DiscordMessage]; -export const MESSAGE_CREATE: RawHandler = (session, _shardId, message) => { +export const MESSAGE_CREATE: RawHandler = (session, _shardId, message) => { session.emit("messageCreate", new Message(session, message)); }; -export type MessageUpdate = [DiscordMessage]; -export const MESSAGE_UPDATE: RawHandler = (session, _shardId, new_message) => { +export const MESSAGE_UPDATE: RawHandler = (session, _shardId, new_message) => { session.emit("messageUpdate", new Message(session, new_message)); }; -export type MessageDelete = [Snowflake]; -export const MESSAGE_DELETE: RawHandler = (session, _shardId, deleted_message_id) => { - session.emit("messageDelete", deleted_message_id); +export const MESSAGE_DELETE: RawHandler = (session, _shardId, { id, channel_id, guild_id }) => { + session.emit("messageDelete", { id, channelId: channel_id, guildId: guild_id }); }; -export const raw: RawHandler<[unknown]> = (session, shardId, data) => { +export const GUILD_MEMBER_ADD: RawHandler = (session, _shardId, member) => { + session.emit("guildMemberAdd", new Member(session, member, member.guild_id)); +}; + +export const GUILD_MEMBER_UPDATE: RawHandler = (session, _shardId, member) => { + session.emit("guildMemberUpdate", new Member(session, member, member.guild_id)); +}; + +export const GUILD_MEMBER_REMOVE: RawHandler = (session, _shardId, member) => { + session.emit("guildMemberRemove", new User(session, member.user), member.guild_id); +}; + +export const INTERACTION_CREATE: RawHandler = (session, _shardId, interaction) => { + session.unrepliedInteractions.add(BigInt(interaction.id)); + + // could be improved + setTimeout(() => session.unrepliedInteractions.delete(BigInt(interaction.id)), 15 * 60 * 1000); + + session.emit("interactionCreate", new Interaction(session, interaction)); +}; + +export const raw: RawHandler = (session, shardId, data) => { session.emit("raw", data, shardId); }; -export interface Events { - "ready": Handler<[DiscordReady, number]>; - "messageCreate": Handler<[Message]>; - "messageUpdate": Handler<[Message]>; - "messageDelete": Handler<[Snowflake]>; - "raw": Handler<[unknown]>; +export interface Ready extends Omit { + user: User; +} + +// deno-fmt-ignore-file +export interface Events { + "ready": Handler<[Ready, number]>; + "messageCreate": Handler<[Message]>; + "messageUpdate": Handler<[Message]>; + "messageDelete": Handler<[{ id: Snowflake, channelId: Snowflake, guildId?: Snowflake }]>; + "guildMemberAdd": Handler<[Member]>; + "guildMemberUpdate": Handler<[Member]>; + "guildMemberRemove": Handler<[User, Snowflake]>; + "interactionCreate": Handler<[Interaction]>; + "raw": Handler<[unknown, number]>; } diff --git a/mod.ts b/mod.ts index b462a6d..8e72101 100644 --- a/mod.ts +++ b/mod.ts @@ -2,12 +2,15 @@ export * from "./structures/AnonymousGuild.ts"; export * from "./structures/Attachment.ts"; export * from "./structures/Base.ts"; export * from "./structures/BaseGuild.ts"; -export * from "./structures/Channel.ts"; +export * from "./structures/BaseChannel.ts"; +export * from "./structures/Component.ts"; export * from "./structures/DMChannel.ts"; +export * from "./structures/Embed.ts"; export * from "./structures/Emoji.ts"; export * from "./structures/Guild.ts"; export * from "./structures/GuildChannel.ts"; export * from "./structures/GuildEmoji.ts"; +export * from "./structures/Interaction.ts"; export * from "./structures/Invite.ts"; export * from "./structures/InviteGuild.ts"; export * from "./structures/Member.ts"; diff --git a/session/Session.ts b/session/Session.ts index 31263fb..10e74e4 100644 --- a/session/Session.ts +++ b/session/Session.ts @@ -4,7 +4,7 @@ import type { Events } from "../handlers/Actions.ts"; import { Snowflake } from "../util/Snowflake.ts"; import { EventEmitter } from "../util/EventEmmiter.ts"; -import { createGatewayManager, createRestManager } from "../vendor/external.ts"; +import { createGatewayManager, createRestManager, getBotIdFromToken } from "../vendor/external.ts"; import * as Routes from "../util/Routes.ts"; import * as Actions from "../handlers/Actions.ts"; @@ -39,6 +39,27 @@ export class Session extends EventEmitter { rest: ReturnType; gateway: ReturnType; + unrepliedInteractions: Set = new Set(); + + #botId: Snowflake; + #applicationId?: Snowflake; + + set applicationId(id: Snowflake) { + this.#applicationId = id; + } + + get applicationId() { + return this.#applicationId!; + } + + set botId(id: Snowflake) { + this.#botId = id; + } + + get botId() { + return this.#botId; + } + constructor(options: SessionOptions) { super(); this.options = options; @@ -71,7 +92,8 @@ export class Session extends EventEmitter { }, handleDiscordPayload: this.options.rawHandler ?? defHandler, }); - // TODO: set botId in Session.botId or something + + this.#botId = getBotIdFromToken(options.token).toString(); } override on(event: K, func: Events[K]): this; @@ -89,6 +111,11 @@ export class Session extends EventEmitter { return super.once(event, func); } + override emit(event: K, ...params: Parameters): boolean; + override emit(event: K, ...params: unknown[]): boolean { + return super.emit(event, ...params); + } + async start() { const getGatewayBot = () => this.rest.runMethod(this.rest, "GET", Routes.GATEWAY_BOT()); diff --git a/structures/BaseChannel.ts b/structures/BaseChannel.ts new file mode 100644 index 0000000..a0ed80b --- /dev/null +++ b/structures/BaseChannel.ts @@ -0,0 +1,49 @@ +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 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 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 this instanceof TextChannel; + } + + isVoice(): this is VoiceChannel { + return this instanceof VoiceChannel; + } + + isDM(): this is DMChannel { + return this instanceof DMChannel; + } + + isNews(): this is NewsChannel { + return this instanceof NewsChannel; + } + + isThread(): this is ThreadChannel { + return this instanceof ThreadChannel; + } + + toString(): string { + return `<#${this.id}>`; + } +} + +export default BaseChannel; diff --git a/structures/Channel.ts b/structures/Channel.ts deleted file mode 100644 index daf18ac..0000000 --- a/structures/Channel.ts +++ /dev/null @@ -1,24 +0,0 @@ -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"; - -export abstract class Channel 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; - - toString(): string { - return `<#${this.id}>`; - } -} - -export default Channel; diff --git a/structures/Component.ts b/structures/Component.ts new file mode 100644 index 0000000..02daff5 --- /dev/null +++ b/structures/Component.ts @@ -0,0 +1,41 @@ +import type { Session } from "../session/Session.ts"; +import type { DiscordComponent, MessageComponentTypes } from "../vendor/external.ts"; +import Emoji from "./Emoji.ts"; + +export class Component { + constructor(session: Session, data: DiscordComponent) { + this.session = session; + this.customId = data.custom_id; + this.type = data.type; + this.components = data.components?.map((component) => new Component(session, component)); + this.disabled = !!data.disabled; + + if (data.emoji) { + this.emoji = new Emoji(session, data.emoji); + } + + this.maxValues = data.max_values; + this.minValues = data.min_values; + this.label = data.label; + this.value = data.value; + this.options = data.options ?? []; + this.placeholder = data.placeholder; + } + + readonly session: Session; + + customId?: string; + type: MessageComponentTypes; + components?: Component[]; + disabled: boolean; + emoji?: Emoji; + maxValues?: number; + minValues?: number; + label?: string; + value?: string; + // deno-lint-ignore no-explicit-any + options: any[]; + placeholder?: string; +} + +export default Component; diff --git a/structures/DMChannel.ts b/structures/DMChannel.ts index 077514f..d13bd5d 100644 --- a/structures/DMChannel.ts +++ b/structures/DMChannel.ts @@ -1,16 +1,23 @@ +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 Channel from "./Channel.ts"; +import BaseChannel from "./BaseChannel.ts"; +import User from "./User.ts"; import * as Routes from "../util/Routes.ts"; -export class DMChannel extends Channel { +export class DMChannel extends BaseChannel implements Model { constructor(session: Session, data: DiscordChannel) { super(session, data); - data.last_message_id ? this.lastMessageId = data.last_message_id : undefined; - // Waiting for implementation of botId in session - //this.user = new User(this.session, data.recipents!.find((r) => r.id !== this.session.botId)); + + this.user = new User(this.session, data.recipents!.find((r) => r.id !== this.session.botId)!); + + if (data.last_message_id) { + this.lastMessageId = data.last_message_id; + } } + + user: User; lastMessageId?: Snowflake; async close() { diff --git a/structures/Embed.ts b/structures/Embed.ts new file mode 100644 index 0000000..19b5de3 --- /dev/null +++ b/structures/Embed.ts @@ -0,0 +1,101 @@ +import type { DiscordEmbed, EmbedTypes } from "../vendor/external.ts"; + +export interface Embed { + title?: string; + timestamp?: string; + type?: EmbedTypes; + url?: string; + color?: number; + description?: string; + author?: { + name: string; + iconURL?: string; + proxyIconURL?: string; + url?: string; + }; + footer?: { + text: string; + iconURL?: string; + proxyIconURL?: string; + }; + fields?: Array<{ + name: string; + value: string; + inline?: boolean; + }>; + thumbnail?: { + url: string; + proxyURL?: string; + width?: number; + height?: number; + }; + video?: { + url?: string; + proxyURL?: string; + width?: number; + height?: number; + }; + image?: { + url: string; + proxyURL?: string; + width?: number; + height?: number; + }; + provider?: { + url?: string; + name?: string; + }; +} + +export function embed(data: Embed): DiscordEmbed { + return { + title: data.title, + timestamp: data.timestamp, + type: data.type, + url: data.url, + color: data.color, + description: data.description, + author: { + name: data.author?.name!, + url: data.author?.url, + icon_url: data.author?.iconURL, + proxy_icon_url: data.author?.proxyIconURL, + }, + footer: data.footer || { + text: data.footer!.text, + icon_url: data.footer!.iconURL, + proxy_icon_url: data.footer!.proxyIconURL, + }, + fields: data.fields?.map((f) => { + return { + name: f.name, + value: f.value, + inline: f.inline, + }; + }), + thumbnail: data.thumbnail || { + url: data.thumbnail!.url, + proxy_url: data.thumbnail!.proxyURL, + width: data.thumbnail!.width, + height: data.thumbnail!.height, + }, + video: { + url: data.video?.url, + proxy_url: data.video?.proxyURL, + width: data.video?.width, + height: data.video?.height, + }, + image: data.image || { + url: data.image!.url, + proxy_url: data.image!.proxyURL, + width: data.image!.width, + height: data.image!.height, + }, + provider: { + url: data.provider?.url, + name: data.provider?.name, + }, + }; +} + +export default Embed; diff --git a/structures/Guild.ts b/structures/Guild.ts index ef265cb..1013814 100644 --- a/structures/Guild.ts +++ b/structures/Guild.ts @@ -1,7 +1,13 @@ 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, DiscordRole } from "../vendor/external.ts"; +import type { + DiscordEmoji, + DiscordGuild, + DiscordInviteMetadata, + DiscordMemberWithUser, + DiscordRole, +} from "../vendor/external.ts"; import type { GetInvite } from "../util/Routes.ts"; import { DefaultMessageNotificationLevels, @@ -46,6 +52,40 @@ export interface ModifyGuildEmoji { 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 @@ -62,7 +102,8 @@ export class Guild extends BaseGuild implements Model { 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! })) ?? []; + 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)); } @@ -79,6 +120,20 @@ export class Guild extends BaseGuild implements Model { 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); @@ -162,6 +217,40 @@ export class Guild extends BaseGuild implements Model { 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, @@ -190,6 +279,91 @@ export class Guild extends BaseGuild implements Model { 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/GuildChannel.ts b/structures/GuildChannel.ts index 99ea4ff..73dfd3b 100644 --- a/structures/GuildChannel.ts +++ b/structures/GuildChannel.ts @@ -2,11 +2,11 @@ import type { Model } from "./Base.ts"; import type { Snowflake } from "../util/Snowflake.ts"; import type { Session } from "../session/Session.ts"; import type { DiscordChannel, DiscordInviteMetadata } from "../vendor/external.ts"; -import Channel from "./Channel.ts"; +import BaseChannel from "./BaseChannel.ts"; import Invite from "./Invite.ts"; import * as Routes from "../util/Routes.ts"; -export abstract class GuildChannel extends Channel implements Model { +export abstract class GuildChannel extends BaseChannel implements Model { constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { super(session, data); this.guildId = guildId; diff --git a/structures/Interaction.ts b/structures/Interaction.ts new file mode 100644 index 0000000..f11dc99 --- /dev/null +++ b/structures/Interaction.ts @@ -0,0 +1,143 @@ +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; +} + +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; diff --git a/structures/Member.ts b/structures/Member.ts index 355d9d4..6468ce3 100644 --- a/structures/Member.ts +++ b/structures/Member.ts @@ -1,31 +1,24 @@ import type { Model } from "./Base.ts"; import type { Snowflake } from "../util/Snowflake.ts"; import type { Session } from "../session/Session.ts"; -import type { DiscordMember, MakeRequired } from "../vendor/external.ts"; +import type { DiscordMemberWithUser } from "../vendor/external.ts"; import type { ImageFormat, ImageSize } from "../util/shared/images.ts"; +import type { CreateGuildBan, ModifyGuildMember } from "./Guild.ts"; import { iconBigintToHash, iconHashToBigInt } from "../util/hash.ts"; import User from "./User.ts"; +import Guild from "./Guild.ts"; import * as Routes from "../util/Routes.ts"; -/** - * @link https://discord.com/developers/docs/resources/guild#create-guild-ban - */ -export interface CreateGuildBan { - /** Number of days to delete messages for (0-7) */ - deleteMessageDays?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; - /** Reason for the ban */ - reason?: string; -} - /** * Represents a guild member * TODO: add a `guild` property somehow * @link https://discord.com/developers/docs/resources/guild#guild-member-object */ export class Member implements Model { - constructor(session: Session, data: MakeRequired) { + constructor(session: Session, data: DiscordMemberWithUser, guildId: Snowflake) { this.session = session; this.user = new User(session, data.user); + this.guildId = guildId; this.avatarHash = data.avatar ? iconHashToBigInt(data.avatar) : undefined; this.nickname = data.nick ? data.nick : undefined; this.joinedTimestamp = Number.parseInt(data.joined_at); @@ -39,8 +32,8 @@ export class Member implements Model { } readonly session: Session; - user: User; + guildId: Snowflake; avatarHash?: bigint; nickname?: string; joinedTimestamp: number; @@ -63,39 +56,40 @@ export class Member implements Model { return new Date(this.joinedTimestamp); } - /** - * Bans the member - */ - async ban(guildId: Snowflake, options: CreateGuildBan): Promise { - await this.session.rest.runMethod( - this.session.rest, - "PUT", - Routes.GUILD_BAN(guildId, this.id), - options - ? { - delete_message_days: options.deleteMessageDays, - reason: options.reason, - } - : {}, - ); + async ban(options: CreateGuildBan): Promise { + await Guild.prototype.banMember.call({ id: this.guildId, session: this.session }, this.user.id, options); return this; } - /** - * Kicks the member - */ - async kick(guildId: Snowflake, { reason }: { reason?: string }): Promise { - await this.session.rest.runMethod( - this.session.rest, - "DELETE", - Routes.GUILD_MEMBER(guildId, this.id), - { reason }, - ); + async kick(options: { reason?: string }): Promise { + await Guild.prototype.kickMember.call({ id: this.guildId, session: this.session }, this.user.id, options); return this; } + async unban() { + await Guild.prototype.unbanMember.call({ id: this.guildId, session: this.session }, this.user.id); + } + + async edit(options: ModifyGuildMember): Promise { + const member = await Guild.prototype.editMember.call( + { id: this.guildId, session: this.session }, + this.user.id, + options, + ); + + return member; + } + + async addRole(roleId: Snowflake, options: { reason?: string } = {}) { + await Guild.prototype.addRole.call({ id: this.guildId, session: this.session }, this.user.id, roleId, options); + } + + async removeRole(roleId: Snowflake, options: { reason?: string } = {}) { + await Guild.prototype.removeRole.call({ id: this.guildId, session: this.session }, this.user.id, roleId, options); + } + /** gets the user's avatar */ avatarUrl(options: { format?: ImageFormat; size?: ImageSize } = { size: 128 }) { let url: string; @@ -103,7 +97,7 @@ export class Member implements Model { if (!this.avatarHash) { url = Routes.USER_DEFAULT_AVATAR(Number(this.user.discriminator) % 5); } else { - url = Routes.USER_AVATAR(this.id, iconBigintToHash(this.avatarHash)); + url = Routes.USER_AVATAR(this.user.id, iconBigintToHash(this.avatarHash)); } return `${url}.${options.format ?? (url.includes("/a_") ? "gif" : "jpg")}?size=${options.size}`; diff --git a/structures/Message.ts b/structures/Message.ts index 2f101b0..65f3c35 100644 --- a/structures/Message.ts +++ b/structures/Message.ts @@ -1,7 +1,14 @@ import type { Model } from "./Base.ts"; import type { Snowflake } from "../util/Snowflake.ts"; import type { Session } from "../session/Session.ts"; -import type { AllowedMentionsTypes, DiscordMessage, FileContent } from "../vendor/external.ts"; +import type { + AllowedMentionsTypes, + DiscordEmbed, + DiscordMessage, + DiscordUser, + FileContent, +} from "../vendor/external.ts"; +import type { GetReactions } from "../util/Routes.ts"; import { MessageFlags } from "../util/shared/flags.ts"; import User from "./User.ts"; import Member from "./Member.ts"; @@ -33,6 +40,7 @@ export interface CreateMessage { allowedMentions?: AllowedMentions; files?: FileContent[]; messageReference?: CreateMessageReference; + embeds?: DiscordEmbed[]; } /** @@ -42,6 +50,11 @@ export interface EditMessage extends Partial { flags?: MessageFlags; } +export type ReactionResolvable = string | { + name: string; + id: Snowflake; +}; + /** * Represents a message * @link https://discord.com/developers/docs/resources/channel#message-object @@ -63,12 +76,10 @@ export class Message implements Model { this.attachments = data.attachments.map((attachment) => new Attachment(session, attachment)); // user is always null on MessageCreate and its replaced with author - this.member = data.member - ? new Member(session, { - ...data.member, - user: data.author, - }) - : undefined; + + if (data.guild_id && data.member) { + this.member = new Member(session, { ...data.member, user: data.author }, data.guild_id); + } } readonly session: Session; @@ -89,21 +100,38 @@ export class Message implements Model { return `https://discord.com/channels/${this.guildId ?? "@me"}/${this.channelId}/${this.id}`; } + async pin() { + await this.session.rest.runMethod( + this.session.rest, + "PUT", + Routes.CHANNEL_PIN(this.channelId, this.id), + ); + } + + async unpin() { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.CHANNEL_PIN(this.channelId, this.id), + ); + } + /** Edits the current message */ - async edit({ content, allowedMentions, flags }: EditMessage): Promise { + async edit(options: EditMessage): Promise { const message = await this.session.rest.runMethod( this.session.rest, "POST", Routes.CHANNEL_MESSAGE(this.id, this.channelId), { - content, + content: options.content, allowed_mentions: { - parse: allowedMentions?.parse, - roles: allowedMentions?.roles, - users: allowedMentions?.users, - replied_user: allowedMentions?.repliedUser, + parse: options.allowedMentions?.parse, + roles: options.allowedMentions?.roles, + users: options.allowedMentions?.users, + replied_user: options.allowedMentions?.repliedUser, }, - flags, + flags: options.flags, + embeds: options.embeds, }, ); @@ -156,12 +184,98 @@ export class Message implements Model { fail_if_not_exists: options.messageReference.failIfNotExists ?? true, } : undefined, + embeds: options.embeds, }, ); return new Message(this.session, message); } + /** + * alias for Message.addReaction + */ + get react() { + return this.addReaction; + } + + async addReaction(reaction: ReactionResolvable) { + const r = typeof reaction === "string" ? reaction : `${reaction.name}:${reaction.id}`; + + await this.session.rest.runMethod( + this.session.rest, + "PUT", + Routes.CHANNEL_MESSAGE_REACTION_ME(this.channelId, this.id, r), + {}, + ); + } + + async removeReaction(reaction: ReactionResolvable, options?: { userId: Snowflake }) { + const r = typeof reaction === "string" ? reaction : `${reaction.name}:${reaction.id}`; + + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + options?.userId + ? Routes.CHANNEL_MESSAGE_REACTION_USER( + this.channelId, + this.id, + r, + options.userId, + ) + : Routes.CHANNEL_MESSAGE_REACTION_ME(this.channelId, this.id, r), + ); + } + + /** + * Get users who reacted with this emoji + */ + async fetchReactions(reaction: ReactionResolvable, options?: GetReactions): Promise { + const r = typeof reaction === "string" ? reaction : `${reaction.name}:${reaction.id}`; + + const users = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.CHANNEL_MESSAGE_REACTION(this.channelId, this.id, encodeURIComponent(r), options), + ); + + return users.map((user) => new User(this.session, user)); + } + + async removeReactionEmoji(reaction: ReactionResolvable) { + const r = typeof reaction === "string" ? reaction : `${reaction.name}:${reaction.id}`; + + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.CHANNEL_MESSAGE_REACTION(this.channelId, this.id, r), + ); + } + + async nukeReactions() { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.CHANNEL_MESSAGE_REACTIONS(this.channelId, this.id), + ); + } + + async crosspost() { + const message = await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.CHANNEL_MESSAGE_CROSSPOST(this.channelId, this.id), + ); + + return new Message(this.session, message); + } + + /* + * alias of Message.crosspost + * */ + get publish() { + return this.crosspost; + } + inGuild(): this is { guildId: Snowflake } & Message { return !!this.guildId; } diff --git a/structures/NewsChannel.ts b/structures/NewsChannel.ts index fcd15aa..ef76049 100644 --- a/structures/NewsChannel.ts +++ b/structures/NewsChannel.ts @@ -2,6 +2,7 @@ import type { Snowflake } from "../util/Snowflake.ts"; import type { Session } from "../session/Session.ts"; import type { DiscordChannel } from "../vendor/external.ts"; import TextChannel from "./TextChannel.ts"; +import Message from "./Message.ts"; export class NewsChannel extends TextChannel { constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { @@ -9,6 +10,14 @@ export class NewsChannel extends TextChannel { this.defaultAutoArchiveDuration = data.default_auto_archive_duration; } defaultAutoArchiveDuration?: number; + + crosspostMessage(messageId: Snowflake): Promise { + return Message.prototype.crosspost.call({ id: messageId, channelId: this.id, session: this.session }); + } + + get publishMessage() { + return this.crosspostMessage; + } } export default NewsChannel; diff --git a/structures/Role.ts b/structures/Role.ts index b8a53cf..41a4c97 100644 --- a/structures/Role.ts +++ b/structures/Role.ts @@ -48,7 +48,6 @@ export class Role implements Model { } async delete(): Promise { - // cool jS trick await Guild.prototype.deleteRole.call({ id: this.guildId, session: this.session }, this.id); } @@ -57,6 +56,14 @@ export class Role implements Model { return role; } + async add(memberId: Snowflake, options: { reason?: string } = {}) { + await Guild.prototype.addRole.call({ id: this.guildId, session: this.session }, memberId, this.id, options); + } + + async remove(memberId: Snowflake, options: { reason?: string } = {}) { + await Guild.prototype.removeRole.call({ id: this.guildId, session: this.session }, memberId, this.id, options); + } + toString() { switch (this.id) { case this.guildId: diff --git a/structures/TextChannel.ts b/structures/TextChannel.ts index a24dc29..7ecd39b 100644 --- a/structures/TextChannel.ts +++ b/structures/TextChannel.ts @@ -1,9 +1,9 @@ import type { Session } from "../session/Session.ts"; import type { Snowflake } from "../util/Snowflake.ts"; -import type { GetMessagesOptions } from "../util/Routes.ts"; +import type { GetMessagesOptions, GetReactions } from "../util/Routes.ts"; import type { DiscordChannel, DiscordInvite, DiscordMessage, TargetTypes } from "../vendor/external.ts"; +import type { CreateMessage, EditMessage, ReactionResolvable } from "./Message.ts"; import GuildChannel from "./GuildChannel.ts"; -import Guild from "./Guild.ts"; import ThreadChannel from "./ThreadChannel.ts"; import Message from "./Message.ts"; import Invite from "./Invite.ts"; @@ -37,7 +37,7 @@ export interface ThreadCreateOptions { } export class TextChannel extends GuildChannel { - constructor(session: Session, data: DiscordChannel, guildId: Guild["id"]) { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { super(session, data, guildId); data.last_message_id ? this.lastMessageId = data.last_message_id : undefined; data.last_pin_timestamp ? this.lastPinTimestamp = data.last_pin_timestamp : undefined; @@ -107,6 +107,58 @@ export class TextChannel extends GuildChannel { 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); + } } export default TextChannel; diff --git a/structures/VoiceChannel.ts b/structures/VoiceChannel.ts index 30a4d50..fa26b7e 100644 --- a/structures/VoiceChannel.ts +++ b/structures/VoiceChannel.ts @@ -1,16 +1,31 @@ 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, guildId: Snowflake, data: DiscordChannel) { + constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { super(session, data, guildId); this.bitRate = data.bitrate; this.userLimit = data.user_limit ?? 0; - data.rtc_region ? this.rtcRegion = data.rtc_region : undefined; this.videoQuality = data.video_quality_mode; this.nsfw = !!data.nsfw; + + if (data.rtc_region) { + this.rtcRegion = data.rtc_region; + } } bitRate?: number; userLimit: number; @@ -18,6 +33,28 @@ export class VoiceChannel extends GuildChannel { 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/util/Routes.ts b/util/Routes.ts index 03442a7..db2654e 100644 --- a/util/Routes.ts +++ b/util/Routes.ts @@ -46,10 +46,6 @@ export function MESSAGE_CREATE_THREAD(channelId: Snowflake, messageId: Snowflake return `/channels/${channelId}/messages/${messageId}/threads`; } -export function CHANNEL_PINS(channelId: Snowflake) { - return `/channels/${channelId}/pins`; -} - /** used to send messages */ export function CHANNEL_MESSAGES(channelId: Snowflake, options?: GetMessagesOptions) { let url = `/channels/${channelId}/messages?`; @@ -139,3 +135,92 @@ export function INVITE(inviteCode: string, options?: GetInvite) { export function GUILD_INVITES(guildId: Snowflake) { return `/guilds/${guildId}/invites`; } + +export function INTERACTION_ID_TOKEN(interactionId: Snowflake, token: string) { + return `/interactions/${interactionId}/${token}/callback`; +} + +export function WEBHOOK(webhookId: Snowflake, token: string, options?: { wait?: boolean; threadId?: Snowflake }) { + let url = `/webhooks/${webhookId}/${token}?`; + + if (options?.wait !== undefined) url += `wait=${options.wait}`; + if (options?.threadId) url += `threadId=${options.threadId}`; + + return url; +} + +export function USER_NICK(guildId: Snowflake) { + return `/guilds/${guildId}/members/@me`; +} + +/** + * @link https://discord.com/developers/docs/resources/guild#get-guild-prune-count + */ +export interface GetGuildPruneCountQuery { + days?: number; + includeRoles?: Snowflake | Snowflake[]; +} + +export function GUILD_PRUNE(guildId: Snowflake, options?: GetGuildPruneCountQuery) { + let url = `/guilds/${guildId}/prune?`; + + if (options?.days) url += `days=${options.days}`; + if (options?.includeRoles) url += `&include_roles=${options.includeRoles}`; + + return url; +} + +export function CHANNEL_PIN(channelId: Snowflake, messageId: Snowflake) { + return `/channels/${channelId}/pins/${messageId}`; +} + +export function CHANNEL_PINS(channelId: Snowflake) { + return `/channels/${channelId}/pins`; +} + +export function CHANNEL_MESSAGE_REACTION_ME(channelId: Snowflake, messageId: Snowflake, emoji: string) { + return `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`; +} + +export function CHANNEL_MESSAGE_REACTION_USER( + channelId: Snowflake, + messageId: Snowflake, + emoji: string, + userId: Snowflake, +) { + return `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/${userId}`; +} + +export function CHANNEL_MESSAGE_REACTIONS(channelId: Snowflake, messageId: Snowflake) { + return `/channels/${channelId}/messages/${messageId}/reactions`; +} + +/** + * @link https://discord.com/developers/docs/resources/channel#get-reactions-query-string-params + */ +export interface GetReactions { + after?: string; + limit?: number; +} + +export function CHANNEL_MESSAGE_REACTION( + channelId: Snowflake, + messageId: Snowflake, + emoji: string, + options?: GetReactions, +) { + let url = `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}?`; + + if (options?.after) url += `after=${options.after}`; + if (options?.limit) url += `&limit=${options.limit}`; + + return url; +} + +export function CHANNEL_MESSAGE_CROSSPOST(channelId: Snowflake, messageId: Snowflake) { + return `/channels/${channelId}/messages/${messageId}/crosspost`; +} + +export function GUILD_MEMBER_ROLE(guildId: Snowflake, memberId: Snowflake, roleId: Snowflake) { + return `/guilds/${guildId}/members/${memberId}/roles/${roleId}`; +} diff --git a/vendor/external.ts b/vendor/external.ts index efb9d06..b96e207 100644 --- a/vendor/external.ts +++ b/vendor/external.ts @@ -2,3 +2,4 @@ export * from "./gateway/mod.ts"; export * from "./rest/mod.ts"; export * from "./types/mod.ts"; export * from "./util/constants.ts"; +export * from "./util/token.ts";