From 1cd41c67a7d3b98cbfb0ddfb5f7e797711e79e22 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Sun, 3 Jul 2022 13:14:47 -0500 Subject: [PATCH] feat: threads --- handlers/Actions.ts | 69 ++++++++++++++++-------- structures/Message.ts | 80 ++++++++++++++++++++++++---- structures/MessageReaction.ts | 23 ++++++++ structures/ThreadMember.ts | 47 ++++++++++++++++ structures/channels/GuildChannel.ts | 39 +++++++++++++- structures/channels/ThreadChannel.ts | 51 +++++++++++++++++- structures/guilds/Guild.ts | 21 ++++++++ util/Routes.ts | 71 ++++++++++++++++++++++++ 8 files changed, 367 insertions(+), 34 deletions(-) create mode 100644 structures/MessageReaction.ts create mode 100644 structures/ThreadMember.ts diff --git a/handlers/Actions.ts b/handlers/Actions.ts index 5975a4b..986e8f3 100644 --- a/handlers/Actions.ts +++ b/handlers/Actions.ts @@ -8,6 +8,10 @@ import type { DiscordMemberWithUser, DiscordMessage, DiscordMessageDelete, + DiscordMessageReactionAdd, + DiscordMessageReactionRemove, + DiscordMessageReactionRemoveAll, + DiscordMessageReactionRemoveEmoji, DiscordReady, // DiscordThreadMemberUpdate, // DiscordThreadMembersUpdate, @@ -19,6 +23,7 @@ import type { Channel } from "../structures/channels/ChannelFactory.ts"; import ChannelFactory from "../structures/channels/ChannelFactory.ts"; import GuildChannel from "../structures/channels/GuildChannel.ts"; import ThreadChannel from "../structures/channels/ThreadChannel.ts"; +import ThreadMember from "../structures/ThreadMember.ts"; import Member from "../structures/Member.ts"; import Message from "../structures/Message.ts"; import User from "../structures/User.ts"; @@ -103,10 +108,7 @@ export const THREAD_LIST_SYNC: RawHandler = (session, _sh guildId: payload.guild_id, channelIds: payload.channel_ids ?? [], threads: payload.threads.map((channel) => new ThreadChannel(session, channel, payload.guild_id)), - members: payload.members.map((member) => - // @ts-ignore: TODO: thread member structure - new Member(session, member as DiscordMemberWithUser, payload.guild_id) - ), + members: payload.members.map((member) => new ThreadMember(session, member)), }); }; @@ -118,6 +120,24 @@ export const CHANNEL_PINS_UPDATE: RawHandler = (sessio }); }; +/* +export const MESSAGE_REACTION_ADD: RawHandler = (session, _shardId, reaction) => { + session.emit("messageReactionAdd", null); +}; + +export const MESSAGE_REACTION_REMOVE: RawHandler = (session, _shardId, reaction) => { + session.emit("messageReactionRemove", null); +}; + +export const MESSAGE_REACTION_REMOVE_ALL: RawHandler = (session, _shardId, reaction) => { + session.emit("messageReactionRemoveAll", null); +}; + +export const MESSAGE_REACTION_REMOVE_EMOJI: RawHandler = (session, _shardId, reaction) => { + session.emit("messageReactionRemoveEmoji", null); +}; +*/ + export const raw: RawHandler = (session, shardId, data) => { session.emit("raw", data, shardId); }; @@ -126,23 +146,30 @@ export interface Ready extends Omit { user: User; } +// TODO: add partial reactions or something +type MessageReaction = any; + // 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]>; - "channelCreate": Handler<[Channel]>; - "channelUpdate": Handler<[Channel]>; - "channelDelete": Handler<[GuildChannel]>; - "channelPinsUpdate": Handler<[{ guildId?: Snowflake, channelId: Snowflake, lastPinTimestamp?: number }]> - "threadCreate": Handler<[ThreadChannel]>; - "threadUpdate": Handler<[ThreadChannel]>; - "threadDelete": Handler<[ThreadChannel]>; - "threadListSync": Handler<[{ guildId: Snowflake, channelIds: Snowflake[], threads: ThreadChannel[], members: Member[] }]> - "interactionCreate": Handler<[Interaction]>; - "raw": Handler<[unknown, number]>; + "ready": Handler<[Ready, number]>; + "messageCreate": Handler<[Message]>; + "messageUpdate": Handler<[Message]>; + "messageDelete": Handler<[{ id: Snowflake, channelId: Snowflake, guildId?: Snowflake }]>; + "messageReactionAdd": Handler<[MessageReaction]>; + "messageReactionRemove": Handler<[MessageReaction]>; + "messageReactionRemoveAll": Handler<[MessageReaction]>; + "messageReactionRemoveEmoji": Handler<[MessageReaction]>; + "guildMemberAdd": Handler<[Member]>; + "guildMemberUpdate": Handler<[Member]>; + "guildMemberRemove": Handler<[User, Snowflake]>; + "channelCreate": Handler<[Channel]>; + "channelUpdate": Handler<[Channel]>; + "channelDelete": Handler<[GuildChannel]>; + "channelPinsUpdate": Handler<[{ guildId?: Snowflake, channelId: Snowflake, lastPinTimestamp?: number }]> + "threadCreate": Handler<[ThreadChannel]>; + "threadUpdate": Handler<[ThreadChannel]>; + "threadDelete": Handler<[ThreadChannel]>; + "threadListSync": Handler<[{ guildId: Snowflake, channelIds: Snowflake[], threads: ThreadChannel[], members: ThreadMember[] }]> + "interactionCreate": Handler<[Interaction]>; + "raw": Handler<[unknown, number]>; } diff --git a/structures/Message.ts b/structures/Message.ts index 483516b..c1287bd 100644 --- a/structures/Message.ts +++ b/structures/Message.ts @@ -1,5 +1,4 @@ import type { Model } from "./Base.ts"; -import type { Snowflake } from "../util/Snowflake.ts"; import type { Session } from "../session/Session.ts"; import type { AllowedMentionsTypes, @@ -7,15 +6,20 @@ import type { DiscordMessage, DiscordUser, FileContent, + MessageTypes, + MessageActivityTypes, } from "../vendor/external.ts"; import type { Component } from "./components/Component.ts"; import type { GetReactions } from "../util/Routes.ts"; import { MessageFlags } from "../util/shared/flags.ts"; +import { iconHashToBigInt } from "../util/hash.ts"; +import { Snowflake } from "../util/Snowflake.ts"; import User from "./User.ts"; import Member from "./Member.ts"; import Attachment from "./Attachment.ts"; import ComponentFactory from "./components/ComponentFactory.ts"; -import { iconHashToBigInt } from "../util/hash.ts"; +import MessageReaction from "./MessageReaction.ts"; +import ThreadChannel from "./channels/ThreadChannel.ts"; import * as Routes from "../util/Routes.ts"; /** @@ -39,11 +43,11 @@ export interface CreateMessageReference { * @link https://discord.com/developers/docs/resources/channel#create-message-json-params */ export interface CreateMessage { + embeds?: DiscordEmbed[]; content?: string; allowedMentions?: AllowedMentions; files?: FileContent[]; messageReference?: CreateMessageReference; - embeds?: DiscordEmbed[]; } /** @@ -74,19 +78,32 @@ export class Message implements Model { this.session = session; this.id = data.id; + this.type = data.type; this.channelId = data.channel_id; this.guildId = data.guild_id; + this.applicationId = data.application_id; this.author = new User(session, data.author); this.flags = data.flags; this.pinned = !!data.pinned; this.tts = !!data.tts; this.content = data.content!; + this.nonce = data.nonce; + this.mentionEveryone = data.mention_everyone; + this.timestamp = Date.parse(data.timestamp); + this.editedTimestamp = data.edited_timestamp ? Date.parse(data.edited_timestamp) : undefined; + + this.reactions = data.reactions?.map((react) => new MessageReaction(session, react)) ?? []; this.attachments = data.attachments.map((attachment) => new Attachment(session, attachment)); + this.embeds = data.embeds; + + if (data.thread && data.guild_id) { + this.thread = new ThreadChannel(session, data.thread, data.guild_id); + } // webhook handling - if (data.author && data.author.discriminator === "0000") { + if (data.author.discriminator === "0000") { this.webhook = { id: data.author.id, username: data.author.username, @@ -96,30 +113,70 @@ export class Message implements Model { } // user is always null on MessageCreate and its replaced with author - - if (data.guild_id && data.member && data.author && !this.isWebhookMessage()) { + if (data.guild_id && data.member && !this.isWebhookMessage()) { this.member = new Member(session, { ...data.member, user: data.author }, data.guild_id); } - this.components = data.components?.map((component) => ComponentFactory.from(session, component)); + this.components = data.components?.map((component) => ComponentFactory.from(session, component)) ?? []; + + if (data.activity) { + this.activity = { + partyId: data.activity.party_id, + type: data.activity.type, + }; + } } readonly session: Session; readonly id: Snowflake; + type: MessageTypes; channelId: Snowflake; guildId?: Snowflake; + applicationId?: Snowflake; author: User; flags?: MessageFlags; pinned: boolean; tts: boolean; content: string; + nonce?: string | number; + mentionEveryone: boolean; + timestamp: number; + editedTimestamp?: number; + + reactions: MessageReaction[]; attachments: Attachment[]; + embeds: DiscordEmbed[]; member?: Member; - components?: Component[]; + thread?: ThreadChannel; + components: Component[]; webhook?: WebhookAuthor; + activity?: { + partyId?: Snowflake; + type: MessageActivityTypes; + }; + + get createdTimestamp() { + return Snowflake.snowflakeToTimestamp(this.id); + } + + get createdAt() { + return new Date(this.createdTimestamp); + } + + get sentAt() { + return new Date(this.timestamp); + } + + get editedAt() { + return this.editedTimestamp ? new Date(this.editedTimestamp) : undefined; + } + + get edited() { + return this.editedTimestamp; + } get url() { return `https://discord.com/channels/${this.guildId ?? "@me"}/${this.channelId}/${this.id}`; @@ -301,12 +358,13 @@ export class Message implements Model { return this.crosspost; } - inGuild(): this is { guildId: Snowflake } & Message { + /** wheter the message comes from a guild **/ + inGuild(): this is Message & { guildId: Snowflake } { return !!this.guildId; } - /** isWebhookMessage if the messages comes from a Webhook */ - isWebhookMessage(): this is User & { author: Partial; webhook: WebhookAuthor; member: undefined } { + /** wheter the messages comes from a Webhook */ + isWebhookMessage(): this is Message & { author: Partial; webhook: WebhookAuthor; member: undefined } { return !!this.webhook; } } diff --git a/structures/MessageReaction.ts b/structures/MessageReaction.ts new file mode 100644 index 0000000..d819d2c --- /dev/null +++ b/structures/MessageReaction.ts @@ -0,0 +1,23 @@ +import type { Session } from "../session/Session.ts"; +import type { DiscordReaction } from "../vendor/external.ts"; +import Emoji from "./Emoji.ts"; + +/** + * Represents a reaction + * @link https://discord.com/developers/docs/resources/channel#reaction-object + * */ +export class MessageReaction { + constructor(session: Session, data: DiscordReaction) { + this.session = session; + this.me = data.me; + this.count = data.count; + this.emoji = new Emoji(session, data.emoji); + } + + readonly session: Session; + me: boolean; + count: number; + emoji: Emoji; +} + +export default MessageReaction; diff --git a/structures/ThreadMember.ts b/structures/ThreadMember.ts new file mode 100644 index 0000000..7679d88 --- /dev/null +++ b/structures/ThreadMember.ts @@ -0,0 +1,47 @@ +import type { Model } from "./Base.ts"; +import type { Session } from "../session/Session.ts"; +import type { Snowflake } from "../util/Snowflake.ts"; +import type { DiscordThreadMember } from "../vendor/external.ts"; +import * as Routes from "../util/Routes.ts"; + +/** + * A member that comes from a thread + * @link https://discord.com/developers/docs/resources/channel#thread-member-object + * **/ +export class ThreadMember implements Model { + constructor(session: Session, data: DiscordThreadMember) { + this.session = session; + this.id = data.id; + this.flags = data.flags; + this.timestamp = Date.parse(data.join_timestamp); + } + + readonly session: Session; + readonly id: Snowflake; + flags: number; + timestamp: number; + + get threadId() { + return this.id; + } + + async quitThread(memberId: Snowflake = this.session.botId) { + await this.session.rest.runMethod( + this.session.rest, + "DELETE", + Routes.THREAD_USER(this.id, memberId), + ); + } + + async fetchMember(memberId: Snowflake = this.session.botId) { + const member = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.THREAD_USER(this.id, memberId), + ); + + return new ThreadMember(this.session, member); + } +} + +export default ThreadMember; diff --git a/structures/channels/GuildChannel.ts b/structures/channels/GuildChannel.ts index aaa4e53..b07ae95 100644 --- a/structures/channels/GuildChannel.ts +++ b/structures/channels/GuildChannel.ts @@ -1,8 +1,11 @@ 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 type { ChannelTypes, DiscordChannel, DiscordInviteMetadata, DiscordListArchivedThreads } from "../../vendor/external.ts"; +import type { ListArchivedThreads } from "../../util/Routes.ts"; import BaseChannel from "./BaseChannel.ts"; +import ThreadChannel from "./ThreadChannel.ts"; +import ThreadMember from "../ThreadMember.ts"; import Invite from "../Invite.ts"; import * as Routes from "../../util/Routes.ts"; @@ -44,7 +47,41 @@ export class GuildChannel extends BaseChannel implements Model { return invites.map((invite) => new Invite(this.session, invite)); } + + async getArchivedThreads(options: ListArchivedThreads & { type: "public" | "private" | "privateJoinedThreads" }) { + let func: (channelId: Snowflake, options: 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, + }; + } + /* + * TODO: should be in TextChannel async createThread(options: ThreadCreateOptions): Promise { const thread = await this.session.rest.runMethod( this.session.rest, diff --git a/structures/channels/ThreadChannel.ts b/structures/channels/ThreadChannel.ts index 459ba6e..0bb036d 100644 --- a/structures/channels/ThreadChannel.ts +++ b/structures/channels/ThreadChannel.ts @@ -1,9 +1,11 @@ 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 type { ChannelTypes, DiscordChannel, DiscordThreadMember } from "../../vendor/external.ts"; import GuildChannel from "./GuildChannel.ts"; import TextChannel from "./TextChannel.ts"; +import ThreadMember from "../ThreadMember.ts"; +import * as Routes from "../../util/Routes.ts"; export class ThreadChannel extends GuildChannel implements Model { constructor(session: Session, data: DiscordChannel, guildId: Snowflake) { @@ -16,6 +18,10 @@ export class ThreadChannel extends GuildChannel implements Model { 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; @@ -25,7 +31,50 @@ export class ThreadChannel extends GuildChannel implements Model { 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); diff --git a/structures/guilds/Guild.ts b/structures/guilds/Guild.ts index fe39151..727ee39 100644 --- a/structures/guilds/Guild.ts +++ b/structures/guilds/Guild.ts @@ -7,6 +7,8 @@ import type { DiscordInviteMetadata, DiscordMemberWithUser, DiscordRole, + DiscordListActiveThreads, + DiscordListArchivedThreads, } from "../../vendor/external.ts"; import type { GetInvite } from "../../util/Routes.ts"; import { @@ -21,6 +23,8 @@ import BaseGuild from "./BaseGuild.ts"; import Role from "../Role.ts"; import GuildEmoji from "../GuildEmoji.ts"; import Invite from "../Invite.ts"; +import ThreadMember from "../ThreadMember.ts"; +import ThreadChannel from "../channels/ThreadChannel.ts"; import * as Routes from "../../util/Routes.ts"; export interface CreateRole { @@ -362,6 +366,23 @@ export class Guild extends BaseGuild implements Model { return result.pruned; } + + async getActiveThreads() { + const { threads, members } = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.THREAD_ACTIVE(this.id) + ); + + 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, + }; + } } export default Guild; diff --git a/util/Routes.ts b/util/Routes.ts index 247cadd..916e319 100644 --- a/util/Routes.ts +++ b/util/Routes.ts @@ -228,3 +228,74 @@ export function GUILD_MEMBER_ROLE(guildId: Snowflake, memberId: Snowflake, roleI export function CHANNEL_WEBHOOKS(channelId: Snowflake) { return `/channels/${channelId}/webhooks`; } + + +export function THREAD_START_PUBLIC(channelId: Snowflake, messageId: Snowflake) { + return `/channels/${channelId}/messages/${messageId}/threads`; +} + +export function THREAD_START_PRIVATE(channelId: Snowflake) { + return `/channels/${channelId}/threads`; +} + +export function THREAD_ACTIVE(guildId: Snowflake) { + return `/guilds/${guildId}/threads/active`; +} + +export interface ListArchivedThreads { + before?: number; + limit?: number; +} + +export function THREAD_ME(channelId: Snowflake) { + return `/channels/${channelId}/thread-members/@me`; +} + +export function THREAD_MEMBERS(channelId: Snowflake) { + return `/channels/${channelId}/thread-members`; +} + +export function THREAD_USER(channelId: Snowflake, userId: Snowflake) { + return `/channels/${channelId}/thread-members/${userId}`; +} + +export function THREAD_ARCHIVED(channelId: Snowflake) { + return `/channels/${channelId}/threads/archived`; +} + +export function THREAD_ARCHIVED_PUBLIC(channelId: Snowflake, options?: ListArchivedThreads) { + let url = `/channels/${channelId}/threads/archived/public?`; + + if (options) { + if (options.before) url += `before=${new Date(options.before).toISOString()}`; + if (options.limit) url += `&limit=${options.limit}`; + } + + return url; +} + +export function THREAD_ARCHIVED_PRIVATE(channelId: Snowflake, options?: ListArchivedThreads) { + let url = `/channels/${channelId}/threads/archived/private?`; + + if (options) { + if (options.before) url += `before=${new Date(options.before).toISOString()}`; + if (options.limit) url += `&limit=${options.limit}`; + } + + return url; +} + +export function THREAD_ARCHIVED_PRIVATE_JOINED(channelId: Snowflake, options?: ListArchivedThreads) { + let url = `/channels/${channelId}/users/@me/threads/archived/private?`; + + if (options) { + if (options.before) url += `before=${new Date(options.before).toISOString()}`; + if (options.limit) url += `&limit=${options.limit}`; + } + + return url; +} + +export function FORUM_START(channelId: Snowflake) { + return `/channels/${channelId}/threads?has_message=true`; +}