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/.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/CHANGELOG.md b/CHANGELOG.md index 0ab8b90f..ea0b1f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- New `sendStory` command to post file attachment stories to "My Story" or to a group via `--group-id` + ### 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..791c9825 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,19 @@ public interface Manager extends Closeable { long editTargetTimestamp ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException; + /** + * 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, + Optional groupId + ) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException; + 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..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 @@ -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,145 @@ 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; + } + + /** + * 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 226f7455..a4a2e1fc 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; @@ -105,10 +106,14 @@ 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.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; @@ -117,6 +122,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; @@ -827,6 +833,122 @@ public class ManagerImpl implements Manager { return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp), message.urgent()); } + @Override + public SendMessageResults sendStory( + String attachment, + 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/"))) { + throw new AttachmentInvalidException(attachment, + 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() + .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 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 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 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 f6ed732d..217f9f69 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -632,6 +632,20 @@ 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. + +*-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. + === 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..f52ff707 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/SendStoryCommand.java @@ -0,0 +1,71 @@ +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.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; + +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."); + subparser.addArgument("-g", "--group-id") + .help("Specify a group to post the story to. Without this, posts to My 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")); + + 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, 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 45b937ae..3d0ab841 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -542,6 +542,15 @@ public class DbusManagerImpl implements Manager { return new SendMessageResults(timestamp, Map.of()); } + @Override + public SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) { + 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..d9db912f 100644 --- a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java +++ b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java @@ -349,6 +349,15 @@ class SseInitialFlushTest { return null; } + @Override + public SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) { + 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..bd99e7d5 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -367,6 +367,15 @@ class SubscribeCallEventsTest { return null; } + @Override + public SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) { + return new SendMessageResults(0, Map.of()); + } + @Override public void hideRecipient(RecipientIdentifier.Single r) { }