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;