fix(discord): include component text in reply context

This commit is contained in:
Peter Steinberger
2026-05-02 02:56:11 +01:00
parent a08f6ebdda
commit 62b20e7fa2
4 changed files with 136 additions and 2 deletions

View File

@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- Discord/DMs: set inbound direct-message `ctx.To` to the semantic `user:<id>` 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.

View File

@@ -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;

View File

@@ -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 || "";
}

View File

@@ -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<Record<string, unknown>>;
embeds?: Array<{ title?: string; description?: string }>;
attachments?: Array<Record<string, unknown>>;
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", () => {