Compare commits

...

15 Commits

Author SHA1 Message Date
Stefan Meinecke
f27eb524de Merge branch 'master' of https://github.com/AsamK/signal-cli into fix/unauthenticated-socket-keepalive 2026-04-17 11:49:13 +02:00
Stefan Meinecke
0006fd0dc0 Tie unauthenticated WebSocket keep-alive to active client connections
Keep the unidentified socket alive while a JSON-RPC connection is open (including stdio mode) and while a D-Bus object is exported, instead of for the lifetime of the receive loop. The receive loop does not use the unauthenticated socket, so keeping it alive there was semantically wrong.

This also covers --receive-mode=manual, where no receive loop runs butclients still send messages.
2026-04-17 11:49:05 +02:00
AsamK
417d2ce971 Keep websocket connection alive during call 2026-04-16 21:04:20 +02:00
AsamK
33b2b563b3 Don't send busy call response to allow linked devices to accept call 2026-04-16 20:54:19 +02:00
AsamK
740cd6f89b Update dependencies 2026-04-16 20:53:58 +02:00
AsamK
7887ed408d Update libsignal-service 2026-04-16 20:47:39 +02:00
tonycpsu
ddfad2c4ce
Add distinct JSON-RPC error code for captcha rejection (#2021)
* Add distinct JSON-RPC error code for captcha rejection

Previously submitRateLimitChallenge mapped CaptchaRejectedException to
the generic USER_ERROR code (-1), making it indistinguishable from any
other user error (bad params, unknown command, etc.).

Introduce CaptchaRejectedErrorException and wire it to a new error code
(-6 / CAPTCHA_REJECTED_ERROR) throughout the JSON-RPC layer. Callers can
now reliably distinguish a rejected captcha token (user must obtain a
fresh token) from a network failure (transient, worth retrying) or a
generic argument error.

The CLI exit code for this path becomes 6, consistent with the existing
per-error-type exit code convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add exit code 6 to man page

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:00:39 +02:00
AsamK
7e95ea7403 Log identifier of failed profile download
Fixes #2003
2026-04-15 21:18:26 +02:00
BarbossHack
2991cdafe7
Add reproducible builds (#1954) 2026-04-15 21:10:42 +02:00
AsamK
561dfc373f Refactor retry after handling 2026-04-15 21:01:29 +02:00
Gara Dorta
5bfb044245
JSON Schema for JSON-RPC (#1952)
* Add OpenAPIDocs

* Remove the json prefix from the names

* Format file

* Rename models to schemas

* Add required = true to all the required attributes

* Add missing required = true schemas

* Deprecated fields are not required

* switch to micronout json generation

* Fix generator for JsonUnwrapped files

* Fix layout of manual schemas

* Pretty print the json files

* Remove @JsonProperty(required = true)

* Make references local

* Updated the readme

* Removed uneeded import

* Remove extra empty lines

* Clean readme

* Add docs depedency only when needed

* Revert uneeded changes

* Revert more changes

* Better formatting

* Simplified name

* fix: remove jsonunwrapped workaround by upgrading to micronaut-json-schema version 2.0.0-M6

* Simplified jsonSchemas task definition

* Updated readme with the new schemas path

* typo fixing

* Remove empty space from merge
2026-04-15 20:57:36 +02:00
tonycpsu
e1b17bf863
Surface server Retry-After for rate-limit send failures (#2016)
* 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>
2026-04-15 20:17:54 +02:00
Stefan Meinecke
72332750a8 Merge branch 'master' of https://github.com/AsamK/signal-cli into fix/unauthenticated-socket-keepalive 2026-04-15 11:42:43 +02:00
AsamK
aafb40fd94 Increase connection disconnect duration to match android app
Related #2018
2026-04-15 00:17:44 +02:00
Stefan Meinecke
7dc55eba81
Fix sender key re-distribution on every group message (#2019)
* 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().
2026-04-15 00:06:19 +02:00
85 changed files with 975 additions and 517 deletions

57
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: build
on:
push:
branches:
- "**"
pull_request:
workflow_call:
permissions: {}
jobs:
build:
strategy:
matrix:
# The "reproducible" entry is used to build the project with the LTS Java version used in reproducible builds script.
# More Java versions can be added to test compatibility, eg. "26".
java: ["reproducible", "26"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: |
if [ "${{ matrix.java }}" != "reproducible" ]; then
export OVERRIDE_JAVA_VERSION="${{ matrix.java }}"
fi
./reproducible-builds/build.sh
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-${{ matrix.java }}-${{ github.job }}
path: dist/*
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -1,96 +0,0 @@
name: signal-cli CI
on:
push:
branches:
- '**'
pull_request:
workflow_call:
permissions:
contents: write # to fetch code (actions/checkout) and submit dependency graph (gradle/gradle-build-action)
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '25', '26' ]
steps:
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: ${{ matrix.java }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Install asciidoc
run: sudo apt update && sudo apt --no-install-recommends install -y asciidoc-base
- name: Build with Gradle
run: ./gradlew --no-daemon build
- name: Build man page
run: |
cd man
make install
- name: Add man page to archive
run: |
version=$(tar tf build/distributions/signal-cli-*.tar | head -n1 | sed 's|signal-cli-\([^/]*\)/.*|\1|')
echo $version
tar --transform="flags=r;s|man|signal-cli-${version}/man|" -rf build/distributions/signal-cli-${version}.tar man/man{1,5}
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
build-graalvm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: graalvm/setup-graalvm@v1
with:
distribution: 'graalvm'
java-version: '25'
cache: 'gradle'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -5,8 +5,7 @@ on:
tags:
- v*
permissions:
contents: write # to fetch code (actions/checkout) and create release
permissions: {}
env:
IMAGE_NAME: signal-cli
@ -15,96 +14,25 @@ env:
REGISTRY_PASSWORD: ${{ github.token }}
jobs:
build:
uses: AsamK/signal-cli/.github/workflows/build.yml@master
ci_wf:
permissions:
contents: write
uses: AsamK/signal-cli/.github/workflows/ci.yml@master
# ${{ github.repository }} not accepted here
lib_to_jar:
needs: ci_wf
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
signal_cli_version: ${{ steps.cli_ver.outputs.version }}
release_id: ${{ steps.create_release.outputs.id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
id: version
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Extract archive
run: |
tree .
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar -xzf ./"${ARCHIVE_DIR}"/*.tar.gz
mv ./"${ARCHIVE_DIR}"/*.tar.gz signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
rm -rf signal-cli-archive-*/
# - name: Get signal-client jar version
# id: lib_ver
# run: |
# JAR_PREFIX=libsignal-client-
# jar_file=$(find ./signal-cli-*/lib/ -name "$JAR_PREFIX*.jar")
# jar_version=$(echo "$jar_file" | xargs basename | sed "s/$JAR_PREFIX//; s/.jar//")
# echo "$jar_version"
# echo "signal_client_version=${jar_version}" >> $GITHUB_OUTPUT
#
# - name: Download signal-client builds
# env:
# RELEASES_URL: https://github.com/signalapp/libsignal/releases/download/
# FILE_NAMES: signal_jni.dll libsignal_jni.dylib
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# for file_name in $FILE_NAMES; do
# curl -sOL "${RELEASES_URL}/v${SIGNAL_CLIENT_VER}/${file_name}" # note: added v
# done
# tree .
- name: Compress native app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-native/signal-cli
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-native.tar.gz -C signal-cli-native signal-cli
rm -rf signal-cli-native/
- name: Compress client app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-client-ubuntu/signal-cli-client
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-client.tar.gz -C signal-cli-client-ubuntu signal-cli-client
rm -rf signal-cli-client-ubuntu/
# - name: Replace Windows lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# mv signal_jni.dll libsignal_jni.so
# zip -u ./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar ./libsignal_jni.so
# tar -czf signal-cli-${SIGNAL_CLI_VER}-Windows.tar.gz signal-cli-*/
#
# - name: Replace macOS lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# jar_file=./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar
# zip -d $jar_file libsignal_jni.so
# zip $jar_file libsignal_jni.dylib
# tar -czf signal-cli-${SIGNAL_CLI_VER}-macOS.tar.gz signal-cli-*/
mv ./signal-cli-reproducible-build/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create release
id: create_release
@ -112,8 +40,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
release_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
tag_name: v${{ steps.version.outputs.version }} # note: added `v`
release_name: v${{ steps.version.outputs.version }} # note: added `v`
draft: true
- name: Upload archive
@ -122,19 +50,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload Linux 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.cli_ver.outputs.version }}-Linux.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
asset_path: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux native archive
uses: actions/upload-release-asset@v1
@ -142,9 +60,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux client archive
uses: actions/upload-release-asset@v1
@ -152,35 +70,14 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload windows 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.cli_ver.outputs.version }}-Windows.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
#
# - name: Upload macos 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.cli_ver.outputs.version }}-macOS.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-macOS.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
build-container:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -188,28 +85,19 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar xf ./"${ARCHIVE_DIR}"/*.tar.gz
rm -r signal-cli-archive-* signal-cli-native
tar xf ./signal-cli-reproducible-build/signal-cli-${{ needs.release.outputs.version }}.tar.gz
mkdir -p build/install/
mv ./signal-cli-"${GITHUB_REF_NAME#v}"/ build/install/signal-cli
mv ./signal-cli-"${{ needs.release.outputs.version }}"/ build/install/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ steps.cli_ver.outputs.version }}
containerfiles:
./Containerfile
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
containerfiles: ./Containerfile
oci: true
- name: Push To GHCR
@ -227,10 +115,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-native:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -238,26 +125,20 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
tar xf ./signal-cli-reproducible-build/signal-cli-${{ needs.release.outputs.version }}-Linux-native.tar.gz
mkdir -p build/native/nativeCompile/
chmod +x ./signal-cli-native/signal-cli
mv ./signal-cli-native/signal-cli build/native/nativeCompile/
mv signal-cli build/native/nativeCompile/
chmod +x build/native/nativeCompile/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ steps.cli_ver.outputs.version }}-native
containerfiles:
./native.Containerfile
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
containerfiles: ./native.Containerfile
oci: true
- name: Push To GHCR
@ -275,10 +156,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-client:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -286,26 +166,20 @@ jobs:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
tar xf ./signal-cli-reproducible-build/signal-cli-${{ needs.release.outputs.version }}-Linux-client.tar.gz
mkdir -p client/target/release/
chmod +x ./signal-cli-client-ubuntu/signal-cli-client
mv ./signal-cli-client-ubuntu/signal-cli-client client/target/release/
mv signal-cli-client client/target/release/
chmod +x client/target/release/signal-cli-client
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ steps.cli_ver.outputs.version }}-client
containerfiles:
./client.Containerfile
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
containerfiles: ./client.Containerfile
oci: true
- name: Push To GHCR

7
.gitignore vendored
View File

@ -1,4 +1,5 @@
.gradle/
.kotlin/
.idea/*
!.idea/codeStyles/
build/
@ -13,3 +14,9 @@ out/
.DS_Store
/bin/
/test-config/
/dist/
/github/
man/*.1
man/*.5
man/man1
man/man5

View File

@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Send message results now surface server-advised retry time for plain rate-limit (HTTP 413) failures, not only for proof-required challenges. The `retryAfterSeconds` field in JSON-RPC `SendMessageResult` is populated whenever the server sends a `Retry-After` header. The canonical way to distinguish proof-required failures remains `token != null`. Text output includes "retry after N seconds" when known.
## [0.14.2] - 2026-04-04
### Added

View File

@ -148,6 +148,16 @@ version installed, you can replace `./gradlew` with `gradle` in the following st
./gradlew run --args="--help"
```
### JSON Schemas for the JSON-RPC mode
1. Generate [JSON Schema](https://json-schema.org/) files for all the JSON-RPC data classes (`src/main/java/org/asamk/signal/json`):
```sh
./gradlew jsonSchemas
```
2. The generated files can be found in the `build/generated/META-INF/schemas` folder.
### Building a native binary with GraalVM (EXPERIMENTAL)
It is possible to build a native binary with [GraalVM](https://www.graalvm.org). This is still experimental and will not

View File

@ -1,3 +1,5 @@
import groovy.json.JsonOutput
plugins {
java
application
@ -72,6 +74,11 @@ val excludePatterns = mapOf(
)
)
val schemaAnnotationProcessor by configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
}
dependencies {
registerTransform(JarFileExcluder::class) {
from.attribute(minified, false).attribute(artifactType, "jar")
@ -82,6 +89,8 @@ dependencies {
}
}
schemaAnnotationProcessor(libs.micronaut.json.schema.processor)
schemaAnnotationProcessor(libs.micronaut.inject.java)
implementation(libs.bouncycastle)
implementation(libs.jackson.databind)
implementation(libs.argparse4j)
@ -90,6 +99,10 @@ dependencies {
implementation(libs.slf4j.jul)
implementation(libs.logback)
implementation(libs.zxing)
implementation(libs.micronaut.json.schema.annotations)
if (gradle.startParameter.taskNames.any { it.contains("jsonSchemas") }) {
implementation(libs.micronaut.json.schema.generator)
}
implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
@ -160,3 +173,30 @@ tasks.register("writeLibsignalVersion") {
}
}
}
tasks.register<JavaCompile>("jsonSchemas") {
dependsOn(tasks.compileJava)
val schemaBaseUri = "http://localhost:8080/schemas/"
source = sourceSets.main.get().java
include("org/asamk/signal/json/**/*.java")
classpath = sourceSets.main.get().compileClasspath + files(sourceSets.main.get().java.destinationDirectory)
destinationDirectory.set(layout.buildDirectory.dir("generated"))
options.annotationProcessorPath = schemaAnnotationProcessor
options.compilerArgs.addAll(
listOf(
"-Amicronaut.processing.group=org.asamk",
"-Amicronaut.processing.module=signal-cli",
"-Amicronaut.processing.annotations=org.asamk.signal.json.*",
"-Amicronaut.jsonschema.baseUri=$schemaBaseUri",
)
)
doLast {
fileTree(destinationDirectory.get().dir("META-INF/schemas").asFile) {
include("*.schema.json")
}.forEach { schemaFile ->
val normalized = schemaFile.readText().replace("\"$schemaBaseUri/", "\"")
val prettyJson = JsonOutput.prettyPrint(normalized)
schemaFile.writeText("$prettyJson\n")
}
}
}

323
client/Cargo.lock generated
View File

@ -4,9 +4,9 @@ version = 4
[[package]]
name = "anstream"
version = "0.6.21"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@ -19,15 +19,15 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.13"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@ -83,9 +83,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bytes"
@ -95,9 +95,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.56"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"shlex",
@ -117,9 +117,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.60"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@ -127,9 +127,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.60"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
@ -140,9 +140,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.55"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
@ -152,15 +152,15 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "1.0.0"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
@ -326,9 +326,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.16.1"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heck"
@ -377,9 +377,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@ -391,7 +391,6 @@ dependencies = [
"httparse",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@ -399,16 +398,15 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.7"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
@ -436,12 +434,13 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@ -449,9 +448,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@ -462,9 +461,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@ -476,15 +475,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@ -496,15 +495,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@ -538,9 +537,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.13.0"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
@ -554,9 +553,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
@ -567,7 +566,7 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
@ -576,9 +575,31 @@ dependencies = [
[[package]]
name = "jni-sys"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "jsonrpsee"
@ -668,9 +689,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "linux-raw-sys"
@ -680,9 +701,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
@ -698,9 +719,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
@ -709,9 +730,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.3"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
@ -757,26 +778,20 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "potential_utf"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
@ -792,9 +807,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@ -815,9 +830,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
@ -834,9 +849,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
dependencies = [
"log",
"once_cell",
@ -897,9 +912,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"ring",
"rustls-pki-types",
@ -917,9 +932,9 @@ dependencies = [
[[package]]
name = "schannel"
version = "0.1.28"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
@ -1026,12 +1041,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -1082,12 +1097,12 @@ dependencies = [
[[package]]
name = "terminal_size"
version = "0.4.3"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -1132,9 +1147,9 @@ dependencies = [
[[package]]
name = "tinystr"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@ -1142,9 +1157,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776"
dependencies = [
"bytes",
"libc",
@ -1157,9 +1172,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@ -1202,18 +1217,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
"indexmap",
"toml_datetime",
@ -1223,9 +1238,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
@ -1360,14 +1375,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
dependencies = [
"webpki-root-certs 1.0.6",
"webpki-root-certs 1.0.7",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
@ -1414,15 +1429,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -1456,30 +1462,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@ -1492,12 +1481,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@ -1510,12 +1493,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@ -1528,24 +1505,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@ -1558,12 +1523,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@ -1576,12 +1535,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@ -1594,12 +1547,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@ -1612,32 +1559,26 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
[[package]]
name = "writeable"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yoke"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@ -1646,9 +1587,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@ -1658,18 +1599,18 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@ -1685,9 +1626,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@ -1696,9 +1637,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@ -1707,9 +1648,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",

View File

@ -1,19 +1,25 @@
[versions]
slf4j = "2.0.17"
junit = "6.0.2"
junit = "6.0.3"
micronaut-json-schema = "2.0.0-M8"
micronaut-core = "4.9.3"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.83"
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.20.2"
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4"
micronaut-json-schema-annotations = { module = "io.micronaut.jsonschema:micronaut-json-schema-annotations", version.ref = "micronaut-json-schema" }
micronaut-json-schema-processor = { module = "io.micronaut.jsonschema:micronaut-json-schema-processor", version.ref = "micronaut-json-schema" }
micronaut-json-schema-generator = { module = "io.micronaut.jsonschema:micronaut-json-schema-generator", version.ref = "micronaut-json-schema" }
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.32"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_143"
sqlite = "org.xerial:sqlite-jdbc:3.51.2.0"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_144"
sqlite = "org.xerial:sqlite-jdbc:3.53.0.0"
hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }

View File

@ -407,6 +407,10 @@ public interface Manager extends Closeable {
void addClosedListener(Runnable listener);
void addUnidentifiedKeepAlive(String token);
void removeUnidentifiedKeepAlive(String token);
InputStream retrieveAttachment(final String id) throws IOException;
InputStream retrieveContactAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;

View File

@ -2,11 +2,11 @@ package org.asamk.signal.manager.api;
public class CaptchaRequiredException extends Exception {
private long nextAttemptTimestamp;
private long nextVerificationAttemptMilliseconds;
public CaptchaRequiredException(final long nextAttemptTimestamp) {
public CaptchaRequiredException(final long nextVerificationAttemptMilliseconds) {
super("Captcha required");
this.nextAttemptTimestamp = nextAttemptTimestamp;
this.nextVerificationAttemptMilliseconds = nextVerificationAttemptMilliseconds;
}
public CaptchaRequiredException(final String message) {
@ -17,7 +17,7 @@ public class CaptchaRequiredException extends Exception {
super(message, cause);
}
public long getNextAttemptTimestamp() {
return nextAttemptTimestamp;
public long getNextVerificationAttemptMilliseconds() {
return nextVerificationAttemptMilliseconds;
}
}

View File

@ -10,12 +10,19 @@ public class ProofRequiredException extends Exception {
private final String token;
private final Set<Option> options;
private final long retryAfterSeconds;
private final long retryAfterMilliseconds;
public ProofRequiredException(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
this.token = e.getToken();
this.options = e.getOptions().stream().map(Option::from).collect(Collectors.toSet());
this.retryAfterSeconds = e.getRetryAfterSeconds();
public ProofRequiredException(final String token, final Set<Option> options, final long retryAfterMilliseconds) {
super("Rate limit");
this.token = token;
this.options = options;
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public static ProofRequiredException from(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
return new ProofRequiredException(e.getToken(),
e.getOptions().stream().map(Option::from).collect(Collectors.toSet()),
e.getRetryAfterSeconds() * 1000L);
}
public String getToken() {
@ -26,8 +33,8 @@ public class ProofRequiredException extends Exception {
return options;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
public long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
}
public enum Option {

View File

@ -2,14 +2,18 @@ package org.asamk.signal.manager.api;
public class RateLimitException extends Exception {
private final long nextAttemptTimestamp;
private final Long retryAfterMilliseconds;
public RateLimitException(final long nextAttemptTimestamp) {
public RateLimitException(final Long retryAfterMilliseconds) {
super("Rate limit");
this.nextAttemptTimestamp = nextAttemptTimestamp;
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public long getNextAttemptTimestamp() {
return nextAttemptTimestamp;
public static RateLimitException from(org.whispersystems.signalservice.api.push.exceptions.RateLimitException e) {
return new RateLimitException(e.getRetryAfterMilliseconds().orElse(null));
}
public Long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
}
}

View File

@ -9,13 +9,13 @@ public record SendMessageResult(
boolean isNetworkFailure,
boolean isUnregisteredFailure,
boolean isIdentityFailure,
boolean isRateLimitFailure,
RateLimitException rateLimitException,
ProofRequiredException proofRequiredFailure,
boolean isInvalidPreKeyFailure
) {
public static SendMessageResult unregisteredFailure(RecipientAddress address) {
return new SendMessageResult(address, false, false, true, false, false, null, false);
return new SendMessageResult(address, false, false, true, false, null, null, false);
}
public static SendMessageResult from(
@ -23,16 +23,30 @@ public record SendMessageResult(
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
final var rateLimitFailure = sendMessageResult.getRateLimitFailure();
final var proofRequiredFailure = sendMessageResult.getProofRequiredFailure();
return new SendMessageResult(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
sendMessageResult.getAddress())).toApiRecipientAddress(),
sendMessageResult.isSuccess(),
sendMessageResult.isNetworkFailure(),
sendMessageResult.isUnregisteredFailure(),
sendMessageResult.getIdentityFailure() != null,
sendMessageResult.getRateLimitFailure() != null || sendMessageResult.getProofRequiredFailure() != null,
sendMessageResult.getProofRequiredFailure() == null
? null
: new ProofRequiredException(sendMessageResult.getProofRequiredFailure()),
rateLimitFailure == null ? null : RateLimitException.from(rateLimitFailure),
proofRequiredFailure == null ? null : ProofRequiredException.from(proofRequiredFailure),
sendMessageResult.isInvalidPreKeyFailure());
}
public boolean isRateLimitFailure() {
return this.rateLimitException != null || this.proofRequiredFailure != null;
}
public Long rateLimitRetryAfterMilliseconds() {
if (proofRequiredFailure != null) {
return proofRequiredFailure.getRetryAfterMilliseconds();
} else if (rateLimitException != null) {
return rateLimitException.getRetryAfterMilliseconds();
} else {
return null;
}
}
}

View File

@ -2,6 +2,7 @@ package org.asamk.signal.manager.api;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<SendMessageResult>> results) {
@ -26,4 +27,18 @@ public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<S
.flatMap(res -> res.stream().map(SendMessageResult::isRateLimitFailure))
.allMatch(r -> r) && results.values().stream().mapToInt(List::size).sum() > 0;
}
/**
* Longest rate-limit retry-after window across all rate-limited recipients, in milliseconds.
* Null when no recipient reported one (server omitted Retry-After, or no rate-limit failures).
*/
public Long maxRateLimitRetryAfterMilliseconds() {
return results.values()
.stream()
.flatMap(List::stream)
.map(SendMessageResult::rateLimitRetryAfterMilliseconds)
.filter(Objects::nonNull)
.max(Long::compareTo)
.orElse(null);
}
}

View File

@ -105,6 +105,8 @@ public class CallManager implements AutoCloseable {
recipientAddress,
recipientId);
activeCalls.put(callId, state);
dependencies.getAuthenticatedSignalWebSocket().registerKeepAliveToken("call" + callId);
dependencies.getUnauthenticatedSignalWebSocket().registerKeepAliveToken("call" + callId);
fireCallEvent(state, null);
// Spawn call tunnel binary and connect control channel
@ -197,11 +199,6 @@ public class CallManager implements AutoCloseable {
if (callEventListeners.isEmpty()) {
logger.debug("Ignoring incoming offer for call {}: no call event listeners registered",
callIdUnsigned(callId));
final var result = sendBusyMessage(callId, recipientId, deviceId);
if (!result.isSuccess()) {
logger.warn("Failed to send busy for unhandled call {}", callIdUnsigned(callId));
}
return;
}
@ -701,6 +698,8 @@ public class CallManager implements AutoCloseable {
private void endCall(final long callId, final String reason) {
var state = activeCalls.remove(callId);
dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
if (state == null) return;
state.state = CallInfo.State.ENDED;

View File

@ -134,7 +134,9 @@ public final class ProfileHelper {
SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL,
false));
} catch (IOException e) {
logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
logger.warn("Failed to retrieve profile key credential for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
return null;
}
@ -263,7 +265,9 @@ public final class ProfileHelper {
try {
blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE, false));
} catch (IOException e) {
logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
logger.warn("Failed to retrieve profile for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
}
return account.getProfileStore().getProfile(recipientId);
@ -381,7 +385,9 @@ public final class ProfileHelper {
logger.trace("Done handling retrieved profile");
}).doOnError(e -> {
logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
logger.warn("Failed to retrieve profile for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
final var profile = account.getProfileStore().getProfile(recipientId);
final var newProfile = (
profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)

View File

@ -102,9 +102,6 @@ public class ReceiveHelper {
signalWebSocket.connect();
signalWebSocket.registerKeepAliveToken("receive");
final var unauthenticatedSignalWebSocket = dependencies.getUnauthenticatedSignalWebSocket();
unauthenticatedSignalWebSocket.registerKeepAliveToken("receive");
try {
receiveMessagesInternal(signalWebSocket, timeout, maxMessages, handler, queuedActions);
} finally {
@ -113,7 +110,6 @@ public class ReceiveHelper {
queuedActions.clear();
signalWebSocket.removeKeepAliveToken("receive");
signalWebSocket.disconnect();
unauthenticatedSignalWebSocket.removeKeepAliveToken("receive");
webSocketStateDisposable.dispose();
shouldStop = false;
}

View File

@ -278,7 +278,7 @@ public class ManagerImpl implements Manager {
registeredUsers = context.getRecipientHelper().getRegisteredUsers(canonicalizedNumbersSet);
} catch (CdsiResourceExhaustedException e) {
logger.debug("CDSI resource exhausted: {}", e.getMessage());
throw new RateLimitException(System.currentTimeMillis() + e.getRetryAfterSeconds() * 1000L);
throw new RateLimitException(e.getRetryAfterSeconds() * 1000L);
}
return numbers.stream().collect(Collectors.toMap(n -> n, n -> {
@ -1712,6 +1712,16 @@ public class ManagerImpl implements Manager {
}
}
@Override
public void addUnidentifiedKeepAlive(final String token) {
dependencies.getUnauthenticatedSignalWebSocket().registerKeepAliveToken(token);
}
@Override
public void removeUnidentifiedKeepAlive(final String token) {
dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken(token);
}
@Override
public void addCallEventListener(final CallEventListener listener) {
context.getCallManager().addCallEventListener(listener);

View File

@ -301,7 +301,7 @@ public class SignalDependencies {
getLibSignalNetwork(),
credentialsProvider,
allowStories,
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(10));
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(30));
healthMonitor.monitor(authenticatedSignalWebSocket);
});
}
@ -316,7 +316,7 @@ public class SignalDependencies {
getLibSignalNetwork(),
null,
allowStories,
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(10));
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(30));
healthMonitor.monitor(unauthenticatedSignalWebSocket);
});
}

View File

@ -204,7 +204,7 @@ public class SenderKeySharedStore {
).formatted(TABLE_SENDER_KEY_SHARED);
try (final var statement = connection.prepareStatement(sql)) {
for (final var entry : newEntries) {
statement.setString(1, entry.toString());
statement.setString(1, entry.address());
statement.setInt(2, entry.deviceId());
statement.setBytes(3, UuidUtil.toByteArray(distributionId.asUuid()));
statement.setLong(4, System.currentTimeMillis());

View File

@ -65,14 +65,12 @@ public class NumberVerificationUtils {
if (nextAttempt == null) {
throw new VerificationMethodNotAvailableException();
} else if (nextAttempt > 0) {
final var timestamp = sessionResponse.getClientReceivedAtMilliseconds() + nextAttempt * 1000;
throw new RateLimitException(timestamp);
throw new RateLimitException(nextAttempt * 1000L);
}
final var nextVerificationAttempt = sessionResponse.getMetadata().getNextVerificationAttempt();
if (nextVerificationAttempt != null && nextVerificationAttempt > 0) {
final var timestamp = sessionResponse.getClientReceivedAtMilliseconds() + nextVerificationAttempt * 1000;
throw new CaptchaRequiredException(timestamp);
throw new CaptchaRequiredException(nextVerificationAttempt * 1000L);
}
if (sessionResponse.getMetadata().getRequestedInformation().contains("captcha")) {

View File

@ -14,8 +14,8 @@ all: $(MANPAGESRC)
.PHONY: install
install: all
$(MKDIR) -p man1 man5
for f in *.1; do $(GZIP) < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) < "$$f" > man5/"$$f".gz ; done
for f in *.1; do $(GZIP) -n < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) -n < "$$f" > man5/"$$f".gz ; done
.PHONY: clean
clean:

View File

@ -1168,6 +1168,7 @@ signal-cli -a ACCOUNT trust -a RECIPIENT
* *3*: Server or IO error
* *4*: Sending failed due to untrusted key
* *5*: Server rate limiting error
* *6*: CAPTCHA was rejected
== Files

View File

@ -0,0 +1,34 @@
# Reproducible builds
This process lets you verify that the version of signal-cli that was downloaded from the Github Releases matches the source code in the public repository.
This is achieved by replicating the build environment as Docker images.
Currently, only the following binaries are reproducible:
- [x] JAR package (`signal-cli-XXX.tar.gz`)
- [ ] Native binary (`signal-cli-XXX-Linux-native.tar.gz`)
- [x] Rust client binary (`signal-cli-XXX-Linux-client.tar.gz`)
In the following section, we will use signal-cli version 0.14.2 as the reference example. Simply replace all occurrences of 0.14.2 with the version number you are about to verify.
## Step-by-step instructions
### 0. Prerequisites
Before you begin, ensure you have the following installed:
- git
- docker (or podman)
### 1. Verifying reproducibility
```bash
git clone --depth 1 --branch v0.14.2 https://github.com/AsamK/signal-cli
cd ./signal-cli
./reproducible-builds/verify.sh
```
If each one ends with `... matches!` for every binary (except the native one for now), you're good to go! You've successfully verified that the Github Release binaries were built from exactly the same code as is in the signal-cli git repository.
If you get `... doesn't match!`, it means something went wrong (except for the native one for now). Please [open an issue](https://github.com/AsamK/signal-cli/issues/new/choose).

View File

@ -0,0 +1,13 @@
ARG ZULU_TAG="25.0.2-jdk@sha256:9582df6c4415d9c770eb5ff8fce426ebba53631149c9eb083ee126568d32fab3"
FROM docker.io/azul/zulu-openjdk:$ZULU_TAG
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
ARG SNAPSHOT=20260101T000000Z
RUN echo "deb http://snapshot.ubuntu.com/ubuntu/${SNAPSHOT}/ jammy main" > /etc/apt/sources.list \
&& echo "deb http://snapshot.ubuntu.com/ubuntu/${SNAPSHOT}/ jammy universe" >> /etc/apt/sources.list
RUN apt update && apt install -y make asciidoc-base
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "build" ]

50
reproducible-builds/build.sh Executable file
View File

@ -0,0 +1,50 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/dist"
mkdir -p "$ROOT_DIR/dist"
if command -v podman >/dev/null; then
ENGINE=podman
USER=
else
ENGINE=docker
USER="--user $(id -u):$(id -g)"
fi
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "$VERSION" >dist/VERSION
$ENGINE build -t signal-cli:build ${OVERRIDE_JAVA_VERSION:+--build-arg ZULU_TAG=$OVERRIDE_JAVA_VERSION} -f reproducible-builds/build.Containerfile .
$ENGINE build -t signal-cli:native -f reproducible-builds/native.Containerfile .
$ENGINE build -t signal-cli:client -f reproducible-builds/client.Containerfile .
# Build jar
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/
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"
exit 0
fi
# Build native-image
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:native
mv build/signal-cli-*-Linux-native.tar.gz dist/
# Build rust client
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:client
mv build/signal-cli-*-Linux-client.tar.gz dist/
ls -lsh dist/
echo -e "\e[32mBuild successful!\e[0m"

View File

@ -0,0 +1,7 @@
FROM docker.io/rust:1.94.1-slim-trixie@sha256:c6a474d7164ea2455e09b60a759b1edca38db7373c5689c1dae31780de4e71ac
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "client" ]

View File

@ -0,0 +1,78 @@
#!/bin/bash
set -eu
echo "Build '$1' variant $VERSION ..."
function reset_file_dates() {
find . -exec touch -m -d "@$SOURCE_DATE_EPOCH" {} \;
}
reset_file_dates
if [ "$1" == "build" ]; then
./gradlew build \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
cd man
make install
cd ..
tar_archive="build/distributions/signal-cli-${VERSION}.tar"
tar --transform="flags=r;s|man|signal-cli-${VERSION}/man|" -rf "$tar_archive" man/man{1,5}
# Remake the tarball to ensure reproducible file order and timestamps
mkdir -p build/extracted
tar -xf "$tar_archive" -C build/extracted/
reset_file_dates
rm -f "$tar_archive"
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --transform='s|^\./||' --owner=0 --group=0 --numeric-owner -cf "$tar_archive" -C build/extracted .
gzip -n -9 "$tar_archive"
elif [ "$1" == "native" ]; then
./gradlew nativeCompile \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Dgraalvm.native-image.build-time=2026-01-01T00:00:00Z \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
strip --strip-all \
--remove-section=.note.gnu.build-id \
--remove-section=.comment \
--remove-section=.gnu_debuglink \
--remove-section=.annobin.notes \
--remove-section=.gnu.build.attributes \
--remove-section=.note.ABI-tag \
build/native/nativeCompile/signal-cli
chmod +x build/native/nativeCompile/signal-cli
reset_file_dates
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "build/signal-cli-${VERSION}-Linux-native.tar" -C build/native/nativeCompile signal-cli
gzip -n -9 "build/signal-cli-${VERSION}-Linux-native.tar"
elif [ "$1" == "client" ]; then
cd client
cargo build --release --locked
cd ..
chmod +x client/target/release/signal-cli-client
mkdir -p build
reset_file_dates
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "build/signal-cli-${VERSION}-Linux-client.tar" -C client/target/release signal-cli-client
gzip -n -9 "build/signal-cli-${VERSION}-Linux-client.tar"
else
echo "Unknown build variant '$1'"
exit 1
fi

View File

@ -0,0 +1,7 @@
FROM container-registry.oracle.com/graalvm/native-image:25.0.2@sha256:4c0d5919f6840d89721274eb8cf81962faa2f870b816967e6732e2a151b150d8
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "native" ]

44
reproducible-builds/verify.sh Executable file
View File

@ -0,0 +1,44 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/github"
mkdir -p "$ROOT_DIR/github"
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "Download latest release from GitHub..."
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}.tar.gz" -o "github/signal-cli-${VERSION}.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" -o "github/signal-cli-${VERSION}-Linux-native.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-client.tar.gz" -o "github/signal-cli-${VERSION}-Linux-client.tar.gz"
./reproducible-builds/build.sh
rm -f {github,dist}/VERSION
echo "commit: $(git rev-parse HEAD)"
echo "sha256 hashes of GitHub release:"
sha256sum github/*
echo "sha256 hashes of locally built files:"
sha256sum dist/*
reproducible=true
for file in $(cd github && find . -type f); do
if diff "github/$file" "dist/$file" >/dev/null 2>&1; then
echo -e "\e[32m[+] '$(basename "$file")' matches!\e[0m"
elif [[ "$file" =~ "native" ]]; then
echo -e "\e[33m[-] '$(basename "$file")' doesn't match! (not supported yet)\e[0m"
reproducible=false
else
echo -e "\e[31m[-] '$(basename "$file")' doesn't match!\e[0m"
reproducible=false
fi
done
if [ "$reproducible" = false ]; then
exit 1
fi

View File

@ -8,7 +8,7 @@ public class BaseConfig {
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
.orElse("Signal-Android/8.6.1");
.orElse("Signal-Android/8.8.0");
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
? "signal-cli"
: PROJECT_NAME + "/" + PROJECT_VERSION;

View File

@ -22,6 +22,7 @@ import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.ArgumentParserException;
import net.sourceforge.argparse4j.inf.Namespace;
import org.asamk.signal.commands.exceptions.CaptchaRejectedErrorException;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.RateLimitErrorException;
@ -128,6 +129,7 @@ public class Main {
case IOErrorException ioErrorException -> 3;
case UntrustedKeyErrorException untrustedKeyErrorException -> 4;
case RateLimitErrorException rateLimitErrorException -> 5;
case CaptchaRejectedErrorException captchaRejectedErrorException -> 6;
case null -> 2;
};
}

View File

@ -3,9 +3,9 @@ package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CaptchaRejectedErrorException;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.output.OutputWriter;
@ -41,8 +41,8 @@ public class SubmitRateLimitChallengeCommand implements JsonRpcLocalCommand {
} catch (IOException e) {
throw new IOErrorException("Submit challenge error: " + e.getMessage(), e);
} catch (CaptchaRejectedException e) {
throw new UserErrorException(
"Captcha rejected, it may be outdated, already used or solved from a different IP address.");
throw new CaptchaRejectedErrorException(
"Captcha rejected, it may be outdated, already used or solved from a different IP address.", e);
}
}
}

View File

@ -0,0 +1,10 @@
package org.asamk.signal.commands.exceptions;
import org.asamk.signal.manager.api.CaptchaRejectedException;
public final class CaptchaRejectedErrorException extends CommandException {
public CaptchaRejectedErrorException(final String message, final CaptchaRejectedException cause) {
super(message, cause);
}
}

View File

@ -1,6 +1,6 @@
package org.asamk.signal.commands.exceptions;
public sealed abstract class CommandException extends Exception permits IOErrorException, RateLimitErrorException, UnexpectedErrorException, UntrustedKeyErrorException, UserErrorException {
public sealed abstract class CommandException extends Exception permits CaptchaRejectedErrorException, IOErrorException, RateLimitErrorException, UnexpectedErrorException, UntrustedKeyErrorException, UserErrorException {
public CommandException(final String message) {
super(message);

View File

@ -916,6 +916,14 @@ public class DbusManagerImpl implements Manager {
}
}
@Override
public void addUnidentifiedKeepAlive(final String token) {
}
@Override
public void removeUnidentifiedKeepAlive(final String token) {
}
@Override
public void addCallEventListener(final CallEventListener listener) {
// Not supported over DBus

View File

@ -100,6 +100,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
public void initObjects() {
exportObjects();
m.addUnidentifiedKeepAlive("dbus");
if (!noReceiveOnStart) {
subscribeReceive();
}
@ -116,6 +117,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
@Override
public void close() {
m.removeUnidentifiedKeepAlive("dbus");
if (dbusMessageHandler != null) {
m.removeReceiveHandler(dbusMessageHandler);
dbusMessageHandler = null;
@ -700,9 +702,13 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
} catch (IOException e) {
throw new Error.Failure(e.getMessage());
} catch (RateLimitException e) {
throw new Error.Failure(e.getMessage()
+ ", retry at "
+ DateUtils.formatTimestamp(e.getNextAttemptTimestamp()));
final var retryAfterMilliseconds = e.getRetryAfterMilliseconds();
throw new Error.Failure(e.getMessage() + (
retryAfterMilliseconds == null
? ""
: ", retry at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ retryAfterMilliseconds)
));
}
return numbers.stream().map(number -> registered.get(number).uuid() != null).toList();

View File

@ -1,9 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "AdminDelete")
public record JsonAdminDelete(
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
) {

View File

@ -1,7 +1,10 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "Attachment")
record JsonAttachment(
String contentType,
String filename,

View File

@ -1,5 +1,8 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "AttachmentData")
public record JsonAttachmentData(
String data
) {}

View File

@ -1,6 +1,7 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@ -10,6 +11,7 @@ import java.util.List;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
@JsonSchema(title = "CallMessage")
record JsonCallMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) Answer answerMessage,

View File

@ -1,9 +1,11 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import java.util.List;
@JsonSchema(title = "Contact")
public record JsonContact(
String number,
String uuid,
@ -26,6 +28,7 @@ public record JsonContact(
@JsonInclude(JsonInclude.Include.NON_NULL) JsonInternal internal
) {
@JsonSchema(title = "Profile")
public record JsonProfile(
long lastUpdateTimestamp,
String givenName,
@ -36,6 +39,7 @@ public record JsonContact(
String mobileCoinAddress
) {}
@JsonSchema(title = "Internal")
public record JsonInternal(
List<String> capabilities,
String unidentifiedAccessMode,

View File

@ -1,8 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util;
@JsonSchema(title = "ContactAddress")
public record JsonContactAddress(
String type,
String label,

View File

@ -1,7 +1,10 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "ContactAvatar")
public record JsonContactAvatar(JsonAttachment attachment, boolean isProfile) {
static JsonContactAvatar from(MessageEnvelope.Data.SharedContact.Avatar avatar) {

View File

@ -1,8 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util;
@JsonSchema(title = "ContactEmail")
public record JsonContactEmail(String value, String type, String label) {
static JsonContactEmail from(MessageEnvelope.Data.SharedContact.Email email) {

View File

@ -1,8 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util;
@JsonSchema(title = "ContactName")
public record JsonContactName(
String nickname, String given, String family, String prefix, String suffix, String middle
) {

View File

@ -1,8 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util;
@JsonSchema(title = "ContactPhone")
public record JsonContactPhone(String value, String type, String label) {
static JsonContactPhone from(MessageEnvelope.Data.SharedContact.Phone phone) {

View File

@ -1,12 +1,14 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List;
@JsonSchema(title = "DataMessage")
record JsonDataMessage(
long timestamp,
String message,

View File

@ -1,8 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "EditMessage")
record JsonEditMessage(long targetSentTimestamp, JsonDataMessage dataMessage) {
static JsonEditMessage from(MessageEnvelope.Edit editMessage, Manager m) {

View File

@ -1,5 +1,8 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "Error")
public record JsonError(String message, String type) {
public static JsonError from(Throwable exception) {

View File

@ -1,8 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "GroupInfo")
record JsonGroupInfo(String groupId, String groupName, int revision, String type) {
static JsonGroupInfo from(MessageEnvelope.Data.GroupContext groupContext, Manager m) {

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "Mention")
public record JsonMention(@Deprecated String name, String number, String uuid, int start, int length) {
static JsonMention from(MessageEnvelope.Data.Mention mention) {

View File

@ -1,6 +1,7 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope;
@ -10,6 +11,7 @@ import org.asamk.signal.manager.api.UntrustedIdentityException;
import java.util.UUID;
@JsonSchema(title = "MessageEnvelope")
public record JsonMessageEnvelope(
@Deprecated String source,
String sourceNumber,

View File

@ -1,7 +1,10 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "Payment")
public record JsonPayment(String note, byte[] receipt) {
static JsonPayment from(MessageEnvelope.Data.Payment payment) {

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "PinMessage")
public record JsonPinMessage(
@Deprecated String targetAuthor,
String targetAuthorNumber,

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List;
@JsonSchema(title = "PollCreate")
public record JsonPollCreate(
String question, boolean allowMultiple, List<String> options
) {

View File

@ -1,7 +1,10 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "PollTerminate")
public record JsonPollTerminate(long targetSentTimestamp) {
static JsonPollTerminate from(MessageEnvelope.Data.PollTerminate pollTerminate) {

View File

@ -1,10 +1,13 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List;
import java.util.UUID;
@JsonSchema(title = "PollVote")
public record JsonPollVote(
@Deprecated String author,
String authorNumber,

View File

@ -1,7 +1,10 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "Preview")
public record JsonPreview(String url, String title, String description, JsonAttachment image) {
static JsonPreview from(MessageEnvelope.Data.Preview preview) {

View File

@ -1,12 +1,14 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List;
import java.util.UUID;
@JsonSchema(title = "Quote")
public record JsonQuote(
long id,
@Deprecated String author,

View File

@ -1,9 +1,11 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "QuotedAttachment")
public record JsonQuotedAttachment(
String contentType, String filename, @JsonInclude(JsonInclude.Include.NON_NULL) JsonAttachment thumbnail
) {

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "Reaction")
public record JsonReaction(
String emoji,
@Deprecated String targetAuthor,

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List;
@JsonSchema(title = "ReceiptMessage")
record JsonReceiptMessage(long when, boolean isDelivery, boolean isRead, boolean isViewed, List<Long> timestamps) {
static JsonReceiptMessage from(MessageEnvelope.Receipt receiptMessage) {

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.RecipientAddress;
import java.util.UUID;
@JsonSchema(title = "RecipientAddress")
public record JsonRecipientAddress(String uuid, String number, String username) {
public static JsonRecipientAddress from(RecipientAddress address) {

View File

@ -1,3 +1,6 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "RemoteDelete")
record JsonRemoteDelete(long timestamp) {}

View File

@ -1,10 +1,12 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.SendMessageResult;
@JsonSchema(title = "SendMessageResult")
public record JsonSendMessageResult(
JsonRecipientAddress recipientAddress,
@JsonInclude(JsonInclude.Include.NON_NULL) String groupId,
@ -18,6 +20,7 @@ public record JsonSendMessageResult(
}
public static JsonSendMessageResult from(SendMessageResult result, GroupId groupId) {
final var rateLimitRetryAfterMilliseconds = result.rateLimitRetryAfterMilliseconds();
return new JsonSendMessageResult(JsonRecipientAddress.from(result.address()),
groupId != null ? groupId.toBase64() : null,
result.isSuccess()
@ -32,7 +35,7 @@ public record JsonSendMessageResult(
? Type.INVALID_PRE_KEY_FAILURE
: Type.IDENTITY_FAILURE,
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getToken() : null,
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getRetryAfterSeconds() : null);
rateLimitRetryAfterMilliseconds == null ? null : Math.ceilDiv(rateLimitRetryAfterMilliseconds, 1000L));
}
public enum Type {

View File

@ -1,11 +1,13 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List;
@JsonSchema(title = "SharedContact")
public record JsonSharedContact(
JsonContactName name,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonContactAvatar avatar,

View File

@ -1,8 +1,11 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Hex;
@JsonSchema(title = "Sticker")
public record JsonSticker(String packId, int stickerId) {
static JsonSticker from(MessageEnvelope.Data.Sticker sticker) {

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "StoryContext")
record JsonStoryContext(
String authorNumber, String authorUuid, long sentTimestamp
) {

View File

@ -1,6 +1,7 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.Color;
import org.asamk.signal.manager.api.GroupId;
@ -8,6 +9,7 @@ import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.List;
@JsonSchema(title = "StoryMessage")
record JsonStoryMessage(
boolean allowsReplies,
@JsonInclude(JsonInclude.Include.NON_NULL) String groupId,

View File

@ -2,6 +2,7 @@ package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.MessageEnvelope;
@ -9,6 +10,7 @@ import org.asamk.signal.manager.api.RecipientAddress;
import java.util.UUID;
@JsonSchema(title = "SyncDataMessage")
record JsonSyncDataMessage(
@Deprecated String destination,
String destinationNumber,

View File

@ -9,12 +9,15 @@ import org.asamk.signal.manager.api.RecipientAddress;
import java.util.List;
import io.micronaut.jsonschema.JsonSchema;
enum JsonSyncMessageType {
CONTACTS_SYNC,
GROUPS_SYNC,
REQUEST_SYNC
}
@JsonSchema(title = "SyncMessage")
record JsonSyncMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) JsonSyncDataMessage sentMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonSyncStoryMessage sentStoryMessage,

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "SyncReadMessage")
record JsonSyncReadMessage(
@Deprecated String sender, String senderNumber, String senderUuid, long timestamp
) {

View File

@ -1,11 +1,13 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "SyncStoryMessage")
record JsonSyncStoryMessage(
String destinationNumber, String destinationUuid, @JsonUnwrapped JsonStoryMessage dataMessage
) {

View File

@ -1,7 +1,10 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.TextStyle;
@JsonSchema(title = "TextStyle")
public record JsonTextStyle(String style, int start, int length) {
static JsonTextStyle from(TextStyle textStyle) {

View File

@ -1,10 +1,12 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.MessageEnvelope;
@JsonSchema(title = "TypingMessage")
record JsonTypingMessage(
String action, long timestamp, @JsonInclude(JsonInclude.Include.NON_NULL) String groupId
) {

View File

@ -1,9 +1,12 @@
package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
@JsonSchema(title = "UnpinMessage")
public record JsonUnpinMessage(
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
) {

View File

@ -12,6 +12,7 @@ import org.asamk.signal.commands.Command;
import org.asamk.signal.commands.JsonRpcMultiCommand;
import org.asamk.signal.commands.JsonRpcRegistrationCommand;
import org.asamk.signal.commands.JsonRpcSingleCommand;
import org.asamk.signal.commands.exceptions.CaptchaRejectedErrorException;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.RateLimitErrorException;
@ -39,6 +40,7 @@ public class SignalJsonRpcCommandHandler {
private static final int IO_ERROR = -3;
private static final int UNTRUSTED_KEY_ERROR = -4;
private static final int RATELIMIT_ERROR = -5;
private static final int CAPTCHA_REJECTED_ERROR = -6;
private final Manager m;
private final MultiAccountManager c;
@ -258,6 +260,10 @@ public class SignalJsonRpcCommandHandler {
case RateLimitErrorException e -> throw new JsonRpcException(new JsonRpcResponse.Error(RATELIMIT_ERROR,
e.getMessage(),
getErrorDataNode(objectMapper, result)));
case CaptchaRejectedErrorException e -> throw new JsonRpcException(new JsonRpcResponse.Error(
CAPTCHA_REJECTED_ERROR,
e.getMessage(),
getErrorDataNode(objectMapper, result)));
case UnexpectedErrorException e -> {
logger.error("Command execution failed with unexpected error", e);
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INTERNAL_ERROR,

View File

@ -25,10 +25,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.channels.ClosedChannelException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
@ -43,6 +45,9 @@ public class SignalJsonRpcDispatcherHandler {
private final Map<Integer, List<Pair<Manager, Manager.ReceiveMessageHandler>>> receiveHandlers = new HashMap<>();
private final Map<Integer, List<Pair<Manager, Manager.CallEventListener>>> callEventHandlers = new HashMap<>();
private final String connectionKeepAliveToken = "jsonrpc-" + UUID.randomUUID();
private final List<Manager> keepAliveManagers = new ArrayList<>();
private boolean connectionActive = true;
private SignalJsonRpcCommandHandler commandHandler;
public SignalJsonRpcDispatcherHandler(
@ -69,6 +74,10 @@ public class SignalJsonRpcDispatcherHandler {
c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createCallEventHandler(m, subscriptionId))));
c.getManagers().forEach(this::registerKeepAlive);
c.addOnManagerAddedHandler(this::registerKeepAlive);
c.addOnManagerRemovedHandler(this::unregisterKeepAlive);
handleConnection();
}
@ -82,6 +91,8 @@ public class SignalJsonRpcDispatcherHandler {
final var currentThread = Thread.currentThread();
m.addClosedListener(currentThread::interrupt);
registerKeepAlive(m);
handleConnection();
}
@ -200,14 +211,29 @@ public class SignalJsonRpcDispatcherHandler {
subscriptionId.ifPresent(this::unsubscribeReceive);
}
private void registerKeepAlive(final Manager m) {
if (!connectionActive) return;
m.addUnidentifiedKeepAlive(connectionKeepAliveToken);
keepAliveManagers.add(m);
}
private void unregisterKeepAlive(final Manager m) {
if (!connectionActive) return;
m.removeUnidentifiedKeepAlive(connectionKeepAliveToken);
keepAliveManagers.remove(m);
}
private void handleConnection() {
try {
jsonRpcReader.readMessages((method, params) -> commandHandler.handleRequest(objectMapper, method, params),
response -> logger.debug("Received unexpected response for id {}", response.getId()));
} finally {
connectionActive = false;
receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler));
receiveHandlers.clear();
unsubscribeAllCallEvents();
keepAliveManagers.forEach(m -> m.removeUnidentifiedKeepAlive(connectionKeepAliveToken));
keepAliveManagers.clear();
}
}

View File

@ -129,16 +129,19 @@ public class CommandUtil {
} else {
message = "Invalid captcha given.";
}
if (e.getNextAttemptTimestamp() > 0) {
message += "\nNext Captcha may be provided at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
if (e.getNextVerificationAttemptMilliseconds() > 0) {
message += "\nNext Captcha may be provided at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ e.getNextVerificationAttemptMilliseconds());
}
return message;
}
public static String getRateLimitMessage(final RateLimitException e) {
String message = "Rate limit reached";
if (e.getNextAttemptTimestamp() > 0) {
message += "\nNext attempt may be tried at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
final var retryAfterMilliseconds = e.getRetryAfterMilliseconds();
if (retryAfterMilliseconds != null) {
message += "\nNext attempt may be tried at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ retryAfterMilliseconds);
}
return message;
}

View File

@ -59,8 +59,10 @@ public class SendMessageResultUtils {
if (sendMessageResults.hasOnlyUntrustedIdentity()) {
throw new UntrustedKeyErrorException("Failed to send message due to untrusted identities");
} else if (sendMessageResults.hasOnlyRateLimitFailure()) {
final var retryAfter = sendMessageResults.maxRateLimitRetryAfterMilliseconds();
final var nextAttempt = retryAfter == null ? 0L : System.currentTimeMillis() + retryAfter;
throw new RateLimitErrorException("Failed to send message due to rate limiting",
new RateLimitException(0));
new RateLimitException(nextAttempt));
} else {
throw new UserErrorException("Failed to send message");
}
@ -106,11 +108,14 @@ public class SendMessageResultUtils {
.map(ProofRequiredException.Option::toString)
.collect(Collectors.joining(", ")),
failure.getToken(),
failure.getRetryAfterSeconds());
Math.ceilDiv(failure.getRetryAfterMilliseconds(), 1000L));
} else if (result.isNetworkFailure()) {
return String.format("Network failure for \"%s\"", identifier);
} else if (result.isRateLimitFailure()) {
return String.format("Rate limit failure for \"%s\"", identifier);
final var retryAfter = result.rateLimitRetryAfterMilliseconds();
return retryAfter != null ? String.format("Rate limit failure for \"%s\", retry after %d seconds",
identifier,
Math.ceilDiv(retryAfter, 1000L)) : String.format("Rate limit failure for \"%s\"", identifier);
} else if (result.isUnregisteredFailure()) {
return String.format("Unregistered user \"%s\"", identifier);
} else if (result.isIdentityFailure()) {

View File

@ -1564,6 +1564,9 @@
}
]
},
{
"type": "kotlin.reflect.jvm.internal.impl.km.jvm.internal.JvmMetadataExtensions"
},
{
"type": "kotlin.reflect.jvm.internal.impl.load.java.ErasedOverridabilityCondition"
},
@ -1626,6 +1629,9 @@
}
]
},
{
"type": "kotlinx.coroutines.CancelledContinuation"
},
{
"type": "kotlinx.coroutines.CompletedExceptionally",
"fields": [
@ -1681,6 +1687,9 @@
}
]
},
{
"type": "kotlinx.coroutines.internal.LimitedDispatcher"
},
{
"type": "kotlinx.coroutines.internal.LockFreeLinkedListNode",
"fields": [
@ -1714,6 +1723,9 @@
}
]
},
{
"type": "kotlinx.coroutines.internal.ThreadSafeHeap"
},
{
"type": "kotlinx.coroutines.scheduling.CoroutineScheduler",
"fields": [
@ -1728,6 +1740,12 @@
}
]
},
{
"type": "kotlinx.coroutines.scheduling.CoroutineScheduler$Worker"
},
{
"type": "kotlinx.coroutines.scheduling.WorkQueue"
},
{
"type": "libcore.io.Memory"
},
@ -4898,6 +4916,15 @@
}
]
},
{
"type": "org.bouncycastle.jcajce.provider.kdf.PBKDF2$Mappings",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "org.bouncycastle.jcajce.provider.kdf.SCRYPT$Mappings",
"methods": [
@ -10115,6 +10142,9 @@
{
"glob": "META-INF/services/java.util.spi.ResourceBundleControlProvider"
},
{
"glob": "META-INF/services/kotlin.reflect.jvm.internal.impl.km.internal.extensions.MetadataExtensions"
},
{
"glob": "META-INF/services/kotlin.reflect.jvm.internal.impl.resolve.ExternalOverridabilityCondition"
},

View File

@ -0,0 +1,114 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class JsonSendMessageResultTest {
private static final RecipientAddress ADDRESS = new RecipientAddress(null, null, "+15551234567", null);
@Test
void rateLimitFailureSurfacesRetryAfterSeconds() {
var result = new SendMessageResult(ADDRESS,
false,
false,
false,
false,
new RateLimitException(3600_000L),
null,
false);
var json = JsonSendMessageResult.from(result);
assertEquals(JsonSendMessageResult.Type.RATE_LIMIT_FAILURE, json.type());
assertEquals(3600L, json.retryAfterSeconds());
assertNull(json.token());
}
@Test
void rateLimitFailureWithoutRetryAfterLeavesFieldNull() {
var result = new SendMessageResult(ADDRESS,
false,
false,
false,
false,
new RateLimitException(null),
null,
false);
var json = JsonSendMessageResult.from(result);
assertEquals(JsonSendMessageResult.Type.RATE_LIMIT_FAILURE, json.type());
assertNull(json.retryAfterSeconds());
}
@Test
void successLeavesRetryAfterNull() {
var result = new SendMessageResult(ADDRESS, true, false, false, false, null, null, false);
var json = JsonSendMessageResult.from(result);
assertEquals(JsonSendMessageResult.Type.SUCCESS, json.type());
assertNull(json.retryAfterSeconds());
}
@Test
void aggregateReturnsLongestRetryAfter() {
var small = rateLimited("+15551234567", 60L);
var big = rateLimited("+15559876543", 3600L);
var unknown = rateLimited("+15550000000", null);
var aggregate = new SendMessageResults(1L,
Map.of(new RecipientIdentifier.Uuid(UUID.randomUUID()), List.of(small, big, unknown)));
assertEquals(3600L, aggregate.maxRateLimitRetryAfterMilliseconds());
}
@Test
void aggregateReturnsNullWhenNoRetryAfter() {
var aggregate = new SendMessageResults(1L,
Map.of(new RecipientIdentifier.Uuid(UUID.randomUUID()), List.of(rateLimited("+15551234567", null))));
assertNull(aggregate.maxRateLimitRetryAfterMilliseconds());
}
/**
* Regression for a bug where the aggregate helper could overlook the longest
* wait if only some recipients reported a value. Ensures the max is picked
* across any mix which is what downstream captcha/rate-limit clients rely on.
*/
@Test
void aggregatePicksMaxEvenWhenSomeValuesAreNull() {
var withValue = rateLimited("+15551111111", 7200L);
var withoutValue = rateLimited("+15552222222", null);
var alsoWithValue = rateLimited("+15553333333", 120L);
var aggregate = new SendMessageResults(1L,
Map.of(new RecipientIdentifier.Uuid(UUID.randomUUID()),
List.of(withoutValue, withValue, alsoWithValue)));
assertEquals(7200L, aggregate.maxRateLimitRetryAfterMilliseconds());
}
private static SendMessageResult rateLimited(String number, Long retryAfterSeconds) {
return new SendMessageResult(new RecipientAddress(null, null, number, null),
false,
false,
false,
false,
new RateLimitException(retryAfterSeconds),
null,
false);
}
}

View File

@ -496,6 +496,14 @@ class SubscribeCallEventsTest {
public void addClosedListener(Runnable l) {
}
@Override
public void addUnidentifiedKeepAlive(String token) {
}
@Override
public void removeUnidentifiedKeepAlive(String token) {
}
@Override
public InputStream retrieveAttachment(String id) {
return null;
@ -741,8 +749,8 @@ class SubscribeCallEventsTest {
assertEquals(1, manager1.addCount.get(), "manager1 should have one listener");
assertEquals(1, manager2.addCount.get(), "manager2 should have one listener");
// Also registers an onManagerAdded handler for receive and one for call events
assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
// Registers onManagerAdded handlers for receive, call events, and keep-alive
assertEquals(3, multi.addedHandlers.size(), "should register onManagerAdded handlers");
}
@Test