From 6808b66897ba7d33cf15da0f35909671644e28a1 Mon Sep 17 00:00:00 2001 From: tonycpsu Date: Sun, 12 Jul 2026 10:32:40 -0400 Subject: [PATCH] Add group story support via --group-id (#2083) * 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. * 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. * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- .gitignore | 1 + CHANGELOG.md | 2 +- .../org/asamk/signal/manager/Manager.java | 9 +- .../signal/manager/helper/SendHelper.java | 92 +++++++++++++++++++ .../signal/manager/internal/ManagerImpl.java | 67 +++++++++++++- man/signal-cli.1.adoc | 4 + .../signal/commands/SendStoryCommand.java | 16 +++- .../asamk/signal/dbus/DbusManagerImpl.java | 6 +- 8 files changed, 190 insertions(+), 7 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/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/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 75a25fcb..80f24e39 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -221,12 +221,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 6d2ceb46..170a140d 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,98 @@ 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 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)); + 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 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(), + eligibleRecipientIds.stream() + .collect(Collectors.toMap(id -> (ACI) addressesMap.get(id).getServiceId(), + endorsementMap::get)), + senderCertificate, + groupSecretParams); + + 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)) + .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); + } + + final var allResults = new ArrayList<>(results); + allResults.addAll(skippedResults); + return allResults; + } + 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 62679428..24040d78 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; @@ -108,9 +109,11 @@ 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; +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; @@ -844,8 +847,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/"))) { @@ -853,6 +857,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() @@ -885,6 +893,61 @@ 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"); + } + + 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) + .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 | NoSessionException 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 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. diff --git a/src/main/java/org/asamk/signal/commands/SendStoryCommand.java b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java index d2f032a1..f52ff707 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,19 @@ 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 | NotAGroupMemberException e) { + throw new UserErrorException(e.getMessage()); } } } diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 8e50356f..9c1cdab8 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -548,7 +548,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(); }