Skip to content

fix(discord): preserve content-type and skip optimization for animated/WebP images#41265

Open
skidder wants to merge 1 commit intoopenclaw:mainfrom
skidder:skidder/fix-webp-blob-type
Open

fix(discord): preserve content-type and skip optimization for animated/WebP images#41265
skidder wants to merge 1 commit intoopenclaw:mainfrom
skidder:skidder/fix-webp-blob-type

Conversation

@skidder
Copy link
Copy Markdown

@skidder skidder commented Mar 9, 2026

Summary

  • Problem: Discord media uploads were sending files without a MIME content-type, causing some clients/bots to misidentify the file format. Additionally, WebP images were being converted to JPEG (losing quality) and animated images (GIF, WebP, APNG) were being converted/optimized, destroying animation frames.
  • Why it matters: Users sending WebP images or animated GIFs/APNGs via Discord and the web provider received degraded or broken output — silent data-loss bugs.
  • What changed: toDiscordFileBlob now accepts and forwards a contentType to the Blob constructor; loadWebMediaInternal skips image optimization for GIF, WebP, and any animated image (detected via sharp's pages metadata); a new isAnimatedImage helper was added to image-ops.ts.
  • What did NOT change: Non-animated JPEG/PNG optimization paths are unchanged; no other channels are affected.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • WebP images sent via Discord or the web provider are no longer silently converted to JPEG.
  • Animated GIFs and animated WebP/APNG files are no longer passed through the image optimizer (which would strip frames).
  • Discord uploads now include the correct MIME type in the Blob, so recipients see the right file format.
  • Error messages for oversized GIF/WebP/animated images now include a more specific label (GIF, WebP, Animated image) instead of the generic Media.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22 / Bun
  • Model/provider: Any
  • Integration/channel: Discord
  • Relevant config (redacted): N/A

Steps

  1. Send an animated GIF or a WebP image to a Discord channel via OpenClaw.
  2. Observe the uploaded file in Discord.

Expected

  • Animated GIFs play in Discord.
  • WebP images display as WebP (not JPEG).
  • File MIME type is correctly reported.

Actual (before fix)

  • WebP images were converted to JPEG.
  • Animated WebP images lost their animation frames.
  • Blob had no type, so Discord received files with an unknown MIME type.

Evidence

  • Failing test/log before + passing after — new tests added in src/discord/send.shared.test.ts, src/media/image-ops.helpers.test.ts, and src/web/media.test.ts covering all three fix areas.

Human Verification (required)

  • Verified scenarios: fill in
  • Edge cases checked: static WebP (should still skip optimization), single-frame GIF
  • What you did not verify: live Discord upload end-to-end (depends on bot credentials)

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: Revert src/web/media.ts to re-enable optimization for WebP/animated; revert src/discord/send.shared.ts to drop content-type from Blob.
  • Files/config to restore: src/web/media.ts, src/discord/send.shared.ts, src/discord/send.components.ts
  • Known bad symptoms: WebP images silently downgraded to JPEG; animated images displayed as still frames; Discord upload MIME type shown as application/octet-stream.

Risks and Mitigations

  • Risk: Large WebP or animated images that previously would have been resized/optimized will now be passed through at full size and may hit the cap limit sooner.
    • Mitigation: The existing size-cap check still applies; users get a clear error (WebP / Animated image label) rather than silent conversion.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 9, 2026

Greptile Summary

This PR fixes three related silent data-loss bugs in the Discord and web media pipeline: (1) Blob objects were constructed without a type, causing Discord to receive files with no MIME type; (2) WebP images were silently converted to JPEG; and (3) animated images (GIF, WebP, APNG) were passed through the optimizer, stripping their animation frames.

The changes are targeted and well-scoped — only the image-optimization branch and the Blob-construction helper are touched, and no other channels are affected.

Key observations:

  • toDiscordFileBlob correctly handles all four input combinations (typed Blob, typeless Blob, Uint8Array with/without a content-type).
  • isAnimatedImage follows the established hasAlphaChannel pattern and uses the correct sharp pages metadata field.
  • The optimization-skip logic in loadWebMediaInternal is sound; the isAnimated check is correctly short-circuited for GIF/WebP to avoid redundant sharp decodes.
  • Test coverage for toDiscordFileBlob is thorough. However, isAnimatedImage's true path — detecting an actual multi-frame image — is not exercised anywhere in the test suite (all test cases, including claimed integration tests, assert false). This means the core animation-detection behavior could silently regress.

Confidence Score: 4/5

  • Safe to merge — the fixes address real silent data-loss bugs with no regressions to existing non-animated JPEG/PNG paths.
  • The implementation is clean and consistent with existing patterns. The primary gap is that isAnimatedImage returning true is never tested in the unit suite (and the claimed integration test coverage is not present), so the animation-detection path could silently regress. No security, data-access, or breaking-change concerns. The changes preserve backward compatibility and fix well-documented data-loss issues.
  • src/media/image-ops.helpers.test.ts — add test coverage for isAnimatedImage returning true with an actual multi-frame image.

Last reviewed commit: 181720d

Comment thread src/media/image-ops.helpers.test.ts Outdated
Comment on lines +25 to +77
describe("isAnimatedImage", () => {
it("returns false for static GIF (single frame)", async () => {
// Note: Creating true multi-frame animated GIFs with sharp programmatically
// is complex. This test verifies single-frame images return false.
// Animated image detection is also tested via integration in web/media.test.ts
const staticGif = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#ff0000" },
})
.gif()
.toBuffer();

expect(await isAnimatedImage(staticGif)).toBe(false);
});

it("returns false for static JPEG", async () => {
const jpeg = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#ff0000" },
})
.jpeg()
.toBuffer();

expect(await isAnimatedImage(jpeg)).toBe(false);
});

it("returns false for static PNG", async () => {
const png = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#ff0000" },
})
.png()
.toBuffer();

expect(await isAnimatedImage(png)).toBe(false);
});

it("returns false for static WebP", async () => {
const webp = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#ff0000" },
})
.webp()
.toBuffer();

expect(await isAnimatedImage(webp)).toBe(false);
});

it("returns false for invalid/corrupt data", async () => {
const garbage = Buffer.from("not an image at all");
expect(await isAnimatedImage(garbage)).toBe(false);
});

it("returns false for empty buffer", async () => {
expect(await isAnimatedImage(Buffer.alloc(0))).toBe(false);
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite for isAnimatedImage does not exercise the true path — every test case asserts false. While the comment notes that animated detection is "also tested via integration in web/media.test.ts", examining that integration suite shows it only tests static GIFs and static WebPs, not actual multi-frame images.

The core contract of isAnimatedImage — detecting and returning true for a buffer with multiple frames — is untested. If the pages > 1 comparison is accidentally inverted or the metadata key changes, all tests still pass.

Consider adding a programmatic test for a multi-frame image, or at minimum a binary fixture (e.g., a real 2-frame GIF file) to ensure the true path is exercised.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/media/image-ops.helpers.test.ts
Line: 25-77

Comment:
The test suite for `isAnimatedImage` does not exercise the `true` path — every test case asserts `false`. While the comment notes that animated detection is "also tested via integration in web/media.test.ts", examining that integration suite shows it only tests static GIFs and static WebPs, not actual multi-frame images. 

The core contract of `isAnimatedImage` — detecting and returning `true` for a buffer with multiple frames — is untested. If the `pages > 1` comparison is accidentally inverted or the metadata key changes, all tests still pass.

Consider adding a programmatic test for a multi-frame image, or at minimum a binary fixture (e.g., a real 2-frame GIF file) to ensure the `true` path is exercised.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 181720dbbc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/media/image-ops.ts Outdated
const sharp = await loadSharp();
const meta = await sharp(buffer).metadata();
// 'pages' indicates number of frames in multi-frame formats (GIF, WebP, APNG)
return (meta.pages ?? 1) > 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict animated-image detection to animated formats

isAnimatedImage currently treats any image with meta.pages > 1 as animated, but Sharp uses pages for general multi-page formats (for example TIFF/HEIF) as well, not only animated images. Because loadWebMediaInternal now skips optimization when this helper returns true, multi-page still images can bypass the JPEG/PNG conversion path and either exceed the raw-size cap or be sent in less-compatible source formats that were previously normalized. Please gate this logic by format (or animation-specific metadata) before marking an image as animated.

Useful? React with 👍 / 👎.

@openclaw-barnacle openclaw-barnacle Bot added channel: discord Channel integration: discord channel: whatsapp-web Channel integration: whatsapp-web size: M size: S and removed size: M labels Mar 9, 2026
@skidder skidder force-pushed the skidder/fix-webp-blob-type branch from 52fa769 to f69d859 Compare March 16, 2026 17:31
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f69d859d99

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/whatsapp/src/media.ts Outdated
Comment on lines +298 to +300
const isWebp = params.contentType === "image/webp";
const shouldSkipOptimization = isGif || isWebp;
if (shouldSkipOptimization || !optimizeImages) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve WebP downscaling when media exceeds cap

loadWebMediaInternal now classifies every image/webp as shouldSkipOptimization, so oversized WebP files throw at the cap check instead of using the existing optimizeAndClampImage compression path. I traced this through extensions/whatsapp/src/send.ts and extensions/whatsapp/src/auto-reply/deliver-reply.ts, both of which call loadWebMedia for capped outbound media, so large WebP messages that previously could be recompressed and sent will now fail with an exceeds ... limit error and fall back to non-media behavior. This is a regression for WhatsApp send/auto-reply flows; skipping optimization should not bypass the reduce-to-fit path when the original WebP is over the size limit.

Useful? React with 👍 / 👎.

@skidder skidder force-pushed the skidder/fix-webp-blob-type branch from f69d859 to 97f5e67 Compare March 17, 2026 03:33
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a2bcced74

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@@ -1822,6 +1822,7 @@ async function deliverDiscordInteractionReply(params: {
mediaList.map(async (url) => {
const loaded = await loadWebMedia(url, {
localRoots: params.mediaLocalRoots,
preserveWebp: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate MIME type when preserving WebP replies

Enabling preserveWebp here allows loadWebMedia to return WebP bytes unchanged, but this interaction-reply flow still wraps buffers as new Blob([arrayBuffer]) without a type and falls back to the generic filename upload. For media URLs that do not provide a filename/extension, Discord receives an application/octet-stream attachment, so preserved WebP replies can lose image typing and show up as generic files instead of inline images.

Useful? React with 👍 / 👎.

@skidder skidder force-pushed the skidder/fix-webp-blob-type branch from 4a2bcce to b7fa90e Compare March 20, 2026 17:24
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7fa90ee32

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/discord/src/send.shared.ts Outdated
);
const media = await loadWebMedia(mediaUrl, {
...buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots }),
preserveWebp: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire Discord's preserveWebp flag into the shared loader

loadWebMedia here comes from openclaw/plugin-sdk/web-media, which just re-exports src/media/web-media.ts (src/plugin-sdk/web-media.ts:1-5). That shared loader still has no preserveWebp option and still always normalizes non-GIF images through optimizeAndClampImage (src/media/web-media.ts:26-35, src/media/web-media.ts:239-307). Because only extensions/whatsapp/src/media.ts was updated, this new flag is a no-op for Discord: regular Discord sends will still convert .webp uploads to JPEG, so the feature this commit is trying to add never actually takes effect outside the WhatsApp-local tests.

Useful? React with 👍 / 👎.

@skidder skidder force-pushed the skidder/fix-webp-blob-type branch from b7fa90e to 6ef346b Compare March 20, 2026 17:37
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de719075a1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/media/web-media.ts Outdated
Comment on lines +297 to +299
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {
const isWebp = params.contentType === "image/webp";
const skipOptimization = isGif || (isWebp && preserveWebp) || !optimizeImages;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect APNGs before optimizing animated image uploads

This branch still only bypasses optimization for image/gif and image/webp. Animated PNGs come through as image/png, so Discord message sends still fall into optimizeAndClampImage, and the optimizeImageToPng / resizeToJpeg pipelines in src/media/image-ops.ts re-encode a single static frame. In other words, APNG attachments are still silently flattened in the flow this change is trying to preserve for animated images.

Useful? React with 👍 / 👎.

Comment thread src/media/web-media.ts Outdated
Comment on lines 299 to 302
const skipOptimization = isGif || (isWebp && preserveWebp) || !optimizeImages;
if (skipOptimization) {
if (params.buffer.length > cap) {
throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass Discord's upload cap into preserved WebP sends

The new WebP-preservation path rejects against the loader's default image cap, but the Discord interaction-reply and component-send call sites now opt into preserveWebp: true without supplying maxBytes (extensions/discord/src/monitor/native-command.ts:942-945, extensions/discord/src/send.components.ts:101-104). Because maxBytesForKind("image") is only 6 MB in src/media/constants.ts, valid 6–8 MB WebPs that Discord would accept now start failing in those flows instead of uploading, whereas the regular Discord send path still uses the larger channel cap.

Useful? React with 👍 / 👎.

@skidder skidder force-pushed the skidder/fix-webp-blob-type branch from de71907 to 4f82827 Compare March 28, 2026 02:06
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f828273bc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +104 to +106
maxBytes: params.opts.mediaMaxBytes,
preserveWebp: true,
preserveAvif: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive Discord component media cap from account config

This code now preserves WebP/AVIF (preserveWebp/preserveAvif) but still relies on params.opts.mediaMaxBytes, which is usually undefined, so loadWebMedia falls back to the global 6MB image cap instead of Discord’s per-account cap (default 8MB). Fresh evidence in this commit is that mediaMaxBytes was added to DiscordComponentSendOpts but production callsites still don’t populate it (checked extensions/discord/src/outbound-adapter.ts and extensions/discord/src/actions/runtime.messaging.ts), so component sends with 6–8MB WebP/AVIF attachments can now fail with Media exceeds 6MB limit where normal Discord sends still succeed.

Useful? React with 👍 / 👎.

@skidder skidder force-pushed the skidder/fix-webp-blob-type branch from 4f82827 to c62292f Compare March 28, 2026 04:12
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c62292f20f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/media/web-media.ts Outdated
Comment on lines +226 to +229
const isPreservedFormat =
(params.contentType === "image/webp" && preserveWebp) ||
(params.contentType === "image/avif" && preserveAvif);
const skipOptimization = isGif || isPreservedFormat || !optimizeImages;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap preserved image fetches before full remote download

This new skipOptimization path now includes preserved WebP/AVIF images, but remote fetching still derives fetchCap from optimizeImages alone (up to the 100MB document cap), so Discord uploads with an 8MB channel limit can download and buffer very large preserved images before failing at the later size check. In practice, a remote 30–100MB WebP/AVIF now incurs unnecessary bandwidth, latency, and memory pressure before returning the same cap error; preserved formats should be treated as non-optimizable when computing fetch limits.

Useful? React with 👍 / 👎.

@skidder skidder force-pushed the skidder/fix-webp-blob-type branch from c62292f to d9e5436 Compare April 21, 2026 19:05
@openclaw-barnacle openclaw-barnacle Bot removed the channel: whatsapp-web Channel integration: whatsapp-web label Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Discord media uploads lose content-type and animated images lose frames

1 participant