From 5d7ad9feb8baad88df3a9c85a5bceda62a3cd831 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sat, 17 Oct 2026 11:53:13 +0200 Subject: [PATCH] feat(plugin-gateway): resolve messages and attachments, and uncached mentioned users transformResolved resolves messages (IDs from the cache of the new channelId option, raw data to the cached message or a built one) and attachments (built as Attachment), like discord.js. MessageMentions#parsedUsers includes the users the payload mentions even when uncached, without writing the cache. Document why MessagePayload keeps its URL-aware file name helper. --- .changeset/gateway-util-followups.md | 5 ++ .../src/managers/MessageManager.ts | 6 +- .../structures/messages/MessageMentions.ts | 12 ++- .../src/structures/messages/MessagePayload.ts | 3 + packages/plugin-gateway/src/util/Util.ts | 51 +++++++++++-- .../tests/message-payload.test.ts | 3 +- .../tests/relations-messages.test.ts | 16 ++++ packages/plugin-gateway/tests/util.test.ts | 76 ++++++++++++++++++- 8 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 .changeset/gateway-util-followups.md diff --git a/.changeset/gateway-util-followups.md b/.changeset/gateway-util-followups.md new file mode 100644 index 00000000..f9b3bfe1 --- /dev/null +++ b/.changeset/gateway-util-followups.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +`transformResolved` now also resolves `messages` (IDs read from the cache of the new `channelId` option, raw data resolved to the cached message or built from it) and `attachments` (built as `Attachment`), like discord.js. `MessageMentions#parsedUsers` now includes the users the payload mentions even when they are not cached, as discord.js does by adding them to its cache; nothing is written to the cache. diff --git a/packages/plugin-gateway/src/managers/MessageManager.ts b/packages/plugin-gateway/src/managers/MessageManager.ts index 8aaa5889..13a1fcbd 100644 --- a/packages/plugin-gateway/src/managers/MessageManager.ts +++ b/packages/plugin-gateway/src/managers/MessageManager.ts @@ -148,11 +148,13 @@ export class MessageManager extends CachedManager< ); } - // The cached copies of the users, members, roles, and channels a message mentions, like discord.js's mentions. + // The users, members, roles, and channels a message mentions, like discord.js's mentions: the cached copies, and for + // the mentioned users the payload's own when they are not cached, as discord.js adds them to its cache, so that + // `parsedUsers` finds them. Nothing is written to the cache. private resolveMentions(data: CacheEntityTypes["messages"]): Awaitable { const { guild_id: guildId, content = "" } = data; const users = [ - ...data.mentions.map((user) => user.id), + ...data.mentions.map(({ member: _member, ...user }) => user), ...MessageMentions.parseIds(content, MessageMentions.UsersPattern), ...(data.referenced_message ? [data.referenced_message.author.id] : []), ]; diff --git a/packages/plugin-gateway/src/structures/messages/MessageMentions.ts b/packages/plugin-gateway/src/structures/messages/MessageMentions.ts index 63dadecc..596eb993 100644 --- a/packages/plugin-gateway/src/structures/messages/MessageMentions.ts +++ b/packages/plugin-gateway/src/structures/messages/MessageMentions.ts @@ -13,7 +13,8 @@ import { transformAPIChannelMention, type CrosspostedChannel } from "../../util/ */ export interface MessageMentionsRelations { /** - * The mentioned users and the ones parsed from the content, and the author of the replied message. + * The mentioned users and the ones parsed from the content, and the author of the replied message. The users the + * payload mentions are there even when they are not cached. */ users?: ReadonlyMap; members?: ReadonlyMap; @@ -125,8 +126,13 @@ export class MessageMentions { } /** - * The cached users the content mentions, by ID, like discord.js's `MessageMentions#parsedUsers`, which also - * counts the users the message does not ping. + * The users the content mentions, by ID, like discord.js's `MessageMentions#parsedUsers`, which also counts the + * users the message does not ping. + * + * @remarks + * Like discord.js, it reads the cache: a user the content mentions without the payload listing it is skipped when + * it is not cached. The users the payload does list are included even when uncached, as discord.js adds them to its + * cache first; here nothing is written to it. */ public get parsedUsers(): Map { return pick(this.parsedUserIds, this.#relations.users); diff --git a/packages/plugin-gateway/src/structures/messages/MessagePayload.ts b/packages/plugin-gateway/src/structures/messages/MessagePayload.ts index c2f74b32..296d248f 100644 --- a/packages/plugin-gateway/src/structures/messages/MessagePayload.ts +++ b/packages/plugin-gateway/src/structures/messages/MessagePayload.ts @@ -684,6 +684,9 @@ function isBufferResolvable(file: AttachmentResolvable): file is BufferResolvabl ); } +// The name of a file given as a path or URL. Unlike `findName`, which follows discord.js and takes the last segment of +// the whole string, a URL is read as one: the query string (even one holding a `/`) and the fragment are dropped, and a +// URL without a file name falls back to `file.jpg` instead of naming the file after its host. function nameOf(path: string): string { if (/^https?:\/\//.test(path)) { const name = basename(new URL(path).pathname); diff --git a/packages/plugin-gateway/src/util/Util.ts b/packages/plugin-gateway/src/util/Util.ts index 4863e0c9..6c8b2ed8 100644 --- a/packages/plugin-gateway/src/util/Util.ts +++ b/packages/plugin-gateway/src/util/Util.ts @@ -6,13 +6,20 @@ import { parse } from "node:path"; import type { Awaitable, CacheEntityTypes } from "@wolfstar/plugin-cache"; -import { ChannelType, FormattingPatterns, type Snowflake } from "discord-api-types/v10"; +import { + ChannelType, + FormattingPatterns, + type APIAttachment, + type Snowflake, +} from "discord-api-types/v10"; import type { GatewayClient } from "../GatewayClient.js"; import type { AnyChannel } from "../managers/ChannelManager.js"; import type { GuildEmoji } from "../structures/emojis/GuildEmoji.js"; import type { EmojiIdentifierResolvable } from "../structures/emojis/ReactionEmoji.js"; import type { GuildMember } from "../structures/guilds/GuildMember.js"; import type { Role } from "../structures/guilds/Role.js"; +import { Attachment } from "../structures/messages/Attachment.js"; +import type { Message } from "../structures/messages/Message.js"; import type { User } from "../structures/users/User.js"; import type { ColorResolvable, SKUResolvable } from "../types.js"; import { whenAll, whenCachedMap } from "./cache.js"; @@ -484,6 +491,17 @@ export interface ResolvableData { */ roles?: Iterable>; channels?: Iterable; + /** + * The messages, e.g. an interaction's `resolved.messages`: IDs are read from the cache of + * {@link SupportingResolvedData.channelId}, raw data (which carries its own `channel_id`) is resolved to the cached + * message or built from the data. + */ + messages?: Iterable; + /** + * The raw attachments, e.g. an interaction's `resolved.attachments`. Nothing caches attachments, so they are always + * built. + */ + attachments?: Iterable; } /** @@ -495,6 +513,10 @@ export interface SupportingResolvedData { * The guild of the members and roles: without it, they are not resolved. */ guildId?: Snowflake | null; + /** + * The channel the message IDs belong to: without it, only the messages given as raw data are resolved. + */ + channelId?: Snowflake | null; } /** @@ -505,22 +527,24 @@ export interface TransformedResolvedData { members?: Map; roles?: Map; channels?: Map; + messages?: Map; + attachments?: Map; } /** - * Resolves the users, members, roles, and channels some data refers to into structures, like discord.js's - * `transformResolved`. `client.messages` builds the relations of a message's `MessageMentions` with it. + * Resolves the users, members, roles, channels, messages, and attachments some data refers to into structures, like + * discord.js's `transformResolved`. `client.messages` builds the relations of a message's `MessageMentions` with it. * * @remarks * Nothing is written to the cache. Synchronous when every cache it reads is. * - * @param supportingData The client, and the guild of the members and roles. + * @param supportingData The client, the guild of the members and roles, and the channel of the message IDs. * @param data The IDs or raw data to resolve. * @returns A map for each kind of data given, keyed by ID, without duplicates nor the IDs that are not cached. */ export function transformResolved( - { client, guildId }: SupportingResolvedData, - { users, members, roles, channels }: ResolvableData, + { client, guildId, channelId }: SupportingResolvedData, + { users, members, roles, channels, messages, attachments }: ResolvableData, ): Awaitable { return whenAll( [ @@ -551,13 +575,26 @@ export function transformResolved( (id) => client.channels.cache.get(id), (raw) => client.channels._resolveData(raw), ), + messages && + resolveEach( + messages, + (id) => + channelId ? client.messages.cache.get(client.messages.resolveKey(channelId, id)) : null, + (raw) => client.messages._resolveData(raw), + ), ], - ([resolvedUsers, resolvedMembers, resolvedRoles, resolvedChannels]) => { + ([resolvedUsers, resolvedMembers, resolvedRoles, resolvedChannels, resolvedMessages]) => { const result: TransformedResolvedData = {}; if (resolvedUsers) result.users = resolvedUsers; if (resolvedMembers) result.members = resolvedMembers; if (resolvedRoles) result.roles = resolvedRoles; if (resolvedChannels) result.channels = resolvedChannels; + if (resolvedMessages) result.messages = resolvedMessages; + if (attachments) { + result.attachments = new Map( + [...attachments].map((attachment) => [attachment.id, new Attachment(attachment)]), + ); + } return result; }, ); diff --git a/packages/plugin-gateway/tests/message-payload.test.ts b/packages/plugin-gateway/tests/message-payload.test.ts index 1e16aa25..5e6d23cd 100644 --- a/packages/plugin-gateway/tests/message-payload.test.ts +++ b/packages/plugin-gateway/tests/message-payload.test.ts @@ -212,10 +212,11 @@ describe("Webhook relations", () => { "https://cdn.example/wolf.png?size=64", "https://cdn.example/wolf.gif#frag", "https://cdn.example/", + "https://cdn.example/wolf.webp?next=/a/b", ].map(async (url) => (await MessagePayload.resolveFile(url)).name), ); - expect(names).toEqual(["wolf.png", "wolf.gif", "file.jpg"]); + expect(names).toEqual(["wolf.png", "wolf.gif", "file.jpg", "wolf.webp"]); }); test("GIVEN a fetched webhook THEN its guild and channel come from the cache", async () => { diff --git a/packages/plugin-gateway/tests/relations-messages.test.ts b/packages/plugin-gateway/tests/relations-messages.test.ts index a29f3bf8..ed74b90c 100644 --- a/packages/plugin-gateway/tests/relations-messages.test.ts +++ b/packages/plugin-gateway/tests/relations-messages.test.ts @@ -166,6 +166,22 @@ describe("message mentions", () => { ); }); + test("GIVEN uncached mentioned users THEN parsedUsers has the payload's, without caching them", async () => { + const client = createClient(); + const unlisted = "700000000000000701"; + const built = await client.messages._build( + message({ + content: `<@${mentioned.id}> <@${unlisted}>`, + mentions: [mentioned], + }), + ); + + expect([...built.mentions.parsedUsers.keys()]).toEqual([mentioned.id]); + expect(built.mentions.parsedUsers.get(mentioned.id)?.username).toBe("stale"); + expect(built.cleanContent).toBe(`@stale <@${unlisted}>`); + expect(await client.cache!.users.get(mentioned.id)).toBeUndefined(); + }); + test("GIVEN a content patch THEN the resolved mentions are dropped", async () => { const client = createClient(); await seed(client); diff --git a/packages/plugin-gateway/tests/util.test.ts b/packages/plugin-gateway/tests/util.test.ts index 3a995b06..6630e7d0 100644 --- a/packages/plugin-gateway/tests/util.test.ts +++ b/packages/plugin-gateway/tests/util.test.ts @@ -1,7 +1,8 @@ -import { createInMemoryCache, memberKey, roleKey } from "@wolfstar/plugin-cache"; -import { ApplicationFlags, ChannelType } from "discord-api-types/v10"; +import { createInMemoryCache, memberKey, messageKey, roleKey } from "@wolfstar/plugin-cache"; +import { ApplicationFlags, ChannelType, MessageType } from "discord-api-types/v10"; import { describe, expect, test } from "vitest"; import { GatewayClient } from "../src/GatewayClient.js"; +import { Attachment } from "../src/structures/messages/Attachment.js"; import { ApplicationFlagsBitField } from "../src/util/flags.js"; import { Colors } from "../src/util/Colors.js"; import { @@ -251,6 +252,77 @@ describe("transformResolved", () => { expect(await client.cache!.users.get(other.id)).toBeUndefined(); }); + describe("messages and attachments", () => { + const channelId = "200000000000000020"; + const rawMessage = { + id: "1200000000000000000", + channel_id: channelId, + author: user, + content: "raw", + 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, + }; + const rawAttachment = { + id: "1300000000000000000", + filename: "wolf.png", + size: 4, + url: "https://cdn.example/wolf.png", + proxy_url: "https://media.example/wolf.png", + }; + + test("GIVEN raw messages THEN the cached one wins and the others are built, without writing the cache", async () => { + const client = await createClient(); + await client.cache!.messages.set(messageKey(channelId, rawMessage.id), { + ...rawMessage, + content: "cached", + } as never); + const other = { ...rawMessage, id: "1200000000000000001" }; + + const resolved = await transformResolved( + { client, guildId }, + { messages: [rawMessage as never, other as never] }, + ); + + expect(resolved.messages!.get(rawMessage.id)?.content).toBe("cached"); + expect(resolved.messages!.get(other.id)?.content).toBe("raw"); + expect(resolved.messages!.get(other.id)?.channelId).toBe(channelId); + expect(await client.cache!.messages.get(messageKey(channelId, other.id))).toBeUndefined(); + }); + + test("GIVEN message IDs THEN they are read from the cache of the channel", async () => { + const client = await createClient(); + await client.cache!.messages.set(messageKey(channelId, rawMessage.id), rawMessage as never); + + const withChannel = await transformResolved( + { client, channelId }, + { messages: [rawMessage.id, "1200000000000000002"] }, + ); + const withoutChannel = await transformResolved({ client }, { messages: [rawMessage.id] }); + + expect([...withChannel.messages!.keys()]).toEqual([rawMessage.id]); + expect(withoutChannel.messages!.size).toBe(0); + }); + + test("GIVEN raw attachments THEN they are built by ID", async () => { + const client = await createClient(); + + const resolved = await transformResolved({ client }, { attachments: [rawAttachment] }); + + const attachment = resolved.attachments!.get(rawAttachment.id); + expect(attachment).toBeInstanceOf(Attachment); + expect(attachment?.name).toBe("wolf.png"); + expect((await transformResolved({ client }, {})).attachments).toBeUndefined(); + }); + }); + test("GIVEN no guild THEN members and roles are not resolved", async () => { const client = await createClient();