Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.66'
version = '0.0.67'
description = 'Flextuma App'

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import com.flexcodelabs.flextuma.core.entities.auth.User;
import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage;
import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

Expand All @@ -17,6 +19,12 @@ public interface WhatsAppInboxMessageRepository extends BaseRepository<WhatsAppI
JpaSpecificationExecutor<WhatsAppInboxMessage> {
boolean existsByProviderMessageId(String providerMessageId);

/** Candidates for {@code WhatsAppMediaBackfillWorker}: messages that carry a Meta media id
* but never got a cached copy on disk, bounded to recent messages since Meta's CDN only
* keeps media retrievable for a limited window. */
List<WhatsAppInboxMessage> findByMediaIdIsNotNullAndMediaPathIsNullAndReceivedAtAfter(
LocalDateTime receivedAfter, Pageable pageable);

/** Bytes of WhatsApp media stored for one tenant: everyone in {@code organisation} when it
* is non-null, otherwise just {@code user} (the org-less-account fallback). */
@Query("SELECT COALESCE(SUM(m.mediaSize), 0) FROM WhatsAppInboxMessage m WHERE m.mediaSize IS NOT NULL AND "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

@Service @RequiredArgsConstructor
Expand Down Expand Up @@ -56,14 +57,29 @@ public record MediaContent(byte[] bytes, String mimeType) {}
throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, "Inbox messages cannot be updated manually");
}

/** Serves the cached copy of the message's media, re-downloading it from Meta on the fly if
* it was never successfully cached (e.g. the connector token was briefly unusable at
* ingestion time) or the cached file has since gone missing from disk. */
@Transactional
public MediaContent getMedia(UUID id) {
WhatsAppInboxMessage message = findAccessibleById(id);
if (message.getMediaPath() == null) {
if (message.getMediaId() == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "This message has no stored media");
}
byte[] bytes = mediaService.read(message.getMediaPath())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media file is no longer available"));
return new MediaContent(bytes, message.getMimeType());
Optional<byte[]> bytes = message.getMediaPath() != null
? mediaService.read(message.getMediaPath())
: Optional.empty();
if (bytes.isEmpty()) {
bytes = mediaService.download(message.getConfig(), message.getMediaId()).flatMap(downloaded -> {
message.setMediaPath(downloaded.path());
message.setMediaSize(downloaded.size());
repository.save(message);
return mediaService.read(downloaded.path());
});
}
return new MediaContent(
bytes.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media file is no longer available")),
message.getMimeType());
}

/** WhatsApp media storage usage. SUPER_ADMIN sees the breakdown across every tenant
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.flexcodelabs.flextuma.modules.whatsapp.services;

import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage;
import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

/**
* Retries downloading WhatsApp inbound media that failed to cache at webhook ingestion time
* (e.g. a briefly unusable connector token), so a message doesn't stay permanently unavailable
* just because nobody opened the conversation to trigger {@link WhatsAppInboxMessageService}'s
* on-read retry. Bounded to recently received messages -- Meta's CDN only keeps media
* retrievable for a limited window, so retrying older failures indefinitely would just waste
* batch slots on media that's gone for good.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WhatsAppMediaBackfillWorker {

private final WhatsAppInboxMessageRepository inboxMessageRepository;
private final WhatsAppMediaService mediaService;

@Value("${flextuma.whatsapp.media-backfill.batch-size:25}")
private int batchSize;

@Value("${flextuma.whatsapp.media-backfill.max-age-days:3}")
private int maxAgeDays;

@Scheduled(fixedDelayString = "${flextuma.whatsapp.media-backfill.interval-ms:300000}")
@Transactional
public void backfillMissingMedia() {
List<WhatsAppInboxMessage> candidates = inboxMessageRepository
.findByMediaIdIsNotNullAndMediaPathIsNullAndReceivedAtAfter(
LocalDateTime.now().minusDays(maxAgeDays), PageRequest.of(0, batchSize));

if (candidates.isEmpty()) {
return;
}

log.info("WhatsAppMediaBackfillWorker: Retrying {} message(s) with missing media", candidates.size());

for (WhatsAppInboxMessage message : candidates) {
backfillOne(message);
}
}

private void backfillOne(WhatsAppInboxMessage message) {
try {
mediaService.download(message.getConfig(), message.getMediaId()).ifPresentOrElse(downloaded -> {
message.setMediaPath(downloaded.path());
message.setMediaSize(downloaded.size());
inboxMessageRepository.save(message);
log.info("WhatsAppMediaBackfillWorker: Recovered media for message [{}]", message.getId());
}, () -> log.debug("WhatsAppMediaBackfillWorker: Media for message [{}] still unavailable", message.getId()));
} catch (Exception e) {
log.error("WhatsAppMediaBackfillWorker: Error backfilling media for message [{}]: {}",
message.getId(), e.getMessage());
}
}
}
7 changes: 7 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ flextuma.app.frontend.directory=${APP_FRONTEND_DIRECTORY:/tmp/frontend}
# persistent volume the deployed client assets use.
flextuma.whatsapp.media.directory=${FLEXTUMA_WHATSAPP_MEDIA_DIRECTORY:client/media/whatsapp}

# Backfill job that retries downloading inbound WhatsApp media that failed to cache at ingestion
# time. Only considers messages received within max-age-days, since Meta's CDN stops serving
# media after a limited window anyway.
flextuma.whatsapp.media-backfill.interval-ms=${FLEXTUMA_WHATSAPP_MEDIA_BACKFILL_INTERVAL_MS:300000}
flextuma.whatsapp.media-backfill.batch-size=${FLEXTUMA_WHATSAPP_MEDIA_BACKFILL_BATCH_SIZE:25}
flextuma.whatsapp.media-backfill.max-age-days=${FLEXTUMA_WHATSAPP_MEDIA_BACKFILL_MAX_AGE_DAYS:3}

# Upload size limits. These can be overridden per deployment through environment variables.
spring.servlet.multipart.max-file-size=${MAX_UPLOAD_FILE_SIZE:100MB}
spring.servlet.multipart.max-request-size=${MAX_UPLOAD_REQUEST_SIZE:100MB}
Expand Down