diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cfbb40471..ac5e2dbe1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,8 +23,10 @@ jobs: if: ${{ github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'run-tests' }} run: DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} deno test --unstable --coverage=coverage --allow-net tests/mod.ts - name: Create coverage report + if: github.ref == 'refs/heads/main' run: deno --unstable coverage ./coverage --lcov > coverage.lcov - name: Collect and upload the coverage report + if: github.ref == 'refs/heads/main' uses: codecov/codecov-action@v1.0.10 with: file: ./coverage.lcov diff --git a/src/handlers/channels/CHANNEL_DELETE.ts b/src/handlers/channels/CHANNEL_DELETE.ts index ea68fd58f..511aae6c9 100644 --- a/src/handlers/channels/CHANNEL_DELETE.ts +++ b/src/handlers/channels/CHANNEL_DELETE.ts @@ -15,7 +15,7 @@ export async function handleChannelDelete(data: DiscordGatewayPayload) { if (!cachedChannel) return; if ( - cachedChannel.type === DiscordChannelTypes.GUILD_VOICE && payload.guildId + cachedChannel.type === DiscordChannelTypes.GuildVoice && payload.guildId ) { const guild = await cacheHandlers.get("guilds", cachedChannel.guildId); @@ -34,15 +34,27 @@ export async function handleChannelDelete(data: DiscordGatewayPayload) { } } + if ( + [ + DiscordChannelTypes.GuildText, + DiscordChannelTypes.Dm, + DiscordChannelTypes.GroupDm, + DiscordChannelTypes.GuildNews, + ].includes(payload.type) + ) { + await cacheHandlers.delete("channels", snowflakeToBigint(payload.id)); + cacheHandlers.forEach("messages", (message) => { + eventHandlers.debug?.( + "loop", + `Running forEach messages loop in CHANNEL_DELTE file.`, + ); + if (message.channelId === snowflakeToBigint(payload.id)) { + cacheHandlers.delete("messages", message.id); + } + }); + } + await cacheHandlers.delete("channels", snowflakeToBigint(payload.id)); - cacheHandlers.forEach("messages", (message) => { - eventHandlers.debug?.( - "loop", - `Running forEach messages loop in CHANNEL_DELTE file.`, - ); - if (message.channelId === snowflakeToBigint(payload.id)) { - cacheHandlers.delete("messages", message.id); - } - }); + eventHandlers.channelDelete?.(cachedChannel); } diff --git a/src/handlers/channels/CHANNEL_UPDATE.ts b/src/handlers/channels/CHANNEL_UPDATE.ts index 7fffd35ac..59a0d8693 100644 --- a/src/handlers/channels/CHANNEL_UPDATE.ts +++ b/src/handlers/channels/CHANNEL_UPDATE.ts @@ -11,11 +11,10 @@ export async function handleChannelUpdate(data: DiscordGatewayPayload) { "channels", snowflakeToBigint(payload.id), ); + if (!cachedChannel) return; const discordenoChannel = await structures.createDiscordenoChannel(payload); await cacheHandlers.set("channels", discordenoChannel.id, discordenoChannel); - if (!cachedChannel) return; - eventHandlers.channelUpdate?.(discordenoChannel, cachedChannel); } diff --git a/src/handlers/channels/THREAD_CREATE.ts b/src/handlers/channels/THREAD_CREATE.ts new file mode 100644 index 000000000..ac24436b5 --- /dev/null +++ b/src/handlers/channels/THREAD_CREATE.ts @@ -0,0 +1,14 @@ +import { eventHandlers } from "../../bot.ts"; +import { cacheHandlers } from "../../cache.ts"; +import { structures } from "../../structures/mod.ts"; +import { Channel } from "../../types/channels/channel.ts"; +import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; + +export async function handleThreadCreate(data: DiscordGatewayPayload) { + const payload = data.d as Channel; + + const discordenoChannel = await structures.createDiscordenoChannel(payload); + await cacheHandlers.set("channels", discordenoChannel.id, discordenoChannel); + + eventHandlers.threadCreate?.(discordenoChannel); +} diff --git a/src/handlers/channels/THREAD_DELETE.ts b/src/handlers/channels/THREAD_DELETE.ts new file mode 100644 index 000000000..e4c2b4d87 --- /dev/null +++ b/src/handlers/channels/THREAD_DELETE.ts @@ -0,0 +1,28 @@ +import { eventHandlers } from "../../bot.ts"; +import { cacheHandlers } from "../../cache.ts"; +import { Channel } from "../../types/channels/channel.ts"; +import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; +import { snowflakeToBigint } from "../../util/bigint.ts"; + +export async function handleThreadDelete(data: DiscordGatewayPayload) { + const payload = data.d as Channel; + + const cachedChannel = await cacheHandlers.get( + "channels", + snowflakeToBigint(payload.id), + ); + if (!cachedChannel) return; + + await cacheHandlers.delete("channels", snowflakeToBigint(payload.id)); + cacheHandlers.forEach("messages", (message) => { + eventHandlers.debug?.( + "loop", + `Running forEach messages loop in CHANNEL_DELTE file.`, + ); + if (message.channelId === snowflakeToBigint(payload.id)) { + cacheHandlers.delete("messages", message.id); + } + }); + + eventHandlers.threadDelete?.(cachedChannel); +} diff --git a/src/handlers/channels/THREAD_LIST_SYNC.ts b/src/handlers/channels/THREAD_LIST_SYNC.ts new file mode 100644 index 000000000..aedcab173 --- /dev/null +++ b/src/handlers/channels/THREAD_LIST_SYNC.ts @@ -0,0 +1,39 @@ +import { eventHandlers } from "../../bot.ts"; +import { cacheHandlers } from "../../cache.ts"; +import { DiscordenoChannel } from "../../structures/channel.ts"; +import { structures } from "../../structures/mod.ts"; +import { ThreadListSync } from "../../types/channels/threads/thread_list_sync.ts"; +import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; +import { snowflakeToBigint } from "../../util/bigint.ts"; +import { Collection } from "../../util/collection.ts"; + +export async function handleThreadListSync(data: DiscordGatewayPayload) { + const payload = data.d as ThreadListSync; + + const discordenoChannels = await Promise.all( + payload.threads.map(async (thread) => { + const discordenoChannel = await structures.createDiscordenoChannel( + thread, + snowflakeToBigint(payload.guildId), + ); + + await cacheHandlers.set( + "channels", + discordenoChannel.id, + discordenoChannel, + ); + + return discordenoChannel; + }), + ); + + const threads = new Collection( + discordenoChannels.map((t) => [t.id, t]), + ); + + eventHandlers.threadListSync?.( + threads, + payload.members, + snowflakeToBigint(payload.guildId), + ); +} diff --git a/src/handlers/channels/THREAD_MEMBERS_UPDATE.ts b/src/handlers/channels/THREAD_MEMBERS_UPDATE.ts new file mode 100644 index 000000000..b614f143e --- /dev/null +++ b/src/handlers/channels/THREAD_MEMBERS_UPDATE.ts @@ -0,0 +1,19 @@ +import { eventHandlers } from "../../bot.ts"; +import { cacheHandlers } from "../../cache.ts"; +import { ThreadMembersUpdate } from "../../types/channels/threads/thread_members_update.ts"; +import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; +import { snowflakeToBigint } from "../../util/bigint.ts"; + +export async function handleThreadMembersUpdate(data: DiscordGatewayPayload) { + const payload = data.d as ThreadMembersUpdate; + const thread = await cacheHandlers.get( + "channels", + snowflakeToBigint(payload.id), + ); + if (!thread) return; + + thread.memberCount = payload.memberCount; + await cacheHandlers.set("channels", thread.id, thread); + + eventHandlers.threadMembersUpdate?.(payload); +} diff --git a/src/handlers/channels/THREAD_MEMBER_UPDATE.ts b/src/handlers/channels/THREAD_MEMBER_UPDATE.ts new file mode 100644 index 000000000..a25490523 --- /dev/null +++ b/src/handlers/channels/THREAD_MEMBER_UPDATE.ts @@ -0,0 +1,20 @@ +import { eventHandlers } from "../../bot.ts"; +import { cacheHandlers } from "../../cache.ts"; +import { ThreadMember } from "../../types/channels/threads/thread_member.ts"; +import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; +import { snowflakeToBigint } from "../../util/bigint.ts"; + +export async function handleThreadMemberUpdate(data: DiscordGatewayPayload) { + const payload = data.d as ThreadMember; + const thread = await cacheHandlers.get( + "channels", + snowflakeToBigint(payload.id), + ); + if (!thread) return; + + thread.member = payload; + + await cacheHandlers.set("channels", thread.id, thread); + + eventHandlers.threadMemberUpdate?.(payload); +} diff --git a/src/handlers/channels/THREAD_UPDATE.ts b/src/handlers/channels/THREAD_UPDATE.ts new file mode 100644 index 000000000..1e3ae12d0 --- /dev/null +++ b/src/handlers/channels/THREAD_UPDATE.ts @@ -0,0 +1,20 @@ +import { eventHandlers } from "../../bot.ts"; +import { cacheHandlers } from "../../cache.ts"; +import { structures } from "../../structures/mod.ts"; +import { Channel } from "../../types/channels/channel.ts"; +import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; +import { snowflakeToBigint } from "../../util/bigint.ts"; + +export async function handleThreadUpdate(data: DiscordGatewayPayload) { + const payload = data.d as Channel; + const oldChannel = await cacheHandlers.get( + "channels", + snowflakeToBigint(payload.id), + ); + if (!oldChannel) return; + + const discordenoChannel = await structures.createDiscordenoChannel(payload); + await cacheHandlers.set("channels", discordenoChannel.id, discordenoChannel); + + eventHandlers.threadUpdate?.(discordenoChannel, oldChannel); +} diff --git a/src/handlers/mod.ts b/src/handlers/mod.ts index 2eda4a9c7..169e79d21 100644 --- a/src/handlers/mod.ts +++ b/src/handlers/mod.ts @@ -2,6 +2,12 @@ import { handleChannelCreate } from "./channels/CHANNEL_CREATE.ts"; import { handleChannelDelete } from "./channels/CHANNEL_DELETE.ts"; import { handleChannelPinsUpdate } from "./channels/CHANNEL_PINS_UPDATE.ts"; import { handleChannelUpdate } from "./channels/CHANNEL_UPDATE.ts"; +import { handleThreadCreate } from "./channels/THREAD_CREATE.ts"; +import { handleThreadDelete } from "./channels/THREAD_DELETE.ts"; +import { handleThreadListSync } from "./channels/THREAD_LIST_SYNC.ts"; +import { handleThreadMembersUpdate } from "./channels/THREAD_MEMBERS_UPDATE.ts"; +import { handleThreadMemberUpdate } from "./channels/THREAD_MEMBER_UPDATE.ts"; +import { handleThreadUpdate } from "./channels/THREAD_UPDATE.ts"; import { handleApplicationCommandCreate } from "./commands/APPLICATION_COMMAND_CREATE.ts"; import { handleApplicationCommandDelete } from "./commands/APPLICATION_COMMAND_DELETE.ts"; import { handleApplicationCommandUpdate } from "./commands/APPLICATION_COMMAND_UPDATE.ts"; @@ -77,6 +83,12 @@ export { handleMessageUpdate, handlePresenceUpdate, handleReady, + handleThreadCreate, + handleThreadDelete, + handleThreadListSync, + handleThreadMembersUpdate, + handleThreadMemberUpdate, + handleThreadUpdate, handleTypingStart, handleUserUpdate, handleVoiceServerUpdate, @@ -92,6 +104,12 @@ export let handlers = { CHANNEL_DELETE: handleChannelDelete, CHANNEL_PINS_UPDATE: handleChannelPinsUpdate, CHANNEL_UPDATE: handleChannelUpdate, + THREAD_CREATE: handleThreadCreate, + THREAD_UPDATE: handleThreadUpdate, + THREAD_DELETE: handleThreadDelete, + THREAD_LIST_SYNC: handleThreadListSync, + THREAD_MEMBER_UPDATE: handleThreadMemberUpdate, + THREAD_MEMBERS_UPDATE: handleThreadMembersUpdate, // commands APPLICATION_COMMAND_CREATE: handleApplicationCommandCreate, APPLICATION_COMMAND_DELETE: handleApplicationCommandDelete, diff --git a/src/helpers/channels/clone_channel.ts b/src/helpers/channels/clone_channel.ts index d7ba2aab5..632fc770f 100644 --- a/src/helpers/channels/clone_channel.ts +++ b/src/helpers/channels/clone_channel.ts @@ -14,8 +14,8 @@ export async function cloneChannel(channelId: bigint, reason?: string) { //Check for DM channel if ( - channelToClone.type === DiscordChannelTypes.DM || - channelToClone.type === DiscordChannelTypes.GROUP_DM + channelToClone.type === DiscordChannelTypes.Dm || + channelToClone.type === DiscordChannelTypes.GroupDm ) { throw new Error(Errors.CHANNEL_NOT_IN_GUILD); } diff --git a/src/helpers/channels/create_channel.ts b/src/helpers/channels/create_channel.ts index cb0b70bb6..ec5288575 100644 --- a/src/helpers/channels/create_channel.ts +++ b/src/helpers/channels/create_channel.ts @@ -1,4 +1,3 @@ -import { eventHandlers } from "../../bot.ts"; import { cacheHandlers } from "../../cache.ts"; import { rest } from "../../rest/rest.ts"; import { structures } from "../../structures/mod.ts"; @@ -8,11 +7,10 @@ import type { CreateGuildChannel, DiscordCreateGuildChannel, } from "../../types/guilds/create_guild_channel.ts"; -import type { PermissionStrings } from "../../types/permissions/permission_strings.ts"; import { endpoints } from "../../util/constants.ts"; import { calculateBits, - requireBotGuildPermissions, + requireOverwritePermissions, } from "../../util/permissions.ts"; import { camelKeysToSnakeCase } from "../../util/utils.ts"; @@ -22,18 +20,12 @@ export async function createChannel( options?: CreateGuildChannel, reason?: string, ) { - const requiredPerms: Set = new Set(["MANAGE_CHANNELS"]); - - options?.permissionOverwrites?.forEach((overwrite) => { - eventHandlers.debug?.( - "loop", - `Running forEach loop in create_channel file.`, + if (options?.permissionOverwrites) { + await requireOverwritePermissions( + guildId, + options.permissionOverwrites, ); - overwrite.allow.forEach(requiredPerms.add, requiredPerms); - overwrite.deny.forEach(requiredPerms.add, requiredPerms); - }); - - await requireBotGuildPermissions(guildId, [...requiredPerms]); + } // BITRATES ARE IN THOUSANDS SO IF USER PROVIDES 32 WE CONVERT TO 32000 if (options?.bitrate && options.bitrate < 1000) options.bitrate *= 1000; @@ -48,7 +40,7 @@ export async function createChannel( allow: calculateBits(perm.allow), deny: calculateBits(perm.deny), })), - type: options?.type || DiscordChannelTypes.GUILD_TEXT, + type: options?.type || DiscordChannelTypes.GuildText, reason, }, ); diff --git a/src/helpers/channels/delete_channel.ts b/src/helpers/channels/delete_channel.ts index b8588c7b0..8634f827f 100644 --- a/src/helpers/channels/delete_channel.ts +++ b/src/helpers/channels/delete_channel.ts @@ -1,26 +1,39 @@ import { cacheHandlers } from "../../cache.ts"; import { rest } from "../../rest/rest.ts"; import { Errors } from "../../types/misc/errors.ts"; +import { ChannelTypes } from "../../types/mod.ts"; import { endpoints } from "../../util/constants.ts"; import { requireBotGuildPermissions } from "../../util/permissions.ts"; /** Delete a channel in your server. Bot needs MANAGE_CHANNEL permissions in the server. */ export async function deleteChannel( - guildId: bigint, channelId: bigint, reason?: string, -): Promise { - await requireBotGuildPermissions(guildId, ["MANAGE_CHANNELS"]); +) { + const channel = await cacheHandlers.get("channels", channelId); - const guild = await cacheHandlers.get("guilds", guildId); - if (!guild) throw new Error(Errors.GUILD_NOT_FOUND); + if (channel?.guildId) { + const guild = await cacheHandlers.get("guilds", channel.guildId); + if (!guild) throw new Error(Errors.GUILD_NOT_FOUND); - if (guild?.rulesChannelId === channelId) { - throw new Error(Errors.RULES_CHANNEL_CANNOT_BE_DELETED); - } + // TODO(threads): check if this requires guild perms or channel is enough + await requireBotGuildPermissions( + guild, + [ + ChannelTypes.GuildNewsThread, + ChannelTypes.GuildPivateThread, + ChannelTypes.GuildPublicThread, + ].includes(channel.type) + ? ["MANAGE_THREADS"] + : ["MANAGE_CHANNELS"], + ); + if (guild.rulesChannelId === channelId) { + throw new Error(Errors.RULES_CHANNEL_CANNOT_BE_DELETED); + } - if (guild?.publicUpdatesChannelId === channelId) { - throw new Error(Errors.UPDATES_CHANNEL_CANNOT_BE_DELETED); + if (guild.publicUpdatesChannelId === channelId) { + throw new Error(Errors.UPDATES_CHANNEL_CANNOT_BE_DELETED); + } } return await rest.runMethod( diff --git a/src/helpers/channels/edit_channel.ts b/src/helpers/channels/edit_channel.ts index 57d302637..d22feab6e 100644 --- a/src/helpers/channels/edit_channel.ts +++ b/src/helpers/channels/edit_channel.ts @@ -1,22 +1,68 @@ import { eventHandlers } from "../../bot.ts"; +import { cacheHandlers } from "../../cache.ts"; import { rest } from "../../rest/rest.ts"; import type { Channel } from "../../types/channels/channel.ts"; +import { DiscordChannelTypes } from "../../types/channels/channel_types.ts"; import type { ModifyChannel } from "../../types/channels/modify_channel.ts"; +import type { ModifyThread } from "../../types/channels/threads/modify_thread.ts"; +import type { PermissionStrings } from "../../types/permissions/permission_strings.ts"; import { endpoints } from "../../util/constants.ts"; import { calculateBits, requireBotChannelPermissions, + requireOverwritePermissions, } from "../../util/permissions.ts"; +import { camelKeysToSnakeCase, hasOwnProperty } from "../../util/utils.ts"; +//TODO: implement DM group channel edit +//TODO(threads): check thread perms /** Update a channel's settings. Requires the `MANAGE_CHANNELS` permission for the guild. */ export async function editChannel( channelId: bigint, - options: ModifyChannel, + options: ModifyChannel | ModifyThread, reason?: string, ) { - await requireBotChannelPermissions(channelId, ["MANAGE_CHANNELS"]); + const channel = await cacheHandlers.get("channels", channelId); - if (options.name || options.topic) { + if (channel) { + if ( + [ + DiscordChannelTypes.GuildNewsThread, + DiscordChannelTypes.GuildPivateThread, + DiscordChannelTypes.GuildPublicThread, + ].includes(channel.type) + ) { + const permissions = new Set(); + + if (hasOwnProperty(options, "archive") && options.archive === false) { + permissions.add("SEND_MESSAGES"); + } + + // TODO(threads): change this to a better check + // hacky way of checking if more is being modified + if (Object.keys(options).length > 1) { + permissions.add("MANAGE_THREADS"); + } + + await requireBotChannelPermissions(channel.parentId ?? 0n, [ + ...permissions, + ]); + } + + if ( + hasOwnProperty( + options, + "permissionOverwrites", + ) && Array.isArray(options.permissionOverwrites) + ) { + await requireOverwritePermissions( + channel.guildId, + options.permissionOverwrites, + ); + } + } + + if (options.name || (options as ModifyChannel).topic) { const request = editChannelNameTopicQueue.get(channelId); if (!request) { // If this hasnt been done before simply add 1 for it @@ -42,21 +88,18 @@ export async function editChannel( } const payload = { - ...options, + ...camelKeysToSnakeCase>(options), // deno-lint-ignore camelcase - rate_limit_per_user: options.rateLimitPerUser, - // deno-lint-ignore camelcase - parent_id: options.parentId, - // deno-lint-ignore camelcase - user_limit: options.userLimit, - // deno-lint-ignore camelcase - permission_overwrites: options.permissionOverwrites?.map((overwrite) => { - return { - ...overwrite, - allow: calculateBits(overwrite.allow), - deny: calculateBits(overwrite.deny), - }; - }), + permission_overwrites: + hasOwnProperty(options, "permissionOverwrites") + ? options.permissionOverwrites?.map((overwrite) => { + return { + ...overwrite, + allow: calculateBits(overwrite.allow), + deny: calculateBits(overwrite.deny), + }; + }) + : undefined, }; return await rest.runMethod( diff --git a/src/helpers/channels/start_typing.ts b/src/helpers/channels/start_typing.ts index 91ef100f9..4f65aa771 100644 --- a/src/helpers/channels/start_typing.ts +++ b/src/helpers/channels/start_typing.ts @@ -16,9 +16,12 @@ export async function startTyping(channelId: bigint) { if (channel) { if ( ![ - DiscordChannelTypes.DM, - DiscordChannelTypes.GUILD_NEWS, - DiscordChannelTypes.GUILD_TEXT, + DiscordChannelTypes.Dm, + DiscordChannelTypes.GuildNews, + DiscordChannelTypes.GuildText, + DiscordChannelTypes.GuildNewsThread, + DiscordChannelTypes.GuildPivateThread, + DiscordChannelTypes.GuildPublicThread, ].includes(channel.type) ) { throw new Error(Errors.CHANNEL_NOT_TEXT_BASED); diff --git a/src/helpers/channels/threads/add_to_thread.ts b/src/helpers/channels/threads/add_to_thread.ts new file mode 100644 index 000000000..1fd913b5f --- /dev/null +++ b/src/helpers/channels/threads/add_to_thread.ts @@ -0,0 +1,35 @@ +import { cacheHandlers } from "../../../cache.ts"; +import { rest } from "../../../rest/rest.ts"; +import { ChannelTypes, Errors } from "../../../types/mod.ts"; +import { endpoints } from "../../../util/constants.ts"; +//TODO(threads): this does not work rn +/** Adds the current user to a thread. Returns a 204 empty response on success. Also requires the thread is not archived. Fires a Thread Members Update Gateway event.Adds another user to a thread. Requires the ability to send messages in the thread. Also requires the thread is not archived. Returns a 204 empty response on success. Fires a Thread Members Update Gateway event. + * @param userId the user to add to the thread defaults to bot + */ +export async function addToThread(channelId: bigint, userId?: bigint) { + // TODO(threads): perm check + const channel = await cacheHandlers.get("channels", channelId); + if (channel) { + if ( + ![ + ChannelTypes.GuildNewsThread, + ChannelTypes.GuildPivateThread, + ChannelTypes.GuildPublicThread, + ].includes(channel.type) + ) { + throw new Error(Errors.NOT_A_THREAD_CHANNEL); + } + } + console.log( + "put", + userId + ? endpoints.THREAD_USER(channelId, userId) + : endpoints.THREAD_ME(channelId), + ); + return await rest.runMethod( + "put", + userId + ? endpoints.THREAD_USER(channelId, userId) + : endpoints.THREAD_ME(channelId), + ); +} diff --git a/src/helpers/channels/threads/get_active_threads.ts b/src/helpers/channels/threads/get_active_threads.ts new file mode 100644 index 000000000..2ed30691d --- /dev/null +++ b/src/helpers/channels/threads/get_active_threads.ts @@ -0,0 +1,12 @@ +import { rest } from "../../../rest/rest.ts"; +import { endpoints } from "../../../util/constants.ts"; + +/** Returns all active threads in the channel, including public and private threads. Threads are ordered by their id, in descending order. Requires the READ_MESSAGE_HISTORY permission. */ +export async function getActiveThreads(channelId: bigint) { + // TODO(threads): perm check + // TODO(threads): test if it works + return await rest.runMethod( + "get", + endpoints.THREAD_ACTIVE(channelId), + ); +} diff --git a/src/helpers/channels/threads/get_archived_threads.ts b/src/helpers/channels/threads/get_archived_threads.ts new file mode 100644 index 000000000..4a4de2927 --- /dev/null +++ b/src/helpers/channels/threads/get_archived_threads.ts @@ -0,0 +1,24 @@ +import { rest } from "../../../rest/rest.ts"; +import { ListPublicArchivedThreads } from "../../../types/channels/threads/list_public_archived_threads.ts"; +import { endpoints } from "../../../util/constants.ts"; +import { camelKeysToSnakeCase } from "../../../util/utils.ts"; + +export async function getArchivedThreads( + channelId: bigint, + options?: ListPublicArchivedThreads & { + type?: "public" | "private" | "privateJoinedThreads"; + }, +) { + // TODO(threads): perm check + // TODO(threads): check if this works + + return await rest.runMethod( + "get", + options?.type === "privateJoinedThreads" + ? endpoints.THREAD_ARCHIVED_PRIVATE_JOINED(channelId) + : options?.type === "private" + ? endpoints.THREAD_ARCHIVED_PRIVATE(channelId) + : endpoints.THREAD_ARCHIVED_PUBLIC(channelId), + camelKeysToSnakeCase(options ?? {}), + ); +} diff --git a/src/helpers/channels/threads/get_thread_members.ts b/src/helpers/channels/threads/get_thread_members.ts new file mode 100644 index 000000000..34c1d0e13 --- /dev/null +++ b/src/helpers/channels/threads/get_thread_members.ts @@ -0,0 +1,26 @@ +import { cacheHandlers } from "../../../cache.ts"; +import { rest } from "../../../rest/rest.ts"; +import { ChannelTypes } from "../../../types/channels/channel_types.ts"; +import { Errors } from "../../../types/misc/errors.ts"; +import { endpoints } from "../../../util/constants.ts"; + +// TODO(threads): it seems like the documented return type is wrong +/** Returns array of thread members objects that are members of the thread. */ +export async function getThreadMembers(channelId: bigint) { + // TODO(threads): perm check + // TODO(threads): intents check + const channel = await cacheHandlers.get("channels", channelId); + if (channel) { + if ( + ![ + ChannelTypes.GuildNewsThread, + ChannelTypes.GuildPivateThread, + ChannelTypes.GuildPublicThread, + ].includes(channel.type) + ) { + throw new Error(Errors.NOT_A_THREAD_CHANNEL); + } + } + + return await rest.runMethod("get", endpoints.THREAD_MEMBERS(channelId)); +} diff --git a/src/helpers/channels/threads/remove_from_thread.ts b/src/helpers/channels/threads/remove_from_thread.ts new file mode 100644 index 000000000..9e967a843 --- /dev/null +++ b/src/helpers/channels/threads/remove_from_thread.ts @@ -0,0 +1,29 @@ +import { cacheHandlers } from "../../../cache.ts"; +import { rest } from "../../../rest/rest.ts"; +import { ChannelTypes } from "../../../types/channels/channel_types.ts"; +import { Errors } from "../../../types/misc/errors.ts"; +import { endpoints } from "../../../util/constants.ts"; + +/** Removes another user from a thread. Requires the MANAGE_THREADS permission or that you are the creator of the thread. Also requires the thread is not archived. Returns a 204 empty response on success. Fires a Thread Members Update Gateway event. */ +export async function removeFromThread(channelId: bigint, userId?: bigint) { + // TODO(threads): perm check + const channel = await cacheHandlers.get("channels", channelId); + if (channel) { + if ( + ![ + ChannelTypes.GuildNewsThread, + ChannelTypes.GuildPivateThread, + ChannelTypes.GuildPublicThread, + ].includes(channel.type) + ) { + throw new Error(Errors.NOT_A_THREAD_CHANNEL); + } + } + + return await rest.runMethod( + "delete", + userId + ? endpoints.THREAD_USER(channelId, userId) + : endpoints.THREAD_ME(channelId), + ); +} diff --git a/src/helpers/channels/threads/start_thread.ts b/src/helpers/channels/threads/start_thread.ts new file mode 100644 index 000000000..dc4f8bbde --- /dev/null +++ b/src/helpers/channels/threads/start_thread.ts @@ -0,0 +1,38 @@ +import { cacheHandlers } from "../../../cache.ts"; +import { rest } from "../../../rest/rest.ts"; +import { ChannelTypes } from "../../../types/channels/channel_types.ts"; +import { StartThread } from "../../../types/channels/threads/start_thread.ts"; +import { Errors } from "../../../types/misc/errors.ts"; +import { endpoints } from "../../../util/constants.ts"; +import { camelKeysToSnakeCase } from "../../../util/utils.ts"; + +/** + * Creates a new public thread from an existing message. Returns a channel on success, and a 400 BAD REQUEST on invalid parameters. Fires a Thread Create Gateway event. + * @param messageId when provided the thread will be public + */ +export async function startThread( + channelId: bigint, + options: StartThread & { messageId?: bigint }, +) { + const channel = await cacheHandlers.get("channels", channelId); + if (channel) { + // TODO(threads): perm check + if ( + ![ChannelTypes.GuildText, ChannelTypes.GuildNews].includes(channel.type) + ) { + throw new Error(Errors.INVALID_THREAD_PARENT_CHANNEL_TYPE); + } + + if (!options.messageId && channel.type === ChannelTypes.GuildNews) { + throw new Error(Errors.GUILD_NEWS_CHANNEL_ONLY_SUPPORT_PUBLIC_THREADS); + } + } + + return rest.runMethod( + "post", + options?.messageId + ? endpoints.THREAD_START_PUBLIC(channelId, options.messageId) + : endpoints.THREAD_START_PRIVATE(channelId), + camelKeysToSnakeCase(options), + ); +} diff --git a/src/helpers/messages/send_message.ts b/src/helpers/messages/send_message.ts index 9b08d706a..ff80dfb53 100644 --- a/src/helpers/messages/send_message.ts +++ b/src/helpers/messages/send_message.ts @@ -26,9 +26,12 @@ export async function sendMessage( if (channel) { if ( ![ - DiscordChannelTypes.DM, - DiscordChannelTypes.GUILD_NEWS, - DiscordChannelTypes.GUILD_TEXT, + DiscordChannelTypes.Dm, + DiscordChannelTypes.GuildNews, + DiscordChannelTypes.GuildText, + DiscordChannelTypes.GuildPublicThread, + DiscordChannelTypes.GuildPivateThread, + DiscordChannelTypes.GuildNewsThread, ].includes(channel.type) ) { throw new Error(Errors.CHANNEL_NOT_TEXT_BASED); diff --git a/src/structures/channel.ts b/src/structures/channel.ts index 920a7cfaa..d9e81b357 100644 --- a/src/structures/channel.ts +++ b/src/structures/channel.ts @@ -62,8 +62,8 @@ const baseChannel: Partial = { disconnect(memberId) { return disconnectMember(this.guildId!, memberId); }, - delete() { - return deleteChannel(this.guildId!, this.id!); + delete(reason) { + return deleteChannel(this.id!, reason); }, editOverwrite(id, options) { return editChannelOverwrite( @@ -204,7 +204,7 @@ export interface DiscordenoChannel extends /** Disconnect a member from a voice channel. Requires MOVE_MEMBERS permission. */ disconnect(memberId: bigint): ReturnType; /** Delete the channel */ - delete(): ReturnType; + delete(reason?: string): ReturnType; /** Edit a channel Overwrite */ editOverwrite( overwriteId: bigint, diff --git a/src/structures/guild.ts b/src/structures/guild.ts index a6373c1a2..e90f0f5b2 100644 --- a/src/structures/guild.ts +++ b/src/structures/guild.ts @@ -158,6 +158,7 @@ export async function createDiscordenoGuild( memberCount = 0, voiceStates = [], channels = [], + threads = [], presences = [], joinedAt = "", emojis, @@ -181,7 +182,7 @@ export async function createDiscordenoGuild( voiceStates.map((vs) => structures.createDiscordenoVoiceState(guildId, vs)), ); - await Promise.all(channels.map(async (channel) => { + await Promise.all([...channels, ...threads].map(async (channel) => { const discordenoChannel = await structures.createDiscordenoChannel( channel, guildId, diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index 7970c4f2c..ceda62144 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -1,6 +1,8 @@ import { User } from "../users/user.ts"; import { DiscordChannelTypes } from "./channel_types.ts"; import { DiscordOverwrite } from "./overwrite.ts"; +import { ThreadMember } from "./threads/thread_member.ts"; +import { ThreadMetadata } from "./threads/thread_metadata.ts"; import { DiscordVideoQualityModes } from "./video_quality_modes.ts"; /** https://discord.com/developers/docs/resources/channel#channel-object */ @@ -33,11 +35,11 @@ export interface Channel { recipients?: User[]; /** Icon hash */ icon?: string | null; - /** id of the DM creator */ + /** Id of the creator of the group DM or thread */ ownerId?: string; /** Application id of the group DM creator if it is bot-created */ applicationId?: string; - /** Id of the parent category for a channel (each parent category can contain up to 50 channels) */ + /** For guild channels: Id of the parent category for a channel (each parent category can contain up to 50 channels), for threads: id of the text channel this thread was created */ parentId?: string | null; /** When the last pinned message was pinned. This may be null in events such as GUILD_CREATE when a message is not pinned. */ lastPinTimestamp?: string | null; @@ -45,4 +47,13 @@ export interface Channel { rtcRegion?: string | null; /** The camera video quality mode of the voice channel, 1 when not present */ videoQualityMode?: DiscordVideoQualityModes; + // TODO(threads): consider a ThreadChannel object + /** An approximate count of messages in a thread, stops counting at 50 */ + messageCount?: number; + /** An approximate count of users in a thread, stops counting at 50 */ + memberCount?: number; + /** Thread-specifig fields not needed by other channels */ + threadMetadata?: ThreadMetadata; + /** Thread member object for the current user, if they have joined the thread, only included on certain API endpoints */ + member?: ThreadMember; } diff --git a/src/types/channels/channel_types.ts b/src/types/channels/channel_types.ts index 02d2c85b2..26a3ce5e4 100644 --- a/src/types/channels/channel_types.ts +++ b/src/types/channels/channel_types.ts @@ -1,21 +1,27 @@ /** https://discord.com/developers/docs/resources/channel#channel-object-channel-types */ export enum DiscordChannelTypes { /** A text channel within a server */ - GUILD_TEXT, + GuildText, /** A direct message between users */ - DM, + Dm, /** A voice channel within a server */ - GUILD_VOICE, + GuildVoice, /** A direct message between multiple users */ - GROUP_DM, + GroupDm, /** An organizational category that contains up to 50 channels */ - GUILD_CATEGORY, + GuildCategory, /** A channel that users can follow and crosspost into their own server */ - GUILD_NEWS, + GuildNews, /** A channel in which game developers can sell their game on Discord */ - GUILD_STORE, + GuildStore, + /** A temporary sub-channel within a GUILD_NEWS channel */ + GuildNewsThread = 10, + /** A temporary sub-channel within a GUILD_TEXT channel */ + GuildPublicThread, + /** A temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission */ + GuildPivateThread, /** A voice channel for hosting events with an audience */ - GUILD_STAGE_VOICE = 13, + GuildStageVoice = 13, } export type ChannelTypes = DiscordChannelTypes; diff --git a/src/types/channels/threads/list_active_threads.ts b/src/types/channels/threads/list_active_threads.ts new file mode 100644 index 000000000..55753d444 --- /dev/null +++ b/src/types/channels/threads/list_active_threads.ts @@ -0,0 +1,12 @@ +import { Channel } from "../channel.ts"; +import { ThreadMember } from "./thread_member.ts"; + +// TODO: add docs link +export interface ListActiveThreads { + /** The active threads */ + threads: Channel[]; + /** A thread member object for each returned thread the current user has joined */ + members: ThreadMember[]; + /** Whether there are potentially additional threads that could be returned on subsequent call */ + hasMore: boolean; +} diff --git a/src/types/channels/threads/list_public_archived_threads.ts b/src/types/channels/threads/list_public_archived_threads.ts new file mode 100644 index 000000000..95a7a2a0a --- /dev/null +++ b/src/types/channels/threads/list_public_archived_threads.ts @@ -0,0 +1,8 @@ +// TODO: add docs link +export interface ListPublicArchivedThreads { + // TODO: convert unix to ISO9601 timestamp + /** Returns threads before this timestamp. UNIX or ISO8601 timestamp */ + before?: number | string; + /** Optional maximum number of threads to return */ + limit?: number; +} diff --git a/src/types/channels/threads/modify_thread.ts b/src/types/channels/threads/modify_thread.ts new file mode 100644 index 000000000..d261cab1d --- /dev/null +++ b/src/types/channels/threads/modify_thread.ts @@ -0,0 +1,13 @@ +// TODO: add docs link +export interface ModifyThread { + /** 2-100 character thread name */ + name?: string; + /** Whether the thread is archived */ + archived?: boolean; + /** Duration in minutes to automatically archive the thread after recent activity */ + autoArchiveDuration?: 60 | 1440 | 4320 | 10080; + /** When a thread is locked, only users with `MANAGE_THREADS` can unarchive it */ + locked?: boolean; + /** Amount of seconds a user has to wait before sending another message (0-21600); bots, as well as users with the permission `MANAGE_MESSAGES`, `MANAGE_THREAD` or `MANAGE_CHANNEL` are unaffected */ + rateLimitPerUser?: number; +} diff --git a/src/types/channels/threads/start_thread.ts b/src/types/channels/threads/start_thread.ts new file mode 100644 index 000000000..10a5c0b02 --- /dev/null +++ b/src/types/channels/threads/start_thread.ts @@ -0,0 +1,7 @@ +// TODO: add docs link +export interface StartThread { + /** 2-100 character thread name */ + name: string; + /** Duration in minutes to automatically archive the thread after recent activity */ + autoArchiveDuration: 60 | 1440 | 4320 | 10080; +} diff --git a/src/types/channels/threads/thread_list_sync.ts b/src/types/channels/threads/thread_list_sync.ts new file mode 100644 index 000000000..2c905a907 --- /dev/null +++ b/src/types/channels/threads/thread_list_sync.ts @@ -0,0 +1,15 @@ +import { Channel } from "../channel.ts"; +import { ThreadMember } from "./thread_member.ts"; + +// TODO: add docs link +export interface ThreadListSync { + /** The id of the guild */ + guildId: string; + /** The parent channel ids whose threads are being synced. If omitted, then threads were synced for the entire guild. This array may contain channelIds that have no active threads as well, so you know to clear that data */ + channelIds?: string[]; + // TODO: check if need to omit + /** All active threads in the given channels that the current user can access */ + threads: Channel[]; + /** All thread member objects from the synced threads for the current user, indicating which threads the current user has been added to */ + members: ThreadMember[]; +} diff --git a/src/types/channels/threads/thread_member.ts b/src/types/channels/threads/thread_member.ts new file mode 100644 index 000000000..2b3d1b54a --- /dev/null +++ b/src/types/channels/threads/thread_member.ts @@ -0,0 +1,10 @@ +export interface ThreadMember { + /** The id of the thread */ + id: string; + /** The id of the user */ + userId: string; + /** The time the current user last joined the thread */ + joinTimestamp: string; + /** Any user-thread settings, currently only used for notifications */ + flags: number; +} diff --git a/src/types/channels/threads/thread_members_update.ts b/src/types/channels/threads/thread_members_update.ts new file mode 100644 index 000000000..2190f6297 --- /dev/null +++ b/src/types/channels/threads/thread_members_update.ts @@ -0,0 +1,15 @@ +import { ThreadMember } from "./thread_member.ts"; + +// TODO: add docs link +export interface ThreadMembersUpdate { + /** The id of the thread */ + id: string; + /** The id of the guild */ + guildId: string; + /** The approximate number of members in the thread, capped at 50 */ + memberCount: number; + /** The users who were added to the thread */ + addedMembers?: ThreadMember[]; + /** The id of the users who were removed from the thread */ + removedMemberIds?: string[]; +} diff --git a/src/types/channels/threads/thread_metadata.ts b/src/types/channels/threads/thread_metadata.ts new file mode 100644 index 000000000..ea67fd966 --- /dev/null +++ b/src/types/channels/threads/thread_metadata.ts @@ -0,0 +1,13 @@ +export interface ThreadMetadata { + /** Whether the thread is archived */ + archived: boolean; + /** Id of the user that last archived or unarchived the thread */ + archiverId?: string; + /** Duration in minutes to automatically archive the thread after recent activity */ + autoArchiveDuration: 60 | 1440 | 4320 | 10080; + // TODO(threads): channel struct should convert this to a unixx + /** Timestamp when the thread's archive status was last changed, used for calculating recent activity */ + archiveTimestamp: string; + /** When a thread is locked, only users with `MANAGE_THREADS` can unarchive it */ + locked?: boolean; +} diff --git a/src/types/codes/json_error_codes.ts b/src/types/codes/json_error_codes.ts index 1d05ebb74..d26fa3133 100644 --- a/src/types/codes/json_error_codes.ts +++ b/src/types/codes/json_error_codes.ts @@ -80,6 +80,11 @@ export enum DiscordJsonErrorCodes { InvalidApiVersionProvided = 50041, CannotDeleteAChannelRequiredForCommunityGuilds = 50074, InvalidStickerSent = 50081, + TriedToPerformAnOperationOnAnArchivedThreadSuchAsEditingAMessageOrAddingAUserToTheThread = + 50083, + InvalidThreadNotificationSettings, + BeforeValueIsEarlierThanTheThreadCreationDate, + TwoFactorIsRequiredForThisOperation = 60003, ReqctionWasBlocked = 90001, ApiResourceIsCurrentlyOverloadedTryAgainALittleLater = 130000, } diff --git a/src/types/discordeno/eventHandlers.ts b/src/types/discordeno/eventHandlers.ts index 4c97cea77..5386935ab 100644 --- a/src/types/discordeno/eventHandlers.ts +++ b/src/types/discordeno/eventHandlers.ts @@ -4,6 +4,8 @@ import { DiscordenoMember } from "../../structures/member.ts"; import { DiscordenoMessage } from "../../structures/message.ts"; import { DiscordenoRole } from "../../structures/role.ts"; import { Collection } from "../../util/collection.ts"; +import { ThreadMember } from "../channels/threads/thread_member.ts"; +import { ThreadMembersUpdate } from "../channels/threads/thread_members_update.ts"; import { IntegrationCreateUpdate } from "../integration/integration_create_update.ts"; import { ApplicationCommandCreateUpdateDelete } from "../interactions/application_command_create_update_delete.ts"; import { @@ -211,6 +213,25 @@ export interface EventHandlers { shardId: number, unavailableGuildIds: Set, ) => unknown; + /** Sent when a thread is created */ + threadCreate?: (channel: DiscordenoChannel) => unknown; + /** Sent when a thread is updated */ + threadUpdate?: ( + cahnnel: DiscordenoChannel, + oldChannel: DiscordenoChannel, + ) => unknown; + /** Sent when the bot gains access to threads */ + threadListSync?: ( + channels: Collection, + members: ThreadMember[], + guildId: bigint, + ) => unknown; + /** Sent when the current users thread member is updated */ + threadMemberUpdate?: (threadMember: ThreadMember) => unknown; + /** Sent when anyone is added to or removed from a thread */ + threadMembersUpdate?: (update: ThreadMembersUpdate) => unknown; + /** Sent when a thread is deleted */ + threadDelete?: (channel: DiscordenoChannel) => unknown; /** Sent when a user starts typing in a channel. */ typingStart?: (data: TypingStart) => unknown; /** Sent when a user joins a voice channel */ diff --git a/src/types/guilds/guild.ts b/src/types/guilds/guild.ts index 1f7ea91fe..a6b050529 100644 --- a/src/types/guilds/guild.ts +++ b/src/types/guilds/guild.ts @@ -79,6 +79,9 @@ export interface Guild { members?: GuildMember[]; /** Channels in the guild */ channels?: Channel[]; + // TODO: check if need to omit + /** All active threads in the guild that the current user has permission to view */ + threads?: Channel[]; /** Presences of the members in the guild, will only include non-offline members if the size is greater than large threshold */ presences?: Partial[]; /** The maximum number of presences for the guild (the default value, currently 25000, is in effect when null is returned) */ diff --git a/src/types/messages/message.ts b/src/types/messages/message.ts index 3d373a69f..9ae7b8879 100644 --- a/src/types/messages/message.ts +++ b/src/types/messages/message.ts @@ -1,4 +1,6 @@ +import { Channel } from "../channels/channel.ts"; import { ChannelMention } from "../channels/channel_mention.ts"; +import { ThreadMember } from "../channels/threads/thread_member.ts"; import { Embed } from "../embeds/embed.ts"; import { GuildMember } from "../guilds/guild_member.ts"; import { MessageInteraction } from "../interactions/message_interaction.ts"; @@ -83,6 +85,8 @@ export interface Message { referencedMessage?: Message; /** Sent if the message is a response to an Interaction */ interaction?: MessageInteraction; + /** The thread that was started from this message, includes thread member object */ + thread?: Omit & { member: ThreadMember }; /** The components related to this message */ components: MessageComponents; } diff --git a/src/types/messages/message_flags.ts b/src/types/messages/message_flags.ts index 57de222e1..76f4f422c 100644 --- a/src/types/messages/message_flags.ts +++ b/src/types/messages/message_flags.ts @@ -1,19 +1,21 @@ /** https://discord.com/developers/docs/resources/channel#message-object-message-flags */ export enum DiscordMessageFlags { /** This message has been published to subscribed channels (via Channel Following) */ - CROSSPOSTED = 1 << 0, + Crossposted = 1 << 0, /** This message originated from a message in another channel (via Channel Following) */ - IS_CROSSPOST = 1 << 1, + IsCrosspost = 1 << 1, /** Do not include any embeds when serializing this message */ - SUPPRESS_EMBEDS = 1 << 2, + SuppressEmbeeds = 1 << 2, /** The source message for this crosspost has been deleted (via Channel Following) */ - SOURCE_MESSAGE_DELETED = 1 << 3, + SourceMessageDeleted = 1 << 3, /** This message came from the urgent message system */ - URGENT = 1 << 4, + Urgent = 1 << 4, + /** This message has an associated thread, with the same id as the message */ + HasThread = 1 << 5, /** This message is only visible to the user who invoked the Interaction */ - EMPHERAL = 1 << 6, + Empheral = 1 << 6, /** This message is an Interaction Response and the bot is "thinking" */ - LOADING = 1 << 7, + Loading = 1 << 7, } export type MessageFlags = DiscordMessageFlags; diff --git a/src/types/messages/message_types.ts b/src/types/messages/message_types.ts index 263c8851f..540359326 100644 --- a/src/types/messages/message_types.ts +++ b/src/types/messages/message_types.ts @@ -1,25 +1,26 @@ /** https://discord.com/developers/docs/resources/channel#message-object-message-types */ export enum DiscordMessageTypes { - DEFAULT, - RECIPIENT_ADD, - RECIPIENT_REMOVE, - CALL, - CHANNEL_NAME_CHANGE, - CHANNEL_ICON_CHANGE, - CHANNEL_PINNED_MESSAGE, - GUILD_MEMBER_JOIN, - USER_PREMIUM_GUILD_SUBSCRIPTION, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3, - CHANNEL_FOLLOW_ADD, - GUILD_DISCOVERY_DISQUALIFIED = 14, - GUILD_DISCOVERY_REQUALIFIED, - GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING, - GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING, - REPLY = 19, - APPLICATION_COMMAND, - GUILD_INVITE_REMINDER = 22, + Default, + RecipientAdd, + RecipientRemove, + Call, + ChannelNameChange, + ChannelIconChange, + ChannelPinnedMessage, + GuildMemberJoin, + UserPremiumGuildSubscription, + UserPremiumGuildSubscriptionTier1, + UserPremiumGuildSubscriptionTier2, + UserPremiumGuildSubscriptionTier3, + ChannelFollowAdd, + GuildDiscoveryDisqualified = 14, + GuildDiscoveryRequalified, + GuildDiscoveryGracePeriodInitialWarning, + GuildDiscoveryGracePeriodFinalWarning, + ThreadCreated, + Reply = 19, + ApplicationCommand, + GuildInviteReminder = 22, } export type MessageTypes = DiscordMessageTypes; diff --git a/src/types/misc/errors.ts b/src/types/misc/errors.ts index ee55755c0..34c50ccea 100644 --- a/src/types/misc/errors.ts +++ b/src/types/misc/errors.ts @@ -18,6 +18,11 @@ export enum Errors { MEMBER_SEARCH_LIMIT_TOO_LOW = "MEMBER_SEARCH_LIMIT_TOO_LOW", PRUNE_MAX_DAYS = "PRUNE_MAX_DAYS", ROLE_NOT_FOUND = "ROLE_NOT_FOUND", + // Thread errors + INVALID_THREAD_PARENT_CHANNEL_TYPE = "INVALID_THREAD_PARENT_CHANNEL_TYPE", + GUILD_NEWS_CHANNEL_ONLY_SUPPORT_PUBLIC_THREADS = + "GUILD_NEWS_CHANNEL_ONLY_SUPPORT_PUBLIC_THREADS", + NOT_A_THREAD_CHANNEL = "NOT_A_THREAD_CHANNEL", // Message Delete Errors DELETE_MESSAGES_MIN = "DELETE_MESSAGES_MIN", PRUNE_MIN_DAYS = "PRUNE_MIN_DAYS", diff --git a/src/types/permissions/bitwise_permission_flags.ts b/src/types/permissions/bitwise_permission_flags.ts index cd539e5d0..c76891e99 100644 --- a/src/types/permissions/bitwise_permission_flags.ts +++ b/src/types/permissions/bitwise_permission_flags.ts @@ -65,7 +65,13 @@ export enum DiscordBitwisePermissionFlags { /** Allows members to use slash commands in text channels */ USE_SLASH_COMMANDS = 0x80000000, /** Allows for requesting to speak in stage channels. */ - REQUEST_TO_SPEAK = 0x100000000, + REQUEST_TO_SPEAK = 0x100000001, + /** Allows for deleting and archiving threads, and viewing all private threads */ + MANAGE_THREADS = 0x0400000000, + /** Allows for creating and participating in threads */ + USE_PUBLIC_THREADS = 0x0800000000, + /** Allows for creating and participating in private threads */ + USE_PRIVATE_THREADS = 0x1000000000, } export type BitwisePermissions = DiscordBitwisePermissionFlags; diff --git a/src/util/constants.ts b/src/util/constants.ts index 132f802f7..71e17f1e9 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -2,13 +2,14 @@ export const BASE_URL = "https://discord.com/api"; /** https://discord.com/developers/docs/reference#api-versioning-api-versions */ -export const API_VERSION = 8; +export const API_VERSION = 9; /** https://discord.com/developers/docs/topics/gateway#gateways-gateway-versions */ -export const GATEWAY_VERSION = 8; +export const GATEWAY_VERSION = 9; +// TODO: update this version /** https://github.com/discordeno/discordeno/releases */ -export const DISCORDENO_VERSION = 11; +export const DISCORDENO_VERSION = "11.0.0"; /** https://discord.com/developers/docs/reference#user-agent */ export const USER_AGENT = @@ -77,6 +78,28 @@ export const endpoints = { // Bots SHALL NOT use this endpoint but they can CHANNEL_TYPING: (channelId: bigint) => `${CHANNEL_BASE(channelId)}/typing`, + // Thread Endpoints + THREAD_START_PUBLIC: (channelId: bigint, messageId: bigint) => + `${endpoints.CHANNEL_MESSAGE(channelId, messageId)}/threads`, + THREAD_START_PRIVATE: (channelId: bigint) => + `${CHANNEL_BASE(channelId)}/threads`, + THREAD_ACTIVE: (channelId: bigint) => + `${CHANNEL_BASE(channelId)}/threads/active`, + THREAD_MEMBERS: (channelId: bigint) => + `${CHANNEL_BASE(channelId)}/thread-members`, + THREAD_ME: (channelId: bigint) => + `${endpoints.THREAD_MEMBERS(channelId)}/@me`, + THREAD_USER: (channelId: bigint, userId: bigint) => + `${endpoints.THREAD_MEMBERS(channelId)}/${userId}`, + THREAD_ARCHIVED_BASE: (channelId: bigint) => + `${CHANNEL_BASE(channelId)}/threads/archived`, + THREAD_ARCHIVED_PUBLIC: (channelId: bigint) => + `${endpoints.THREAD_ARCHIVED_BASE(channelId)}/public`, + THREAD_ARCHIVED_PRIVATE: (channelId: bigint) => + `${endpoints.THREAD_ARCHIVED_BASE(channelId)}/private`, + THREAD_ARCHIVED_PRIVATE_JOINED: (channelId: bigint) => + `${CHANNEL_BASE(channelId)}/users/@me/threads/archived/private`, + // Guild Endpoints GUILDS: `${baseEndpoints.BASE_URL}/guilds`, GUILD_AUDIT_LOGS: (guildId: bigint) => `${GUILDS_BASE(guildId)}/audit-logs`, diff --git a/src/util/permissions.ts b/src/util/permissions.ts index 75206d8c5..fec52ffb8 100644 --- a/src/util/permissions.ts +++ b/src/util/permissions.ts @@ -4,6 +4,7 @@ import { DiscordenoChannel } from "../structures/channel.ts"; import { DiscordenoGuild } from "../structures/guild.ts"; import { DiscordenoMember } from "../structures/member.ts"; import { DiscordenoRole } from "../structures/role.ts"; +import { Overwrite } from "../types/channels/overwrite.ts"; import { Errors } from "../types/misc/errors.ts"; import { DiscordBitwisePermissionFlags } from "../types/permissions/bitwise_permission_flags.ts"; import type { PermissionStrings } from "../types/permissions/permission_strings.ts"; @@ -28,11 +29,6 @@ async function getCached( ? // @ts-ignore TS is wrong here await cacheHandlers.get(table, key) : key; - if (!cached || typeof cached === "bigint") { - throw new Error( - Errors[`${table.slice(0, -1).toUpperCase()}_NOT_FOUND` as Errors], - ); - } return typeof cached === "bigint" ? undefined : cached; } @@ -50,7 +46,7 @@ export async function calculateBasePermissions( let permissions = 0n; // Calculate the role permissions bits, @everyone role is not in memberRoleIds so we need to pass guildId manualy permissions |= [...(member.guilds.get(guild.id)?.roles || []), guild.id] - .map((id) => (guild as DiscordenoGuild).roles.get(id)?.permissions) + .map((id) => guild.roles.get(id)?.permissions) // Removes any edge case undefined .filter((perm) => perm) .reduce((bits, perms) => { @@ -76,7 +72,7 @@ export async function calculateChannelOverwrites( const member = await getCached("members", memberOrId); - if (!member) return "8"; + if (!channel || !member) return "8"; // Get all the role permissions this member already has let permissions = BigInt( @@ -84,8 +80,8 @@ export async function calculateChannelOverwrites( ); // First calculate @everyone overwrites since these have the lowest priority - const overwriteEveryone = channel?.permissionOverwrites?.find( - (overwrite) => overwrite.id === (channel as DiscordenoChannel).guildId, + const overwriteEveryone = channel.permissionOverwrites?.find( + (overwrite) => overwrite.id === channel.guildId, ); if (overwriteEveryone) { // First remove denied permissions since denied < allowed @@ -93,7 +89,7 @@ export async function calculateChannelOverwrites( permissions |= BigInt(overwriteEveryone.allow); } - const overwrites = channel?.permissionOverwrites; + const overwrites = channel.permissionOverwrites; // In order to calculate the role permissions correctly we need to temporarily save the allowed and denied permissions let allow = 0n; @@ -112,7 +108,7 @@ export async function calculateChannelOverwrites( // Third calculate member specific overwrites since these have the highest priority const overwriteMember = overwrites?.find( - (overwrite) => overwrite.id === (member as DiscordenoMember).id, + (overwrite) => overwrite.id === member.id, ); if (overwriteMember) { permissions &= ~BigInt(overwriteMember.deny); @@ -290,6 +286,26 @@ export function calculateBits(permissions: PermissionStrings[]) { .toString(); } +/** Internal function to check if the bot has the permissions to set these overwrites */ +export async function requireOverwritePermissions( + guildOrId: bigint | DiscordenoGuild, + overwrites: Overwrite[], +) { + let requiredPerms: Set = new Set(["MANAGE_CHANNELS"]); + + overwrites?.forEach((overwrite) => { + overwrite.allow.forEach(requiredPerms.add, requiredPerms); + overwrite.deny.forEach(requiredPerms.add, requiredPerms); + }); + + // MANAGE_ROLES permission can only be set by administrators + if (requiredPerms.has("MANAGE_ROLES")) { + requiredPerms = new Set(["ADMINISTRATOR"]); + } + + await requireGuildPermissions(guildOrId, botId, [...requiredPerms]); +} + /** Gets the highest role from the member in this guild */ export async function highestRole( guildOrId: bigint | DiscordenoGuild, diff --git a/src/util/utils.ts b/src/util/utils.ts index af5a45a43..c37bcb0fd 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -204,3 +204,15 @@ export function validateSlashCommands( } } } + +// Typescript is not so good as we developers so we need this little utility function to help it out +// Taken from https://fettblog.eu/typescript-hasownproperty/ +/** TS save way to check if a property exists in an object */ +// deno-lint-ignore ban-types +export function hasOwnProperty( + obj: T, + prop: Y, +): obj is T & Record { + // deno-lint-ignore no-prototype-builtins + return obj.hasOwnProperty(prop); +} diff --git a/tests/channels/category_children.ts b/tests/channels/category_children.ts index 3c012bec1..a649e066f 100644 --- a/tests/channels/category_children.ts +++ b/tests/channels/category_children.ts @@ -1,17 +1,17 @@ -import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; -import { assertExists } from "../deps.ts"; import { cache } from "../../src/cache.ts"; import { categoryChildren, createChannel } from "../../src/helpers/mod.ts"; import { DiscordChannelTypes } from "../../src/types/channels/channel_types.ts"; -import { delayUntil } from "../util/delay_until.ts"; import { bigintToSnowflake } from "../../src/util/bigint.ts"; +import { assertExists } from "../deps.ts"; +import { delayUntil } from "../util/delay_until.ts"; +import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; Deno.test({ name: "[channel] category channel ids", async fn() { const category = await createChannel(tempData.guildId, { name: "Discordeno-test", - type: DiscordChannelTypes.GUILD_CATEGORY, + type: DiscordChannelTypes.GuildCategory, }); // Assertions diff --git a/tests/channels/channel_overwrite_has_permission.ts b/tests/channels/channel_overwrite_has_permission.ts index 2eaa988c7..71d3402b3 100644 --- a/tests/channels/channel_overwrite_has_permission.ts +++ b/tests/channels/channel_overwrite_has_permission.ts @@ -15,7 +15,7 @@ async function ifItFailsBlameWolf(options: CreateGuildChannel) { // Assertions assertExists(channel); - assertEquals(channel.type, options.type || DiscordChannelTypes.GUILD_TEXT); + assertEquals(channel.type, options.type || DiscordChannelTypes.GuildText); // Delay the execution by 5 seconds to allow CHANNEL_CREATE event to be processed await delayUntil(10000, () => cache.channels.has(channel.id)); diff --git a/tests/channels/clone_channel.ts b/tests/channels/clone_channel.ts index 168c60b86..4b526da61 100644 --- a/tests/channels/clone_channel.ts +++ b/tests/channels/clone_channel.ts @@ -61,7 +61,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "Discordeno-clone-test", - type: DiscordChannelTypes.GUILD_CATEGORY, + type: DiscordChannelTypes.GuildCategory, }, ); }, @@ -74,7 +74,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "Discordeno-clone-test", - type: DiscordChannelTypes.GUILD_VOICE, + type: DiscordChannelTypes.GuildVoice, }, ); }, @@ -87,7 +87,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "discordeno-clone-test", - type: DiscordChannelTypes.GUILD_VOICE, + type: DiscordChannelTypes.GuildVoice, bitrate: 32000, }, ); @@ -101,7 +101,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "Discordeno-clone-test", - type: DiscordChannelTypes.GUILD_VOICE, + type: DiscordChannelTypes.GuildVoice, userLimit: 32, }, ); diff --git a/tests/channels/create_channel.ts b/tests/channels/create_channel.ts index b6148d75c..fbc85d1d5 100644 --- a/tests/channels/create_channel.ts +++ b/tests/channels/create_channel.ts @@ -1,20 +1,20 @@ -import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; -import { assertEquals, assertExists } from "../deps.ts"; -import { cache } from "../../src/cache.ts"; -import { DiscordChannelTypes } from "../../src/types/channels/channel_types.ts"; -import { CreateGuildChannel } from "../../src/types/guilds/create_guild_channel.ts"; -import { createChannel } from "../../src/helpers/channels/create_channel.ts"; -import { delayUntil } from "../util/delay_until.ts"; -import { DiscordOverwriteTypes } from "../../src/types/channels/overwrite_types.ts"; import { botId } from "../../src/bot.ts"; +import { cache } from "../../src/cache.ts"; +import { createChannel } from "../../src/helpers/channels/create_channel.ts"; +import { DiscordChannelTypes } from "../../src/types/channels/channel_types.ts"; +import { DiscordOverwriteTypes } from "../../src/types/channels/overwrite_types.ts"; +import { CreateGuildChannel } from "../../src/types/guilds/create_guild_channel.ts"; import { bigintToSnowflake } from "../../src/util/bigint.ts"; +import { assertEquals, assertExists } from "../deps.ts"; +import { delayUntil } from "../util/delay_until.ts"; +import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; async function ifItFailsBlameWolf(options: CreateGuildChannel, save = false) { const channel = await createChannel(tempData.guildId, options); // Assertions assertExists(channel); - assertEquals(channel.type, options.type || DiscordChannelTypes.GUILD_TEXT); + assertEquals(channel.type, options.type || DiscordChannelTypes.GuildText); if (save) tempData.channelId = channel.id; @@ -61,7 +61,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "Discordeno-test", - type: DiscordChannelTypes.GUILD_CATEGORY, + type: DiscordChannelTypes.GuildCategory, }, true, ); @@ -91,7 +91,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "Discordeno-test", - type: DiscordChannelTypes.GUILD_VOICE, + type: DiscordChannelTypes.GuildVoice, }, true, ); @@ -105,7 +105,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "discordeno-test", - type: DiscordChannelTypes.GUILD_VOICE, + type: DiscordChannelTypes.GuildVoice, bitrate: 32000, }, true, @@ -120,7 +120,7 @@ Deno.test({ await ifItFailsBlameWolf( { name: "Discordeno-test", - type: DiscordChannelTypes.GUILD_VOICE, + type: DiscordChannelTypes.GuildVoice, userLimit: 32, }, true, diff --git a/tests/channels/delete_channel.ts b/tests/channels/delete_channel.ts index d2b46cb05..57cb9c986 100644 --- a/tests/channels/delete_channel.ts +++ b/tests/channels/delete_channel.ts @@ -1,8 +1,8 @@ import { cache } from "../../src/cache.ts"; import { createChannel } from "../../src/helpers/channels/create_channel.ts"; import { deleteChannel } from "../../src/helpers/channels/delete_channel.ts"; -import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; import { delayUntil } from "../util/delay_until.ts"; +import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; Deno.test({ name: "[channel] delete a channel without a reason.", @@ -21,7 +21,7 @@ Deno.test({ } // Delete the channel now without a reason - await deleteChannel(tempData.guildId, channel.id); + await deleteChannel(channel.id); // wait 5 seconds to give it time for CHANNEL_DELETE event await delayUntil(3000, () => !cache.channels.has(channel.id)); // Make sure it is gone from cache @@ -51,7 +51,7 @@ Deno.test({ } // Delete the channel now without a reason - await deleteChannel(tempData.guildId, channel.id, "with a reason"); + await deleteChannel(channel.id, "with a reason"); // wait 5 seconds to give it time for CHANNEL_DELETE event await delayUntil(10000, () => !cache.channels.has(channel.id)); // Make sure it is gone from cache diff --git a/tests/channels/delete_channel_overwrite.ts b/tests/channels/delete_channel_overwrite.ts index a17e2f099..fc033dff3 100644 --- a/tests/channels/delete_channel_overwrite.ts +++ b/tests/channels/delete_channel_overwrite.ts @@ -15,7 +15,7 @@ async function ifItFailsBlameWolf(options: CreateGuildChannel, _save = false) { // Assertions assertExists(channel); - assertEquals(channel.type, options.type || DiscordChannelTypes.GUILD_TEXT); + assertEquals(channel.type, options.type || DiscordChannelTypes.GuildText); // Delay the execution by 5 seconds to allow CHANNEL_CREATE event to be processed await delayUntil(10000, () => cache.channels.has(channel.id)); diff --git a/tests/channels/edit_channel_overwrite.ts b/tests/channels/edit_channel_overwrite.ts index c26dcff6a..b1482b4f5 100644 --- a/tests/channels/edit_channel_overwrite.ts +++ b/tests/channels/edit_channel_overwrite.ts @@ -16,7 +16,7 @@ async function ifItFailsBlameWolf(options: CreateGuildChannel) { // Assertions assertExists(channel); - assertEquals(channel.type, options.type || DiscordChannelTypes.GUILD_TEXT); + assertEquals(channel.type, options.type || DiscordChannelTypes.GuildText); // Delay the execution by 5 seconds to allow CHANNEL_CREATE event to be processed await delayUntil(10000, () => cache.channels.has(channel.id)); diff --git a/tests/channels/is_channel_synced.ts b/tests/channels/is_channel_synced.ts index 76c9c51dc..c341b3374 100644 --- a/tests/channels/is_channel_synced.ts +++ b/tests/channels/is_channel_synced.ts @@ -1,20 +1,20 @@ +import { botId } from "../../src/bot.ts"; import { cache } from "../../src/cache.ts"; import { createChannel } from "../../src/helpers/channels/create_channel.ts"; -import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; -import { delayUntil } from "../util/delay_until.ts"; -import { assertEquals, assertExists } from "../deps.ts"; import { isChannelSynced } from "../../src/helpers/channels/is_channel_synced.ts"; import { DiscordChannelTypes } from "../../src/types/channels/channel_types.ts"; -import { botId } from "../../src/bot.ts"; import { DiscordOverwriteTypes } from "../../src/types/channels/overwrite_types.ts"; import { bigintToSnowflake } from "../../src/util/bigint.ts"; +import { assertEquals, assertExists } from "../deps.ts"; +import { delayUntil } from "../util/delay_until.ts"; +import { defaultTestOptions, tempData } from "../ws/start_bot.ts"; Deno.test({ name: "[channel] is channel synced.", async fn() { const category = await createChannel(tempData.guildId, { name: "synced-category", - type: DiscordChannelTypes.GUILD_CATEGORY, + type: DiscordChannelTypes.GuildCategory, permissionOverwrites: [ { id: bigintToSnowflake(botId),