Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gateway-message-threadable.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/plugin-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions packages/plugin-gateway/src/structures/messages/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,35 @@ export class Message<InGuild extends boolean = boolean> 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<boolean> {
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.
*
Expand Down Expand Up @@ -856,12 +885,12 @@ export class Message<InGuild extends boolean = boolean> extends BaseMessage<"">
return this.content;
}

// `hasPermission` from the cache alone.
private hasCachedPermission(permission: PermissionsString): Awaitable<boolean> {
// `hasPermission` from the cache alone, true when the bot has every one of the permissions.
private hasCachedPermission(...permissions: PermissionsString[]): Awaitable<boolean> {
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)),
);
}

Expand Down
97 changes: 97 additions & 0 deletions packages/plugin-gateway/tests/sync-getters.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createInMemoryCache, memberKey, roleKey } from "@wolfstar/plugin-cache";
import {
ChannelType,
MessageFlags,
MessageType,
OverwriteType,
PermissionFlagsBits,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<APIMessage> = {}) =>
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 () => {
Expand Down Expand Up @@ -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<APIMessage> = {}) =>
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", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-gateway/tests/types/sync-getters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const messageChecks: boolean[] = [
message.deletable,
message.pinnable,
message.crosspostable,
message.threadable,
message.bulkDeletable,
];
export const roleEditable: boolean = role.editable;
Expand Down
Loading