Skip to content
Open
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 api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>com.messagebird</groupId>
<artifactId>messagebird-api</artifactId>
<version>6.3.1</version>
<version>6.4.0</version>
<packaging>jar</packaging>

<name>${project.groupId}:${project.artifactId}</name>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package com.messagebird.objects.conversations;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

/**
* ConversationContent wraps actual content. The field that should be set here
* is indicated by ConversationContentType.
*
* <p>Unknown keys are ignored on deserialization so that consumers parsing
* webhook payloads in their own handlers are not broken by content fields added
* after their SDK version. Serialization is unaffected.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationContent {

private ConversationContentMedia audio;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.messagebird.objects.conversations;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import java.util.Date;

/**
Expand All @@ -8,6 +10,7 @@
* the BSUID when Meta provides one. When both identifiers exist, the phone
* number appears in the parent {@code from} field, not in this object.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationMessageMetadata {

private ConversationSenderMetadata sender;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.messagebird.objects.conversations;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

/**
* Identifies the recipient of an outbound WhatsApp message, as reported back on
* status webhook payloads under {@code status.metadata.recipient}. Mirrors
* {@link ConversationSenderMetadata} on the inbound side.
*
* <p>{@code userId} is the recipient's BSUID (e.g. "US.13491208655302741918");
* {@code parentUserId} is the parent business-scoped user ID of the enterprise
* that owns the business portfolio it was scoped against (e.g.
* "US.ENT.11815799212886844830").
*
* <p>Either field may be {@code null}: Meta only supplies them for accounts
* enrolled in the BSUID rollout, and the enclosing {@code recipient} object is
* omitted entirely when neither is present. This is the only place a status
* payload carries the recipient's own identity — {@code messageMetadata.to} is
* an echo of the address the message was addressed to.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationRecipientMetadata {

private String userId;
private String parentUserId;

public String getUserId() {
return userId;
}

public void setUserId(String userId) {
this.userId = userId;
}

public String getParentUserId() {
return parentUserId;
}

public void setParentUserId(String parentUserId) {
this.parentUserId = parentUserId;
}

@Override
public String toString() {
return "ConversationRecipientMetadata{" +
"userId='" + userId + '\'' +
", parentUserId='" + parentUserId + '\'' +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
package com.messagebird.objects.conversations;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

/**
* Metadata about the sender of a WhatsApp message. {@code userId} always
* contains the BSUID (e.g. "US.13491208655302741918") when Meta supplies one.
* When both a phone number and a BSUID are available, the phone number appears
* in the parent message's {@code from} field — not here.
*
* <p>{@code parentUserId} carries the sender's parent business-scoped user ID
* (e.g. "US.ENT.11815799212886844830"), which identifies the enterprise that
* owns the business portfolio the {@code userId} was scoped against. It is only
* present for accounts enrolled in Meta's parent-BSUID rollout; for everyone
* else it stays {@code null}.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationSenderMetadata {

private String userId;
private String parentUserId;
private String username;
private String displayName;

Expand All @@ -20,6 +30,14 @@ public void setUserId(String userId) {
this.userId = userId;
}

public String getParentUserId() {
return parentUserId;
}

public void setParentUserId(String parentUserId) {
this.parentUserId = parentUserId;
}

public String getUsername() {
return username;
}
Expand All @@ -40,6 +58,7 @@ public void setDisplayName(String displayName) {
public String toString() {
return "ConversationSenderMetadata{" +
"userId='" + userId + '\'' +
", parentUserId='" + parentUserId + '\'' +
", username='" + username + '\'' +
", displayName='" + displayName + '\'' +
'}';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.messagebird.objects.conversations;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

/**
* The {@code messageMetadata} block delivered inside status webhook payloads
* (e.g. {@code statusSent}, {@code statusDelivered}). Reflects the original
Expand All @@ -12,7 +14,13 @@
* <p>Both {@code from} and {@code to} accept either a phone number or a
* WhatsApp Business-Scoped User ID (BSUID, e.g. "US.13491208655302741918").
* The BSUID is also available via {@code metadata.sender.userId}.
*
* <p>{@code to} echoes back the address the message was originally addressed
* to, so it is not a reliable source of the recipient's BSUID. That identity
* lives alongside this block, under {@code status.metadata.recipient} — see
* {@link ConversationStatusMetadata}.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationStatusMessageMetadata {

private String id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.messagebird.objects.conversations;

import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* The {@code status.metadata} block delivered inside status webhook payloads
* (e.g. {@code statusSent}, {@code statusDelivered}).
*
* <p>This class is not produced by any SDK request — it is a standalone POJO
* intended for consumers who deserialize incoming webhook payloads in their own
* HTTP handlers. Use it via {@code ObjectMapper.readValue(body, ...)}.
*
* <p>Note the mixed casing of this object. {@code pricing} and
* {@code conversation} are near-verbatim passthroughs of Meta's own objects and
* so keep their snake_case keys ({@code pricing_model}, {@code category}, …);
* they are exposed here as raw maps rather than modelled types, because their
* contents track Meta's schema rather than ours. {@code recipient} is ours and
* follows the camelCase convention used everywhere else in the API. Any other
* key present on the payload — for example {@code biz_opaque_callback_data} —
* is collected into {@link #getAdditionalProperties()} rather than dropped.
*
* <p>{@code recipient} is absent from payloads for accounts that never receive
* BSUIDs, in which case {@link #getRecipient()} returns {@code null}.
*/
public class ConversationStatusMetadata {

private Map<String, Object> pricing;
private Map<String, Object> conversation;
private ConversationRecipientMetadata recipient;
private final Map<String, Object> additionalProperties = new LinkedHashMap<>();

public Map<String, Object> getPricing() {
return pricing;
}

public void setPricing(Map<String, Object> pricing) {
this.pricing = pricing;
}

public Map<String, Object> getConversation() {
return conversation;
}

public void setConversation(Map<String, Object> conversation) {
this.conversation = conversation;
}

public ConversationRecipientMetadata getRecipient() {
return recipient;
}

public void setRecipient(ConversationRecipientMetadata recipient) {
this.recipient = recipient;
}

/**
* Every key on the payload that has no dedicated accessor above, in the
* order it was encountered. Empty when the payload holds nothing else.
*
* @return the unmodelled remainder of the metadata object
*/
@JsonAnyGetter
public Map<String, Object> getAdditionalProperties() {
return additionalProperties;
}

@JsonAnySetter
public void setAdditionalProperty(String name, Object value) {
additionalProperties.put(name, value);
}

@Override
public String toString() {
return "ConversationStatusMetadata{" +
"pricing=" + pricing +
", conversation=" + conversation +
", recipient=" + recipient +
", additionalProperties=" + additionalProperties +
'}';
}
}
77 changes: 71 additions & 6 deletions api/src/test/java/com/messagebird/ConversationMessagesTest.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.messagebird;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.messagebird.exceptions.GeneralException;
import com.messagebird.exceptions.NotFoundException;
Expand All @@ -25,9 +24,20 @@ public class ConversationMessagesTest {
private static final String JSON_CONVERSATION_MESSAGE_TEXT = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"text\",\"direction\": \"received\",\"content\": {\"text\": \"Hello\"},\"createdDatetime\": \"2018-08-29T11:49:16Z\",\"updatedDatetime\": \"2018-08-29T11:49:16Z\"}";
private static final String JSON_CONVERSATION_MESSAGE_VIDEO = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"video\",\"direction\": \"received\",\"content\": {\"video\": { \"url\": \"https://example.com/video.mp4\" } },\"createdDatetime\": \"2018-08-29T11:49:16Z\",\"updatedDatetime\": \"2018-08-29T11:49:16Z\"}";
private static final String JSON_CONVERSATION_SEND_MESSAGE_RESPONSE = "{\"id\":\"mesid\",\"status\":\"accepted\",\"fallback\":{\"id\":\"mesid\"}}";
private static final String JSON_CONVERSATION_MESSAGE_BSUID = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"text\",\"direction\": \"received\",\"content\": {\"text\": \"Hello\"},\"metadata\": {\"sender\": {\"displayName\": \"Alice\",\"username\": \"alice_shop\",\"userId\": \"US.13491208655302741918\"},\"receivedAt\": \"2025-04-15T16:00:00Z\"},\"createdDatetime\": \"2025-04-15T16:00:00Z\",\"updatedDatetime\": \"2025-04-15T16:00:00Z\"}";
private static final String JSON_CONVERSATION_MESSAGE_BSUID = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"text\",\"direction\": \"received\",\"content\": {\"text\": \"Hello\"},\"metadata\": {\"sender\": {\"displayName\": \"Alice\",\"username\": \"alice_shop\",\"userId\": \"US.13491208655302741918\",\"parentUserId\": \"US.ENT.11815799212886844830\"},\"receivedAt\": \"2025-04-15T16:00:00Z\"},\"createdDatetime\": \"2025-04-15T16:00:00Z\",\"updatedDatetime\": \"2025-04-15T16:00:00Z\"}";
private static final String JSON_STATUS_MESSAGE_METADATA = "{\"id\": \"e5f6a7b8-c9d0-1234-ef01-23456789abcd\",\"from\": \"15551234567\",\"to\": \"US.13491208655302741918\",\"type\": \"text\",\"content\": {\"text\": \"Hello! Your order has been shipped.\"},\"metadata\": {\"sender\": {\"userId\": \"US.13491208655302741918\"},\"receivedAt\": \"0001-01-01T00:00:00Z\"}}";

private static final String JSON_STATUS_METADATA_WITH_RECIPIENT = "{\"pricing\": {\"billable\": true,\"pricing_model\": \"CBP\",\"category\": \"utility\"},\"conversation\": {\"id\": \"a1b2c3d4\",\"origin\": {\"type\": \"utility\"}},\"biz_opaque_callback_data\": \"order-1234\",\"recipient\": {\"userId\": \"US.13491208655302741918\",\"parentUserId\": \"US.ENT.11815799212886844830\"}}";
private static final String JSON_STATUS_METADATA_WITHOUT_RECIPIENT = "{\"pricing\": {\"billable\": true,\"pricing_model\": \"CBP\",\"category\": \"utility\"},\"conversation\": {\"id\": \"a1b2c3d4\",\"origin\": {\"type\": \"utility\"}}}";
private static final String JSON_STATUS_METADATA_PARENT_ONLY = "{\"recipient\": {\"parentUserId\": \"US.ENT.11815799212886844830\"}}";

/**
* The same payload as JSON_STATUS_MESSAGE_METADATA with an unrecognised key
* added at every nesting level, standing in for fields the platform adds
* after this SDK version ships.
*/
private static final String JSON_STATUS_MESSAGE_METADATA_UNKNOWN_FIELDS = "{\"id\": \"e5f6a7b8-c9d0-1234-ef01-23456789abcd\",\"from\": \"15551234567\",\"to\": \"US.13491208655302741918\",\"type\": \"text\",\"futureTopLevelField\": \"ignored\",\"content\": {\"text\": \"Hello! Your order has been shipped.\",\"futureContentField\": \"ignored\"},\"metadata\": {\"sender\": {\"userId\": \"US.13491208655302741918\",\"futureSenderField\": \"ignored\"},\"receivedAt\": \"0001-01-01T00:00:00Z\",\"futureMetadataField\": \"ignored\"}}";

/**
* Epsilon to use when checking two latitudes or longitudes for equality.
*/
Expand Down Expand Up @@ -204,14 +214,69 @@ public void testViewConversationMessageWithBsuidMetadata() throws GeneralExcepti
assertEquals("Alice", sender.getDisplayName());
assertEquals("alice_shop", sender.getUsername());
assertEquals("US.13491208655302741918", sender.getUserId());
assertEquals("US.ENT.11815799212886844830", sender.getParentUserId());
}

@Test
public void testStatusMessageMetadataDeserializes() throws Exception {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
public void testStatusMessageMetadataToleratesUnknownFields() throws Exception {
// A plain mapper, and unknown keys at every level: webhook payload POJOs
// must not force consumers to disable FAIL_ON_UNKNOWN_PROPERTIES, and
// must survive fields the platform adds after this version ships.
ConversationStatusMessageMetadata md = new ObjectMapper().readValue(
JSON_STATUS_MESSAGE_METADATA_UNKNOWN_FIELDS, ConversationStatusMessageMetadata.class);

ConversationStatusMessageMetadata md = mapper.readValue(
assertEquals("e5f6a7b8-c9d0-1234-ef01-23456789abcd", md.getId());
assertEquals("Hello! Your order has been shipped.", md.getContent().getText());
assertEquals("US.13491208655302741918", md.getMetadata().getSender().getUserId());
assertNotNull(md.getMetadata().getReceivedAt());
}

@Test
public void testStatusMetadataDeserializesRecipient() throws Exception {
// A plain mapper: these payload POJOs must not require the caller to
// disable FAIL_ON_UNKNOWN_PROPERTIES.
ConversationStatusMetadata metadata = new ObjectMapper().readValue(
JSON_STATUS_METADATA_WITH_RECIPIENT, ConversationStatusMetadata.class);

ConversationRecipientMetadata recipient = metadata.getRecipient();
assertNotNull(recipient);
assertEquals("US.13491208655302741918", recipient.getUserId());
assertEquals("US.ENT.11815799212886844830", recipient.getParentUserId());

// Meta's own objects pass through untouched, snake_case keys and all.
assertEquals("CBP", metadata.getPricing().get("pricing_model"));
assertEquals("a1b2c3d4", metadata.getConversation().get("id"));

// Anything else on the payload is kept rather than dropped.
assertEquals("order-1234", metadata.getAdditionalProperties().get("biz_opaque_callback_data"));
}

@Test
public void testStatusMetadataWithoutRecipientIsNull() throws Exception {
ConversationStatusMetadata metadata = new ObjectMapper().readValue(
JSON_STATUS_METADATA_WITHOUT_RECIPIENT, ConversationStatusMetadata.class);

// Accounts that never receive BSUIDs get payloads with no recipient key
// at all — the rest of the metadata must still parse.
assertNull(metadata.getRecipient());
assertEquals("CBP", metadata.getPricing().get("pricing_model"));
assertTrue(metadata.getAdditionalProperties().isEmpty());
}

@Test
public void testStatusMetadataRecipientWithParentOnly() throws Exception {
ConversationStatusMetadata metadata = new ObjectMapper().readValue(
JSON_STATUS_METADATA_PARENT_ONLY, ConversationStatusMetadata.class);

ConversationRecipientMetadata recipient = metadata.getRecipient();
assertNotNull(recipient);
assertNull(recipient.getUserId());
assertEquals("US.ENT.11815799212886844830", recipient.getParentUserId());
}

@Test
public void testStatusMessageMetadataDeserializes() throws Exception {
ConversationStatusMessageMetadata md = new ObjectMapper().readValue(
JSON_STATUS_MESSAGE_METADATA, ConversationStatusMessageMetadata.class);

assertEquals("e5f6a7b8-c9d0-1234-ef01-23456789abcd", md.getId());
Expand Down
4 changes: 2 additions & 2 deletions examples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.messagebird</groupId>
<artifactId>examples</artifactId>
<version>6.3.1</version>
<version>6.4.0</version>

<licenses>
<license>
Expand All @@ -20,7 +20,7 @@
<dependency>
<groupId>com.messagebird</groupId>
<artifactId>messagebird-api</artifactId>
<version>6.3.1</version>
<version>6.4.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
Expand Down