diff --git a/docs/message.md b/docs/message.md index d187834b0..c6b6e6de9 100644 --- a/docs/message.md +++ b/docs/message.md @@ -45,6 +45,31 @@ public class Example { > `PresentationMLParser.getTextContent(message.getMessage())` can be used on incoming messages to extract the message content > stripped of all tags. + +### Update a message with an attachment +An existing message can also be updated with new content and file attachments: +```java +@Slf4j +public class Example { + public static final String STREAM_ID = "gXFV8vN37dNqjojYS_y2wX___o2KxfmUdA"; + public static final String MESSAGE_ID = "j7-VAoCY_1pGAYSye_MtI3___oPXo8VHbQ"; + + public static void main(String[] args) throws Exception { + final SymphonyBdk bdk = new SymphonyBdk(loadFromClasspath("/config.yaml")); + + final InputStream attachment = Example.class.getResourceAsStream("/attachment.pdf"); + final Message updatedContent = Message.builder() + .content("Updated message content") + .addAttachment(attachment, "attachment.pdf") + .build(); + + final V4Message updatedMessage = bdk.messages().update(STREAM_ID, MESSAGE_ID, updatedContent); + log.info("Message updated, id: " + updatedMessage.getMessageId()); + } +} +``` +> :information_source: Attachments and previews on message update are supported only starting from SBE v24.1. + ## Using templates The `Message.Builder` also allows you to build a message from a template. So far, the BDK supports two different template engine implementations: diff --git a/openspec/changes/archive/2026-10-06-update-message-attachments/.openspec.yaml b/openspec/changes/archive/2026-10-06-update-message-attachments/.openspec.yaml new file mode 100644 index 000000000..aa32566bf --- /dev/null +++ b/openspec/changes/archive/2026-10-06-update-message-attachments/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-06 diff --git a/openspec/changes/archive/2026-10-06-update-message-attachments/design.md b/openspec/changes/archive/2026-10-06-update-message-attachments/design.md new file mode 100644 index 000000000..5d7a616f0 --- /dev/null +++ b/openspec/changes/archive/2026-10-06-update-message-attachments/design.md @@ -0,0 +1,29 @@ +## Context + +`MessageService.update(streamId, messageId, content)` builds its multipart form with the private `getForm(Message)`, the same helper used by `send` and `blast`. That helper already adds the `attachment` and `preview` parts, so update already forwards attachments. `OboMessageService` declares the same `update` methods and `MessageService` implements both, so OBO shares the code path. When a `MessageSenderOverride` is registered, update is delegated to it and the form is not built by the BDK. + +The gap is documentation and test coverage, not behavior: the javadoc says attachments are unsupported, and existing update tests only send text. + +## Goals / Non-Goals + +**Goals:** +- Make javadoc and docs match actual behavior, including the SBE v24.1 requirement. +- Lock the behavior in with tests that inspect the multipart request body. + +**Non-Goals:** +- Changing production logic or method signatures. +- Promoting `@API(status = EXPERIMENTAL)` to stable (maintainer decision). +- Documenting or enforcing replace-vs-add semantics for existing attachments; unverified against a real pod. +- Guarding against pods older than SBE v24.1; the server's response is surfaced as-is. + +## Decisions + +- **Javadoc only, no code change.** Reuse of `getForm` already satisfies the API. Alternative considered: a dedicated update form builder; rejected as duplication with no benefit. +- **Assert on the multipart body in tests.** Existing tests use `mockApiClient.onPost(...)`, which only matches path and returns a canned response. The new tests need to capture the request (via the mock client's request inspection, or a spy/captor on `ApiClient.invokeAPI`) and check the `attachment`/`preview` parts. The exact capture mechanism is decided during implementation based on what `mockApiClient` exposes. +- **Docs example in `docs/message.md`** under "How to use", mirroring the existing send-with-attachment snippet, with a note about SBE v24.1. +- **Version note, no runtime check.** The BDK has no pod-version detection for messages; a note in javadoc and docs is enough. + +## Risks / Trade-offs + +- Real-pod behavior (do attachments replace or add to existing ones; is `message` required alongside) is unverified. Mitigation: docs state only what the API reference states, and the proposal flags it as open. +- If the mock client cannot expose the request body, the test may need a small helper or a spy on `ApiClient`, adding a little test infrastructure. diff --git a/openspec/changes/archive/2026-10-06-update-message-attachments/proposal.md b/openspec/changes/archive/2026-10-06-update-message-attachments/proposal.md new file mode 100644 index 000000000..6f8411069 --- /dev/null +++ b/openspec/changes/archive/2026-10-06-update-message-attachments/proposal.md @@ -0,0 +1,23 @@ +## Why + +The Update Message v4 endpoint accepts `attachment` and `preview` parts since SBE v24.1, and `MessageService.update(...)` already forwards them (it builds its multipart form with the same `getForm(message)` used by `send`). However, the javadoc on `MessageService` and `OboMessageService` still says "attachments are not supported yet", no test covers update-with-attachment, and the docs show no example. Users reasonably conclude the feature is missing. + +## What Changes + +- Correct the javadoc of both `update(...)` overloads in `MessageService` and `OboMessageService`: attachments and previews are supported, and require a pod on SBE v24.1 or later. +- Add tests asserting that `update(...)` sends `attachment` and `preview` multipart parts (regular and OBO). +- Add an update-with-attachment example to `docs/message.md`. +- No production logic change, no signature change, no change to the `@API(status = EXPERIMENTAL)` annotation (promotion to stable is a maintainer decision, out of scope). + +## Capabilities + +### New Capabilities +- `message-update-attachments`: contract that `MessageService.update(...)` (and its OBO variant) forwards `Message` attachments and previews to the Update Message v4 endpoint. + +### Modified Capabilities + +## Impact + +- `symphony-bdk-core`: `MessageService.java`, `OboMessageService.java` (javadoc only); `MessageServiceTest.java` (new tests). +- `docs/message.md` (example). +- Open question, not resolved by this change: server-side semantics of attachments on update (replace vs add) have not been verified against a real pod; the docs will describe only what the API reference states. diff --git a/openspec/changes/archive/2026-10-06-update-message-attachments/specs/message-update-attachments/spec.md b/openspec/changes/archive/2026-10-06-update-message-attachments/specs/message-update-attachments/spec.md new file mode 100644 index 000000000..1dbb5d9ca --- /dev/null +++ b/openspec/changes/archive/2026-10-06-update-message-attachments/specs/message-update-attachments/spec.md @@ -0,0 +1,24 @@ +## Purpose + +Defines how the BDK forwards file attachments and previews when updating an existing message, so users can rely on update supporting the same content as send. + +## ADDED Requirements + +### Requirement: Update forwards attachments and previews +When a `Message` passed to a message update operation (bot or OBO) contains attachments or previews, the BDK SHALL send each of them as `attachment` and `preview` parts of the multipart body of the Update Message v4 request, exactly as it does for message creation. Attachment support on update requires a pod running SBE v24.1 or later. + +#### Scenario: Update with attachment and preview +- **WHEN** a user updates a message with a `Message` containing one attachment and its preview +- **THEN** the Update Message v4 request contains one `attachment` part and one `preview` part with the provided filename and content type + +#### Scenario: Update with multiple attachments +- **WHEN** a user updates a message with a `Message` containing two attachments +- **THEN** the request contains two `attachment` parts + +#### Scenario: Update through OBO +- **WHEN** a user updates a message through `OboMessageService` with a `Message` containing an attachment +- **THEN** the request contains the `attachment` part, using the delegated session tokens + +#### Scenario: Update without attachments +- **WHEN** a user updates a message with a `Message` containing only text +- **THEN** the request contains no `attachment` or `preview` parts diff --git a/openspec/changes/archive/2026-10-06-update-message-attachments/tasks.md b/openspec/changes/archive/2026-10-06-update-message-attachments/tasks.md new file mode 100644 index 000000000..03edc070a --- /dev/null +++ b/openspec/changes/archive/2026-10-06-update-message-attachments/tasks.md @@ -0,0 +1,17 @@ +## 1. Javadoc + +- [x] 1.1 In `MessageService.java`, replace "(attachments are not supported yet)" on both `update(...)` overloads with a statement that attachments and previews are supported and require SBE v24.1 or later +- [x] 1.2 Apply the same javadoc fix to both `update(...)` declarations in `OboMessageService.java` + +## 2. Tests + +- [x] 2.1 In `MessageServiceTest`, find how to capture the multipart request body from `mockApiClient` (or spy `ApiClient.invokeAPI`) and add a small helper if needed +- [x] 2.2 Add a test: update with one attachment and its preview sends one `attachment` and one `preview` part with the expected filename and content type +- [x] 2.3 Add a test: update with two attachments sends two `attachment` parts +- [x] 2.4 Add a test: update with text only sends no `attachment` or `preview` parts +- [x] 2.5 Add a test: update with an attachment through `messageService.obo(authSession)` sends the `attachment` part +- [x] 2.6 Run `./gradlew :symphony-bdk-core:test --tests "com.symphony.bdk.core.service.message.MessageServiceTest"` + +## 3. Docs + +- [x] 3.1 Add an "update a message with an attachment" snippet to `docs/message.md` under "How to use", with a note that attachments on update require SBE v24.1 or later diff --git a/openspec/specs/message-update-attachments/spec.md b/openspec/specs/message-update-attachments/spec.md new file mode 100644 index 000000000..1a7a6c700 --- /dev/null +++ b/openspec/specs/message-update-attachments/spec.md @@ -0,0 +1,26 @@ +# Message-Update-Attachments Specification + +## Purpose + +Defines how the BDK forwards file attachments and previews when updating an existing message, so users can rely on update supporting the same content as send. + +## Requirements + +### Requirement: Update forwards attachments and previews +When a `Message` passed to a message update operation (bot or OBO) contains attachments or previews, the BDK SHALL send each of them as `attachment` and `preview` parts of the multipart body of the Update Message v4 request, exactly as it does for message creation. Attachment support on update requires a pod running SBE v24.1 or later. + +#### Scenario: Update with attachment and preview +- **WHEN** a user updates a message with a `Message` containing one attachment and its preview +- **THEN** the Update Message v4 request contains one `attachment` part and one `preview` part with the provided filename and content type + +#### Scenario: Update with multiple attachments +- **WHEN** a user updates a message with a `Message` containing two attachments +- **THEN** the request contains two `attachment` parts + +#### Scenario: Update through OBO +- **WHEN** a user updates a message through `OboMessageService` with a `Message` containing an attachment +- **THEN** the request contains the `attachment` part, using the delegated session tokens + +#### Scenario: Update without attachments +- **WHEN** a user updates a message with a `Message` containing only text +- **THEN** the request contains no `attachment` or `preview` parts diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java index 2720ab5c1..ed65aee4d 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/MessageService.java @@ -409,7 +409,7 @@ public List getAttachmentTypes() { * Update an existing message. The existing message must be a valid social message, that has not been deleted. * * @param messageToUpdate the message to be updated - * @param content the update content (attachments are not supported yet) + * @param content the update content (attachments and previews are supported only starting from SBE v24.1) * @return a {@link V4Message} object containing the details of the sent message * @see Create Update v4 */ @@ -424,7 +424,7 @@ public V4Message update(V4Message messageToUpdate, Message content) { * * @param streamId the ID of the stream where the message to be updated comes from * @param messageId the ID of the message to be updated - * @param content the update content (attachments are not supported yet) + * @param content the update content (attachments and previews are supported only starting from SBE v24.1) * @return a {@link V4Message} object containing the details of the sent message * @see Create Update v4 */ diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java index 802fba9a0..5fc41eeb1 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/OboMessageService.java @@ -178,7 +178,7 @@ public interface OboMessageService { * Update an existing message. The existing message must be a valid social message, that has not been deleted. * * @param messageToUpdate the message to be updated - * @param content the update content (attachments are not supported yet) + * @param content the update content (attachments and previews are supported only starting from SBE v24.1) * @return a {@link V4Message} object containing the details of the sent message * @see Create Update v4 */ @@ -189,7 +189,7 @@ public interface OboMessageService { * * @param streamId the ID of the stream where the message to be updated comes from * @param messageId the ID of the message to be updated - * @param content the update content (attachments are not supported yet) + * @param content the update content (attachments and previews are supported only starting from SBE v24.1) * @return a {@link V4Message} object containing the details of the sent message * @see Create Update v4 */ diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java index bda085b88..640781a27 100644 --- a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/service/message/MessageServiceTest.java @@ -97,6 +97,7 @@ class MessageServiceTest { private static final String TOKEN = "1234"; private MockApiClient mockApiClient; + private ApiClient agentClient; private MessageService messageService; private StreamsApi streamsApi; private AttachmentsApi attachmentsApi; @@ -116,7 +117,7 @@ void setUp() { mockApiClient = new MockApiClient(); ApiClient podClient = mockApiClient.getApiClient("/pod"); - ApiClient agentClient = mockApiClient.getApiClient("/agent"); + agentClient = spy(mockApiClient.getApiClient("/agent")); templateEngine = mock(TemplateEngine.class); streamsApi = spy(new StreamsApi(podClient)); @@ -482,6 +483,133 @@ void testMessageSilentUpdate() throws IOException { assertEquals(false, updateMessage.getSilent()); } + private Map captureUpdateFormParams(Message message) throws IOException, ApiException { + return captureUpdateFormParams(message, null); + } + + private Map captureUpdateFormParams(Message message, AuthSession oboSession) + throws IOException, ApiException { + mockApiClient.onPost(V4_STREAM_MESSAGE_UPDATE.replace("{sid}", STREAM_ID).replace("{mid}", MESSAGE_ID), + JsonHelper.readFromClasspath("/message/update_message.json")); + + final V4Message messageToUpdate = new V4Message().stream(new V4Stream().streamId(STREAM_ID)).messageId(MESSAGE_ID); + + final V4Message updateMessage; + if (oboSession != null) { + updateMessage = this.messageService.obo(oboSession).update(messageToUpdate, message); + } else { + updateMessage = this.messageService.update(messageToUpdate, message); + } + + assertNotNull(updateMessage); + assertEquals(MESSAGE_ID, updateMessage.getMessageId()); + + final Map expectedHeaders = new HashMap<>(); + expectedHeaders.put("sessionToken", oboSession != null ? oboSession.getSessionToken() : TOKEN); + expectedHeaders.put("keyManagerToken", oboSession != null ? oboSession.getKeyManagerToken() : TOKEN); + + final ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + + verify(agentClient) + .invokeAPI( + eq(String.format("/v4/stream/%s/message/%s/update", STREAM_ID, MESSAGE_ID)), + eq("POST"), + eq(Collections.emptyList()), + isNull(), + eq(expectedHeaders), + eq(Collections.emptyMap()), + captor.capture(), + eq("application/json"), + eq("multipart/form-data"), + eq(new String[0]), + any()); + + return captor.getValue(); + } + + @Test + void testUpdateWithAttachmentAndPreview() throws IOException, ApiException { + final InputStream attachmentStream = IOUtils.toInputStream("Attached file", StandardCharsets.UTF_8); + final InputStream previewStream = IOUtils.toInputStream("Preview file", StandardCharsets.UTF_8); + final Message content = Message.builder() + .content("This is a message update") + .addAttachment(attachmentStream, previewStream, "file.txt", "text/plain") + .build(); + + final Map form = captureUpdateFormParams(content); + + final ApiClientBodyPart[] attachments = (ApiClientBodyPart[]) form.get("attachment"); + assertNotNull(attachments); + assertEquals(1, attachments.length); + assertEquals("file.txt", attachments[0].getFilename()); + assertEquals("text/plain", attachments[0].getContentType()); + + final ApiClientBodyPart[] previews = (ApiClientBodyPart[]) form.get("preview"); + assertNotNull(previews); + assertEquals(1, previews.length); + assertEquals("preview-file.txt", previews[0].getFilename()); + assertEquals("text/plain", previews[0].getContentType()); + } + + @Test + void testUpdateWithMultipleAttachments() throws IOException, ApiException { + final InputStream firstAttachment = IOUtils.toInputStream("First attached file", StandardCharsets.UTF_8); + final InputStream secondAttachment = IOUtils.toInputStream("Second attached file", StandardCharsets.UTF_8); + final Message content = Message.builder() + .content("This is a message update") + .addAttachment(firstAttachment, "file1.txt") + .addAttachment(secondAttachment, "file2.txt") + .build(); + + final Map form = captureUpdateFormParams(content); + + final ApiClientBodyPart[] attachments = (ApiClientBodyPart[]) form.get("attachment"); + assertNotNull(attachments); + assertEquals(2, attachments.length); + assertEquals("file1.txt", attachments[0].getFilename()); + assertEquals("file2.txt", attachments[1].getFilename()); + + final ApiClientBodyPart[] previews = (ApiClientBodyPart[]) form.get("preview"); + assertNotNull(previews); + assertEquals(0, previews.length); + } + + @Test + void testUpdateWithTextOnlySendsNoAttachmentOrPreviewParts() throws IOException, ApiException { + final Message content = Message.builder().content("This is a message update with text only").build(); + + final Map form = captureUpdateFormParams(content); + + final ApiClientBodyPart[] attachments = (ApiClientBodyPart[]) form.get("attachment"); + assertNotNull(attachments); + assertEquals(0, attachments.length); + + final ApiClientBodyPart[] previews = (ApiClientBodyPart[]) form.get("preview"); + assertNotNull(previews); + assertEquals(0, previews.length); + } + + @Test + void testUpdateWithAttachmentObo() throws IOException, ApiException { + final AuthSession oboSession = mock(AuthSession.class); + when(oboSession.getSessionToken()).thenReturn("oboSessionToken"); + when(oboSession.getKeyManagerToken()).thenReturn("oboKmToken"); + + final InputStream attachmentStream = IOUtils.toInputStream("Attached file", StandardCharsets.UTF_8); + final Message content = Message.builder() + .content("This is an OBO message update") + .addAttachment(attachmentStream, "obo-file.txt", "text/plain") + .build(); + + final Map form = captureUpdateFormParams(content, oboSession); + + final ApiClientBodyPart[] attachments = (ApiClientBodyPart[]) form.get("attachment"); + assertNotNull(attachments); + assertEquals(1, attachments.length); + assertEquals("obo-file.txt", attachments[0].getFilename()); + assertEquals("text/plain", attachments[0].getContentType()); + } + @Test void testGetAttachment() throws ApiException { final String attachmentId = "attachmentId";