mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-08-18 04:16:22 +00:00
Merge 6dd5ce18b3b7b2f1ad7d5d39f8f9de8aa288a599 into fb10e1a501e02446cd49ca9adfd0565f26215366
This commit is contained in:
commit
5e0ab8bc23
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,3 +20,4 @@ man/*.1
|
||||
man/*.5
|
||||
man/man1
|
||||
man/man5
|
||||
.superpowers/
|
||||
|
||||
64
.superpowers/sdd/task-1-report.md
Normal file
64
.superpowers/sdd/task-1-report.md
Normal file
@ -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> 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> 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.
|
||||
@ -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.
|
||||
|
||||
@ -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> groupId
|
||||
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException;
|
||||
|
||||
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,145 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a story message (file attachment) to a group.
|
||||
*/
|
||||
public List<SendMessageResult> 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<SendMessageResult>();
|
||||
final var unregisteredRecipientIds = account.getRecipientStore().getUnregisteredRecipientIds(allRecipientIds);
|
||||
final Set<RecipientId> 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<SendMessageResult> 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<SendMessageResult> sendAsGroupMessage(
|
||||
final SignalServiceDataMessage.Builder messageBuilder,
|
||||
final GroupInfo g,
|
||||
|
||||
@ -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> 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<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 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<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,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.
|
||||
|
||||
@ -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,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.<GroupId>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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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> groupId
|
||||
) {
|
||||
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,15 @@ class SseInitialFlushTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResults sendStory(
|
||||
String attachment,
|
||||
boolean allowsReplies,
|
||||
Optional<GroupId> groupId
|
||||
) {
|
||||
return new SendMessageResults(0, Map.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideRecipient(RecipientIdentifier.Single recipient) {
|
||||
}
|
||||
|
||||
@ -367,6 +367,15 @@ class SubscribeCallEventsTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResults sendStory(
|
||||
String attachment,
|
||||
boolean allowsReplies,
|
||||
Optional<GroupId> groupId
|
||||
) {
|
||||
return new SendMessageResults(0, Map.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideRecipient(RecipientIdentifier.Single r) {
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user