mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-08-21 04:46:06 +00:00
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:
parent
fb10e1a501
commit
ac5ed431d3
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user