diff --git a/api/pom.xml b/api/pom.xml index d9c1f27..b64ee8b 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -4,7 +4,7 @@ com.messagebird messagebird-api - 6.3.1 + 6.4.0 jar ${project.groupId}:${project.artifactId} diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java index 9ba82bd..8b4fe16 100644 --- a/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java +++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java @@ -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. + * + *

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; diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java index 7d908e6..c41e814 100644 --- a/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java +++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java @@ -1,5 +1,7 @@ package com.messagebird.objects.conversations; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + import java.util.Date; /** @@ -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; diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationRecipientMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationRecipientMetadata.java new file mode 100644 index 0000000..7537da8 --- /dev/null +++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationRecipientMetadata.java @@ -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. + * + *

{@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"). + * + *

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 + '\'' + + '}'; + } +} diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java index 33d7e22..b7fdcbb 100644 --- a/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java +++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java @@ -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. + * + *

{@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; @@ -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; } @@ -40,6 +58,7 @@ public void setDisplayName(String displayName) { public String toString() { return "ConversationSenderMetadata{" + "userId='" + userId + '\'' + + ", parentUserId='" + parentUserId + '\'' + ", username='" + username + '\'' + ", displayName='" + displayName + '\'' + '}'; diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java index df7feeb..47a4a8d 100644 --- a/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java +++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java @@ -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 @@ -12,7 +14,13 @@ *

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}. + * + *

{@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; diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMetadata.java new file mode 100644 index 0000000..0b4d5f7 --- /dev/null +++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMetadata.java @@ -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}). + * + *

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, ...)}. + * + *

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. + * + *

{@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 pricing; + private Map conversation; + private ConversationRecipientMetadata recipient; + private final Map additionalProperties = new LinkedHashMap<>(); + + public Map getPricing() { + return pricing; + } + + public void setPricing(Map pricing) { + this.pricing = pricing; + } + + public Map getConversation() { + return conversation; + } + + public void setConversation(Map 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 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 + + '}'; + } +} diff --git a/api/src/test/java/com/messagebird/ConversationMessagesTest.java b/api/src/test/java/com/messagebird/ConversationMessagesTest.java index 5bd0a5c..beac543 100644 --- a/api/src/test/java/com/messagebird/ConversationMessagesTest.java +++ b/api/src/test/java/com/messagebird/ConversationMessagesTest.java @@ -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; @@ -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. */ @@ -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()); diff --git a/examples/pom.xml b/examples/pom.xml index 9ffe9f1..cb9b748 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -6,7 +6,7 @@ com.messagebird examples - 6.3.1 + 6.4.0 @@ -20,7 +20,7 @@ com.messagebird messagebird-api - 6.3.1 + 6.4.0 compile