From 862567ac93196ab4a7c39f44b7183601751d59ff Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 01:48:24 -0400 Subject: [PATCH 01/11] Add sendStory method for posting file attachment stories to My Story Adds Manager.sendStory(attachment, allowsReplies), which uploads a file attachment, builds a SignalServiceStoryMessage, and sends it to all registered, non-blocked, non-hidden contacts that haven't opted out of seeing the user's story (Contact.hideStory), excluding self. SendHelper gains sendStoryMessage(), which resolves recipient addresses and unidentified access and delegates to SignalServiceMessageSender.sendGroupStory() against DistributionId.MY_STORY, following the same address/access resolution pattern used for group sends. A sync transcript is sent afterwards via sendStorySyncMessage() so linked devices see the story was posted. This is core library plumbing only; no CLI command, stub implementations, or documentation are added yet. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs --- .../org/asamk/signal/manager/Manager.java | 8 +++ .../signal/manager/helper/SendHelper.java | 49 +++++++++++++++++++ .../signal/manager/internal/ManagerImpl.java | 49 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index dac629e7..4c625e79 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -213,6 +213,14 @@ public interface Manager extends Closeable { long editTargetTimestamp ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException; + /** + * Post a file attachment story to "My Story". + * + * @param attachment path to the file to upload and post as a story + * @param allowsReplies whether other users are allowed to reply to this story + */ + SendMessageResults sendStory(String attachment, boolean allowsReplies) throws IOException, AttachmentInvalidException; + SendMessageResults sendRemoteDeleteMessage( long targetSentTimestamp, Set recipients diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index 87e67b7a..261ce228 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -35,6 +35,8 @@ import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; @@ -330,6 +332,53 @@ public class SendHelper { return result; } + /** + * Send a story message (file attachment) to "My Story". + */ + public List sendStoryMessage( + SignalServiceStoryMessage storyMessage, + long timestamp, + Set recipientIds, + boolean allowsReplies + ) throws IOException { + final var messageSender = dependencies.getMessageSender(); + + final var recipientIdList = List.copyOf(recipientIds); + final var addressesMap = recipientIdList.stream() + .collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress)); + final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds); + + final var addresses = recipientIdList.stream().map(addressesMap::get).toList(); + final var unidentifiedAccesses = recipientIdList.stream().map(unidentifiedAccessesMap::get).toList(); + final var storyMessageRecipients = recipientIdList.stream() + .map(id -> new SignalServiceStoryMessageRecipient(addressesMap.get(id), + List.of(DistributionId.MY_STORY.asUuid().toString()), + allowsReplies)) + .collect(Collectors.toSet()); + + final List results; + try { + results = messageSender.sendGroupStory(DistributionId.MY_STORY, + Optional.empty(), + addresses, + unidentifiedAccesses, + null, + false, + storyMessage, + timestamp, + storyMessageRecipients, + null); + } catch (UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException e) { + throw new IOException(e); + } + + for (var r : results) { + handleSendMessageResult(r); + } + + return results; + } + private List sendAsGroupMessage( final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo g, diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 18c261b5..d5a72a88 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -104,10 +104,13 @@ import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.network.exceptions.NonSuccessfulResponseCodeException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; import org.whispersystems.signalservice.api.messages.calls.BusyMessage; @@ -116,6 +119,7 @@ import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; +import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; import org.whispersystems.signalservice.api.util.DeviceNameUtil; @@ -826,6 +830,51 @@ public class ManagerImpl implements Manager { return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp), message.urgent()); } + @Override + public SendMessageResults sendStory( + String attachment, + boolean allowsReplies + ) throws IOException, AttachmentInvalidException { + final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); + final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(), + null, + uploadedAttachment, + allowsReplies, + List.of()); + final var timestamp = getNextMessageTimestamp(); + + final var recipients = account.getRecipientStore() + .getRecipients(true, Optional.of(false), Set.of(), Optional.empty()); + final var recipientIds = recipients.stream() + .filter(r -> !r.getRecipientId().equals(account.getSelfRecipientId())) + .filter(r -> r.getContact() == null || !r.getContact().hideStory()) + .map(r -> r.getRecipientId()) + .collect(Collectors.toSet()); + + final var sendResults = context.getSendHelper() + .sendStoryMessage(storyMessage, timestamp, recipientIds, allowsReplies); + + final var storyMessageRecipients = sendResults.stream() + .filter(org.whispersystems.signalservice.api.messages.SendMessageResult::isSuccess) + .map(r -> new SignalServiceStoryMessageRecipient(r.getAddress(), + List.of(DistributionId.MY_STORY.asUuid().toString()), + allowsReplies)) + .collect(Collectors.toSet()); + try { + dependencies.getMessageSender().sendStorySyncMessage(storyMessage, timestamp, false, storyMessageRecipients); + } catch (UntrustedIdentityException e) { + throw new IOException(e); + } + + final var results = new HashMap>(); + for (final var sendResult : sendResults) { + final var result = toSendMessageResult(sendResult); + results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result)); + } + + return new SendMessageResults(timestamp, results); + } + private void applyMessage( final SignalServiceDataMessage.Builder messageBuilder, final Message message From 1208fdb143e444859de7414deea764404fa9cc98 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 02:02:49 -0400 Subject: [PATCH 02/11] Add sendStory command for posting stories via CLI and JSON-RPC Implements SendStoryCommand to allow users to post stories through the CLI and JSON-RPC interfaces. Command accepts an attachment file path (required) and optional --no-replies flag to disable replies on the story. Handles AttachmentInvalidException and IOException appropriately and outputs results using SendMessageResultUtils. Registered in Commands.java in alphabetical order. --- .../org/asamk/signal/commands/Commands.java | 1 + .../signal/commands/SendStoryCommand.java | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/main/java/org/asamk/signal/commands/SendStoryCommand.java diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 05dc1ff4..4787c7d1 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -52,6 +52,7 @@ public class Commands { addCommand(new SendPollTerminateCommand()); addCommand(new SendReactionCommand()); addCommand(new SendReceiptCommand()); + addCommand(new SendStoryCommand()); addCommand(new SendSyncRequestCommand()); addCommand(new SendTypingCommand()); addCommand(new SendUnpinMessageCommand()); diff --git a/src/main/java/org/asamk/signal/commands/SendStoryCommand.java b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java new file mode 100644 index 00000000..d2f032a1 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java @@ -0,0 +1,57 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.impl.Arguments; +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.UnexpectedErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.AttachmentInvalidException; +import org.asamk.signal.output.OutputWriter; + +import java.io.IOException; + +import static org.asamk.signal.util.SendMessageResultUtils.outputResult; + +public class SendStoryCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "sendStory"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Post a story to your Story."); + subparser.addArgument("-a", "--attachment") + .required(true) + .help("Specify the file path to the image or video to post as a story."); + subparser.addArgument("--no-replies") + .action(Arguments.storeTrue()) + .help("Disable replies on this story."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + final var attachment = ns.getString("attachment"); + if (attachment == null || attachment.isEmpty()) { + throw new UserErrorException("An attachment is required for sending a story."); + } + + final var noReplies = Boolean.TRUE.equals(ns.getBoolean("no-replies")); + + try { + final var results = m.sendStory(attachment, !noReplies); + outputResult(outputWriter, results); + } catch (AttachmentInvalidException | IOException e) { + throw new UnexpectedErrorException("Failed to send story: " + e.getMessage() + " (" + e.getClass() + .getSimpleName() + ")", e); + } + } +} From 68fabb93869e46484e03a59e92a50ab85a6242ea Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 02:05:19 -0400 Subject: [PATCH 03/11] Add sendStory stubs to DbusManagerImpl and StubManager Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs --- src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java | 5 +++++ src/test/java/org/asamk/signal/http/SseInitialFlushTest.java | 5 +++++ .../org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 45b937ae..de8df1f8 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -542,6 +542,11 @@ public class DbusManagerImpl implements Manager { return new SendMessageResults(timestamp, Map.of()); } + @Override + public SendMessageResults sendStory(String attachment, boolean allowsReplies) { + return new SendMessageResults(0, Map.of()); + } + @Override public void sendEndSessionMessage(final Set recipients) throws IOException { signal.sendEndSessionMessage(recipients.stream().map(RecipientIdentifier.Single::getIdentifier).toList()); diff --git a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java index d62c5220..6f029696 100644 --- a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java +++ b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java @@ -349,6 +349,11 @@ class SseInitialFlushTest { return null; } + @Override + public SendMessageResults sendStory(String attachment, boolean allowsReplies) { + return new SendMessageResults(0, Map.of()); + } + @Override public void hideRecipient(RecipientIdentifier.Single recipient) { } diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java index bfc56ed3..50836cac 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -367,6 +367,11 @@ class SubscribeCallEventsTest { return null; } + @Override + public SendMessageResults sendStory(String attachment, boolean allowsReplies) { + return new SendMessageResults(0, Map.of()); + } + @Override public void hideRecipient(RecipientIdentifier.Single r) { } From 2c556f1a8c9d24aba9225f1fc8397c05385a9b4c Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 02:07:06 -0400 Subject: [PATCH 04/11] Document sendStory in man page and changelog --- CHANGELOG.md | 4 ++++ man/signal-cli.1.adoc | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab8b90f..4901041b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- New `sendStory` command to post file attachment stories to "My Story" + ### Fixed - Sending to large groups is no longer slowed down by members that are already known to be unregistered; they are skipped instead of being retried via the legacy 1:1 send path on every send. diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index f6ed732d..738b0c02 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -632,6 +632,16 @@ Specify the timestamp of the message to which to react. *--type* TYPE:: Specify the receipt type, either `read` (the default) or `viewed`. +=== sendStory + +Post a file attachment story to your Story, visible to all contacts. + +*-a* ATTACHMENT, *--attachment* ATTACHMENT:: +Specify the file path to the image or video to post as a story. + +*--no-replies*:: +Disable replies on this story. By default, replies are allowed. + === sendTyping Send typing message to trigger a typing indicator for the recipient. From 4ccad0321f24a5cd641d8b9ea73f829ff99aa809 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 02:18:53 -0400 Subject: [PATCH 05/11] Validate story attachment MIME type and fix D-Bus stub - Reject non-image/video attachments before uploading, since stories only support image and video content - Change DbusManagerImpl.sendStory to throw UnsupportedOperationException to match the pattern used by all other unimplemented D-Bus methods --- .../java/org/asamk/signal/manager/internal/ManagerImpl.java | 6 ++++++ src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index d5a72a88..72f16a95 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -835,6 +835,12 @@ public class ManagerImpl implements Manager { String attachment, boolean allowsReplies ) throws IOException, AttachmentInvalidException { + final var file = new File(attachment); + final var mimeType = MimeUtils.getFileMimeType(file); + if (mimeType.isEmpty() || (!mimeType.get().startsWith("image/") && !mimeType.get().startsWith("video/"))) { + throw new AttachmentInvalidException(attachment, + new IOException("Stories only support image and video attachments")); + } final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(), null, diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index de8df1f8..3de99600 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -544,7 +544,7 @@ public class DbusManagerImpl implements Manager { @Override public SendMessageResults sendStory(String attachment, boolean allowsReplies) { - return new SendMessageResults(0, Map.of()); + throw new UnsupportedOperationException(); } @Override From d6b321dc19e5eab05e49d0b8b6f176050e311397 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 09:35:43 -0400 Subject: [PATCH 06/11] Resolve recipients before uploading story attachment Move recipient resolution ahead of the attachment upload so that an empty contact list is caught early without wasting bandwidth on an upload that would reach nobody. --- .../signal/manager/internal/ManagerImpl.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 72f16a95..3b812c5e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -841,13 +841,6 @@ public class ManagerImpl implements Manager { throw new AttachmentInvalidException(attachment, new IOException("Stories only support image and video attachments")); } - final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); - final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(), - null, - uploadedAttachment, - allowsReplies, - List.of()); - final var timestamp = getNextMessageTimestamp(); final var recipients = account.getRecipientStore() .getRecipients(true, Optional.of(false), Set.of(), Optional.empty()); @@ -857,6 +850,18 @@ public class ManagerImpl implements Manager { .map(r -> r.getRecipientId()) .collect(Collectors.toSet()); + if (recipientIds.isEmpty()) { + throw new IOException("No eligible contacts found for story delivery"); + } + + final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); + final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(), + null, + uploadedAttachment, + allowsReplies, + List.of()); + final var timestamp = getNextMessageTimestamp(); + final var sendResults = context.getSendHelper() .sendStoryMessage(storyMessage, timestamp, recipientIds, allowsReplies); From ad34477700e6ebdcc0acc17bc1e3554167db7772 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 10:23:12 -0400 Subject: [PATCH 07/11] Add group story support to core library layer Extend Manager.sendStory() with an optional GroupId parameter and add SendHelper.sendGroupStoryMessage() for endorsement-aware group story delivery, laying the groundwork for group story support (task 1 of 4). The existing My Story code path is unchanged. --- .superpowers/sdd/task-1-report.md | 64 +++++++++++++++++++ .../org/asamk/signal/manager/Manager.java | 9 ++- .../signal/manager/helper/SendHelper.java | 61 ++++++++++++++++++ .../signal/manager/internal/ManagerImpl.java | 62 +++++++++++++++++- 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 .superpowers/sdd/task-1-report.md diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 00000000..815811e6 --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,64 @@ +# Task 1 Report: Core Library Layer — Group Story Support + +## Changes + +### `lib/src/main/java/org/asamk/signal/manager/Manager.java` +- `sendStory(String, boolean)` → `sendStory(String attachment, boolean allowsReplies, Optional groupId)`. +- Throws clause extended with `GroupNotFoundException, NotAGroupMemberException`. +- All required imports (`Optional`, `GroupId`, `GroupNotFoundException`, `NotAGroupMemberException`) were already present in the file, so no import changes were needed. +- Updated the javadoc above the method to describe the new `groupId` parameter. + +### `lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java` +- `sendStory()` now takes the third `Optional groupId` parameter and throws `GroupNotFoundException, NotAGroupMemberException` in addition to the existing exceptions. +- MIME validation runs first (shared by both paths); if `groupId.isPresent()`, control is handed off to a new private `sendGroupStory(String attachment, boolean allowsReplies, GroupId groupId)` method before any attachment upload happens (fail-fast). +- The pre-existing "My Story" branch (`groupId.isEmpty()`) is untouched — same code, same order of operations. +- New private `sendGroupStory` method: + 1. Resolves the group via `context.getGroupHelper().getGroup(groupId)`; throws `GroupNotFoundException` if null. + 2. Checks `groupInfo.isMember(account.getSelfRecipientId())`; throws `NotAGroupMemberException` if false. + 3. Requires a V2 group via pattern-match cast to `GroupInfoV2`; throws `IOException("Stories are only supported for V2 groups")` otherwise. + 4. Uploads the attachment, builds a `SignalServiceGroupV2` context (master key + revision, revision 0 if `getGroup()` is null), builds the `SignalServiceStoryMessage.forFileAttachment(...)` with the group context. + 5. Uses `getNextMessageTimestamp()` for the timestamp (not `System.currentTimeMillis()`). + 6. Calls `context.getSendHelper().sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies)`. + 7. Builds sync transcript recipients using `groupInfoV2.getDistributionId().asUuid().toString()` (not `DistributionId.MY_STORY`). + 8. Sends the sync message via `dependencies.getMessageSender().sendStorySyncMessage(...)`, wrapping `UntrustedIdentityException` in `IOException`, same as the My Story path. + 9. Returns `SendMessageResults` built the same way as the My Story path. +- Added imports: `org.asamk.signal.manager.storage.groups.GroupInfoV2` and `org.whispersystems.signalservice.api.messages.SignalServiceGroupV2`. + +### `lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java` +- New public method `sendGroupStoryMessage(SignalServiceStoryMessage storyMessage, long timestamp, GroupInfoV2 groupInfo, boolean allowsReplies) throws IOException`, inserted right after the existing `sendStoryMessage` (My Story) method. +- Flow follows the brief exactly: + 1. `groupInfo.getMembersWithout(account.getSelfRecipientId())` for recipients, converted to `List.copyOf(...)`. + 2. Resolves addresses via `context.getRecipientHelper()::resolveSignalServiceAddress` and unidentified access via `context.getUnidentifiedAccessHelper().getAccessFor(recipientIds)`. + 3. Calls the existing private `getGroupSendEndorsements(groupInfo)`; throws `IOException("Group send endorsements unavailable; try again after group state refreshes")` if the result is null. + 4. Derives `GroupSecretParams.deriveFromMasterKey(groupInfo.getMasterKey())` and fetches the sender certificate via `context.getUnidentifiedAccessHelper().getSenderCertificateFor(null)`. + 5. Builds `GroupSendEndorsements` with an `ACI`-keyed map derived from the resolved addresses. + 6. Builds `SignalServiceStoryMessageRecipient`s using `groupInfo.getDistributionId().asUuid().toString()`. + 7. Calls `messageSender.sendGroupStory(groupInfo.getDistributionId(), Optional.of(groupInfo.getMasterKey().serialize()), addresses, unidentifiedAccesses, groupSendEndorsements, false, storyMessage, timestamp, storyMessageRecipients, null)`, catching `UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException` and rethrowing as `IOException`, mirroring `sendStoryMessage`'s existing catch clause. + 8. Calls `handleSendMessageResult(r)` for each result and returns the list. +- No new imports were required — `GroupInfoV2`, `GroupSecretParams`, `ACI`, `GroupSendEndorsements`, and `UnidentifiedAccess` were already imported in this file (used by the existing group-message sending code). + +## Build output + +``` +$ export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home +$ ./gradlew :lib:compileJava 2>&1 | tail -10 +> Task :buildSrc:checkKotlinGradlePluginConfigurationErrors SKIPPED +> Task :buildSrc:compileKotlin UP-TO-DATE +> Task :buildSrc:compileJava NO-SOURCE +> Task :buildSrc:compileGroovy NO-SOURCE +> Task :buildSrc:pluginDescriptors UP-TO-DATE +> Task :buildSrc:processResources UP-TO-DATE +> Task :buildSrc:classes UP-TO-DATE +> Task :buildSrc:jar UP-TO-DATE +> Task :libsignal-cli:compileJava + +BUILD SUCCESSFUL in 1s +``` + +Note: the Gradle project for the `lib/` source tree is actually named `:libsignal-cli` (see `settings.gradle.kts`: `project(":libsignal-cli").projectDir = file("lib")`). Gradle's task-selector prefix matching resolved `:lib:compileJava` to `:libsignal-cli:compileJava`, which compiled successfully — confirming the lib module (including these changes) compiles cleanly on its own, without the command layer. + +As expected/documented in the brief, the command layer (`SendStoryCommand`, `DbusManagerImpl`, `StubManager`) was intentionally left untouched and will fail to compile against the new 3-parameter `sendStory` signature until Tasks 2/3 update those call sites. This was not exercised here since only `:lib:compileJava` was run. + +## Concerns + +None. The implementation follows the brief's step-by-step spec verbatim, reuses the exact patterns already present in the "My Story" code paths (`sendStory` in ManagerImpl, `sendStoryMessage`/`getGroupSendEndorsements`/`GroupSendEndorsements` construction in SendHelper), and required no new imports in SendHelper since all needed types were already imported for the pre-existing sender-key group-message code path. diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 4c625e79..791c9825 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -214,12 +214,17 @@ public interface Manager extends Closeable { ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException; /** - * Post a file attachment story to "My Story". + * Post a file attachment story to "My Story" or to a group. * * @param attachment path to the file to upload and post as a story * @param allowsReplies whether other users are allowed to reply to this story + * @param groupId if present, post the story to this group instead of "My Story" */ - SendMessageResults sendStory(String attachment, boolean allowsReplies) throws IOException, AttachmentInvalidException; + SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException; SendMessageResults sendRemoteDeleteMessage( long targetSentTimestamp, diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index 261ce228..4ec884a6 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -379,6 +379,67 @@ public class SendHelper { return results; } + /** + * Send a story message (file attachment) to a group. + */ + public List sendGroupStoryMessage( + SignalServiceStoryMessage storyMessage, + long timestamp, + GroupInfoV2 groupInfo, + boolean allowsReplies + ) throws IOException { + final var messageSender = dependencies.getMessageSender(); + + final var recipientIds = groupInfo.getMembersWithout(account.getSelfRecipientId()); + final var recipientIdList = List.copyOf(recipientIds); + final var addressesMap = recipientIdList.stream() + .collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress)); + final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds); + + final var groupSendEndorsementsResult = getGroupSendEndorsements(groupInfo); + if (groupSendEndorsementsResult == null) { + throw new IOException("Group send endorsements unavailable; try again after group state refreshes"); + } + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfo.getMasterKey()); + final var senderCertificate = context.getUnidentifiedAccessHelper().getSenderCertificateFor(null); + final var groupSendEndorsements = new GroupSendEndorsements(groupSendEndorsementsResult.first(), + recipientIdList.stream() + .collect(Collectors.toMap(id -> (ACI) addressesMap.get(id).getServiceId(), + groupSendEndorsementsResult.second()::get)), + senderCertificate, + groupSecretParams); + + final var addresses = recipientIdList.stream().map(addressesMap::get).toList(); + final var unidentifiedAccesses = recipientIdList.stream().map(unidentifiedAccessesMap::get).toList(); + final var storyMessageRecipients = recipientIdList.stream() + .map(id -> new SignalServiceStoryMessageRecipient(addressesMap.get(id), + List.of(groupInfo.getDistributionId().asUuid().toString()), + allowsReplies)) + .collect(Collectors.toSet()); + + final List results; + try { + results = messageSender.sendGroupStory(groupInfo.getDistributionId(), + Optional.of(groupInfo.getMasterKey().serialize()), + addresses, + unidentifiedAccesses, + groupSendEndorsements, + false, + storyMessage, + timestamp, + storyMessageRecipients, + null); + } catch (UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException e) { + throw new IOException(e); + } + + for (var r : results) { + handleSendMessageResult(r); + } + + return results; + } + private List sendAsGroupMessage( final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo g, diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 3b812c5e..5f1d90fd 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -83,6 +83,7 @@ import org.asamk.signal.manager.storage.AttachmentStore; import org.asamk.signal.manager.storage.AvatarStore; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.groups.GroupInfo; +import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.identities.IdentityInfo; import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientId; @@ -107,6 +108,7 @@ import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; @@ -833,8 +835,9 @@ public class ManagerImpl implements Manager { @Override public SendMessageResults sendStory( String attachment, - boolean allowsReplies - ) throws IOException, AttachmentInvalidException { + boolean allowsReplies, + Optional groupId + ) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException { final var file = new File(attachment); final var mimeType = MimeUtils.getFileMimeType(file); if (mimeType.isEmpty() || (!mimeType.get().startsWith("image/") && !mimeType.get().startsWith("video/"))) { @@ -842,6 +845,10 @@ public class ManagerImpl implements Manager { new IOException("Stories only support image and video attachments")); } + if (groupId.isPresent()) { + return sendGroupStory(attachment, allowsReplies, groupId.get()); + } + final var recipients = account.getRecipientStore() .getRecipients(true, Optional.of(false), Set.of(), Optional.empty()); final var recipientIds = recipients.stream() @@ -886,6 +893,57 @@ public class ManagerImpl implements Manager { return new SendMessageResults(timestamp, results); } + private SendMessageResults sendGroupStory( + String attachment, + boolean allowsReplies, + GroupId groupId + ) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException { + final var groupInfo = context.getGroupHelper().getGroup(groupId); + if (groupInfo == null) { + throw new GroupNotFoundException(groupId); + } + if (!groupInfo.isMember(account.getSelfRecipientId())) { + throw new NotAGroupMemberException(groupId, groupInfo.getTitle()); + } + if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) { + throw new IOException("Stories are only supported for V2 groups"); + } + + final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); + final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey()) + .withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().revision) + .build(); + final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(), + groupContext, + uploadedAttachment, + allowsReplies, + List.of()); + final var timestamp = getNextMessageTimestamp(); + + final var sendResults = context.getSendHelper() + .sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies); + + final var storyMessageRecipients = sendResults.stream() + .filter(org.whispersystems.signalservice.api.messages.SendMessageResult::isSuccess) + .map(r -> new SignalServiceStoryMessageRecipient(r.getAddress(), + List.of(groupInfoV2.getDistributionId().asUuid().toString()), + allowsReplies)) + .collect(Collectors.toSet()); + try { + dependencies.getMessageSender().sendStorySyncMessage(storyMessage, timestamp, false, storyMessageRecipients); + } catch (UntrustedIdentityException e) { + throw new IOException(e); + } + + final var results = new HashMap>(); + for (final var sendResult : sendResults) { + final var result = toSendMessageResult(sendResult); + results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result)); + } + + return new SendMessageResults(timestamp, results); + } + private void applyMessage( final SignalServiceDataMessage.Builder messageBuilder, final Message message From a0abffd831299fbe756ef33950e1a67ea333d748 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 10:29:27 -0400 Subject: [PATCH 08/11] Add --group-id support to SendStoryCommand Passes an optional GroupId through to Manager.sendStory so stories can be posted to a group instead of only My Story, and surfaces GroupNotFoundException / NotAGroupMemberException as user errors. --- .../signal/commands/SendStoryCommand.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/asamk/signal/commands/SendStoryCommand.java b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java index d2f032a1..5e9e50ba 100644 --- a/src/main/java/org/asamk/signal/commands/SendStoryCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java @@ -9,9 +9,14 @@ import org.asamk.signal.commands.exceptions.UnexpectedErrorException; import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.api.AttachmentInvalidException; +import org.asamk.signal.manager.api.GroupId; +import org.asamk.signal.manager.api.GroupNotFoundException; +import org.asamk.signal.manager.api.NotAGroupMemberException; import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.util.CommandUtil; import java.io.IOException; +import java.util.Optional; import static org.asamk.signal.util.SendMessageResultUtils.outputResult; @@ -31,6 +36,8 @@ public class SendStoryCommand implements JsonRpcLocalCommand { subparser.addArgument("--no-replies") .action(Arguments.storeTrue()) .help("Disable replies on this story."); + subparser.addArgument("-g", "--group-id") + .help("Specify a group to post the story to. Without this, posts to My Story."); } @Override @@ -46,12 +53,21 @@ public class SendStoryCommand implements JsonRpcLocalCommand { final var noReplies = Boolean.TRUE.equals(ns.getBoolean("no-replies")); + final var groupIdStr = ns.getString("group-id"); + final var groupId = groupIdStr != null + ? Optional.of(CommandUtil.getGroupId(groupIdStr)) + : Optional.empty(); + try { - final var results = m.sendStory(attachment, !noReplies); + final var results = m.sendStory(attachment, !noReplies, groupId); outputResult(outputWriter, results); } catch (AttachmentInvalidException | IOException e) { throw new UnexpectedErrorException("Failed to send story: " + e.getMessage() + " (" + e.getClass() .getSimpleName() + ")", e); + } catch (GroupNotFoundException e) { + throw new UserErrorException("Group not found: " + e.getMessage()); + } catch (NotAGroupMemberException e) { + throw new UserErrorException("Not a member of this group: " + e.getMessage()); } } } From 39940fa6ec5c05b04dabffeec7a2b06d59ec8d5c Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 10:31:32 -0400 Subject: [PATCH 09/11] Update stubs for 3-parameter sendStory and add empty-recipient guard Updates DbusManagerImpl and StubManager (in SubscribeCallEventsTest) to match the new 3-parameter sendStory signature: (String attachment, boolean allowsReplies, Optional groupId). Also includes the empty-recipient guard added after Task 1 review to prevent stories from being sent to groups where the user is the only member. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs --- .../java/org/asamk/signal/manager/internal/ManagerImpl.java | 4 ++++ src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java | 6 +++++- .../java/org/asamk/signal/http/SseInitialFlushTest.java | 6 +++++- .../org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java | 6 +++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 5f1d90fd..2d4e5977 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -909,6 +909,10 @@ public class ManagerImpl implements Manager { throw new IOException("Stories are only supported for V2 groups"); } + if (groupInfoV2.getMembersWithout(account.getSelfRecipientId()).isEmpty()) { + throw new IOException("No other members in group for story delivery"); + } + final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey()) .withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().revision) diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 3de99600..3d0ab841 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -543,7 +543,11 @@ public class DbusManagerImpl implements Manager { } @Override - public SendMessageResults sendStory(String attachment, boolean allowsReplies) { + public SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) { throw new UnsupportedOperationException(); } diff --git a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java index 6f029696..d9db912f 100644 --- a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java +++ b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java @@ -350,7 +350,11 @@ class SseInitialFlushTest { } @Override - public SendMessageResults sendStory(String attachment, boolean allowsReplies) { + public SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) { return new SendMessageResults(0, Map.of()); } diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java index 50836cac..bd99e7d5 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -368,7 +368,11 @@ class SubscribeCallEventsTest { } @Override - public SendMessageResults sendStory(String attachment, boolean allowsReplies) { + public SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) { return new SendMessageResults(0, Map.of()); } From 8d421277499b2240e112c271d621058742684e85 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 10:35:16 -0400 Subject: [PATCH 10/11] Document group story support in man page and changelog - Add --group-id (-g) option to sendStory command in man page - Update CHANGELOG to mention group story support via --group-id - Maintain alphabetical order of options in sendStory section Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs --- CHANGELOG.md | 2 +- man/signal-cli.1.adoc | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4901041b..ea0b1f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- New `sendStory` command to post file attachment stories to "My Story" +- New `sendStory` command to post file attachment stories to "My Story" or to a group via `--group-id` ### Fixed diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 738b0c02..217f9f69 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -639,6 +639,10 @@ Post a file attachment story to your Story, visible to all contacts. *-a* ATTACHMENT, *--attachment* ATTACHMENT:: Specify the file path to the image or video to post as a story. +*-g* GROUP, *--group-id* GROUP:: +Specify a group to post the story to. +Without this flag, the story is posted to "My Story". + *--no-replies*:: Disable replies on this story. By default, replies are allowed. From 6dd5ce18b3b7b2f1ad7d5d39f8f9de8aa288a599 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 9 Jul 2026 11:32:04 -0400 Subject: [PATCH 11/11] Address review findings: endorsement safety and error handling - Filter group story recipients by ACI type and endorsement availability to prevent ClassCastException and NPE on edge cases - Add empty-recipient guard after endorsement filtering - Skip known-unregistered recipients before address resolution, matching the pattern from sendGroupMessageInternal - Add debug logging when recipients are filtered out - Fix redundant error message prefixing in SendStoryCommand - Add .superpowers/ to .gitignore Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs --- .gitignore | 1 + .../signal/manager/helper/SendHelper.java | 45 ++++++++++++++++--- .../signal/commands/SendStoryCommand.java | 6 +-- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 1e880c6d..ecbede43 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ man/*.1 man/*.5 man/man1 man/man5 +.superpowers/ diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index 4ec884a6..4a1d1b65 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -390,7 +390,23 @@ public class SendHelper { ) throws IOException { final var messageSender = dependencies.getMessageSender(); - final var recipientIds = groupInfo.getMembersWithout(account.getSelfRecipientId()); + final var allRecipientIds = groupInfo.getMembersWithout(account.getSelfRecipientId()); + final var skippedResults = new ArrayList(); + final var unregisteredRecipientIds = account.getRecipientStore().getUnregisteredRecipientIds(allRecipientIds); + final Set recipientIds; + if (unregisteredRecipientIds.isEmpty()) { + recipientIds = allRecipientIds; + } else { + logger.debug("Skipping {} known-unregistered recipient(s) in group story send.", + unregisteredRecipientIds.size()); + recipientIds = new HashSet<>(allRecipientIds); + recipientIds.removeAll(unregisteredRecipientIds); + for (final var recipientId : unregisteredRecipientIds) { + skippedResults.add(SendMessageResult.unregisteredFailure(context.getRecipientHelper() + .resolveSignalServiceAddress(recipientId))); + } + } + final var recipientIdList = List.copyOf(recipientIds); final var addressesMap = recipientIdList.stream() .collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress)); @@ -402,16 +418,29 @@ public class SendHelper { } final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfo.getMasterKey()); final var senderCertificate = context.getUnidentifiedAccessHelper().getSenderCertificateFor(null); + final var endorsementMap = groupSendEndorsementsResult.second(); + final var eligibleRecipientIds = recipientIdList.stream() + .filter(id -> addressesMap.get(id).getServiceId() instanceof ACI) + .filter(id -> endorsementMap.containsKey(id) && endorsementMap.get(id) != null) + .toList(); + if (eligibleRecipientIds.size() < recipientIdList.size()) { + logger.debug("Filtered {}/{} recipients for group story (missing ACI or endorsement)", + recipientIdList.size() - eligibleRecipientIds.size(), + recipientIdList.size()); + } + if (eligibleRecipientIds.isEmpty()) { + throw new IOException("No group members eligible for story delivery (missing endorsements or ACI)"); + } final var groupSendEndorsements = new GroupSendEndorsements(groupSendEndorsementsResult.first(), - recipientIdList.stream() + eligibleRecipientIds.stream() .collect(Collectors.toMap(id -> (ACI) addressesMap.get(id).getServiceId(), - groupSendEndorsementsResult.second()::get)), + endorsementMap::get)), senderCertificate, groupSecretParams); - final var addresses = recipientIdList.stream().map(addressesMap::get).toList(); - final var unidentifiedAccesses = recipientIdList.stream().map(unidentifiedAccessesMap::get).toList(); - final var storyMessageRecipients = recipientIdList.stream() + final var addresses = eligibleRecipientIds.stream().map(addressesMap::get).toList(); + final var unidentifiedAccesses = eligibleRecipientIds.stream().map(unidentifiedAccessesMap::get).toList(); + final var storyMessageRecipients = eligibleRecipientIds.stream() .map(id -> new SignalServiceStoryMessageRecipient(addressesMap.get(id), List.of(groupInfo.getDistributionId().asUuid().toString()), allowsReplies)) @@ -437,7 +466,9 @@ public class SendHelper { handleSendMessageResult(r); } - return results; + final var allResults = new ArrayList<>(results); + allResults.addAll(skippedResults); + return allResults; } private List sendAsGroupMessage( diff --git a/src/main/java/org/asamk/signal/commands/SendStoryCommand.java b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java index 5e9e50ba..f52ff707 100644 --- a/src/main/java/org/asamk/signal/commands/SendStoryCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java @@ -64,10 +64,8 @@ public class SendStoryCommand implements JsonRpcLocalCommand { } catch (AttachmentInvalidException | IOException e) { throw new UnexpectedErrorException("Failed to send story: " + e.getMessage() + " (" + e.getClass() .getSimpleName() + ")", e); - } catch (GroupNotFoundException e) { - throw new UserErrorException("Group not found: " + e.getMessage()); - } catch (NotAGroupMemberException e) { - throw new UserErrorException("Not a member of this group: " + e.getMessage()); + } catch (GroupNotFoundException | NotAGroupMemberException e) { + throw new UserErrorException(e.getMessage()); } } }