refactor(discord): share thread starter snapshot parsing

This commit is contained in:
Peter Steinberger
2026-04-04 00:17:30 +09:00
parent 0f129c87ba
commit 2e779a1b20
3 changed files with 341 additions and 177 deletions

View File

@@ -635,26 +635,30 @@ function resolveDiscordMentions(text: string, message: Message): string {
}
function resolveDiscordForwardedMessagesText(message: Message): string {
const snapshots = resolveDiscordMessageSnapshots(message);
if (snapshots.length === 0) {
return "";
return resolveDiscordForwardedMessagesTextFromSnapshots(resolveDiscordMessageSnapshots(message));
}
function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapshot[] {
const rawData = (message as { rawData?: { message_snapshots?: unknown } }).rawData;
return normalizeDiscordMessageSnapshots(
rawData?.message_snapshots ??
(message as { message_snapshots?: unknown }).message_snapshots ??
(message as { messageSnapshots?: unknown }).messageSnapshots,
);
}
function normalizeDiscordMessageSnapshots(snapshots: unknown): DiscordMessageSnapshot[] {
if (!Array.isArray(snapshots)) {
return [];
}
const forwardedBlocks = snapshots
.map((snapshot) => {
const snapshotMessage = snapshot.message;
if (!snapshotMessage) {
return null;
}
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
if (!text) {
return null;
}
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
const heading = authorLabel
? `[Forwarded message from ${authorLabel}]`
: "[Forwarded message]";
return `${heading}\n${text}`;
})
return snapshots.filter(
(entry): entry is DiscordMessageSnapshot => Boolean(entry) && typeof entry === "object",
);
}
export function resolveDiscordForwardedMessagesTextFromSnapshots(snapshots: unknown): string {
const forwardedBlocks = normalizeDiscordMessageSnapshots(snapshots)
.map((snapshot) => buildDiscordForwardedMessageBlock(snapshot.message))
.filter((entry): entry is string => Boolean(entry));
if (forwardedBlocks.length === 0) {
return "";
@@ -662,18 +666,19 @@ function resolveDiscordForwardedMessagesText(message: Message): string {
return forwardedBlocks.join("\n\n");
}
function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapshot[] {
const rawData = (message as { rawData?: { message_snapshots?: unknown } }).rawData;
const snapshots =
rawData?.message_snapshots ??
(message as { message_snapshots?: unknown }).message_snapshots ??
(message as { messageSnapshots?: unknown }).messageSnapshots;
if (!Array.isArray(snapshots)) {
return [];
function buildDiscordForwardedMessageBlock(
snapshotMessage: DiscordSnapshotMessage | null | undefined,
): string | null {
if (!snapshotMessage) {
return null;
}
return snapshots.filter(
(entry): entry is DiscordMessageSnapshot => Boolean(entry) && typeof entry === "object",
);
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
if (!text) {
return null;
}
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
return `${heading}\n${text}`;
}
function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string {

View File

@@ -1,24 +1,88 @@
import { ChannelType, type Client } from "@buape/carbon";
import { StickerFormatType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetDiscordThreadStarterCacheForTest,
resolveDiscordThreadStarter,
} from "./threading.js";
async function resolveStarter(
message: Partial<Awaited<ReturnType<Client["rest"]["get"]>>>,
resolveTimestampMs: () => number | undefined,
) {
const get = vi.fn().mockResolvedValue(message);
type ThreadStarterRestMessage = {
content?: string | null;
embeds?: Array<{ title?: string | null; description?: string | null }>;
message_snapshots?: Array<{
message?: {
content?: string | null;
attachments?: unknown[];
embeds?: Array<{ title?: string | null; description?: string | null }>;
sticker_items?: unknown[];
};
}>;
author?: {
id?: string | null;
username?: string | null;
discriminator?: string | null;
};
member?: {
roles?: string[];
};
timestamp?: string | null;
};
function createStarterAuthor(
overrides: Record<string, unknown> = {},
): NonNullable<ThreadStarterRestMessage["author"]> {
return {
id: "u1",
username: "Alice",
discriminator: "0",
...overrides,
} as NonNullable<ThreadStarterRestMessage["author"]>;
}
function createForwardedSnapshotMessage(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
content: "",
attachments: [],
embeds: [],
...overrides,
};
}
function createForwardedSnapshot(overrides: Record<string, unknown> = {}) {
return {
message: createForwardedSnapshotMessage(overrides),
};
}
function createStarterMessage(overrides: ThreadStarterRestMessage = {}): ThreadStarterRestMessage {
return {
content: "",
embeds: [],
author: createStarterAuthor(),
...overrides,
};
}
async function resolveStarter(params: {
message: ThreadStarterRestMessage;
parentId?: string;
parentType?: ChannelType;
resolveTimestampMs?: () => number | undefined;
}) {
const get = vi.fn().mockResolvedValue(params.message);
const client = { rest: { get } } as unknown as Client;
return resolveDiscordThreadStarter({
const result = await resolveDiscordThreadStarter({
channel: { id: "thread-1" },
client,
parentId: "parent-1",
parentType: ChannelType.GuildText,
resolveTimestampMs,
parentId: params.parentId ?? "parent-1",
parentType: params.parentType ?? ChannelType.GuildText,
resolveTimestampMs: params.resolveTimestampMs ?? (() => undefined),
});
return { get, result };
}
describe("resolveDiscordThreadStarter", () => {
@@ -27,15 +91,14 @@ describe("resolveDiscordThreadStarter", () => {
});
it("falls back to joined embed title and description when content is empty", async () => {
const result = await resolveStarter(
{
const { result } = await resolveStarter({
message: createStarterMessage({
content: " ",
embeds: [{ title: "Alert", description: "Details" }],
author: { id: "u1", username: "Alice", discriminator: "0" },
timestamp: "2026-02-24T12:00:00.000Z",
},
() => 123,
);
}),
resolveTimestampMs: () => 123,
});
expect(result).toMatchObject({
text: "Alert\nDetails",
@@ -46,14 +109,12 @@ describe("resolveDiscordThreadStarter", () => {
});
it("prefers starter content over embed fallback text", async () => {
const result = await resolveStarter(
{
const { result } = await resolveStarter({
message: createStarterMessage({
content: "starter content",
embeds: [{ title: "Alert", description: "Details" }],
author: { username: "Alice", discriminator: "0" },
},
() => undefined,
);
}),
});
if (!result) {
throw new Error("starter content should have produced a resolved starter payload");
@@ -62,16 +123,15 @@ describe("resolveDiscordThreadStarter", () => {
});
it("preserves username, tag, and role metadata for downstream visibility checks", async () => {
const result = await resolveStarter(
{
const { result } = await resolveStarter({
message: createStarterMessage({
content: "starter content",
author: { id: "u1", username: "Alice", discriminator: "1234" },
author: createStarterAuthor({ discriminator: "1234" }),
member: {
roles: ["role-1", "role-2"],
},
} as never,
() => undefined,
);
}),
});
expect(result).toMatchObject({
author: "Alice#1234",
@@ -83,24 +143,14 @@ describe("resolveDiscordThreadStarter", () => {
});
it("extracts text from forwarded message snapshots when content is empty", async () => {
const result = await resolveStarter(
{
content: "",
embeds: [],
message_snapshots: [
{
message: {
content: "forwarded task content",
attachments: [],
embeds: [],
},
},
],
author: { id: "u2", username: "Bob", discriminator: "0" },
const { result } = await resolveStarter({
message: createStarterMessage({
message_snapshots: [createForwardedSnapshot({ content: "forwarded task content" })],
author: createStarterAuthor({ id: "u2", username: "Bob" }),
timestamp: "2026-04-03T07:00:00.000Z",
} as never,
() => 456,
);
}),
resolveTimestampMs: () => 456,
});
expect(result).toBeTruthy();
expect(result!.text).toContain("forwarded task content");
@@ -109,68 +159,101 @@ describe("resolveDiscordThreadStarter", () => {
});
it("prefers content over forwarded message snapshots", async () => {
const result = await resolveStarter(
{
const { result } = await resolveStarter({
message: createStarterMessage({
content: "direct content",
message_snapshots: [
{
message: {
content: "forwarded content",
attachments: [],
embeds: [],
},
},
],
author: { id: "u3", username: "Charlie", discriminator: "0" },
} as never,
() => undefined,
);
message_snapshots: [createForwardedSnapshot({ content: "forwarded content" })],
author: createStarterAuthor({ id: "u3", username: "Charlie" }),
}),
});
expect(result).toBeTruthy();
expect(result!.text).toBe("direct content");
});
it("joins multiple forwarded message snapshots", async () => {
const result = await resolveStarter(
{
content: "",
embeds: [],
const { result } = await resolveStarter({
message: createStarterMessage({
message_snapshots: [
{
message: {
content: "first forwarded message",
attachments: [],
embeds: [],
},
},
{
message: {
content: "second forwarded message",
attachments: [],
embeds: [],
},
},
createForwardedSnapshot({ content: "first forwarded message" }),
createForwardedSnapshot({ content: "second forwarded message" }),
],
author: { id: "u5", username: "Eve", discriminator: "0" },
} as never,
() => undefined,
);
author: createStarterAuthor({ id: "u5", username: "Eve" }),
}),
});
expect(result).toBeTruthy();
expect(result!.text).toContain("first forwarded message");
expect(result!.text).toContain("second forwarded message");
});
it("returns null when content, embeds, and snapshots are all empty", async () => {
const result = await resolveStarter(
{
content: "",
embeds: [],
message_snapshots: [],
author: { id: "u4", username: "Dave", discriminator: "0" },
} as never,
() => undefined,
it("preserves forwarded attachment placeholders in thread starter context", async () => {
const { result } = await resolveStarter({
message: createStarterMessage({
message_snapshots: [
createForwardedSnapshot({
attachments: [
{
id: "a1",
filename: "forwarded.png",
content_type: "image/png",
url: "https://cdn.discordapp.com/forwarded.png",
},
],
}),
],
author: createStarterAuthor({ id: "u6", username: "Frank" }),
}),
});
expect(result).toBeTruthy();
expect(result!.text).toContain("[Forwarded message]");
expect(result!.text).toContain("<media:image> (1 image)");
});
it("preserves forwarded sticker placeholders in thread starter context", async () => {
const { result } = await resolveStarter({
message: createStarterMessage({
message_snapshots: [
createForwardedSnapshot({
sticker_items: [
{
id: "s1",
name: "party",
format_type: StickerFormatType.PNG,
},
],
}),
],
author: createStarterAuthor({ id: "u7", username: "Grace" }),
}),
});
expect(result).toBeTruthy();
expect(result!.text).toContain("[Forwarded message]");
expect(result!.text).toContain("<media:sticker> (1 sticker)");
});
it("uses the thread id as the message channel id for forum parents", async () => {
const { get, result } = await resolveStarter({
message: createStarterMessage({ content: "starter content" }),
parentId: undefined,
parentType: ChannelType.GuildForum,
});
expect(result?.text).toBe("starter content");
expect(get).toHaveBeenCalledWith(
expect.stringContaining("/channels/thread-1/messages/thread-1"),
);
});
it("returns null when content, embeds, and snapshots are all empty", async () => {
const { result } = await resolveStarter({
message: createStarterMessage({
message_snapshots: [],
author: createStarterAuthor({ id: "u4", username: "Dave" }),
}),
});
expect(result).toBeNull();
});

View File

@@ -1,5 +1,5 @@
import { ChannelType, type Client } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { Routes, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
import {
resolveChannelModelOverride,
type OpenClawConfig,
@@ -14,6 +14,7 @@ import type { DiscordMessageEvent } from "./listeners.js";
import {
resolveDiscordChannelInfo,
resolveDiscordEmbedText,
resolveDiscordForwardedMessagesTextFromSnapshots,
resolveDiscordMessageChannelId,
} from "./message-utils.js";
import { generateThreadTitle } from "./thread-title.js";
@@ -42,6 +43,39 @@ type DiscordThreadParentInfo = {
type?: ChannelType;
};
type DiscordThreadStarterRestEmbed = {
title?: string | null;
description?: string | null;
};
type DiscordThreadStarterRestSnapshotMessage = {
content?: string | null;
attachments?: APIAttachment[] | null;
embeds?: DiscordThreadStarterRestEmbed[] | null;
sticker_items?: APIStickerItem[] | null;
};
type DiscordThreadStarterRestAuthor = {
id?: string | null;
username?: string | null;
discriminator?: string | null;
};
type DiscordThreadStarterRestMember = {
nick?: string | null;
displayName?: string | null;
roles?: string[];
};
type DiscordThreadStarterRestMessage = {
content?: string | null;
embeds?: DiscordThreadStarterRestEmbed[] | null;
message_snapshots?: Array<{ message?: DiscordThreadStarterRestSnapshotMessage | null }> | null;
member?: DiscordThreadStarterRestMember | null;
author?: DiscordThreadStarterRestAuthor | null;
timestamp?: string | null;
};
// Cache entry with timestamp for TTL-based eviction
type DiscordThreadStarterCacheEntry = {
value: DiscordThreadStarter;
@@ -98,6 +132,10 @@ function isDiscordThreadType(type: ChannelType | undefined): boolean {
);
}
function isDiscordForumParentType(parentType: ChannelType | undefined): boolean {
return parentType === ChannelType.GuildForum || parentType === ChannelType.GuildMedia;
}
function resolveTrimmedDiscordMessageChannelId(params: {
message: DiscordMessageEvent["message"];
messageChannelId?: string;
@@ -186,70 +224,25 @@ export async function resolveDiscordThreadStarter(params: {
return cached;
}
try {
const parentType = params.parentType;
const isForumParent =
parentType === ChannelType.GuildForum || parentType === ChannelType.GuildMedia;
const messageChannelId = isForumParent ? params.channel.id : params.parentId;
const messageChannelId = resolveDiscordThreadStarterMessageChannelId(params);
if (!messageChannelId) {
return null;
}
const starter = (await params.client.rest.get(
Routes.channelMessage(messageChannelId, params.channel.id),
)) as {
content?: string | null;
embeds?: Array<{ title?: string | null; description?: string | null }>;
message_snapshots?: Array<{
message?: {
content?: string | null;
attachments?: unknown[];
embeds?: Array<{ title?: string | null; description?: string | null }>;
sticker_items?: unknown[];
};
}>;
member?: { nick?: string | null; displayName?: string | null };
author?: {
id?: string | null;
username?: string | null;
discriminator?: string | null;
};
timestamp?: string | null;
};
const starter = await fetchDiscordThreadStarterMessage({
client: params.client,
messageChannelId,
threadId: params.channel.id,
});
if (!starter) {
return null;
}
const content = starter.content?.trim() ?? "";
const embedText = resolveDiscordEmbedText(starter.embeds?.[0]);
const forwardedText = resolveStarterForwardedText(starter.message_snapshots);
const text = content || embedText || forwardedText;
if (!text) {
const payload = buildDiscordThreadStarterPayload({
starter,
resolveTimestampMs: params.resolveTimestampMs,
});
if (!payload) {
return null;
}
const author =
starter.member?.nick ??
starter.member?.displayName ??
(starter.author
? starter.author.discriminator && starter.author.discriminator !== "0"
? `${starter.author.username ?? "Unknown"}#${starter.author.discriminator}`
: (starter.author.username ?? starter.author.id ?? "Unknown")
: "Unknown");
const timestamp = params.resolveTimestampMs(starter.timestamp);
const payload: DiscordThreadStarter = {
text,
author,
authorId: starter.author?.id ?? undefined,
authorName: starter.author?.username ?? undefined,
authorTag:
starter.author?.username && starter.author?.discriminator
? starter.author.discriminator !== "0"
? `${starter.author.username}#${starter.author.discriminator}`
: starter.author.username
: undefined,
memberRoleIds: (() => {
const roles = (starter.member as { roles?: string[] } | undefined)?.roles;
return Array.isArray(roles) ? roles.map((roleId) => String(roleId)) : undefined;
})(),
timestamp: timestamp ?? undefined,
};
setCachedThreadStarter(cacheKey, payload, Date.now());
return payload;
} catch {
@@ -257,6 +250,89 @@ export async function resolveDiscordThreadStarter(params: {
}
}
function resolveDiscordThreadStarterMessageChannelId(params: {
channel: DiscordThreadChannel;
parentId?: string;
parentType?: ChannelType;
}): string | undefined {
return isDiscordForumParentType(params.parentType) ? params.channel.id : params.parentId;
}
async function fetchDiscordThreadStarterMessage(params: {
client: Client;
messageChannelId: string;
threadId: string;
}): Promise<DiscordThreadStarterRestMessage | null> {
const starter = await params.client.rest.get(
Routes.channelMessage(params.messageChannelId, params.threadId),
);
return starter ? (starter as DiscordThreadStarterRestMessage) : null;
}
function buildDiscordThreadStarterPayload(params: {
starter: DiscordThreadStarterRestMessage;
resolveTimestampMs: (value?: string | null) => number | undefined;
}): DiscordThreadStarter | null {
const text = resolveDiscordThreadStarterText(params.starter);
if (!text) {
return null;
}
return {
text,
...resolveDiscordThreadStarterIdentity(params.starter),
timestamp: params.resolveTimestampMs(params.starter.timestamp) ?? undefined,
};
}
function resolveDiscordThreadStarterText(starter: DiscordThreadStarterRestMessage): string {
const content = starter.content?.trim() ?? "";
const embedText = resolveDiscordEmbedText(starter.embeds?.[0]);
const forwardedText = resolveDiscordForwardedMessagesTextFromSnapshots(starter.message_snapshots);
return content || embedText || forwardedText;
}
function resolveDiscordThreadStarterIdentity(
starter: DiscordThreadStarterRestMessage,
): Omit<DiscordThreadStarter, "text" | "timestamp"> {
const author = resolveDiscordThreadStarterAuthor(starter);
return {
author,
authorId: starter.author?.id ?? undefined,
authorName: starter.author?.username ?? undefined,
authorTag: resolveDiscordThreadStarterAuthorTag(starter.author),
memberRoleIds: resolveDiscordThreadStarterRoleIds(starter.member),
};
}
function resolveDiscordThreadStarterAuthor(starter: DiscordThreadStarterRestMessage): string {
return (
starter.member?.nick ??
starter.member?.displayName ??
resolveDiscordThreadStarterAuthorTag(starter.author) ??
starter.author?.username ??
starter.author?.id ??
"Unknown"
);
}
function resolveDiscordThreadStarterAuthorTag(
author: DiscordThreadStarterRestAuthor | null | undefined,
): string | undefined {
if (!author?.username || !author.discriminator) {
return undefined;
}
if (author.discriminator !== "0") {
return `${author.username}#${author.discriminator}`;
}
return author.username;
}
function resolveDiscordThreadStarterRoleIds(
member: DiscordThreadStarterRestMember | null | undefined,
): string[] | undefined {
return Array.isArray(member?.roles) ? member.roles.map((roleId) => String(roleId)) : undefined;
}
export function resolveDiscordReplyTarget(opts: {
replyToMode: ReplyToMode;
replyToId?: string;