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/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 e910df55..6d2ceb46 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 226f7455..bbfd9106 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 @@ -105,10 +105,12 @@ 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.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; import org.whispersystems.signalservice.api.messages.calls.BusyMessage; @@ -827,6 +829,50 @@ 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 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 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()); + + 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); + + 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 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. 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); + } + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 45b937ae..3de99600 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) { + throw new UnsupportedOperationException(); + } + @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) { }