fix(discord): preserve text after oversized uploads (#99577)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: lin-hongkuan <lin-hongkuan@users.noreply.github.com>
This commit is contained in:
lin-hongkuan
2026-07-06 09:25:05 +01:00
committed by GitHub
co-authored by Peter Steinberger lin-hongkuan
parent 9b0c3abbca
commit 9607c3a2bf
3 changed files with 118 additions and 4 deletions
+1
View File
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **iOS Apple Watch pairing:** react immediately to Watch pairing and companion-install changes, and wait for asynchronous WatchConnectivity activation before sending, preventing stale unavailable state and cold-launch delivery races.
- **Discord oversized attachments:** preserve reply text and report the skipped file when Discord rejects an upload as too large. (#99577, #99021) Thanks @lin-hongkuan.
- **Small-context compaction:** cap the effective reserve against the known model context window so small local models do not enter compaction from the first token. (#100621) Thanks @vincentkoc.
- **Plugin install diagnostics:** suppress the misleading hook-pack fallback after plugin install failures only when the hook manifest is absent, while preserving actionable malformed hook-pack errors. (#100554) Thanks @vincentkoc.
- **Config validation diagnostics:** emit each unchanged sanitized validation-warning payload once per config path, reset deduplication after a clean validation, and preserve the warning fingerprint across transient invalid reads and failed refreshes. (#100569, #25574) Thanks @vincentkoc.
@@ -1,6 +1,7 @@
// Discord tests cover send.sends basic channel messages plugin behavior.
import { ChannelType, MessageFlags, PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { Container, TextDisplay } from "./internal/discord.js";
import { discordWebMediaMockFactory, makeDiscordRest } from "./send.test-harness.js";
vi.mock("openclaw/plugin-sdk/web-media", () => discordWebMediaMockFactory());
@@ -594,6 +595,77 @@ describe("sendMessageDiscord", () => {
});
});
it("preserves text when Discord rejects an upload with error 40005", async () => {
const { rest, postMock } = makeDiscordRest();
postMock
.mockRejectedValueOnce(
Object.assign(new Error("Bad Request"), {
status: 400,
rawError: { code: 40005 },
}),
)
.mockResolvedValueOnce({ id: "fallback-msg", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "Here is the report", {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
mediaUrl: "file:///tmp/report.pdf",
replyTo: "orig-123",
components: [new Container([new TextDisplay("Attachment controls")])],
embeds: [{ title: "Attachment preview" }],
});
expect(res.messageId).toBe("fallback-msg");
expect(postMock).toHaveBeenCalledTimes(2);
expectBodyFileName(requireRestBody(postMock, 0), "photo.jpg");
const fallbackBody = requireRestBody(postMock, 1);
expect(fallbackBody.content).toBe(
"Here is the report\n\n[Attachment skipped: Discord rejected the file as too large.]",
);
expect(fallbackBody).not.toHaveProperty("files");
expect(fallbackBody).not.toHaveProperty("components");
expect(fallbackBody).not.toHaveProperty("embeds");
expectReplyReference(fallbackBody, "orig-123");
});
it("reports a media-only upload rejected with HTTP 413", async () => {
const { rest, postMock } = makeDiscordRest();
postMock
.mockRejectedValueOnce(Object.assign(new Error("Bad Request"), { status: 413 }))
.mockResolvedValueOnce({ id: "fallback-msg", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "", {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
mediaUrl: "file:///tmp/photo.jpg",
});
expect(res.messageId).toBe("fallback-msg");
expect(requireRestBody(postMock, 1).content).toBe(
"Attachment skipped: Discord rejected the file as too large.",
);
expect(requireRestBody(postMock, 1)).not.toHaveProperty("files");
});
it("does not mask unrelated media upload failures", async () => {
const { rest, postMock } = makeDiscordRest();
const error = Object.assign(new Error("Internal Server Error"), { status: 500 });
postMock.mockRejectedValue(error);
await expect(
sendMessageDiscord("channel:789", "report", {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
mediaUrl: "file:///tmp/report.pdf",
retry: { attempts: 1 },
}),
).rejects.toBe(error);
expect(postMock).toHaveBeenCalledTimes(1);
});
it("passes mediaAccess workspaceDir when loading relative media attachments", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
+45 -4
View File
@@ -35,6 +35,10 @@ const DISCORD_POLL_MAX_ANSWERS = 10;
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
const DISCORD_MISSING_PERMISSIONS = 50013;
const DISCORD_CANNOT_DM = 50007;
const DISCORD_UPLOAD_TOO_LARGE = 40005;
const DISCORD_UPLOAD_TOO_LARGE_STATUS = 413;
const DISCORD_UPLOAD_TOO_LARGE_NOTICE =
"Attachment skipped: Discord rejected the file as too large.";
type DiscordRequest = RetryRunner;
@@ -155,6 +159,19 @@ function getDiscordErrorStatus(err: unknown) {
return undefined;
}
function isDiscordUploadTooLargeError(err: unknown) {
return (
getDiscordErrorCode(err) === DISCORD_UPLOAD_TOO_LARGE ||
getDiscordErrorStatus(err) === DISCORD_UPLOAD_TOO_LARGE_STATUS
);
}
function buildDiscordUploadTooLargeFallbackText(text: string) {
return text.trim()
? `${text}\n\n[${DISCORD_UPLOAD_TOO_LARGE_NOTICE}]`
: DISCORD_UPLOAD_TOO_LARGE_NOTICE;
}
async function buildDiscordSendError(
err: unknown,
ctx: {
@@ -420,10 +437,34 @@ async function sendDiscordMedia(
},
],
});
const res = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"media",
)) as { id: string; channel_id: string };
let res: { id: string; channel_id: string };
try {
res = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"media",
)) as { id: string; channel_id: string };
} catch (err) {
if (!isDiscordUploadTooLargeError(err)) {
throw err;
}
// The multipart request is all-or-nothing. Retry the portable text only;
// attachment-coupled embeds/components may be invalid or misleading without it.
return sendDiscordText(
rest,
channelId,
buildDiscordUploadTooLargeFallbackText(text),
replyTo,
request,
maxLinesPerMessage,
undefined,
undefined,
chunkMode,
silent,
suppressEmbeds,
maxChars,
onResult,
);
}
await onResult?.(res, "media");
const platformMessageIds = res.id ? [res.id] : [];
for (const chunk of chunks.slice(1)) {