Add sendStory command for posting file attachment stories (#2082)

* Add sendStory method for posting file attachment stories to My Story

Adds Manager.sendStory(attachment, allowsReplies), which uploads a file
attachment, builds a SignalServiceStoryMessage, and sends it to all
registered, non-blocked, non-hidden contacts that haven't opted out of
seeing the user's story (Contact.hideStory), excluding self. SendHelper
gains sendStoryMessage(), which resolves recipient addresses and
unidentified access and delegates to
SignalServiceMessageSender.sendGroupStory() against
DistributionId.MY_STORY, following the same address/access resolution
pattern used for group sends. A sync transcript is sent afterwards via
sendStorySyncMessage() so linked devices see the story was posted.

This is core library plumbing only; no CLI command, stub
implementations, or documentation are added yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Add sendStory command for posting stories via CLI and JSON-RPC

Implements SendStoryCommand to allow users to post stories through the
CLI and JSON-RPC interfaces. Command accepts an attachment file path
(required) and optional --no-replies flag to disable replies on the story.

Handles AttachmentInvalidException and IOException appropriately and
outputs results using SendMessageResultUtils.

Registered in Commands.java in alphabetical order.

* Add sendStory stubs to DbusManagerImpl and StubManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Document sendStory in man page and changelog

* Validate story attachment MIME type and fix D-Bus stub

- Reject non-image/video attachments before uploading, since stories
  only support image and video content
- Change DbusManagerImpl.sendStory to throw UnsupportedOperationException
  to match the pattern used by all other unimplemented D-Bus methods

* Resolve recipients before uploading story attachment

Move recipient resolution ahead of the attachment upload so that an
empty contact list is caught early without wasting bandwidth on an
upload that would reach nobody.

* Address review feedback: fix hideStory filter and remove redundant sync

- Fix hideStory filter to require contact exists (!=null &&) instead of
  permitting null contacts (==null ||), matching the intent of filtering
  to contacts who haven't hidden stories
- Remove manual sendStorySyncMessage call, as the library's sendStory
  already handles sync internally

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHzM2XLKQoX9iraEdhoh3h

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
tonycpsu 2026-07-11 10:34:58 -04:00 committed by GitHub
parent fb10e1a501
commit ac5ed431d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 190 additions and 0 deletions

View File

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

View File

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

View File

@ -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<SendMessageResult> sendStoryMessage(
SignalServiceStoryMessage storyMessage,
long timestamp,
Set<RecipientId> 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<SendMessageResult> 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<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g,

View File

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

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

View File

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

View File

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

View File

@ -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<RecipientIdentifier.Single> recipients) throws IOException {
signal.sendEndSessionMessage(recipients.stream().map(RecipientIdentifier.Single::getIdentifier).toList());

View File

@ -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) {
}

View File

@ -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) {
}