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-util-followups.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions packages/plugin-gateway/src/managers/MessageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageMentionsRelations> {
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] : []),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, User>;
members?: ReadonlyMap<string, GuildMember>;
Expand Down Expand Up @@ -125,8 +126,13 @@ export class MessageMentions<InGuild extends boolean = boolean> {
}

/**
* 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<string, User> {
return pick(this.parsedUserIds, this.#relations.users);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 44 additions & 7 deletions packages/plugin-gateway/src/util/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -484,6 +491,17 @@ export interface ResolvableData {
*/
roles?: Iterable<Snowflake | Omit<CacheEntityTypes["roles"], "guild_id">>;
channels?: Iterable<Snowflake | CacheEntityTypes["channels"]>;
/**
* 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<Snowflake | CacheEntityTypes["messages"]>;
/**
* The raw attachments, e.g. an interaction's `resolved.attachments`. Nothing caches attachments, so they are always
* built.
*/
attachments?: Iterable<APIAttachment>;
}

/**
Expand All @@ -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;
}

/**
Expand All @@ -505,22 +527,24 @@ export interface TransformedResolvedData {
members?: Map<Snowflake, GuildMember>;
roles?: Map<Snowflake, Role>;
channels?: Map<Snowflake, AnyChannel>;
messages?: Map<Snowflake, Message>;
attachments?: Map<Snowflake, Attachment>;
}

/**
* 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<TransformedResolvedData> {
return whenAll(
[
Expand Down Expand Up @@ -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;
},
);
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-gateway/tests/message-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/plugin-gateway/tests/relations-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
76 changes: 74 additions & 2 deletions packages/plugin-gateway/tests/util.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -251,6 +252,77 @@
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();

Expand Down Expand Up @@ -316,7 +388,7 @@
describe("synchronous cache reads", () => {
const user = { id: "600000000000000600", username: "wolf", discriminator: "0", avatar: null };

function createClient(cache?: ReturnType<typeof createAsyncCache>) {

Check warning on line 391 in packages/plugin-gateway/tests/util.test.ts

View workflow job for this annotation

GitHub Actions / Autofix code

unicorn(consistent-function-scoping)

packages/plugin-gateway/tests/util.test.ts:391:12: Function `createClient` does not capture any variables from its parent scope
return new GatewayClient({
discordPublicKey: "0".repeat(64),
discordToken: "test-token",
Expand Down
Loading