Skip to content

fix(baileys): always emit MESSAGES_UPSERT for media even when S3 upload is skipped or fails - #2684

Open
pastoriniMatheus wants to merge 1 commit into
evolution-foundation:developfrom
pastoriniMatheus:fix/baileys-media-messages-upsert-webhook
Open

fix(baileys): always emit MESSAGES_UPSERT for media even when S3 upload is skipped or fails#2684
pastoriniMatheus wants to merge 1 commit into
evolution-foundation:developfrom
pastoriniMatheus:fix/baileys-media-messages-upsert-webhook

Conversation

@pastoriniMatheus

@pastoriniMatheus pastoriniMatheus commented Aug 12, 2026

Copy link
Copy Markdown

Problem

When a media message (audio, image, video, document) is sent from the phone linked to the instance (fromMe, source = 'android'/'ios'), the MESSAGES_UPSERT webhook is never emitted when S3_ENABLED=true. The message is persisted correctly and MESSAGES_UPDATE (status) is emitted normally, but the event carrying the actual content never reaches the webhook consumer. Text messages from the same phone work; media sent through the API works. The failure is specific to media originated on another device.

For any webhook consumer (CRM, chatbot, archiving), this is silent, permanent message loss — no error, no warning, no retry. The consumer only sees a status update referencing a message it never received.

Root cause

src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts, handler messages.upsert: the S3 upload block has two return statements that exit the entire handler, and sendDataWebhook(Events.MESSAGES_UPSERT, ...) sits after them:

if (isVideo && !S3.SAVE_VIDEO) {
  this.logger.warn('Video upload is disabled. Skipping video upload.');
  // Skip video upload by returning early from this block
  return;                       // (1) exits the whole handler
}
...
const media = await this.getBase64FromMediaMessage({ message }, true);
if (!media) {
  this.logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO');
  return;                       // (2) exits the whole handler
}
...
this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw);  // never reached via (1)/(2)

getBase64FromMediaMessage() commonly fails for media sent from another device (the media key belongs to that device) → path (2) fires → the message is dropped from the webhook stream. The inline comment ("Skip video upload by returning early from this block") shows the intent was to skip only the upload. Notably, another method in the same file already handles the equivalent case with throw, confirming the returns are an oversight rather than intended behavior.

Fix

Restructure the block so sendDataWebhook is always reached — skip only the upload, never the handler (without using throw for control flow). When the upload succeeds the behavior is unchanged; when it is skipped or fails, the message is still delivered via the webhook. Webhook delivery must not depend on the outcome of the storage step.

Impact (measured — 48h, production, 4 instances)

Direction Type Delivered Lost Loss rate
fromMe media 18 273 94%
fromMe text 227 2 1%

Roughly 136 media messages lost per day on a single deployment. Because the failure is silent, it can go unnoticed for a long time.

Verification

  • The same fix (patched bundle, identical logic) was validated on the affected production deployment: fromMe media from the phone went from 0/21 to 4/4 (android) and 2/2 (web) delivered, files intact in storage; the messages.upsert fromMe=true audioMessage/imageMessage events that never fired started firing.
  • Locally reproduced the repo's Check Code Quality CI (Node 20 · npm ci · eslint src · npm run build = tsc --noEmit && tsup) — passes.

Suggested regression test

The repo has no test suite today (npm test points at a non-existent ./test/all.test.ts, and the quality CI runs lint + build only), so no test is added here. When a test harness exists, this is the case to lock in:

With S3.ENABLE = true and getBase64FromMediaMessage() returning null, the messages.upsert handler must still call sendDataWebhook(Events.MESSAGES_UPSERT, ...). Second case: isVideo === true with SAVE_VIDEO === false must also still emit the event.

Steps to reproduce

  1. Connect an instance via Baileys with S3_ENABLED=true and a webhook subscribing to MESSAGES_UPSERT.
  2. From the phone linked to the instance, send an audio or image to any contact.
  3. The message is saved (Message, source = 'android'), only MESSAGES_UPDATE reaches the webhook, and no MESSAGES_UPSERT is emitted for it. A text message from the same phone emits MESSAGES_UPSERT normally.

Summary by Sourcery

Ensure WhatsApp Baileys media messages always trigger MESSAGES_UPSERT webhooks regardless of S3 upload success or skip conditions.

Bug Fixes:

  • Prevent silent loss of fromMe media messages when S3 upload is disabled or getBase64FromMediaMessage returns no media by keeping the messages.upsert handler running and emitting MESSAGES_UPSERT.

Enhancements:

  • Refine the S3 media upload flow in the WhatsApp Baileys integration to treat upload failures or skips as non-fatal, updating message records and webhooks only when upload succeeds.

…ad is skipped or fails

In the messages.upsert handler, the S3 upload block returned early from the whole
handler in two cases (video upload disabled; getBase64FromMediaMessage returns
null), aborting before sendDataWebhook(Events.MESSAGES_UPSERT). The message is
persisted but the webhook carrying its content is never emitted.

This silently drops media messages whose upload is skipped or fails — notably
fromMe media sent from another device, where getBase64FromMediaMessage cannot
fetch the file (its mediaKey belongs to that device). Measured at ~94% media loss
for fromMe messages on a production deployment (S3 enabled).

Restructure the block so it skips only the upload, never the handler, so the
webhook is always delivered regardless of the storage outcome. The inline comment
("returning early from this block") shows the original intent was to skip the
upload only; another method in the same file already uses throw for the
equivalent case.
@sourcery-ai

sourcery-ai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Ensures the WhatsApp Baileys messages.upsert handler always emits MESSAGES_UPSERT for media messages, even when S3 upload is disabled, skipped, or fails, by restructuring the S3 upload block to avoid early returns that previously aborted the handler.

Sequence diagram for updated Baileys messages.upsert media handling

sequenceDiagram
  participant BaileysStartupService
  participant S3Service as s3Service
  participant PrismaMedia as prismaRepository_media
  participant PrismaMessage as prismaRepository_message
  participant Webhook as sendDataWebhook

  BaileysStartupService->>BaileysStartupService: messages.upsert(received)
  alt isMedia && S3.ENABLE
    alt isVideo && !S3.SAVE_VIDEO
      BaileysStartupService->>BaileysStartupService: logger.warn('Video upload is disabled. Skipping video upload.')
      note over BaileysStartupService: Skip upload only, continue handler
    else nonVideo or S3.SAVE_VIDEO
      BaileysStartupService->>BaileysStartupService: hasValidMediaContent(message)
      alt !hasRealMedia
        BaileysStartupService->>BaileysStartupService: logger.warn('Message detected as media but contains no valid media content')
      else hasRealMedia
        BaileysStartupService->>BaileysStartupService: getBase64FromMediaMessage(message, true)
        alt media is null
          BaileysStartupService->>BaileysStartupService: logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO')
          note over BaileysStartupService: No upload, continue handler
        else media available
          BaileysStartupService->>S3Service: uploadFile(fullName, buffer, size, headers)
          BaileysStartupService->>PrismaMedia: media.create(data)
          BaileysStartupService->>S3Service: getObjectUrl(fullName)
          BaileysStartupService->>PrismaMessage: message.update({ id: msg.id }, messageRaw)
        end
      end
    end
  else !isMedia or !S3.ENABLE
    BaileysStartupService->>BaileysStartupService: proceed without S3 upload
  end

  BaileysStartupService->>Webhook: sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw)
Loading

File-Level Changes

Change Details Files
Restructure S3 media upload handling so webhook emission is decoupled from storage success and early returns are eliminated.
  • Remove early return paths that exited the messages.upsert handler when video upload was disabled or when media base64 extraction failed.
  • Wrap the media processing and S3 upload logic in an if/else that only skips the upload but keeps the handler running.
  • Add conditional branches to handle cases with no valid media content, failed base64 extraction, and successful uploads, updating the message record only when an upload succeeds.
  • Retain and reuse the existing media upload, metadata creation, and prisma message update flow, now guarded behind successful media retrieval.
src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've found 1 issue, and left some high level feedback:

  • The S3 upload block is now quite deeply nested; consider extracting the media upload and persistence logic into a separate helper to simplify the messages.upsert handler’s control flow and improve readability.
  • You are calling this.configService.get('S3') multiple times in close proximity; caching the S3 config locally within the handler would reduce repetition and make it clearer which configuration values are being used.
  • The new explanatory comment above the try block is detailed but lengthy; trimming it down or moving the deeper context into a commit message would keep the code more focused while still documenting the behavior change.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The S3 upload block is now quite deeply nested; consider extracting the media upload and persistence logic into a separate helper to simplify the messages.upsert handler’s control flow and improve readability.
- You are calling this.configService.get<S3>('S3') multiple times in close proximity; caching the S3 config locally within the handler would reduce repetition and make it clearer which configuration values are being used.
- The new explanatory comment above the try block is detailed but lengthy; trimming it down or moving the deeper context into a commit message would keep the code more focused while still documenting the behavior change.

## Individual Comments

### Comment 1
<location path="src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts" line_range="1603-1604" />
<code_context>
+                      if (!media) {
+                        this.logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO');
+                      } else {
+                        const { buffer, mediaType, fileName, size } = media;
+                        const mimetype = mimeTypes.lookup(fileName).toString();
+                        const fullName = join(
+                          `${this.instance.id}`,
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against mimeTypes.lookup returning a falsy value before calling toString.

`mimeTypes.lookup(fileName)` can return `false`/`null` for unknown types, so `.toString()` may throw and break the upload flow. Please handle the falsy case (e.g. with a default like `'application/octet-stream'` or an explicit check) before converting to string.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +1603 to +1604
const { buffer, mediaType, fileName, size } = media;
const mimetype = mimeTypes.lookup(fileName).toString();

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.

issue (bug_risk): Guard against mimeTypes.lookup returning a falsy value before calling toString.

mimeTypes.lookup(fileName) can return false/null for unknown types, so .toString() may throw and break the upload flow. Please handle the falsy case (e.g. with a default like 'application/octet-stream' or an explicit check) before converting to string.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant