Compare commits

...

13 Commits

Author SHA1 Message Date
Kostia R
4e56fe839b
Merge a041826cd595e1c9f21ebbb4205076f49a6afcfb into 78dba20a597bbeda06b7a2b768e5d7401929df32 2026-07-12 08:42:04 +02:00
tonycpsu
78dba20a59
Bound SessionStore session cache to prevent unbounded memory growth (#2087)
The cachedSessions HashMap grows with every unique (address, deviceId)
pair seen during message processing and is never evicted. In a
long-running daemon handling group messages, this causes linear memory
growth (~47 MB/hour observed) as SessionRecord objects accumulate for
every contact/device the daemon has ever communicated with.

Replace the unbounded HashMap with an LRU-bounded LinkedHashMap (access
order, max 1000 entries). Evicted sessions are reloaded from SQLite on
next access, so correctness is preserved.


Claude-Session: https://claude.ai/code/session_01HHzM2XLKQoX9iraEdhoh3h

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 08:12:30 +02:00
tonycpsu
b48ccf2605
Bound RecipientStore address cache to prevent unbounded memory growth (#2088)
The recipientAddressCache grows with every unique ServiceId resolved via
findByServiceId() and entries are never evicted. In a long-running daemon
handling group messages from many contacts, this map grows monotonically.

Replace the unbounded HashMap with an LRU-bounded LinkedHashMap (access
order, max 2000 entries). Evicted entries are reloaded from SQLite on
next access via an indexed lookup, so correctness is preserved.


Claude-Session: https://claude.ai/code/session_01HHzM2XLKQoX9iraEdhoh3h

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 08:11:24 +02:00
AsamK
54d713ea7d Improve loading accounts by aci 2026-07-11 19:03:48 +02:00
AsamK
4dda7582a3 Add getSelfACI to manager 2026-07-11 18:52:52 +02:00
tilllt
248c0c0dab
Fix HTTP handler to accept ACI/UUID account parameter in SSE endpoint (#2079)
* Fix HTTP handler to accept ACI/UUID account parameter in SSE endpoint

MultiAccountManagerImpl.getManager() only looked up accounts by phone
number, causing HTTP 400 errors when the SSE events endpoint was called
with ?account=<ACI-UUID> (e.g., cc528f93-527e-4566-8c62-d12dc99dbce0).

Changes:
- SignalAccountFiles: Add initManagerByAci() method for ACI-based lookup
- MultiAccountManagerImpl: getManager() now tries ACI lookup when
  phone number lookup fails
- HttpServerHandler: getManagerFromQuery() falls back to returning all
  managers when the specific account identifier is not found (instead of
  HTTP 400)

* Fix SSE endpoint for UUID account parameter & preserve '+' in phone numbers

Three interrelated fixes for the HTTP SSE endpoint:

1. **SignalAccountFiles** — Replace ACI.parseOrThrow() with UUID-string
   lookup from accountsStore.getAllAccounts(). The old approach failed when
   a raw UUID string (from URL query param) was passed. Added
   getAccountNumberByAci() helper to reduce duplication.

2. **MultiAccountManagerImpl** — Catch IllegalArgumentException in
   getManager() for both phone number and ACI lookup paths. Also check if
   the UUID corresponds to an already-loaded manager before trying to
   initByAci(), preventing OverlappingFileLockException when SSE requests
   arrive with a UUID for an account that was loaded at startup.

3. **Util.getQueryMap()** — Preserve '+' characters in query parameter
   values by escaping them before URLDecoder.decode(). Without this,
   URLDecoder converts '+' to space, breaking phone numbers like
   '+4915422389' which become ' 4915422389'.

* fix: address AsamK's review comments

- HttpServerHandler.getManagerFromQuery(): return null when account not
  found instead of falling back to all managers (AsamK: 'should stay
  return null here')
- MultiAccountManagerImpl.getManager(): use UuidUtil.isUuid() to branch
  early on ACI vs phone number, eliminating the try-number-then-fallback
  pattern (AsamK: 'check if identifier is a uuid first')

---------

Co-authored-by: Till L T <tilllt@users.noreply.github.com>
2026-07-11 18:19:15 +02:00
Gara Dorta
e70bddd790
feat: add schemas to the release CI (#2040) 2026-07-11 16:47:41 +02:00
tonycpsu
ac5ed431d3
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>
2026-07-11 16:34:58 +02:00
ziggy
a041826cd5 Report invalid content from retried envelopes 2026-07-10 19:16:03 +03:00
ziggy
712c9ea741 Preserve group filters for invalid content 2026-07-10 18:27:17 +03:00
ziggy
8b74f0653d Handle invalid range variants safely 2026-07-10 18:01:23 +03:00
ziggy
a770b03fe6 Complete invalid envelope diagnostics 2026-07-10 17:54:03 +03:00
ziggy
1cce283aca Expose invalid envelope content in receive output 2026-07-10 17:48:12 +03:00
29 changed files with 970 additions and 82 deletions

View File

@ -75,6 +75,16 @@ jobs:
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload JSON schemas archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
build-container:
needs: release
runs-on: ubuntu-latest

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

@ -54,6 +54,7 @@ import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -74,6 +75,10 @@ public interface Manager extends Closeable {
return PhoneNumberUtil.getInstance().isPossibleNumber(e164Number, countryCode);
}
static boolean isValidAci(final String aci) {
return UuidUtil.INSTANCE.isUuid(aci);
}
static boolean isSignalClientAvailable() {
final Logger logger = LoggerFactory.getLogger(Manager.class);
try {
@ -91,6 +96,8 @@ public interface Manager extends Closeable {
String getSelfNumber();
String getSelfACI();
/**
* This is used for checking a set of phone numbers for registration on Signal
*
@ -213,6 +220,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

@ -15,6 +15,7 @@ import org.asamk.signal.manager.internal.RegistrationManagerImpl;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
@ -67,7 +68,7 @@ public class SignalAccountFiles {
public MultiAccountManager initMultiAccountManager() throws IOException {
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
try {
return new Pair<Manager, Throwable>(initManager(a.number(), a.path()), null);
return new Pair<Manager, Throwable>(initManagerByNumber(a.number(), a.path()), null);
} catch (NotRegisteredException e) {
logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
return null;
@ -90,15 +91,31 @@ public class SignalAccountFiles {
return new MultiAccountManagerImpl(managers, this);
}
public Manager initManager(String number) throws IOException, NotRegisteredException, AccountCheckException {
public Manager initManagerByNumber(String number) throws IOException, NotRegisteredException, AccountCheckException {
final var accountPath = accountsStore.getPathByNumber(number);
return this.initManager(number, accountPath);
return this.initManagerByNumber(number, accountPath);
}
private Manager initManager(
public Manager initManagerByAci(String aciStr) throws IOException, NotRegisteredException, AccountCheckException {
final var aci = ACI.parseOrThrow(aciStr);
final var accountPath = accountsStore.getPathByAci(aci);
return this.initManagerByAci(aci, accountPath);
}
private Manager initManagerByNumber(
String number,
String accountPath
) throws IOException, NotRegisteredException, AccountCheckException {
final var account = loadAccount(accountPath);
if (!number.equals(account.getNumber())) {
account.close();
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
}
return initManagerFromAccount(number, accountPath, account);
}
private SignalAccount loadAccount(final String accountPath) throws NotRegisteredException, IOException {
if (accountPath == null) {
throw new NotRegisteredException();
}
@ -106,12 +123,27 @@ public class SignalAccountFiles {
throw new NotRegisteredException();
}
var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
if (!number.equals(account.getNumber())) {
return SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
}
private Manager initManagerByAci(
ACI aci,
String accountPath
) throws IOException, NotRegisteredException, AccountCheckException {
final var account = loadAccount(accountPath);
if (!aci.equals(account.getAci())) {
account.close();
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
}
return initManagerFromAccount(aci.toString(), accountPath, account);
}
private ManagerImpl initManagerFromAccount(
final String identifier,
final String accountPath,
final SignalAccount account
) throws NotRegisteredException, IOException, AccountCheckException {
if (!account.isRegistered()) {
account.close();
throw new NotRegisteredException();
@ -136,7 +168,7 @@ public class SignalAccountFiles {
throw new IOException("signal-cli version is too old for the Signal-Server, please update.");
} catch (IOException e) {
manager.close();
throw new AccountCheckException("Error while checking account " + number + ": " + e.getMessage(), e);
throw new AccountCheckException("Error while checking account " + identifier + ": " + e.getMessage(), e);
}
if (account.getServiceEnvironment() == null) {

View File

@ -0,0 +1,54 @@
package org.asamk.signal.manager.api;
import java.util.List;
public final class InvalidEnvelopeContentException extends Exception {
public static final String INVALID_ENVELOPE_CONTENT = "INVALID_ENVELOPE_CONTENT";
public static final String DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS = "DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS";
private final String code;
private final String sender;
private final int senderDevice;
private final Integer bodyLength;
private final List<InvalidBodyRange> invalidBodyRanges;
public InvalidEnvelopeContentException(
final String message,
final String code,
final String sender,
final int senderDevice,
final Integer bodyLength,
final List<InvalidBodyRange> invalidBodyRanges,
final Throwable cause
) {
super(message, cause);
this.code = code;
this.sender = sender;
this.senderDevice = senderDevice;
this.bodyLength = bodyLength;
this.invalidBodyRanges = List.copyOf(invalidBodyRanges);
}
public String getCode() {
return code;
}
public String getSender() {
return sender;
}
public int getSenderDevice() {
return senderDevice;
}
public Integer getBodyLength() {
return bodyLength;
}
public List<InvalidBodyRange> getInvalidBodyRanges() {
return invalidBodyRanges;
}
public record InvalidBodyRange(int index, Integer start, Integer length, String type) {}
}

View File

@ -4,6 +4,7 @@ import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@ -1032,6 +1033,8 @@ public record MessageEnvelope(
? recipientResolver.resolveRecipient(serviceId)
: envelope.isUnidentifiedSender() && content != null
? recipientResolver.resolveRecipient(content.getSender())
: exception instanceof InvalidEnvelopeContentException e && e.getSender() != null
? recipientResolver.resolveRecipient(ServiceId.parseOrThrow(e.getSender()))
: exception instanceof ProtocolException e
? recipientResolver.resolveRecipient(e.getSender())
: null;
@ -1039,6 +1042,7 @@ public record MessageEnvelope(
? envelope.getSourceDevice()
: content != null
? content.getSenderDevice()
: exception instanceof InvalidEnvelopeContentException e ? e.getSenderDevice()
: exception instanceof ProtocolException e ? e.getSenderDevice() : 0;
Optional<Receipt> receipt;

View File

@ -21,6 +21,7 @@ import org.asamk.signal.manager.actions.SyncStorageDataAction;
import org.asamk.signal.manager.actions.UpdateAccountAttributesAction;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.ReceiveConfig;
@ -31,6 +32,7 @@ import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.jobs.RetrieveStickerPackJob;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
@ -48,10 +50,12 @@ import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.groups.GroupSessionBuilder;
import org.signal.libsignal.protocol.message.DecryptionErrorMessage;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata;
import org.whispersystems.signalservice.api.crypto.SignalGroupSessionBuilder;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipherResult;
import org.whispersystems.signalservice.api.messages.EnvelopeContentValidator;
@ -69,7 +73,12 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.internal.push.BodyRange;
import org.whispersystems.signalservice.internal.push.Content;
import org.whispersystems.signalservice.internal.push.DataMessage;
import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.GroupContext;
import org.whispersystems.signalservice.internal.push.GroupContextV2;
import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException;
import java.io.IOException;
@ -85,6 +94,12 @@ import java.util.stream.Collectors;
public final class IncomingMessageHandler {
private static final Logger logger = LoggerFactory.getLogger(IncomingMessageHandler.class);
private static final String DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON =
"[DataMessage] Body range with out-of-bounds start/length!";
private static final String DATA_MESSAGE_QUOTE_BODY_RANGE_OUT_OF_BOUNDS_REASON =
"[DataMessage] Quote body range with out-of-bounds start/length!";
private static final String EDIT_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON =
"[EditMessage] Body range with out-of-bounds start/length!";
private final SignalAccount account;
private final SignalDependencies dependencies;
@ -107,6 +122,8 @@ public final class IncomingMessageHandler {
}
SignalServiceContent content = null;
Content decryptedContent = null;
InvalidEnvelopeContentException validationException = null;
if (!envelope.isReceipt()) {
account.getIdentityKeyStore().setRetryingDecryption(true);
try {
@ -114,6 +131,7 @@ public final class IncomingMessageHandler {
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
decryptedContent = cipherResult.getContent();
content = validate(envelope.getProto(), cipherResult, envelope.getServerDeliveredTimestamp());
if (content == null) {
return new Pair<>(List.of(), null);
@ -124,14 +142,21 @@ public final class IncomingMessageHandler {
.resolveRecipientAddress(recipientId)
.toApiRecipientAddress(), e.getSenderDevice());
return new Pair<>(List.of(), exception);
} catch (InvalidEnvelopeContentException e) {
validationException = e;
} catch (Exception e) {
return new Pair<>(List.of(), e);
} finally {
account.getIdentityKeyStore().setRetryingDecryption(false);
}
}
actions.addAll(checkAndHandleMessage(envelope, content, receiveConfig, handler, null));
return new Pair<>(actions, null);
actions.addAll(checkAndHandleMessage(envelope,
content,
validationException == null ? null : decryptedContent,
receiveConfig,
handler,
validationException));
return new Pair<>(actions, validationException);
}
public Pair<List<HandleAction>, Exception> handleEnvelope(
@ -144,6 +169,7 @@ public final class IncomingMessageHandler {
actions.add(RefreshPreKeysAction.create());
}
SignalServiceContent content = null;
Content decryptedContent = null;
Exception exception = null;
if (envelope.getSourceServiceId() != null) {
// Store uuid if we don't have it already
@ -167,6 +193,7 @@ public final class IncomingMessageHandler {
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
decryptedContent = cipherResult.getContent();
content = validate(envelope.getProto(), cipherResult, envelope.getServerDeliveredTimestamp());
if (content == null) {
return new Pair<>(List.of(), null);
@ -217,7 +244,12 @@ public final class IncomingMessageHandler {
}
}
actions.addAll(checkAndHandleMessage(envelope, content, receiveConfig, handler, exception));
actions.addAll(checkAndHandleMessage(envelope,
content,
exception instanceof InvalidEnvelopeContentException ? decryptedContent : null,
receiveConfig,
handler,
exception));
return new Pair<>(actions, exception);
}
@ -225,7 +257,8 @@ public final class IncomingMessageHandler {
Envelope envelope,
SignalServiceCipherResult cipherResult,
long serverDeliveredTimestamp
) throws ProtocolInvalidKeyException, ProtocolInvalidMessageException, UnsupportedDataMessageException, InvalidMessageStructureException {
) throws ProtocolInvalidKeyException, ProtocolInvalidMessageException, UnsupportedDataMessageException,
InvalidMessageStructureException, InvalidEnvelopeContentException {
final var content = cipherResult.getContent();
final var envelopeMetadata = cipherResult.getMetadata();
final var validationResult = EnvelopeContentValidator.INSTANCE.validate(envelope,
@ -234,8 +267,17 @@ public final class IncomingMessageHandler {
cipherResult.getMetadata().getCiphertextMessageType());
if (validationResult instanceof EnvelopeContentValidator.Result.Invalid v) {
logger.warn("Invalid content! {}", v.getReason(), v.getThrowable());
return null;
final var exception = createInvalidEnvelopeContentException(v, envelopeMetadata, content);
logger.warn("Invalid content! reason={} code={} source={} sourceDevice={} timestamp={} bodyLength={} invalidBodyRanges={}",
exception.getMessage(),
exception.getCode(),
exception.getSender(),
exception.getSenderDevice(),
envelope.clientTimestamp,
exception.getBodyLength(),
exception.getInvalidBodyRanges());
logger.debug("Invalid content validation location", v.getThrowable());
throw exception;
}
if (validationResult instanceof EnvelopeContentValidator.Result.UnsupportedDataMessage v) {
@ -252,9 +294,105 @@ public final class IncomingMessageHandler {
serverDeliveredTimestamp);
}
static InvalidEnvelopeContentException createInvalidEnvelopeContentException(
final EnvelopeContentValidator.Result.Invalid validationResult,
final EnvelopeMetadata envelopeMetadata,
final Content content
) {
final var reason = validationResult.getReason();
final var dataMessage = getDataMessage(content, reason);
final String body;
final List<BodyRange> bodyRanges;
if (DATA_MESSAGE_QUOTE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason)) {
if (dataMessage == null || dataMessage.quote == null) {
body = null;
bodyRanges = null;
} else {
body = dataMessage.quote.text;
bodyRanges = dataMessage.quote.bodyRanges;
}
} else if (dataMessage == null) {
body = null;
bodyRanges = null;
} else {
body = dataMessage.body;
bodyRanges = dataMessage.bodyRanges;
}
final Integer bodyLength = bodyRanges == null ? null : body == null ? 0 : body.length();
final List<InvalidEnvelopeContentException.InvalidBodyRange> invalidBodyRanges = new ArrayList<>();
if (bodyRanges != null) {
for (int i = 0; i < bodyRanges.size(); i++) {
final var range = bodyRanges.get(i);
final long start = range.start == null ? 0 : range.start;
final long length = range.length == null ? 0 : range.length;
if (start < 0 || length < 0 || start + length > bodyLength) {
invalidBodyRanges.add(new InvalidEnvelopeContentException.InvalidBodyRange(i,
range.start,
range.length,
getBodyRangeType(range)));
}
}
}
final var code = isBodyRangeOutOfBoundsReason(reason)
? InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS
: InvalidEnvelopeContentException.INVALID_ENVELOPE_CONTENT;
return new InvalidEnvelopeContentException(reason,
code,
envelopeMetadata.getSourceServiceId().toString(),
envelopeMetadata.getSourceDeviceId(),
bodyLength,
invalidBodyRanges,
validationResult.getThrowable());
}
private static DataMessage getDataMessage(final Content content, final String validationReason) {
if (validationReason.startsWith("[EditMessage]")) {
return getEditDataMessage(content);
}
if (content.dataMessage != null) {
return content.dataMessage;
}
if (content.syncMessage != null && content.syncMessage.sent != null
&& content.syncMessage.sent.message != null) {
return content.syncMessage.sent.message;
}
return getEditDataMessage(content);
}
private static DataMessage getEditDataMessage(final Content content) {
if (content.editMessage != null) {
return content.editMessage.dataMessage;
}
if (content.syncMessage != null && content.syncMessage.sent != null
&& content.syncMessage.sent.editMessage != null) {
return content.syncMessage.sent.editMessage.dataMessage;
}
return null;
}
private static boolean isBodyRangeOutOfBoundsReason(final String reason) {
return DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason)
|| DATA_MESSAGE_QUOTE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason)
|| EDIT_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason);
}
private static String getBodyRangeType(final BodyRange range) {
if (range.style != null) {
return "STYLE_" + range.style.name();
}
if (range.mentionAci != null || range.mentionAciBinary != null) {
return "MENTION";
}
return "UNKNOWN";
}
private List<HandleAction> checkAndHandleMessage(
final SignalServiceEnvelope envelope,
final SignalServiceContent content,
final Content invalidContent,
final ReceiveConfig receiveConfig,
final Manager.ReceiveMessageHandler handler,
final Exception exception
@ -282,19 +420,21 @@ public final class IncomingMessageHandler {
account.getMessageSendLogStore().deleteEntryForRecipient(envelope.getTimestamp(), sender, senderDeviceId);
}
var notAllowedToSendToGroup = isNotAllowedToSendToGroup(envelope, content);
final var groupFilterInfo = getGroupFilterInfo(content, invalidContent, exception);
var notAllowedToSendToGroup = isNotAllowedToSendToGroup(envelope, content, exception, groupFilterInfo);
final var groupContext = getGroupContext(content);
if (groupContext != null && groupContext.getGroupV2().isPresent()) {
handleGroupV2Context(groupContext.getGroupV2().get(), receiveConfig.ignoreAvatars());
}
// Check again in case the user just joined the group
notAllowedToSendToGroup = notAllowedToSendToGroup && isNotAllowedToSendToGroup(envelope, content);
notAllowedToSendToGroup = notAllowedToSendToGroup
&& isNotAllowedToSendToGroup(envelope, content, exception, groupFilterInfo);
if (isMessageBlocked(envelope, content)) {
if (isMessageBlocked(envelope, content, exception, groupFilterInfo)) {
logger.info("Ignoring a message from blocked user/group: {}", envelope.getTimestamp());
return List.of();
} else if (notAllowedToSendToGroup) {
final var senderAddress = getSenderAddress(envelope, content);
final var senderAddress = getSenderAddress(envelope, content, exception);
logger.info("Ignoring a group message from an unauthorized sender (no member or admin): {} {}",
senderAddress == null ? null : senderAddress.getIdentifier(),
envelope.getTimestamp());
@ -733,7 +873,7 @@ public final class IncomingMessageHandler {
return new Pair<>(actions, longTexts);
}
private SignalServiceGroupContext getGroupContext(SignalServiceContent content) {
private static SignalServiceGroupContext getGroupContext(SignalServiceContent content) {
if (content == null) {
return null;
}
@ -759,51 +899,62 @@ public final class IncomingMessageHandler {
return null;
}
private boolean isMessageBlocked(SignalServiceEnvelope envelope, SignalServiceContent content) {
SignalServiceAddress source = getSenderAddress(envelope, content);
if (source == null) {
return false;
}
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
if (context.getContactHelper().isContactBlocked(recipientId)) {
return true;
private boolean isMessageBlocked(
SignalServiceEnvelope envelope,
SignalServiceContent content,
Exception exception,
GroupFilterInfo groupFilterInfo
) {
SignalServiceAddress source = getSenderAddress(envelope, content, exception);
if (source != null) {
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
if (context.getContactHelper().isContactBlocked(recipientId)) {
return true;
}
}
final var groupContext = getGroupContext(content);
if (groupContext != null) {
var groupId = GroupUtils.getGroupId(groupContext);
return context.getGroupHelper().isGroupBlocked(groupId);
if (groupFilterInfo != null) {
return isGroupBlocked(context.getGroupHelper().getGroup(groupFilterInfo.groupId()));
}
return false;
}
private boolean isNotAllowedToSendToGroup(SignalServiceEnvelope envelope, SignalServiceContent content) {
SignalServiceAddress source = getSenderAddress(envelope, content);
private boolean isNotAllowedToSendToGroup(
SignalServiceEnvelope envelope,
SignalServiceContent content,
Exception exception,
GroupFilterInfo groupFilterInfo
) {
SignalServiceAddress source = getSenderAddress(envelope, content, exception);
if (source == null) {
return false;
}
final var groupContext = getGroupContext(content);
if (groupContext == null) {
if (groupFilterInfo == null) {
return false;
}
if (groupContext.getGroupV1().isPresent()) {
var groupInfo = groupContext.getGroupV1().get();
if (groupInfo.getType() == SignalServiceGroup.Type.QUIT) {
return false;
}
if (groupFilterInfo.isQuit()) {
return false;
}
final var message = content.getDataMessage().orElse(null);
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
final var groupId = GroupUtils.getGroupId(groupContext);
final var group = context.getGroupHelper().getGroup(groupId);
final var group = context.getGroupHelper().getGroup(groupFilterInfo.groupId());
return isNotAllowedToSendToGroup(group, recipientId, groupFilterInfo);
}
if (message != null && message.getAdminDelete().isPresent() && (group == null || !group.isAdmin(recipientId))) {
static boolean isGroupBlocked(final GroupInfo group) {
return group != null && group.isBlocked();
}
static boolean isNotAllowedToSendToGroup(
final GroupInfo group,
final RecipientId recipientId,
final GroupFilterInfo groupFilterInfo
) {
if (groupFilterInfo.hasAdminDelete() && (group == null || !group.isAdmin(recipientId))) {
return true;
}
@ -811,24 +962,102 @@ public final class IncomingMessageHandler {
return false;
}
if (!group.isMember(recipientId) && !(
group.isPendingMember(recipientId) && message != null && message.isGroupV2Update()
)) {
if (!group.isMember(recipientId)
&& !(group.isPendingMember(recipientId) && groupFilterInfo.isGroupV2Update())) {
return true;
}
if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) {
return message == null
|| message.getBody().isPresent()
|| message.getAttachments().isPresent()
|| message.getQuote().isPresent()
|| message.getPreviews().isPresent()
|| message.getMentions().isPresent()
|| message.getSticker().isPresent();
return groupFilterInfo.hasAnnouncementContent();
}
return false;
}
static GroupFilterInfo getGroupFilterInfo(
final SignalServiceContent content,
final Content invalidContent,
final Exception exception
) {
if (content != null) {
final var groupContext = getGroupContext(content);
if (groupContext == null) {
return null;
}
final var message = content.getDataMessage().orElse(null);
return new GroupFilterInfo(GroupUtils.getGroupId(groupContext),
groupContext.getGroupV1()
.map(group -> group.getType() == SignalServiceGroup.Type.QUIT)
.orElse(false),
message != null && message.getAdminDelete().isPresent(),
message != null && message.isGroupV2Update(),
message == null
|| message.getBody().isPresent()
|| message.getAttachments().isPresent()
|| message.getQuote().isPresent()
|| message.getPreviews().isPresent()
|| message.getMentions().isPresent()
|| message.getSticker().isPresent());
}
if (invalidContent == null || !(exception instanceof InvalidEnvelopeContentException e)) {
return null;
}
final var message = getDataMessage(invalidContent, e.getMessage());
if (message != null) {
final var groupId = getGroupId(message);
if (groupId == null) {
return null;
}
return new GroupFilterInfo(groupId,
message.group != null
&& message.group.type == GroupContext.Type.QUIT,
message.adminDelete != null,
message.groupV2 != null
&& message.groupV2.groupChange != null
&& message.groupV2.groupChange.size() > 0,
message.body != null
|| !message.attachments.isEmpty()
|| message.quote != null
|| !message.preview.isEmpty()
|| message.bodyRanges.stream()
.anyMatch(range -> range.mentionAci != null || range.mentionAciBinary != null)
|| message.sticker != null);
}
if (invalidContent.storyMessage != null && invalidContent.storyMessage.group != null) {
final var groupId = getGroupId(invalidContent.storyMessage.group);
return groupId == null ? null : new GroupFilterInfo(groupId, false, false, false, true);
}
return null;
}
private static GroupId getGroupId(final DataMessage message) {
if (message.group != null && message.group.id != null) {
return GroupId.v1(message.group.id.toByteArray());
}
return message.groupV2 == null ? null : getGroupId(message.groupV2);
}
private static GroupId getGroupId(final GroupContextV2 groupContext) {
if (groupContext.masterKey == null) {
return null;
}
try {
return GroupUtils.getGroupIdV2(new GroupMasterKey(groupContext.masterKey.toByteArray()));
} catch (InvalidInputException e) {
return null;
}
}
record GroupFilterInfo(
GroupId groupId,
boolean isQuit,
boolean hasAdminDelete,
boolean isGroupV2Update,
boolean hasAnnouncementContent
) {}
private Pair<List<HandleAction>, Map<String, String>> handleSignalServiceDataMessage(
SignalServiceDataMessage message,
boolean isSync,
@ -1067,7 +1296,10 @@ public final class IncomingMessageHandler {
this.account.getProfileStore().storeProfileKey(source, profileKey);
}
private SignalServiceAddress getSenderAddress(SignalServiceEnvelope envelope, SignalServiceContent content) {
private static SignalServiceAddress getSenderAddress(
final SignalServiceEnvelope envelope,
final SignalServiceContent content
) {
final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new SignalServiceAddress(serviceId);
@ -1078,6 +1310,24 @@ public final class IncomingMessageHandler {
}
}
static SignalServiceAddress getSenderAddress(
final SignalServiceEnvelope envelope,
final SignalServiceContent content,
final Exception exception
) {
final var source = getSenderAddress(envelope, content);
if (source != null) {
return source;
}
if (exception instanceof InvalidEnvelopeContentException e && e.getSender() != null) {
final var sender = ServiceId.parseOrNull(e.getSender());
if (sender != null) {
return new SignalServiceAddress(sender);
}
}
return null;
}
private DeviceAddress getSender(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) {

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;
@ -245,6 +247,11 @@ public class ManagerImpl implements Manager {
return account.getNumber();
}
@Override
public String getSelfACI() {
return account.getAci().toString();
}
public void checkAccountState() throws IOException {
context.getAccountHelper().checkAccountState();
final var lastRecipientsRefresh = account.getLastRecipientsRefresh();
@ -827,6 +834,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

@ -7,6 +7,7 @@ import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.SignalAccountFiles;
import org.asamk.signal.manager.api.AccountCheckException;
import org.asamk.signal.manager.api.NotRegisteredException;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -95,23 +96,47 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
}
@Override
public Manager getManager(final String number) {
public Manager getManager(final String identifier) {
synchronized (managers) {
final var manager = managers.stream()
.filter(m -> m.getSelfNumber().equals(number))
.findFirst()
.orElse(null);
if (manager != null) {
return manager;
}
try {
final var newManager = signalAccountFiles.initManager(number);
managers.add(newManager);
return newManager;
} catch (IOException | NotRegisteredException | AccountCheckException e) {
logger.warn("Failed to load new manager", e);
return null;
if (UuidUtil.INSTANCE.isUuid(identifier)) {
// Check if UUID corresponds to an already-loaded manager
final var existing = managers.stream()
.filter(m -> m.getSelfACI().equals(identifier))
.findFirst()
.orElse(null);
if (existing != null) {
logger.debug("Found already loaded manager for ACI: {}", identifier);
return existing;
}
// Load by ACI
try {
final var newManager = signalAccountFiles.initManagerByAci(identifier);
managers.add(newManager);
return newManager;
} catch (NotRegisteredException e) {
logger.debug("Manager not found by ACI: {}", identifier);
} catch (IOException | IllegalArgumentException | AccountCheckException e) {
logger.warn("Failed to load new manager by ACI: {}", identifier, e);
}
} else {
// Phone number check already loaded managers
var existing = managers.stream()
.filter(m -> m.getSelfNumber().equals(identifier))
.findFirst()
.orElse(null);
if (existing != null) {
return existing;
}
// Load by phone number
try {
final var newManager = signalAccountFiles.initManagerByNumber(identifier);
managers.add(newManager);
return newManager;
} catch (NotRegisteredException | IOException | IllegalArgumentException | AccountCheckException e) {
logger.warn("Failed to load manager by number: {}", identifier, e);
}
}
return null;
}
}

View File

@ -30,6 +30,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -51,7 +52,16 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private final Map<Long, Long> recipientsMerged = new HashMap<>();
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(new HashMap<>());
private static final int MAX_RECIPIENT_CACHE_SIZE = 2000;
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(
new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<ServiceId, RecipientWithAddress> eldest) {
return size() > MAX_RECIPIENT_CACHE_SIZE;
}
});
public static void createSql(Connection connection) throws SQLException {
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java

View File

@ -18,7 +18,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -28,8 +28,15 @@ public class SessionStore implements SignalServiceSessionStore {
private static final String TABLE_SESSION = "session";
private static final Logger logger = LoggerFactory.getLogger(SessionStore.class);
private static final int MAX_CACHE_SIZE = 1000;
private final Map<Key, SessionRecord> cachedSessions = new HashMap<>();
private final Map<Key, SessionRecord> cachedSessions = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Key, SessionRecord> eldest) {
return size() > MAX_CACHE_SIZE;
}
};
private final Database database;
private final int accountIdType;

View File

@ -0,0 +1,174 @@
package org.asamk.signal.manager.helper;
import okio.ByteString;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
import org.junit.jupiter.api.Test;
import org.signal.core.models.ServiceId.ACI;
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata;
import org.whispersystems.signalservice.api.messages.EnvelopeContentValidator;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.internal.push.BodyRange;
import org.whispersystems.signalservice.internal.push.Content;
import org.whispersystems.signalservice.internal.push.DataMessage;
import org.whispersystems.signalservice.internal.push.EditMessage;
import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.GroupContext;
import org.whispersystems.signalservice.internal.push.SyncMessage;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class IncomingMessageHandlerTest {
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRange() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(4).length(3).style(BodyRange.Style.BOLD).build();
final var dataMessage = new DataMessage.Builder().body("hello").bodyRanges(List.of(bodyRange)).build();
final var content = new Content.Builder().dataMessage(dataMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(sender.toString(), exception.getSender());
assertEquals(2, exception.getSenderDevice());
assertEquals(5, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, 4, 3, "STYLE_BOLD")),
exception.getInvalidBodyRanges());
}
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRangeFromSyncMessage() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(-1).length(2).mentionAci(sender.toString()).build();
final var dataMessage = new DataMessage.Builder().body("hello").bodyRanges(List.of(bodyRange)).build();
final var sent = new SyncMessage.Sent.Builder().message(dataMessage).build();
final var content = new Content.Builder().syncMessage(new SyncMessage.Builder().sent(sent).build()).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(5, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, -1, 2, "MENTION")),
exception.getInvalidBodyRanges());
}
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRangeFromEditMessage() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(5).length(1).style(BodyRange.Style.ITALIC).build();
final var dataMessage = new DataMessage.Builder().body("hello").bodyRanges(List.of(bodyRange)).build();
final var editMessage = new EditMessage.Builder().targetSentTimestamp(1L).dataMessage(dataMessage).build();
final var content = new Content.Builder().editMessage(editMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[EditMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(5, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, 5, 1, "STYLE_ITALIC")),
exception.getInvalidBodyRanges());
}
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRangeFromQuote() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(2).length(2).style(BodyRange.Style.MONOSPACE).build();
final var quote = new DataMessage.Quote.Builder().text("hey").bodyRanges(List.of(bodyRange)).build();
final var dataMessage = new DataMessage.Builder().body("outer body").quote(quote).build();
final var content = new Content.Builder().dataMessage(dataMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Quote body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(3, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, 2, 2, "STYLE_MONOSPACE")),
exception.getInvalidBodyRanges());
}
@Test
void invalidSealedSenderCanBeResolvedForBlocking() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var envelope = new SignalServiceEnvelope(new Envelope.Builder()
.type(Envelope.Type.UNIDENTIFIED_SENDER)
.clientTimestamp(1L)
.build(), 2L);
final var exception = new InvalidEnvelopeContentException("invalid body range",
InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS,
sender.toString(),
2,
5,
List.of(),
new Throwable());
final var source = IncomingMessageHandler.getSenderAddress(envelope, null, exception);
assertEquals(sender, source.getServiceId());
}
@Test
void invalidGroupContextIsAvailableForFiltering() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var groupId = new byte[16];
final var group = new GroupContext.Builder()
.id(ByteString.of(groupId))
.type(GroupContext.Type.DELIVER)
.build();
final var bodyRange = new BodyRange.Builder().start(4).length(3).style(BodyRange.Style.BOLD).build();
final var dataMessage = new DataMessage.Builder()
.body("hello")
.bodyRanges(List.of(bodyRange))
.group(group)
.build();
final var content = new Content.Builder().dataMessage(dataMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
final var filterInfo = IncomingMessageHandler.getGroupFilterInfo(null, content, exception);
assertNotNull(filterInfo);
assertEquals(GroupId.v1(groupId), filterInfo.groupId());
assertTrue(filterInfo.hasAnnouncementContent());
final var storedGroup = new GroupInfoV1(GroupId.v1(groupId));
storedGroup.setBlocked(true);
assertTrue(IncomingMessageHandler.isGroupBlocked(storedGroup));
assertTrue(IncomingMessageHandler.isNotAllowedToSendToGroup(storedGroup,
TestRecipientId.createTestId(1),
filterInfo));
}
}

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

@ -18,12 +18,13 @@ fi
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "$VERSION" >dist/VERSION
# Build jar
# Build jar and schemas
$ENGINE build -t signal-cli:build ${OVERRIDE_JAVA_VERSION:+--build-arg ZULU_TAG=$OVERRIDE_JAVA_VERSION} -f reproducible-builds/build.Containerfile .
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:build
mv build/distributions/signal-cli-*.tar.gz dist/
mv build/signal-cli-*-json-schemas.tar.gz dist/
if [ -n "${OVERRIDE_JAVA_VERSION:-}" ]; then
echo -e "\e[33mBuild was performed with overridden Java version $OVERRIDE_JAVA_VERSION, native-image and client will not be built.\e[0m"

View File

@ -12,7 +12,7 @@ reset_file_dates
if [ "$1" == "build" ]; then
./gradlew build \
./gradlew build jsonSchemas \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
@ -20,6 +20,12 @@ if [ "$1" == "build" ]; then
-Dorg.gradle.caching=false \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
schemas_tar="build/signal-cli-${VERSION}-json-schemas.tar"
reset_file_dates
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "$schemas_tar" -C build/generated/META-INF/schemas .
gzip -n -9 "$schemas_tar"
cd man
make install
cd ..

View File

@ -22,6 +22,8 @@ public interface Signal extends DBusInterface {
String getSelfNumber();
String getSelfACI();
void subscribeReceive();
void unsubscribeReceive();

View File

@ -184,16 +184,20 @@ public class App {
}
account = getAccountIfOnlyOne(signalAccountFiles);
} else if (!Manager.isValidNumber(account, null)) {
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
}
if (command instanceof RegistrationCommand registrationCommand) {
if (!Manager.isValidNumber(account, null)) {
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
}
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
return;
}
if (command instanceof LocalCommand localCommand) {
if (!Manager.isValidNumber(account, null) && !Manager.isValidAci(account)) {
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
}
handleLocalCommand(localCommand, account, signalAccountFiles, commandHandler);
return;
}
@ -330,7 +334,11 @@ public class App {
) throws CommandException {
logger.trace("Loading account file for {}", account);
try {
return signalAccountFiles.initManager(account);
if (Manager.isValidAci(account)) {
return signalAccountFiles.initManagerByAci(account);
} else {
return signalAccountFiles.initManagerByNumber(account);
}
} catch (NotRegisteredException e) {
throw new UserErrorException("User " + account + " is not registered.");
} catch (AccountCheckException ace) {

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

@ -113,6 +113,11 @@ public class DbusManagerImpl implements Manager {
return signal.getSelfNumber();
}
@Override
public String getSelfACI() {
return signal.getSelfACI();
}
@Override
public Map<String, UserStatus> getUserStatus(final Set<String> numbers) throws IOException {
final var numbersList = new ArrayList<>(numbers);
@ -542,6 +547,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

@ -141,6 +141,11 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
return m.getSelfNumber();
}
@Override
public String getSelfACI() {
return m.getSelfACI();
}
@Override
public void subscribeReceive() {
if (dbusMessageHandler == null) {

View File

@ -262,6 +262,9 @@ public class HttpServerHandler implements AutoCloseable {
} else {
final var manager = c.getManager(account);
if (manager == null) {
// Account not found by the given identifier (number or ACI/UUID)
// Log the available accounts to help debug
logger.warn("Account not found for identifier: {}", account);
return null;
}
return List.of(manager);

View File

@ -1,11 +1,29 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import java.util.List;
@JsonSchema(title = "Error")
public record JsonError(String message, String type) {
public record JsonError(
String message,
String type,
@JsonInclude(JsonInclude.Include.NON_NULL) Details details
) {
public static JsonError from(Throwable exception) {
return new JsonError(exception.getMessage(), exception.getClass().getSimpleName());
final var details = exception instanceof InvalidEnvelopeContentException e
? new Details(e.getCode(), e.getBodyLength(), e.getInvalidBodyRanges())
: null;
return new JsonError(exception.getMessage(), exception.getClass().getSimpleName(), details);
}
public record Details(
String code,
Integer bodyLength,
List<InvalidEnvelopeContentException.InvalidBodyRange> invalidBodyRanges
) {}
}

View File

@ -79,7 +79,7 @@ public class Util {
for (var param : params) {
final var paramParts = param.split("=", 2);
var name = URLDecoder.decode(paramParts[0], StandardCharsets.UTF_8);
var value = paramParts.length == 1 ? null : URLDecoder.decode(paramParts[1], StandardCharsets.UTF_8);
var value = paramParts.length == 1 ? null : URLDecoder.decode(paramParts[1].replace("+", "%2B"), StandardCharsets.UTF_8);
map.put(name, value);
}
return map;

View File

@ -2949,6 +2949,12 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.json.JsonError$Details",
"allDeclaredFields": true,
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.json.JsonGroupInfo",
"allDeclaredFields": true,
@ -3419,6 +3425,12 @@
{
"type": "org.asamk.signal.logging.LogConfigurator"
},
{
"type": "org.asamk.signal.manager.api.InvalidEnvelopeContentException$InvalidBodyRange",
"allDeclaredFields": true,
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.manager.api.PhoneNumberSharingMode",
"allDeclaredFields": true

View File

@ -123,6 +123,11 @@ class SseInitialFlushTest {
return "+10000000000";
}
@Override
public String getSelfACI() {
return "00000000-0000-0000-0000-000000000000";
}
@Override
public void addReceiveHandler(ReceiveMessageHandler handler, boolean isWeakListener) {
// no-op
@ -349,6 +354,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

@ -0,0 +1,50 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import org.asamk.signal.util.Util;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonErrorTest {
@Test
void invalidEnvelopeContentIncludesStructuredDetails() throws Exception {
final var invalidRange = new InvalidEnvelopeContentException.InvalidBodyRange(2, 8, 4, "STYLE_BOLD");
final var exception = new InvalidEnvelopeContentException("invalid body range",
InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS,
null,
3,
10,
List.of(invalidRange),
new Throwable());
final var error = JsonError.from(exception);
assertEquals("invalid body range", error.message());
assertEquals("InvalidEnvelopeContentException", error.type());
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS,
error.details().code());
assertEquals(10, error.details().bodyLength());
assertEquals(List.of(invalidRange), error.details().invalidBodyRanges());
final var json = Util.createJsonObjectMapper().writeValueAsString(error);
assertTrue(json.contains("\"code\":\"DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS\""));
assertTrue(json.contains("\"start\":8"));
}
@Test
void ordinaryExceptionDoesNotIncludeDetails() throws Exception {
final var error = JsonError.from(new IllegalArgumentException("bad argument"));
assertEquals("bad argument", error.message());
assertEquals("IllegalArgumentException", error.type());
assertNull(error.details());
final var json = Util.createJsonObjectMapper().writeValueAsString(error);
assertFalse(json.contains("details"));
}
}

View File

@ -120,6 +120,11 @@ class SubscribeCallEventsTest {
return selfNumber;
}
@Override
public String getSelfACI() {
return "00000000-0000-0000-0000-000000000000";
}
// --- Stubs for remaining Manager interface methods ---
@Override
public Map<String, UserStatus> getUserStatus(Set<String> n) {
@ -367,6 +372,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) {
}