From 21172b072ae093f19a6a536ec699958ba006bac2 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sat, 17 Oct 2026 11:51:18 +0200 Subject: [PATCH] feat(plugin-gateway): add Message#threadable Closes #184 --- .changeset/gateway-message-threadable.md | 5 + packages/plugin-gateway/README.md | 4 +- .../src/structures/messages/Message.ts | 35 ++++++- .../plugin-gateway/tests/sync-getters.test.ts | 97 +++++++++++++++++++ .../tests/types/sync-getters.ts | 1 + 5 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 .changeset/gateway-message-threadable.md diff --git a/.changeset/gateway-message-threadable.md b/.changeset/gateway-message-threadable.md new file mode 100644 index 00000000..47d66a19 --- /dev/null +++ b/.changeset/gateway-message-threadable.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Add `Message#threadable`, like discord.js: whether the bot can start a thread from the message (a text or announcement channel, no thread yet, and `ViewChannel` plus `CreatePublicThreads`). It reads the cache, so it is a promise with an asynchronous cache and throws `ChannelUncached`, `GuildUncachedMe` or `GuildUncached` on a miss, like the other message checks. Unlike discord.js, an administrator bot is threadable, as the API allows it. diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index a51d5597..52a79635 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -1107,8 +1107,8 @@ by their own features, there are no placeholders for them. `delete`, `forward`, `pin`, `react`, `crosspost`, `startThread`, `suppressEmbeds`. `message.guild` and `message.channel` read the cache, like every relation getter: `null` when the entity is not cached or the cache is asynchronous, in which case `fetchChannel`, `fetchGuild`, and -`fetchReference` get it. `editable`, `deletable`, `bulkDeletable`, `pinnable`, and `crosspostable` -are discord.js's getters (promises with an asynchronous cache, see above). Text channels +`fetchReference` get it. `editable`, `deletable`, `bulkDeletable`, `pinnable`, `crosspostable`, +and `threadable` are discord.js's getters (promises with an asynchronous cache, see above). Text channels get `messages`, `send`, and `sendTyping`. Guild text-based channels (not direct messages, like in discord.js) also get `bulkDelete`, which takes messages, their IDs, a `Collection`, or a count, and resolves to a `Collection` of the deleted messages by ID: the cached `Message`, else a partial one diff --git a/packages/plugin-gateway/src/structures/messages/Message.ts b/packages/plugin-gateway/src/structures/messages/Message.ts index 160e4bfb..79ac36b8 100644 --- a/packages/plugin-gateway/src/structures/messages/Message.ts +++ b/packages/plugin-gateway/src/structures/messages/Message.ts @@ -596,6 +596,35 @@ export class Message extends BaseMessage<""> ); } + /** + * Whether the bot can start a thread from the message, like discord.js's `Message#threadable`: it is in a text or + * announcement channel, has no thread yet, and the bot has `ViewChannel` and `CreatePublicThreads` there. It throws + * like {@link Message.deletable}. + * + * Unlike discord.js, which ignores the `Administrator` permission (`checkAdmin = false`), an administrator can + * start a thread, as the API allows it. This only checks the permissions and the channel type: a locked or archived + * parent, the limit of active threads, and an announcement message that has to be published first are not checked. + */ + public get threadable(): CacheRead { + if (this.hasThread || !this.inGuild()) return cacheRead(false); + + // Not the `channel` getter, which is `null` with an asynchronous cache. + const cached = + this[kRelations].channel ?? peekCache(this.client.channels.cache, this.channelId); + return cacheRead( + whenAll([cached], ([channel]) => { + if (!channel) throw new GatewayError("ChannelUncached", this.channelId); + if ( + channel.type !== ChannelType.GuildText && + channel.type !== ChannelType.GuildAnnouncement + ) { + return false; + } + return this.hasCachedPermission("ViewChannel", "CreatePublicThreads"); + }), + ); + } + /** * Whether the bot can delete the message: it is the author, or it has `ManageMessages` in the guild. * @@ -856,12 +885,12 @@ export class Message extends BaseMessage<""> return this.content; } - // `hasPermission` from the cache alone. - private hasCachedPermission(permission: PermissionsString): Awaitable { + // `hasPermission` from the cache alone, true when the bot has every one of the permissions. + private hasCachedPermission(...permissions: PermissionsString[]): Awaitable { const { guildId } = this; if (!guildId) return false; return whenAll([requireMe(this.client, guildId)], ([me]) => - whenAll([me.permissionsIn(this.channelId)], ([permissions]) => permissions.has(permission)), + whenAll([me.permissionsIn(this.channelId)], ([granted]) => granted.has(permissions)), ); } diff --git a/packages/plugin-gateway/tests/sync-getters.test.ts b/packages/plugin-gateway/tests/sync-getters.test.ts index 800d4c22..a5dee937 100644 --- a/packages/plugin-gateway/tests/sync-getters.test.ts +++ b/packages/plugin-gateway/tests/sync-getters.test.ts @@ -1,6 +1,7 @@ import { createInMemoryCache, memberKey, roleKey } from "@wolfstar/plugin-cache"; import { ChannelType, + MessageFlags, MessageType, OverwriteType, PermissionFlagsBits, @@ -28,6 +29,9 @@ const channelId = "200000000000000020"; const threadId = "200000000000000021"; const moderatedChannelId = "200000000000000022"; const newsChannelId = "200000000000000023"; +const threadsChannelId = "200000000000000024"; +const threadsNewsChannelId = "200000000000000025"; +const voiceChannelId = "200000000000000026"; const modRoleId = "700000000000000072"; const lowRoleId = "700000000000000071"; const adminRoleId = "700000000000000073"; @@ -154,6 +158,28 @@ async function seed( guild_id: guildId, permission_overwrites: moderated, } as never); + // Where the bot may start threads, including a channel of a type that cannot hold them. + const threadable = [ + { + id: modRoleId, + type: OverwriteType.Role, + allow: String(flags.CreatePublicThreads), + deny: "0", + }, + ]; + for (const [id, type, name] of [ + [threadsChannelId, ChannelType.GuildText, "threads"], + [threadsNewsChannelId, ChannelType.GuildAnnouncement, "threads-news"], + [voiceChannelId, ChannelType.GuildVoice, "voice"], + ] as const) { + await cache.channels.set(id, { + id, + type, + name, + guild_id: guildId, + permission_overwrites: threadable, + } as never); + } await cache.threads.set(threadId, { id: threadId, type: ChannelType.PublicThread, @@ -485,6 +511,53 @@ describe.each(synchronousModes)("message, role and emoji getters with %s", (_, o expect(dm.deletable).toBe(false); expect(dm.pinnable).toBe(true); expect(dm.crosspostable).toBe(false); + expect(dm.threadable).toBe(false); + }); + + test("GIVEN a channel THEN threadable follows its type, the existing thread and CreatePublicThreads", async () => { + const client = createClient(options()); + await seed(client); + const build = (channel: string, extra: Partial = {}) => + client.messages._build(message(targetId, channel, extra)); + const [text, news, voice, denied, deniedNews, started, uncached] = await Promise.all([ + build(threadsChannelId), + build(threadsNewsChannelId), + build(voiceChannelId), + build(moderatedChannelId), + build(newsChannelId), + build(threadsChannelId, { flags: MessageFlags.HasThread }), + build("200000000000000099"), + ]); + + expect([text.threadable, news.threadable]).toEqual([true, true]); + expect(voice.threadable).toBe(false); + expect([denied.threadable, deniedNews.threadable]).toEqual([false, false]); + expect(started.threadable).toBe(false); + expect(() => uncached.threadable).toThrow(expect.objectContaining({ code: "ChannelUncached" })); + }); + + test("GIVEN a thread channel THEN its messages are not threadable", async () => { + const client = createClient(options()); + await seed(client); + const inThread = await client.messages._build(message(targetId, threadId)); + + expect(inThread.threadable).toBe(false); + }); + + test("GIVEN an administrator bot THEN threadable is true, unlike discord.js's checkAdmin = false", async () => { + const client = createClient(options()); + await seed(client, { botRoles: [adminRoleId] }); + const plain = await client.messages._build(message(targetId, channelId)); + + expect(plain.threadable).toBe(true); + }); + + test("GIVEN a bot missing from the cache THEN threadable throws GuildUncachedMe", async () => { + const client = createClient(options()); + await seed(client, { withBot: false }); + const text = await client.messages._build(message(targetId, threadsChannelId)); + + expect(() => text.threadable).toThrow(expect.objectContaining({ code: "GuildUncachedMe" })); }); test("GIVEN an announcement channel THEN crosspostable follows the channel type and permissions", async () => { @@ -568,6 +641,30 @@ describe("message getters with an asynchronous cache", () => { expect(own.editable).toBe(true); expect(await own.deletable).toBe(true); }); + + test("GIVEN an asynchronous cache THEN threadable is a promise resolving like the synchronous caches", async () => { + const client = createClient({ cache: createAsyncCache() }); + await seed(client); + const build = (channel: string, extra: Partial = {}) => + client.messages._build(message(targetId, channel, extra)); + const [text, news, voice, denied, started, uncached] = await Promise.all([ + build(threadsChannelId), + build(threadsNewsChannelId), + build(voiceChannelId), + build(moderatedChannelId), + build(threadsChannelId, { flags: MessageFlags.HasThread }), + build("200000000000000099"), + ]); + + expect(text.threadable).toBeInstanceOf(Promise); + expect(await text.threadable).toBe(true); + expect(await news.threadable).toBe(true); + expect(await voice.threadable).toBe(false); + expect(await denied.threadable).toBe(false); + // Answered from the flags, without the cache. + expect(started.threadable).toBe(false); + await expect(uncached.threadable).rejects.toMatchObject({ code: "ChannelUncached" }); + }); }); describe("every derived getter with an asynchronous cache", () => { diff --git a/packages/plugin-gateway/tests/types/sync-getters.ts b/packages/plugin-gateway/tests/types/sync-getters.ts index 7b3359c4..c1e1aa3b 100644 --- a/packages/plugin-gateway/tests/types/sync-getters.ts +++ b/packages/plugin-gateway/tests/types/sync-getters.ts @@ -31,6 +31,7 @@ export const messageChecks: boolean[] = [ message.deletable, message.pinnable, message.crosspostable, + message.threadable, message.bulkDeletable, ]; export const roleEditable: boolean = role.editable;