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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ Set `FLEXTUMA_PUBLIC_BASE_URL` to Flextuma's public HTTPS origin before creating

#### Multiple WhatsApp numbers and shared callback URLs

An account can hold several WhatsApp connectors, one per Meta phone number (`senderId`), all on the same Graph API `url`; connectors are unique per provider, URL, sender ID and owner. When a send does not pass `connectorId`, the owner's active connector with `isDefault: true` is used (the oldest one wins if several are flagged). Create one `/api/whatsappWebhookConfigs` record per phone number and link each to its connector.
An account can hold several WhatsApp connectors, one per Meta phone number (`senderId`), all on the same Graph API `url`; connectors are unique per provider, URL, sender ID and owner. When a send does not pass `connectorId`, the owner's active connector with `isDefault: true` is used (the oldest one wins if several are flagged). Create one `/api/whatsappWebhookConfigs` record per phone number and link each to its connector. To add another number that uses a token you already saved, create the connector with `"reuseKeyFrom": "<connector id>"` and no `key`. Flextuma copies the key, secret and URL from that connector server-side; it must be yours and use the same provider. `POST /api/whatsappMeta/check` with `{ "connectorId", "tokenOnly": true, "businessAccountId" }` lists that account's numbers using the saved token.

Meta sets the webhook callback URL **once per Meta app** (App Dashboard → WhatsApp → Configuration). Every WhatsApp Business Account subscribed to that app (`POST /{WABA_ID}/subscribed_apps`), and every phone number in it, delivers to that one URL, signed with the app secret. Optional overrides can be set per WABA (`override_callback_uri` on `subscribed_apps`) or per phone number (`webhook_configuration.override_callback_uri` on `POST /{PHONE_NUMBER_ID}`). Meta checks the phone-number override first, then the WABA override, then the app URL. To support this, create a shared Meta app:

Expand All @@ -271,6 +271,10 @@ The response includes a generated `metaCallbackUrl` (`/api/webhooks/whatsapp/app

Events on the shared URL are checked against the app secret. Each change is then routed to the configuration whose `phoneNumberId` matches `value.metadata.phone_number_id`. WABA-level changes carry no phone number (template status, account updates); they go to every linked configuration whose `businessAccountId` matches `entry[].id`, or to the app's only configuration if there is just one. Only configurations linked to that app can receive its events. Each configuration keeps its own per-number `metaCallbackUrl` for use as a phone-number override. When a linked configuration has no `appSecret` of its own, the app's secret validates that URL too. Deleting a Meta app detaches its configurations, which keep working on their own URLs.

#### Checking credentials with Meta

`POST /api/whatsappMeta/check` validates WhatsApp credentials against the Graph API before (or after) they are saved. It takes `{ "accessToken", "graphApiUrl", "businessAccountId" }` for a number not yet saved, or `{ "connectorId" }` to reuse a saved connector's token; that token never leaves the server. The response returns `ok` (or a readable `error` for expired tokens, missing permissions or unknown IDs), the WABA's `phoneNumbers` (display number, verified name, quality rating), the requested `phoneNumber`, and `subscribedApps` (the Meta apps receiving the WABA's webhooks). `POST /api/whatsappMeta/subscribe` calls `POST /{WABA_ID}/subscribed_apps` for the token's app and returns the refreshed check. Only `https://graph.facebook.com` is called, and IDs must be numeric.

### Planned: Flextuma-managed Meta Tech Provider onboarding

The configuration above is the current **bring-your-own-Meta** integration: customers supply their Cloud API credentials and phone-number ID. The planned Tech Provider mode will offer a branded **Connect WhatsApp** flow through Meta Embedded Signup, so customers do not manually handle access tokens, app secrets, callback URLs, or verification tokens.
Expand Down
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.84'
version = '0.0.85'
description = 'Flextuma App'

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ public class SmsConnector extends Owner {
@Column(columnDefinition = "TEXT", name = "extrasettings", nullable = true)
private String extraSettings;

/**
* Create-only: copy key/secret (and url, if blank) from this other connector of the same owner
* and provider, so one stored credential -- e.g. a Meta System User token shared by several
* WhatsApp numbers -- can be reused without ever being sent back to the browser.
*/
@Transient
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private java.util.UUID reuseKeyFrom;

@JsonProperty("key")
public String getMaskedKey() {
return MaskingUtil.mask(this.key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public class WhatsAppWebhookConfig extends Owner {
@Column(name = "display_phone_number")
private String displayPhoneNumber;

/** Business name Meta shows customers for this number (verified_name), read from Meta at setup. */
@Column(name = "verified_name")
private String verifiedName;

/**
* The WHATSAPP-provider SmsConnector whose Meta access token is used to download inbound
* media for this webhook. Optional: when unset, WhatsAppMediaService falls back to the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public interface WhatsAppInboxMessageRepository extends BaseRepository<WhatsAppI
JpaSpecificationExecutor<WhatsAppInboxMessage> {
boolean existsByProviderMessageId(String providerMessageId);

boolean existsByConfigId(UUID configId);

/** 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
@Repository
public interface WhatsAppRelayDeliveryRepository extends BaseRepository<WhatsAppRelayDelivery, UUID>,
JpaSpecificationExecutor<WhatsAppRelayDelivery> {
boolean existsByConfigId(UUID configId);


@Query("SELECT d FROM WhatsAppRelayDelivery d WHERE d.status = :status"
+ " AND (d.nextRetryAt IS NULL OR d.nextRetryAt <= :now) ORDER BY d.created ASC")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.flexcodelabs.flextuma.modules.sms.services;

import java.util.Optional;
import java.util.UUID;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Service;

import com.flexcodelabs.flextuma.core.entities.auth.User;
import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector;
import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver;
import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository;
import com.flexcodelabs.flextuma.core.services.BaseService;

Expand All @@ -17,6 +20,7 @@
public class SmsConnectorService extends BaseService<SmsConnector> {

private final SmsConnectorRepository repository;
private final CurrentUserResolver userResolver;

@Override
protected JpaRepository<SmsConnector, UUID> getRepository() {
Expand Down Expand Up @@ -70,9 +74,36 @@ protected String getTableName() {

@Override
protected void onPreSave(SmsConnector entity) {
reuseSavedKey(entity);
validateProviderConfig(entity);
}

private void reuseSavedKey(SmsConnector entity) {
if (entity.getReuseKeyFrom() == null) {
return;
}
SmsConnector source = repository.findById(entity.getReuseKeyFrom())
.filter(found -> found.getProvider() != null && found.getProvider().equalsIgnoreCase(entity.getProvider()))
.filter(this::isOwnedByCurrentUser)
.orElseThrow(() -> new IllegalArgumentException("The saved credentials to reuse were not found"));
entity.setKey(source.getKey());
if (isBlank(entity.getSecret())) {
entity.setSecret(source.getSecret());
}
if (isBlank(entity.getUrl())) {
entity.setUrl(source.getUrl());
}
}

private boolean isOwnedByCurrentUser(SmsConnector connector) {
Optional<User> user = userResolver.getCurrentUser();
return user.isPresent() && connector.getCreatedBy() != null && connector.getCreatedBy().getId().equals(user.get().getId());
}

private static boolean isBlank(String value) {
return value == null || value.isBlank();
}

@Override
protected SmsConnector onPreUpdate(SmsConnector newEntity, SmsConnector oldEntity) {
SmsConnector merged = super.onPreUpdate(newEntity, oldEntity);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.flexcodelabs.flextuma.modules.whatsapp.controllers;

import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckRequest;
import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckResult;
import com.flexcodelabs.flextuma.modules.whatsapp.services.meta.WhatsAppMetaService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/** Live checks against Meta's Graph API for the WhatsApp setup screens (authenticated). */
@RestController
@RequestMapping("/api/whatsappMeta")
@RequiredArgsConstructor
public class WhatsAppMetaController {
private final WhatsAppMetaService metaService;

/** Validates credentials and returns the account's phone numbers and app subscriptions. */
@PostMapping("/check")
public ResponseEntity<WhatsAppMetaCheckResult> check(@RequestBody WhatsAppMetaCheckRequest request) {
return ResponseEntity.ok(metaService.check(request));
}

/** Subscribes the token's Meta app to the WhatsApp Business Account's webhooks. */
@PostMapping("/subscribe")
public ResponseEntity<WhatsAppMetaCheckResult> subscribe(@RequestBody WhatsAppMetaCheckRequest request) {
return ResponseEntity.ok(metaService.subscribe(request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.flexcodelabs.flextuma.modules.whatsapp.dtos;

import java.util.UUID;

/**
* Credentials to check against Meta. Either supply {@code accessToken} (+ {@code graphApiUrl}) for
* a number not yet saved, or {@code connectorId} to reuse a saved connector's token, which never
* leaves the server. With {@code tokenOnly}, only the connector's token is borrowed -- its own
* WABA and phone number are not used as defaults -- so a saved token can look up another number.
*/
public record WhatsAppMetaCheckRequest(
String graphApiUrl,
String accessToken,
String businessAccountId,
String phoneNumberId,
UUID connectorId,
Boolean tokenOnly) {

public WhatsAppMetaCheckRequest(String graphApiUrl, String accessToken, String businessAccountId, String phoneNumberId, UUID connectorId) {
this(graphApiUrl, accessToken, businessAccountId, phoneNumberId, connectorId, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.flexcodelabs.flextuma.modules.whatsapp.dtos;

import java.util.List;

/**
* Outcome of checking credentials against Meta. {@code ok} is false with {@code error} set when
* Meta rejected them; the HTTP status stays 200 so the UI can show the reason inline.
*
* @param phoneNumbers every number in the WhatsApp Business Account, when one was given
* @param phoneNumber the requested number, when a phone number ID was given
* @param subscribedApps names of Meta apps receiving this WABA's webhooks; null when not checked
*/
public record WhatsAppMetaCheckResult(
boolean ok,
String error,
String errorCode,
List<WhatsAppMetaPhoneNumberDTO> phoneNumbers,
WhatsAppMetaPhoneNumberDTO phoneNumber,
List<String> subscribedApps) {

public static WhatsAppMetaCheckResult failure(String errorCode, String error) {
return new WhatsAppMetaCheckResult(false, error, errorCode, List.of(), null, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.flexcodelabs.flextuma.modules.whatsapp.dtos;

/** A phone number as Meta reports it (GET /{phone-number-id}). */
public record WhatsAppMetaPhoneNumberDTO(
String id,
String displayPhoneNumber,
String verifiedName,
String qualityRating,
String codeVerificationStatus,
String nameStatus,
String platformType) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import com.flexcodelabs.flextuma.core.enums.WhatsAppRelayStatus;
import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver;
import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository;
import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository;
import com.flexcodelabs.flextuma.core.repositories.WhatsAppMetaAppRepository;
import com.flexcodelabs.flextuma.core.repositories.WhatsAppRelayDeliveryRepository;
import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository;
import com.flexcodelabs.flextuma.core.services.BaseService;
import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppActivityItemDTO;
Expand Down Expand Up @@ -46,6 +48,8 @@ public class WhatsAppWebhookConfigService extends BaseService<WhatsAppWebhookCon
private final WhatsAppRelayDeliveryService relayDeliveryService;
private final SmsConnectorRepository smsConnectorRepository;
private final WhatsAppMetaAppRepository metaAppRepository;
private final WhatsAppInboxMessageRepository inboxMessageRepository;
private final WhatsAppRelayDeliveryRepository relayDeliveryRepository;
private final CurrentUserResolver currentUserResolver;
private final WhatsAppEventRetentionPolicy eventRetentionPolicy;
@Value("${flextuma.public-base-url:}") private String publicBaseUrl;
Expand Down Expand Up @@ -77,6 +81,15 @@ public class WhatsAppWebhookConfigService extends BaseService<WhatsAppWebhookCon
if (entity.getAppSecret() != null && entity.getAppSecret().contains("****")) entity.setAppSecret(old.getAppSecret());
WhatsAppWebhookConfig merged = super.onPreUpdate(entity, old); validate(merged); return merged;
}
// Inbox messages and relay deliveries reference their config without a cascade, so deleting
// a number that has history would fail on the foreign key with an unhelpful database error.
@Override
protected void validateDelete(WhatsAppWebhookConfig entity) {
if (inboxMessageRepository.existsByConfigId(entity.getId()) || relayDeliveryRepository.existsByConfigId(entity.getId())) {
throw new IllegalStateException("This number has message history, so it can't be removed. Pause it instead.");
}
}

private void provisionMetaCallback(WhatsAppWebhookConfig entity) {
if (publicBaseUrl == null || publicBaseUrl.isBlank()) throw new IllegalStateException("FLEXTUMA_PUBLIC_BASE_URL must be configured before WhatsApp webhooks can be created");
// A caller may supply their own verifyToken (so they know the value to paste into Meta);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.flexcodelabs.flextuma.modules.whatsapp.services.meta;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;

import java.net.URI;
import java.util.Map;

/**
* Minimal Graph API client for checking WhatsApp credentials. Only https://graph.facebook.com is
* allowed: the base URL comes from user input, and the request carries their access token, so any
* other host would turn "test connection" into a way to probe arbitrary URLs.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MetaGraphClient {
static final String GRAPH_HOST = "graph.facebook.com";
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};
private static final Map<Integer, String> FRIENDLY_ERRORS = Map.of(
190, "The access token is invalid or has expired. Use a permanent System User token from Meta Business Settings.",
100, "Meta couldn't find that ID, or this access token can't access it. Check the ID and that the token's System User is assigned to the WhatsApp account.",
10, "This access token is missing a permission. It needs whatsapp_business_management and whatsapp_business_messaging.",
200, "This access token is missing a permission. It needs whatsapp_business_management and whatsapp_business_messaging.",
4, "Meta is rate limiting requests for this app. Try again in a minute.",
80007, "Meta is rate limiting requests for this WhatsApp account. Try again in a minute.");

private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;

public Map<String, Object> get(String baseUrl, String token, String path) {
return call(HttpMethod.GET, baseUrl, token, path);
}

public Map<String, Object> post(String baseUrl, String token, String path) {
return call(HttpMethod.POST, baseUrl, token, path);
}

public static String validatedBaseUrl(String baseUrl) {
URI uri = parse(baseUrl);
if (!"https".equalsIgnoreCase(uri.getScheme()) || !GRAPH_HOST.equalsIgnoreCase(uri.getHost())) {
throw new MetaGraphException("INVALID_URL", "The Graph API URL must be https://graph.facebook.com/<version>.");
}
return baseUrl.replaceAll("/+$", "");
}

private static URI parse(String baseUrl) {
try {
return URI.create(baseUrl == null ? "" : baseUrl.trim());
} catch (IllegalArgumentException e) {
throw new MetaGraphException("INVALID_URL", "The Graph API URL is not a valid URL.");
}
}

private Map<String, Object> call(HttpMethod method, String baseUrl, String token, String path) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
try {
ResponseEntity<Map> response = restTemplate.exchange(validatedBaseUrl(baseUrl) + path, method, new HttpEntity<>(headers), Map.class);
return objectMapper.convertValue(response.getBody() == null ? Map.of() : response.getBody(), MAP_TYPE);
} catch (HttpStatusCodeException e) {
throw translate(e.getResponseBodyAsString());
} catch (ResourceAccessException e) {
log.warn("Could not reach the Meta Graph API: {}", e.getMessage());
throw new MetaGraphException("UNREACHABLE", "Could not reach Meta. Check your connection and try again.");
}
}

MetaGraphException translate(String body) {
Map<String, Object> error = errorOf(body);
Object code = error.get("code");
String fallback = error.get("message") instanceof String message ? message : "Meta rejected the request.";
Integer numeric = code instanceof Number number ? number.intValue() : null;
return new MetaGraphException(String.valueOf(code), FRIENDLY_ERRORS.getOrDefault(numeric, fallback));
}

private Map<String, Object> errorOf(String body) {
try {
Object error = objectMapper.readValue(body, MAP_TYPE).get("error");
return error instanceof Map<?, ?> ? objectMapper.convertValue(error, MAP_TYPE) : Map.of();
} catch (JsonProcessingException | IllegalArgumentException e) {
return Map.of();
}
}
}
Loading