* Add group story support to core library layer
Extend Manager.sendStory() with an optional GroupId parameter and add
SendHelper.sendGroupStoryMessage() for endorsement-aware group story
delivery, laying the groundwork for group story support (task 1 of 4).
The existing My Story code path is unchanged.
* Add --group-id support to SendStoryCommand
Passes an optional GroupId through to Manager.sendStory so stories can
be posted to a group instead of only My Story, and surfaces
GroupNotFoundException / NotAGroupMemberException as user errors.
* Update stubs for 3-parameter sendStory and add empty-recipient guard
Updates DbusManagerImpl and StubManager (in SubscribeCallEventsTest) to
match the new 3-parameter sendStory signature: (String attachment,
boolean allowsReplies, Optional<GroupId> groupId).
Also includes the empty-recipient guard added after Task 1 review to
prevent stories from being sent to groups where the user is the only member.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs
* Document group story support in man page and changelog
- Add --group-id (-g) option to sendStory command in man page
- Update CHANGELOG to mention group story support via --group-id
- Maintain alphabetical order of options in sendStory section
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs
* Address review findings: endorsement safety and error handling
- Filter group story recipients by ACI type and endorsement availability
to prevent ClassCastException and NPE on edge cases
- Add empty-recipient guard after endorsement filtering
- Skip known-unregistered recipients before address resolution, matching
the pattern from sendGroupMessageInternal
- Add debug logging when recipients are filtered out
- Fix redundant error message prefixing in SendStoryCommand
- Add .superpowers/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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>
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>
* 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>
* 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>
Group sends built the recipient list from the full group membership, so
members already known to be unregistered were retried on every send via
the legacy 1:1 fan-out. On large groups this made a single send take
tens of seconds and could time out, leaving the message undelivered.
Filter out recipients whose unregistered timestamp is set before
sending, returning an unregisteredFailure result for each (so callers
and CLI output are unchanged) without the network attempt. The flag is
maintained independently by profile/CDS discovery and is cleared when a
recipient registers again, so skipped recipients are re-included
automatically.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When filling or updating a V2 group, profile keys were copied from
DecryptedGroup.members into the local profile store but not from
requestingMembers. Admins who never had a prior session with a user in
the join queue then lacked profile keys and could not decrypt profiles
(e.g. for listContacts).
Also process DecryptedRequestingMember entries the same way as full
members, using DecryptedMember / DecryptedRequestingMember types so the
lib module does not require a direct protobuf dependency.
Made-with: Cursor
* Surface retry-after seconds for plain rate-limit failures
libsignal-service's RateLimitException exposes retryAfterMilliseconds
for HTTP 413 responses, but signal-cli only forwarded retry-after for
ProofRequired (428) failures. Clients had no signal for when it was
safe to retry plain rate-limited sends, so every failed retry
potentially extended the server-side window.
SendMessageResult now carries an optional rateLimitRetryAfterSeconds,
populated from the upstream Optional<Long>. JsonSendMessageResult
exposes it for RATE_LIMIT_FAILURE type. Text output includes the
window when known. Aggregate RateLimitErrorException now carries the
real nextAttemptTimestamp (was hardcoded to 0).
Closes#1996.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Address review: include proof-required retry-after and ceiling-round millis
Codex adversarial review flagged two issues in the phase 1 retry-after
plumbing:
* Aggregate retry-after ignored proof-required failures. Because
isRateLimitFailure is true for proof-required cases but
rateLimitRetryAfterSeconds was only populated from plain 413s, an
all-proof-required batch (or a mixed batch where the proof-required
delay was longer) could flow into outputResult() and produce a
RateLimitException(0), telling callers to retry immediately.
* Millisecond Retry-After values were truncated by integer division,
so 1..999ms became 0 and non-second-aligned values lost up to 999ms.
A retry suggested from the floored value can land before the
server's real deadline and re-trigger the limit.
SendMessageResult.from(...) now populates rateLimitRetryAfterSeconds
from either the proof-required seconds or the plain rate-limit ms
(converted via ceiling division), giving maxRateLimitRetryAfterSeconds
a single source of truth. JsonSendMessageResult.from(...) reads the
unified field. New millisToCeilingSeconds helper plus boundary test.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Preserve source compat and document retry-after field change
Add a non-canonical 8-arg SendMessageResult constructor that delegates
to the canonical form with null retry-after. This keeps source
compatibility for any downstream code that constructs the record
directly (tests, mocks) without changing the canonical shape. Records
permit additional constructors alongside the canonical one.
Document the retryAfterSeconds meaning change in the CHANGELOG. The
field was previously populated only for proof-required failures; it
is now populated whenever the server sends a Retry-After header. The
canonical proof-required discriminator is still token != null.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix sender key re-distribution on every group message in daemon mode
sendGroupMessageInternalWithSenderKey() calls sender.send() which handles
distribution and delivery, but never calls markSenderKeySharedWith() on
success. SenderKeySharedStore therefore has no record that the distribution
was sent, causing it to re-distribute to all recipients on every subsequent
sendGroupMessage call.
This results in a fresh unidentified TLS connection being opened for each
group message (~6s delay per send), even for back-to-back sends to the
same group. All send modes are affected: DBus daemon, JSON-RPC socket/http,
and CLI send command all share the same code path.
The fix mirrors the existing pattern in resendMessage() (line 307): after
a successful send, record each successful recipient's address+device in
the sender key shared store.
* Fix sender key re-distribution on every group message
SenderKeySharedStore.markSenderKeysSharedWith() stored the address using
entry.toString() instead of entry.address(). Since SenderKeySharedEntry is
a Java record, toString() returns the full record representation:
SenderKeySharedEntry[address=<uuid>, deviceId=1]
instead of just the UUID. When signal-service-java later calls
getSenderKeySharedWith() and compares the retrieved addresses against the
current group member UUIDs, the comparison always fails — causing the
distribution message to be re-sent to all recipients on every
sendGroupMessage call.
This results in a fresh unidentified TLS connection being opened for each
group message (~6s delay per send), even for immediate consecutive sends
to the same group. All send modes are affected: DBus daemon, JSON-RPC
socket/http, and the CLI send command all share the same code path.
The fix is a one-character change: entry.address() instead of
entry.toString().