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