diff --git a/CHANGELOG.md b/CHANGELOG.md index da3ba543dd3..afe8a0d8797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Discord/DMs: set inbound direct-message `ctx.To` to the semantic `user:` target while keeping delivery routed through the DM channel, so mirror and recovery paths do not treat DMs as channel conversations. Fixes #68126. Thanks @illuminate0623. - Discord/DMs: keep no-guild inbound messages on direct-message routing when Discord channel lookup is temporarily unavailable, preventing degraded DMs from forking into channel sessions. Fixes #59817. Thanks @DooPeePey. - Discord: retry outbound API calls on HTTP 5xx, request-timeout, and transient transport failures instead of only Discord rate limits, reducing dropped cron and agent replies during short Discord or network outages. Fixes #52396. Thanks @sunshineo. +- Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive. - Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom. - Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu. diff --git a/extensions/discord/src/monitor/message-forwarded.ts b/extensions/discord/src/monitor/message-forwarded.ts index 990e60a4c27..1d4373d0ed7 100644 --- a/extensions/discord/src/monitor/message-forwarded.ts +++ b/extensions/discord/src/monitor/message-forwarded.ts @@ -12,6 +12,7 @@ export type DiscordSnapshotAuthor = { export type DiscordSnapshotMessage = { content?: string | null; + components?: unknown; embeds?: Array<{ description?: string | null; title?: string | null }> | null; attachments?: APIAttachment[] | null; stickers?: APIStickerItem[] | null; diff --git a/extensions/discord/src/monitor/message-text.ts b/extensions/discord/src/monitor/message-text.ts index 1978e3927d2..9450cad0917 100644 --- a/extensions/discord/src/monitor/message-text.ts +++ b/extensions/discord/src/monitor/message-text.ts @@ -1,3 +1,4 @@ +import { ComponentType } from "discord-api-types/v10"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { Message } from "../internal/discord.js"; import { @@ -30,6 +31,7 @@ export function resolveDiscordMessageText( (message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ?? null, ); + const componentText = extractDiscordComponentsV2Text(resolveDiscordMessageComponents(message)); const rawText = normalizeOptionalString(message.content) || buildDiscordMediaPlaceholder({ @@ -37,6 +39,7 @@ export function resolveDiscordMessageText( stickers: resolveDiscordMessageStickers(message), }) || embedText || + componentText || normalizeOptionalString(options?.fallbackText) || ""; const baseText = resolveDiscordMentions(rawText, message); @@ -87,6 +90,50 @@ function resolveDiscordForwardedMessagesText(message: Message): string { return `${heading}\n${referencedText}`; } +function resolveDiscordMessageComponents(message: Message): unknown { + const components = (message as { components?: unknown }).components; + if (components !== undefined) { + return components; + } + try { + return (message as { rawData?: { components?: unknown } }).rawData?.components; + } catch { + return undefined; + } +} + +function extractDiscordComponentsV2Text(components: unknown): string { + const parts: string[] = []; + collectDiscordTextDisplayContent(components, parts); + return parts.join("\n"); +} + +function collectDiscordTextDisplayContent(value: unknown, parts: string[]): void { + if (Array.isArray(value)) { + for (const entry of value) { + collectDiscordTextDisplayContent(entry, parts); + } + return; + } + if (!value || typeof value !== "object") { + return; + } + const component = value as { + type?: unknown; + content?: unknown; + components?: unknown; + component?: unknown; + }; + if (component.type === ComponentType.TextDisplay) { + const content = normalizeOptionalString(component.content); + if (content) { + parts.push(content); + } + } + collectDiscordTextDisplayContent(component.components, parts); + collectDiscordTextDisplayContent(component.component, parts); +} + export function resolveDiscordForwardedMessagesTextFromSnapshots(snapshots: unknown): string { const forwardedBlocks = normalizeDiscordMessageSnapshots(snapshots) .map((snapshot) => buildDiscordForwardedMessageBlock(snapshot.message)) @@ -119,5 +166,6 @@ function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): st stickers: resolveDiscordSnapshotStickers(snapshot), }); const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]); - return content || attachmentText || embedText || ""; + const componentText = extractDiscordComponentsV2Text(snapshot.components); + return content || attachmentText || embedText || componentText || ""; } diff --git a/extensions/discord/src/monitor/message-utils.test.ts b/extensions/discord/src/monitor/message-utils.test.ts index 343a57e042a..9e2f01429b1 100644 --- a/extensions/discord/src/monitor/message-utils.test.ts +++ b/extensions/discord/src/monitor/message-utils.test.ts @@ -1,4 +1,9 @@ -import { MessageReferenceType, StickerFormatType } from "discord-api-types/v10"; +import { + ComponentType, + MessageFlags, + MessageReferenceType, + StickerFormatType, +} from "discord-api-types/v10"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { ChannelType, type Client, type Message } from "../internal/discord.js"; @@ -137,6 +142,7 @@ function asForwardedSnapshotMessage(params: { function asReferencedForwardMessage(params: { content?: string; + components?: Array>; embeds?: Array<{ title?: string; description?: string }>; attachments?: Array>; messageReferenceType?: MessageReferenceType; @@ -152,8 +158,10 @@ function asReferencedForwardMessage(params: { id: "m0", channelId: "c1", content: params.content ?? "", + components: params.components ?? [], attachments: params.attachments ?? [], embeds: params.embeds ?? [], + flags: params.components ? MessageFlags.IsComponentsV2 : 0, stickers: [], author: { id: "u2", @@ -980,6 +988,46 @@ describe("resolveDiscordMessageText", () => { expect(text).toBe("Breaking"); }); + it("uses Components v2 text display content when normal message text is empty", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "", + flags: MessageFlags.IsComponentsV2, + components: [ + { + type: ComponentType.Container, + components: [ + { type: ComponentType.TextDisplay, content: "Component headline" }, + { + type: ComponentType.Section, + components: [{ type: ComponentType.TextDisplay, content: "Component body" }], + accessory: { type: ComponentType.Thumbnail, media: { url: "attachment://x.png" } }, + }, + ], + }, + ], + }), + ); + + expect(text).toBe("Component headline\nComponent body"); + }); + + it("uses Components v2 text display content from referenced reply messages", () => { + const text = resolveDiscordMessageText( + asReferencedForwardMessage({ + components: [ + { + type: ComponentType.Container, + components: [{ type: ComponentType.TextDisplay, content: "Referenced component text" }], + }, + ], + messageReferenceType: MessageReferenceType.Default, + }).referencedMessage!, + ); + + expect(text).toBe("Referenced component text"); + }); + it("uses embed description when content is empty", () => { const text = resolveDiscordMessageText( asMessage({ @@ -1025,6 +1073,42 @@ describe("resolveDiscordMessageText", () => { expect(text).toContain("[Forwarded message from @Bob]"); expect(text).toContain("Forwarded title\nForwarded details"); }); + + it("includes Components v2 text display content from forwarded snapshots", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "", + rawData: { + message_snapshots: [ + { + message: { + content: "", + embeds: [], + attachments: [], + components: [ + { + type: ComponentType.Container, + components: [ + { type: ComponentType.TextDisplay, content: "Forwarded component text" }, + ], + }, + ], + author: { + id: "u2", + username: "Bob", + discriminator: "0", + }, + }, + }, + ], + }, + }), + { includeForwarded: true }, + ); + + expect(text).toContain("[Forwarded message from @Bob]"); + expect(text).toContain("Forwarded component text"); + }); }); describe("resolveDiscordChannelInfo", () => {