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> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
tonycpsu 2026-07-12 10:32:40 -04:00 committed by GitHub
parent 14f98602d2
commit 6808b66897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 190 additions and 7 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ man/*.1
man/*.5
man/man1
man/man5
.superpowers/

View File

@ -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

View File

@ -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> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException;
SendMessageResults sendRemoteDeleteMessage(
long targetSentTimestamp,

View File

@ -379,6 +379,98 @@ public class SendHelper {
return results;
}
/**
* Send a story message (file attachment) to a group.
*/
public List<SendMessageResult> 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<SendMessageResult>();
final var unregisteredRecipientIds = account.getRecipientStore().getUnregisteredRecipientIds(allRecipientIds);
final Set<RecipientId> 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<SendMessageResult> 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<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g,

View File

@ -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> 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<RecipientIdentifier, List<SendMessageResult>>();
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

View File

@ -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.

View File

@ -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.<GroupId>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());
}
}
}

View File

@ -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> groupId
) {
throw new UnsupportedOperationException();
}