From 6e155104581c126f0823ee1fbca0cb901dabdd41 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sat, 17 Oct 2026 12:25:44 +0200 Subject: [PATCH] feat(plugin-gateway): validate thread options and guild-only message paths Verification pass of #216 (section 5). threads.create now takes TextThreadCreateOptions or ForumThreadCreateOptions and rejects the options that do not apply to the channel type, instead of dropping them. messages.crosspost, bulkDelete and startThread throw in direct messages before any request. Refs #216 --- .../gateway-verify-thread-dm-managers.md | 5 + packages/plugin-gateway/README.md | 18 ++ .../plugin-gateway/src/errors/Messages.ts | 5 + .../src/managers/ChannelMessageManager.ts | 53 +++- .../src/managers/ChannelThreadManager.ts | 35 ++- .../src/managers/ThreadManager.ts | 71 +++++- .../channels/mixins/ChannelThreadsMixin.ts | 7 +- .../channels/mixins/TextChannelMixin.ts | 2 +- .../plugin-gateway/tests/dm-messages.test.ts | 236 ++++++++++++++++++ packages/plugin-gateway/tests/threads.test.ts | 99 ++++++++ .../types/thread-and-message-managers.ts | 83 ++++++ 11 files changed, 596 insertions(+), 18 deletions(-) create mode 100644 .changeset/gateway-verify-thread-dm-managers.md create mode 100644 packages/plugin-gateway/tests/dm-messages.test.ts create mode 100644 packages/plugin-gateway/tests/types/thread-and-message-managers.ts diff --git a/.changeset/gateway-verify-thread-dm-managers.md b/.changeset/gateway-verify-thread-dm-managers.md new file mode 100644 index 00000000..9aaca6de --- /dev/null +++ b/.changeset/gateway-verify-thread-dm-managers.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Check the options of `threads.create` and the guild-only message paths against discord.js. `ThreadCreateOptions` is now `TextThreadCreateOptions | ForumThreadCreateOptions`, so mixing the options of a thread (`type`, `invitable`) with those of a forum or media post (`message`, `appliedTags`) is a compile error, and throws a `GatewayTypeError` before any request instead of being dropped silently: `ThreadOptionsPostOnly`, `ThreadOptionsThreadOnly`, and, in a forum or media channel without `message`, `ThreadPostMessageRequired`. `ChannelThreadManager` and `ChannelMessageManager` take the channel type as an optional last constructor argument (`channelType`), which `channel.threads` and `channel.messages` pass. In a direct message or group direct message, `messages.crosspost`, `messages.bulkDelete`, and `messages.startThread` now throw `ChannelMessagesGuildOnly` before any request, as discord.js's `DMMessageManager` has no such methods. diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index eb745445..94b1775e 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -981,6 +981,16 @@ const post = await forum.threads.create({ }); ``` +discord.js has a manager per kind of channel (`GuildTextThreadManager`, `GuildForumThreadManager`); here +`threads` is one `ChannelThreadManager`, and `create` takes either the options of a thread (`type`, +`invitable`) or those of a forum or media post (`message`, `appliedTags`), told apart by `message`. Mixing +them is a compile error, and throws a `GatewayTypeError` before any request: `ThreadOptionsPostOnly` +(`appliedTags` without `message`, or `message` in a text or announcement channel), +`ThreadOptionsThreadOnly` (`type` or `invitable` with `message`), and `ThreadPostMessageRequired` (a +forum or media channel without `message`). `autoArchiveDuration` and `rateLimitPerUser` apply to both. Unlike +discord.js, a thread started from a message is `message.startThread()`, not an option, and an explicit `type` +is not overridden in an announcement channel. + ### Voice states and presences `client.voiceStates` and `client.presences` read the voice states and presences the gateway sends @@ -1200,6 +1210,14 @@ discord.js) also get `bulkDelete`, which takes messages, their IDs, a `Collectio resolves to a `Collection` of the deleted messages by ID: the cached `Message`, else a partial one with `Partials.Message`, else `undefined`. Narrow a `Message | PartialMessage` with `partial`. +`channel.messages` is one `ChannelMessageManager` for every kind of channel, where discord.js has +`GuildMessageManager`, `DMMessageManager`, and `PartialGroupDMMessageManager`. Its guild-only methods +(`crosspost`, `bulkDelete`, `startThread`) throw `ChannelMessagesGuildOnly` before any request in a direct +message or group direct message; the rest (`fetch`, `send`, `edit`, `delete`, `pin`, `react`, ...) is the +same everywhere. `crosspost` takes a message ID, not a `MessageResolvable`, like the other methods of the +manager. discord.js's `client.channels.createMessage(channel, options)` is `client.messages.send(channelId, +options)` (or `channel.send(options)`) here. + `Message` is generic like discord.js's: `Message` has a `guildId` string and guild-text-based `channel`s, and `inGuild()` narrows to it. `reply`, `edit`, and the other actions resolve to messages whose `channel` is never a group DM (`OmitPartialGroupDMChannel`), `fetch(force)` answers from the cache when `force` is `false`, `forward` takes a diff --git a/packages/plugin-gateway/src/errors/Messages.ts b/packages/plugin-gateway/src/errors/Messages.ts index 166acab6..e7ebc865 100644 --- a/packages/plugin-gateway/src/errors/Messages.ts +++ b/packages/plugin-gateway/src/errors/Messages.ts @@ -60,6 +60,11 @@ export const GatewayErrorMessages = { GuildResolve: "Cannot resolve the value to a guild ID", GuildRoleUnknown: (guildId: string, roleId: string) => `Role ${roleId} is not a role of guild ${guildId}`, + ThreadPostMessageRequired: "A forum or media post needs a first message: pass message", + ThreadOptionsPostOnly: (options: string) => `Only a forum or media post takes ${options}`, + ThreadOptionsThreadOnly: (options: string) => `Only a thread without a message takes ${options}`, + ChannelMessagesGuildOnly: (channelId: string, action: string) => + `${action} is only available in guild channels, and channel ${channelId} is a direct message`, ThreadParentUnknown: (threadId: string) => `Thread ${threadId} has no known parent`, ThreadOwnerUnknown: (threadId: string) => `Thread ${threadId} has no known owner`, ThreadMemberIdsMissing: (action: string) => diff --git a/packages/plugin-gateway/src/managers/ChannelMessageManager.ts b/packages/plugin-gateway/src/managers/ChannelMessageManager.ts index d3731429..a579a3a9 100644 --- a/packages/plugin-gateway/src/managers/ChannelMessageManager.ts +++ b/packages/plugin-gateway/src/managers/ChannelMessageManager.ts @@ -1,4 +1,5 @@ import type { Collection } from "@discordjs/collection"; +import { ChannelType } from "discord-api-types/v10"; import type { GatewayClient } from "../GatewayClient.js"; import type { Message, PartialMessage } from "../structures/messages/Message.js"; import type { MessageResolvable } from "../types.js"; @@ -16,15 +17,28 @@ import type { MessageEditOptions, MessagePayloadResolvable, } from "../util/messages.js"; +import { GatewayError } from "../errors/GatewayError.js"; import { scopeCache, type ScopedCache } from "../util/cache.js"; import { BaseManager } from "./BaseManager.js"; /** * Manages the messages of one channel: `client.messages`, with the channel's ID filled in. + * + * @remarks + * One class serves every channel, where discord.js has a `MessageManager` subclass per kind (`GuildMessageManager`, + * `DMMessageManager`, `PartialGroupDMMessageManager`). The methods only a guild has (`crosspost`, `bulkDelete`, + * `startThread`) throw `ChannelMessagesGuildOnly` before any request when the manager belongs to a direct message or a + * group direct message, which the channel's own `messages` tells. A manager built by hand without the channel's type + * sends the request, and Discord refuses it. */ export class ChannelMessageManager extends BaseManager { public readonly channelId: string; + /** + * The type of the channel, when known. + */ + public readonly channelType: ChannelType | null; + /** * The cached messages of the channel, by message ID. * @@ -35,9 +49,14 @@ export class ChannelMessageManager extends BaseManager { */ public readonly cache: ScopedCache; - public constructor(client: GatewayClient, channelId: string) { + public constructor( + client: GatewayClient, + channelId: string, + channelType: ChannelType | null = null, + ) { super(client); this.channelId = channelId; + this.channelType = channelType; this.cache = scopeCache(client.messages.cache, (messageId) => client.messages.resolveKey(channelId, messageId), ); @@ -96,14 +115,28 @@ export class ChannelMessageManager extends BaseManager { return this.client.messages.unpin(this.channelId, messageId, reason); } - public crosspost(messageId: string): Promise { + /** + * Publishes a message of an announcement channel to the channels following it, like discord.js's + * `GuildMessageManager#crosspost`. + * + * @param messageId The ID of the message. + * @throws {@link GatewayError} `ChannelMessagesGuildOnly` in a direct message. + */ + public async crosspost(messageId: string): Promise { + this.assertGuild("crosspost"); return this.client.messages.crosspost(this.channelId, messageId); } - public bulkDelete( + /** + * Deletes up to 100 messages at once. + * + * @throws {@link GatewayError} `ChannelMessagesGuildOnly` in a direct message, as the channel has no `bulkDelete`. + */ + public async bulkDelete( messages: Collection | readonly MessageResolvable[] | number, filterOld = false, ): Promise> { + this.assertGuild("bulkDelete"); return this.client.messages.bulkDelete(this.channelId, messages, filterOld); } @@ -123,10 +156,16 @@ export class ChannelMessageManager extends BaseManager { return this.client.messages.removeAllReactions(this.channelId, messageId); } - public startThread( + /** + * Starts a thread from a message. + * + * @throws {@link GatewayError} `ChannelMessagesGuildOnly` in a direct message. + */ + public async startThread( messageId: string, options: MessageThreadCreateOptions, ): Promise { + this.assertGuild("startThread"); return this.client.messages.startThread(this.channelId, messageId, options); } @@ -141,4 +180,10 @@ export class ChannelMessageManager extends BaseManager { ): Promise { return this.client.messages.fetchPollAnswerVoters(this.channelId, messageId, answerId, options); } + + private assertGuild(action: string): void { + if (this.channelType === ChannelType.DM || this.channelType === ChannelType.GroupDM) { + throw new GatewayError("ChannelMessagesGuildOnly", this.channelId, action); + } + } } diff --git a/packages/plugin-gateway/src/managers/ChannelThreadManager.ts b/packages/plugin-gateway/src/managers/ChannelThreadManager.ts index ee2f3c77..2e76950b 100644 --- a/packages/plugin-gateway/src/managers/ChannelThreadManager.ts +++ b/packages/plugin-gateway/src/managers/ChannelThreadManager.ts @@ -1,3 +1,4 @@ +import { ChannelType } from "discord-api-types/v10"; import type { GatewayClient } from "../GatewayClient.js"; import type { AnyThreadChannel, @@ -5,7 +6,7 @@ import type { FetchedThreads, ThreadCreateOptions, } from "./ThreadManager.js"; -import { GatewayError } from "../errors/GatewayError.js"; +import { GatewayError, GatewayTypeError } from "../errors/GatewayError.js"; import { BaseManager } from "./BaseManager.js"; /** @@ -15,18 +16,44 @@ export class ChannelThreadManager extends BaseManager { public readonly channelId: string; public readonly guildId: string | null; - public constructor(client: GatewayClient, channelId: string, guildId: string | null) { + /** + * The type of the channel, when known: it tells a forum or media channel, whose threads are posts created with a + * `message`, from a text or announcement channel. + */ + public readonly channelType: ChannelType | null; + + public constructor( + client: GatewayClient, + channelId: string, + guildId: string | null, + channelType: ChannelType | null = null, + ) { super(client); this.channelId = channelId; this.guildId = guildId; + this.channelType = channelType; } /** - * Creates a thread in the channel, or a post in a forum or media channel (with `message`). + * Creates a thread in the channel, or a post in a forum or media channel (with `message`). discord.js splits this in + * `GuildTextThreadManager` and `GuildForumThreadManager`; here the manager is shared and the options are checked + * against the type of the channel. * * @param options The thread's name and settings. + * @throws {@link GatewayTypeError} `ThreadPostMessageRequired` in a forum or media channel without `message`, + * `ThreadOptionsThreadOnly` there with `type` or `invitable`, and `ThreadOptionsPostOnly` with `message` or + * `appliedTags` in any other channel. */ - public create(options: ThreadCreateOptions): Promise { + public async create(options: ThreadCreateOptions): Promise { + const { channelType: type } = this; + if (type === ChannelType.GuildForum || type === ChannelType.GuildMedia) { + if (options.message === undefined) { + throw new GatewayTypeError("ThreadPostMessageRequired"); + } + } else if (type !== null && options.message !== undefined) { + throw new GatewayTypeError("ThreadOptionsPostOnly", "message"); + } + return this.client.threads.create(this.channelId, options); } diff --git a/packages/plugin-gateway/src/managers/ThreadManager.ts b/packages/plugin-gateway/src/managers/ThreadManager.ts index a6d3931d..0b356166 100644 --- a/packages/plugin-gateway/src/managers/ThreadManager.ts +++ b/packages/plugin-gateway/src/managers/ThreadManager.ts @@ -11,6 +11,7 @@ import { type ThreadAutoArchiveDuration, } from "discord-api-types/v10"; import type { GatewayClient } from "../GatewayClient.js"; +import { GatewayTypeError } from "../errors/GatewayError.js"; import type { AnnouncementThreadChannel } from "../structures/channels/AnnouncementThreadChannel.js"; import type { PrivateThreadChannel } from "../structures/channels/PrivateThreadChannel.js"; import type { PublicThreadChannel } from "../structures/channels/PublicThreadChannel.js"; @@ -33,15 +34,25 @@ export type AnyThreadChannel = | PublicThreadChannel; /** - * The options to create a thread with. + * The options both kinds of threads are created with. */ -export interface ThreadCreateOptions { +export interface ThreadCreateBaseOptions { name: string; autoArchiveDuration?: ThreadAutoArchiveDuration; + /** + * The slowmode of the thread, in seconds. Both threads and forum or media posts take it. + */ rateLimitPerUser?: number; + reason?: string; +} + +/** + * The options to create a thread of a text or announcement channel with, like discord.js's + * `GuildTextThreadCreateOptions`. To start a thread from a message, use `message.startThread()`. + */ +export interface TextThreadCreateOptions extends ThreadCreateBaseOptions { /** - * The type of a thread without a message: public (the default; an announcement thread in announcement channels) or - * private. + * The type of the thread: public (the default; an announcement thread in announcement channels) or private. */ type?: ChannelType.PublicThread | ChannelType.PrivateThread | ChannelType.AnnouncementThread; /** @@ -49,16 +60,44 @@ export interface ThreadCreateOptions { */ invitable?: boolean; /** - * The first message of a forum or media post. + * A thread has no first message: use {@link ForumThreadCreateOptions} to create a forum or media post. */ - message?: MessagePayloadResolvable; + message?: undefined; /** - * The tags of a forum or media post. + * Only forum and media posts have tags. + */ + appliedTags?: undefined; +} + +/** + * The options to create a post of a forum or media channel with, like discord.js's `GuildForumThreadCreateOptions`. + */ +export interface ForumThreadCreateOptions extends ThreadCreateBaseOptions { + /** + * The first message of the post. + */ + message: MessagePayloadResolvable; + /** + * The tags of the post. */ appliedTags?: readonly string[]; - reason?: string; + /** + * A post is always a public thread. + */ + type?: undefined; + /** + * A post is always a public thread. + */ + invitable?: undefined; } +/** + * The options to create a thread with: those of a thread in a text or announcement channel, or with `message`, those + * of a post in a forum or media channel. Mixing the two is a compile error, and throws {@link GatewayTypeError} + * `ThreadOptionsPostOnly` or `ThreadOptionsThreadOnly` at runtime. + */ +export type ThreadCreateOptions = TextThreadCreateOptions | ForumThreadCreateOptions; + /** * The options to fetch archived threads with. */ @@ -122,8 +161,24 @@ export class ThreadManager extends CachedManager<"threads", AnyThreadChannel, [t * * @param channelId The ID of the parent channel. * @param options The thread's name and settings, and the first message of a post. + * @throws {@link GatewayTypeError} `ThreadOptionsPostOnly` when `appliedTags` is set without `message`, and + * `ThreadOptionsThreadOnly` when `type` or `invitable` is set with it. */ public async create(channelId: string, options: ThreadCreateOptions): Promise { + // The types forbid these mixes, but a cast or untyped code still reaches Discord, which ignores the options. + if (options.message === undefined) { + if (options.appliedTags !== undefined) { + throw new GatewayTypeError("ThreadOptionsPostOnly", "appliedTags"); + } + } else { + const inapplicable = (["type", "invitable"] as const).filter( + (option) => options[option] !== undefined, + ); + if (inapplicable.length > 0) { + throw new GatewayTypeError("ThreadOptionsThreadOnly", inapplicable.join(", ")); + } + } + const common = { name: options.name, auto_archive_duration: options.autoArchiveDuration, diff --git a/packages/plugin-gateway/src/structures/channels/mixins/ChannelThreadsMixin.ts b/packages/plugin-gateway/src/structures/channels/mixins/ChannelThreadsMixin.ts index a62e3a28..42cf89f7 100644 --- a/packages/plugin-gateway/src/structures/channels/mixins/ChannelThreadsMixin.ts +++ b/packages/plugin-gateway/src/structures/channels/mixins/ChannelThreadsMixin.ts @@ -17,6 +17,11 @@ export class ChannelThreadsMixin { * The threads of the channel. */ public get threads(): ChannelThreadManager { - return new ChannelThreadManager(this.client, this.id, (this[kData] as Data).guild_id ?? null); + return new ChannelThreadManager( + this.client, + this.id, + (this[kData] as Data).guild_id ?? null, + this.type, + ); } } diff --git a/packages/plugin-gateway/src/structures/channels/mixins/TextChannelMixin.ts b/packages/plugin-gateway/src/structures/channels/mixins/TextChannelMixin.ts index f2dd7a08..f5e6d540 100644 --- a/packages/plugin-gateway/src/structures/channels/mixins/TextChannelMixin.ts +++ b/packages/plugin-gateway/src/structures/channels/mixins/TextChannelMixin.ts @@ -58,7 +58,7 @@ export class TextChannelMixin { * The messages of the channel. */ public get messages(): ChannelMessageManager { - return new ChannelMessageManager(this.client, this.id); + return new ChannelMessageManager(this.client, this.id, this.type); } /** diff --git a/packages/plugin-gateway/tests/dm-messages.test.ts b/packages/plugin-gateway/tests/dm-messages.test.ts new file mode 100644 index 00000000..a423b59a --- /dev/null +++ b/packages/plugin-gateway/tests/dm-messages.test.ts @@ -0,0 +1,236 @@ +import { container } from "@wolfstar/http-framework"; +import { createInMemoryCache } from "@wolfstar/plugin-cache"; +import { + ChannelType, + MessageType, + Routes, + type APIMessage, + type APIUser, +} from "discord-api-types/v10"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ChannelMessageManager, + createChannel, + GatewayClient, + type DMChannel, + type GatewayClientOptions, + type GroupDMChannel, + type TextChannel, +} from "../src/index.js"; +import { cacheModes, type CacheMode } from "./fixtures/cacheModes.js"; + +const dmId = "300000000000000030"; +const groupDmId = "300000000000000031"; +const guildId = "100000000000000010"; +const guildChannelId = "200000000000000020"; +const messageId = "1200000000000000000"; +const wolf: APIUser = { + id: "600000000000000600", + username: "wolf", + discriminator: "0", + global_name: "Wolf", + avatar: null, +}; + +function createClient(options: Partial = { cache: createInMemoryCache() }) { + return new GatewayClient({ + discordPublicKey: "0".repeat(64), + discordToken: "test-token", + clientId: "266624760782258186", + intents: 0, + ...options, + }); +} + +// A message of a direct message: no `guild_id`, and no `member` on its author. +function dmMessage(extra: Partial = {}): APIMessage { + return { + id: messageId, + channel_id: dmId, + author: wolf, + content: "hello", + timestamp: "2026-01-01T00:00:00.000Z", + edited_timestamp: null, + tts: false, + mention_everyone: false, + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + pinned: false, + type: MessageType.Default, + ...extra, + }; +} + +function dmChannel() { + return createChannel({ + id: dmId, + type: ChannelType.DM, + recipients: [wolf], + } as never) as DMChannel; +} + +function groupDmChannel() { + return createChannel({ + id: groupDmId, + type: ChannelType.GroupDM, + recipients: [wolf], + } as never) as GroupDMChannel; +} + +function guildChannel(type = ChannelType.GuildAnnouncement) { + return createChannel({ + id: guildChannelId, + type, + name: "news", + guild_id: guildId, + } as never) as TextChannel; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe.each(Object.keys(cacheModes) as CacheMode[])( + "messages of a direct message with a %s cache", + (mode) => { + test("GIVEN a direct message THEN fetch, send, edit, delete and pin go through its channel", async () => { + createClient(cacheModes[mode]()); + const channel = dmChannel(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(dmMessage()); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(dmMessage({ id: "2" })); + const patch = vi + .spyOn(container.rest, "patch") + .mockResolvedValue(dmMessage({ content: "edited" })); + const del = vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + const put = vi.spyOn(container.rest, "put").mockResolvedValue(undefined); + + const fetched = await channel.messages.fetch(messageId); + const sent = await channel.messages.send("hi"); + const edited = await channel.messages.edit(messageId, "edited"); + await channel.messages.pin(messageId); + await channel.messages.unpin(messageId); + await channel.messages.delete(messageId); + + expect(get).toHaveBeenCalledWith(Routes.channelMessage(dmId, messageId), expect.anything()); + expect(post).toHaveBeenCalledWith(Routes.channelMessages(dmId), { + body: { content: "hi" }, + files: undefined, + }); + expect(patch).toHaveBeenCalledWith(Routes.channelMessage(dmId, messageId), { + body: { content: "edited" }, + files: undefined, + }); + expect(put).toHaveBeenCalledWith(Routes.channelPin(dmId, messageId), expect.anything()); + expect(del).toHaveBeenCalledWith(Routes.channelPin(dmId, messageId), expect.anything()); + expect(del).toHaveBeenCalledWith(Routes.channelMessage(dmId, messageId), expect.anything()); + // Out of a guild: no guild, no member, and the author is the user. + for (const message of [fetched, sent, edited]) { + expect(message.guildId).toBeNull(); + expect(message.inGuild()).toBe(false); + expect(message.author.id).toBe(wolf.id); + } + expect(edited.content).toBe("edited"); + }); + + test("GIVEN a cached direct message THEN pinning patches it and deleting drops it", async () => { + const client = createClient(cacheModes[mode]()); + const channel = dmChannel(); + vi.spyOn(container.rest, "put").mockResolvedValue(undefined); + vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + await client.messages._add(dmMessage()); + + await channel.messages.pin(messageId); + await channel.messages.delete(messageId); + + expect(await channel.messages.cache.get(messageId)).toBeUndefined(); + }); + }, +); + +describe("guild-only paths of the messages of a direct message", () => { + test.each([ + ["a direct message", dmChannel, dmId], + ["a group direct message", groupDmChannel, groupDmId], + ])( + "GIVEN %s THEN crosspost, bulkDelete and startThread throw before any request", + async (_, build, id) => { + createClient(); + const { messages } = build(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(dmMessage()); + const del = vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + const get = vi.spyOn(container.rest, "get").mockResolvedValue([]); + + const errors = await Promise.all([ + messages.crosspost(messageId).catch((e) => e), + messages.bulkDelete([messageId, "2"]).catch((e) => e), + messages.bulkDelete(5).catch((e) => e), + messages.startThread(messageId, { name: "hunt" }).catch((e) => e), + ]); + + const actions = ["crosspost", "bulkDelete", "bulkDelete", "startThread"]; + for (const [index, action] of actions.entries()) { + expect(errors[index]).toMatchObject({ + name: "GatewayError [ChannelMessagesGuildOnly]", + code: "ChannelMessagesGuildOnly", + message: `${action} is only available in guild channels, and channel ${id} is a direct message`, + }); + } + expect(post).not.toHaveBeenCalled(); + expect(del).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + }, + ); + + test("GIVEN a message of a direct message THEN crosspostable and threadable are false", async () => { + const client = createClient(); + const message = await client.messages._build(dmMessage()); + + expect(message.crosspostable).toBe(false); + expect(message.threadable).toBe(false); + }); + + test("GIVEN an announcement channel THEN crosspost publishes the message", async () => { + createClient(); + const post = vi + .spyOn(container.rest, "post") + .mockResolvedValue(dmMessage({ channel_id: guildChannelId, guild_id: guildId })); + + const message = await guildChannel().messages.crosspost(messageId); + + expect(post).toHaveBeenCalledWith( + Routes.channelMessageCrosspost(guildChannelId, messageId), + expect.anything(), + ); + expect(message.guildId).toBe(guildId); + }); + + test("GIVEN a guild channel THEN bulkDelete and startThread are not refused", async () => { + createClient(); + vi.spyOn(container.rest, "post").mockResolvedValue({}); + vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + const { messages } = guildChannel(ChannelType.GuildText); + + await expect(messages.bulkDelete([messageId])).resolves.toBeDefined(); + await expect(messages.startThread(messageId, { name: "hunt" })).resolves.toBeDefined(); + }); + + test("GIVEN a manager built without the channel type THEN the request is left to Discord", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(dmMessage()); + + await new ChannelMessageManager(client, dmId).crosspost(messageId); + + expect(post).toHaveBeenCalledTimes(1); + }); + + test("GIVEN the client-level manager THEN it does not guard by channel", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(dmMessage()); + + await client.messages.crosspost(dmId, messageId); + + expect(post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugin-gateway/tests/threads.test.ts b/packages/plugin-gateway/tests/threads.test.ts index adde192d..b49e28e7 100644 --- a/packages/plugin-gateway/tests/threads.test.ts +++ b/packages/plugin-gateway/tests/threads.test.ts @@ -11,12 +11,14 @@ import { } from "discord-api-types/v10"; import { afterEach, describe, expect, test, vi } from "vitest"; import { + ChannelThreadManager, GatewayClient, ThreadMember, type ForumChannel, type GatewayClientOptions, type GatewayEventMap, type GatewayEventName, + type MediaChannel, type PublicThreadChannel, type TextChannel, } from "../src/index.js"; @@ -161,6 +163,103 @@ describe("thread creation", () => { }); }); +describe("thread creation options", () => { + test("GIVEN a forum or media channel without a message THEN it throws before any request", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(thread()); + + for (const type of [ChannelType.GuildForum, ChannelType.GuildMedia]) { + const forum = (await cacheParent(client, type)) as ForumChannel | MediaChannel; + const error = await forum.threads.create({ name: "hunt" } as never).catch((e) => e); + + expect(error).toBeInstanceOf(TypeError); + expect(error.code).toBe("ThreadPostMessageRequired"); + } + expect(post).not.toHaveBeenCalled(); + }); + + test("GIVEN a text or announcement channel with a message THEN it throws before any request", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(thread()); + + for (const type of [ChannelType.GuildText, ChannelType.GuildAnnouncement]) { + const channel = (await cacheParent(client, type)) as TextChannel; + const error = await channel.threads + .create({ name: "hunt", message: "Awoo" } as never) + .catch((e) => e); + + expect(error).toBeInstanceOf(TypeError); + expect(error.code).toBe("ThreadOptionsPostOnly"); + expect(error.message).toBe("Only a forum or media post takes message"); + } + expect(post).not.toHaveBeenCalled(); + }); + + test("GIVEN appliedTags without a message THEN it throws instead of dropping the tags", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(thread()); + const channel = (await cacheParent(client)) as TextChannel; + + const viaChannel = await channel.threads + .create({ name: "hunt", appliedTags: ["5"] } as never) + .catch((e) => e); + const viaClient = await client.threads + .create(channelId, { name: "hunt", appliedTags: ["5"] } as never) + .catch((e) => e); + + expect(viaChannel.code).toBe("ThreadOptionsPostOnly"); + expect(viaChannel.message).toContain("appliedTags"); + expect(viaClient.code).toBe("ThreadOptionsPostOnly"); + expect(post).not.toHaveBeenCalled(); + }); + + test("GIVEN a message with type or invitable THEN it throws instead of dropping them", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(thread()); + const forum = (await cacheParent(client, ChannelType.GuildForum)) as ForumChannel; + + const type = await forum.threads + .create({ name: "hunt", message: "Awoo", type: ChannelType.PrivateThread } as never) + .catch((e) => e); + const both = await client.threads + .create(channelId, { name: "hunt", message: "Awoo", type: 11, invitable: true } as never) + .catch((e) => e); + + expect(type).toBeInstanceOf(TypeError); + expect(type.code).toBe("ThreadOptionsThreadOnly"); + expect(type.message).toContain("takes type"); + expect(both.code).toBe("ThreadOptionsThreadOnly"); + expect(both.message).toContain("type, invitable"); + expect(post).not.toHaveBeenCalled(); + }); + + test("GIVEN rateLimitPerUser THEN both a thread and a post take it, like discord.js", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(thread()); + const text = (await cacheParent(client)) as TextChannel; + await text.threads.create({ name: "hunt", rateLimitPerUser: 30 }); + const forum = (await cacheParent(client, ChannelType.GuildForum)) as ForumChannel; + await forum.threads.create({ name: "hunt", message: "Awoo", rateLimitPerUser: 60 }); + + const limits = post.mock.calls.map( + ([, options]) => + (options as { body: { rate_limit_per_user: number } }).body.rate_limit_per_user, + ); + expect(limits).toEqual([30, 60]); + }); + + test("GIVEN a manager built without the channel type THEN only the shape of the options is checked", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(thread()); + const manager = new ChannelThreadManager(client, channelId, guildId); + + await manager.create({ name: "hunt", type: ChannelType.PublicThread }); + await manager.create({ name: "hunt", message: "Awoo" }); + + expect(post).toHaveBeenCalledTimes(2); + }); +}); + describe("thread defaults", () => { test("GIVEN no type THEN a public thread is created, an announcement one in announcement channels", async () => { const client = createClient(); diff --git a/packages/plugin-gateway/tests/types/thread-and-message-managers.ts b/packages/plugin-gateway/tests/types/thread-and-message-managers.ts new file mode 100644 index 00000000..4d856ef9 --- /dev/null +++ b/packages/plugin-gateway/tests/types/thread-and-message-managers.ts @@ -0,0 +1,83 @@ +// Type-level test, checked by the root `typecheck` script through `tsconfig.consumption.json`: the options of +// `threads.create` follow discord.js' `GuildTextThreadCreateOptions` and `GuildForumThreadCreateOptions`, and the +// signatures of `crosspost` and of sending a message follow `GuildMessageManager` and `ChannelManager#createMessage`. +import type { ChannelType, ThreadAutoArchiveDuration } from "discord-api-types/v10"; +import type { + AnyThreadChannel, + ChannelMessageManager, + DMChannel, + ForumChannel, + GatewayClient, + Message, + MessagePayload, + TextChannel, + ThreadCreateOptions, +} from "../../src/index.js"; + +declare const text: TextChannel; +declare const forum: ForumChannel; +declare const client: GatewayClient; +declare const payload: MessagePayload; +declare const archive: ThreadAutoArchiveDuration; +declare const privateThread: ChannelType.PrivateThread; + +// A thread takes `type` and `invitable`, a forum or media post takes `message` and `appliedTags`. +export const thread: Promise = text.threads.create({ + name: "hunt", + type: privateThread, + invitable: false, + autoArchiveDuration: archive, + rateLimitPerUser: 30, + reason: "why", +}); +export const post: Promise = forum.threads.create({ + name: "Pack news", + message: "Awoo", + appliedTags: ["1"], + autoArchiveDuration: archive, + rateLimitPerUser: 30, +}); +export const postWithPayload: Promise = client.threads.create("1", { + name: "Pack news", + message: payload, +}); + +// The options of one kind are a compile error with the other. +// @ts-expect-error a thread without a message has no tags +void text.threads.create({ name: "hunt", appliedTags: ["1"] }); +// @ts-expect-error a post is always a public thread +void forum.threads.create({ name: "hunt", message: "Awoo", type: privateThread }); +// @ts-expect-error a post is always a public thread +void forum.threads.create({ name: "hunt", message: "Awoo", invitable: true }); + +// `message` tells the two apart. +declare const options: ThreadCreateOptions; +if (options.message === undefined) { + const noTags: undefined = options.appliedTags; + void noTags; +} else { + // @ts-expect-error a post has no thread type + const type: ChannelType = options.type; + void type; +} + +// `crosspost` takes the message's ID and resolves to the published message, like `GuildMessageManager#crosspost` +// (which takes a `MessageResolvable`). +declare const messages: ChannelMessageManager; +export const published: Promise = messages.crosspost("1"); +export const publishedByClient: Promise = client.messages.crosspost("1", "2"); +// @ts-expect-error the ID is a string +void messages.crosspost(1); + +// `client.messages.send` stands in for `ChannelManager#createMessage`: a string, options, or a payload. +export const sent: Promise = client.messages.send("1", "hi"); +export const sentOptions: Promise = client.messages.send("1", { content: "hi" }); +export const sentPayload: Promise = client.messages.send("1", payload); +export const sentByChannel: Promise = text.send({ content: "hi" }); +export const sentByManager: Promise = text.messages.send("hi"); +// @ts-expect-error a number is not a message +void client.messages.send("1", 1); + +// A direct message has the same manager, and `crosspost` throws there at runtime. +declare const dm: DMChannel; +export const dmMessages: ChannelMessageManager = dm.messages;