mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-05-18 13:14:14 +00:00
Merge 3dbe3fba50f2c2e59a5c8c2c80aec8d07a96b367 into da214817be1b43c6c3da3642167189e47987e24c
This commit is contained in:
commit
60bc32e80a
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `getRateLimitStatus` JSON-RPC/CLI command returning the current rate-limit state for the account (active flag, `retryAfterSeconds`, `challengeToken`, `expiresAtEpochSeconds`). Useful for admin UIs, monitoring, and clients that want to query current state without triggering a send.
|
||||||
|
|
||||||
### Changed
|
### 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.
|
- 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.
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import org.asamk.signal.manager.api.PendingAdminApprovalException;
|
|||||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||||
import org.asamk.signal.manager.api.PinLockedException;
|
import org.asamk.signal.manager.api.PinLockedException;
|
||||||
import org.asamk.signal.manager.api.RateLimitException;
|
import org.asamk.signal.manager.api.RateLimitException;
|
||||||
|
import org.asamk.signal.manager.api.RateLimitStatus;
|
||||||
import org.asamk.signal.manager.api.ReceiveConfig;
|
import org.asamk.signal.manager.api.ReceiveConfig;
|
||||||
import org.asamk.signal.manager.api.Recipient;
|
import org.asamk.signal.manager.api.Recipient;
|
||||||
import org.asamk.signal.manager.api.RecipientIdentifier;
|
import org.asamk.signal.manager.api.RecipientIdentifier;
|
||||||
@ -156,6 +157,12 @@ public interface Manager extends Closeable {
|
|||||||
String captcha
|
String captcha
|
||||||
) throws IOException, CaptchaRejectedException;
|
) throws IOException, CaptchaRejectedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the most recent rate-limit state observed from send results, or an inactive
|
||||||
|
* status if no rate-limit has been seen (or the previous window has elapsed).
|
||||||
|
*/
|
||||||
|
RateLimitStatus getRateLimitStatus();
|
||||||
|
|
||||||
List<Device> getLinkedDevices() throws IOException;
|
List<Device> getLinkedDevices() throws IOException;
|
||||||
|
|
||||||
void updateLinkedDevice(int deviceId, String name) throws IOException, NotPrimaryDeviceException;
|
void updateLinkedDevice(int deviceId, String name) throws IOException, NotPrimaryDeviceException;
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
package org.asamk.signal.manager.api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of the most recent rate-limit state observed from send results.
|
||||||
|
*
|
||||||
|
* <p>{@code active} is true while the current system time is still inside the retry-after
|
||||||
|
* window. When the window elapses, callers see {@code active = false} without needing to
|
||||||
|
* clear the state themselves.
|
||||||
|
*
|
||||||
|
* <p>{@code proofRequired} distinguishes a plain HTTP 413 rate limit (resolved by waiting)
|
||||||
|
* from a HTTP 428 challenge (requires captcha submission via
|
||||||
|
* {@code submitRateLimitChallenge}). When {@code proofRequired} is true,
|
||||||
|
* {@code challengeToken} is populated.
|
||||||
|
*/
|
||||||
|
public record RateLimitStatus(
|
||||||
|
boolean active,
|
||||||
|
boolean proofRequired,
|
||||||
|
Long retryAfterSeconds,
|
||||||
|
String challengeToken,
|
||||||
|
Long expiresAtEpochSeconds
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static RateLimitStatus inactive() {
|
||||||
|
return new RateLimitStatus(false, false, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -54,6 +54,7 @@ import org.asamk.signal.manager.api.PinLockMissingException;
|
|||||||
import org.asamk.signal.manager.api.PinLockedException;
|
import org.asamk.signal.manager.api.PinLockedException;
|
||||||
import org.asamk.signal.manager.api.Profile;
|
import org.asamk.signal.manager.api.Profile;
|
||||||
import org.asamk.signal.manager.api.RateLimitException;
|
import org.asamk.signal.manager.api.RateLimitException;
|
||||||
|
import org.asamk.signal.manager.api.RateLimitStatus;
|
||||||
import org.asamk.signal.manager.api.ReceiveConfig;
|
import org.asamk.signal.manager.api.ReceiveConfig;
|
||||||
import org.asamk.signal.manager.api.Recipient;
|
import org.asamk.signal.manager.api.Recipient;
|
||||||
import org.asamk.signal.manager.api.RecipientIdentifier;
|
import org.asamk.signal.manager.api.RecipientIdentifier;
|
||||||
@ -175,6 +176,10 @@ public class ManagerImpl implements Manager {
|
|||||||
private final List<Runnable> addressChangedListeners = new ArrayList<>();
|
private final List<Runnable> addressChangedListeners = new ArrayList<>();
|
||||||
private final CompositeDisposable disposable = new CompositeDisposable();
|
private final CompositeDisposable disposable = new CompositeDisposable();
|
||||||
private final AtomicLong lastMessageTimestamp = new AtomicLong();
|
private final AtomicLong lastMessageTimestamp = new AtomicLong();
|
||||||
|
private final java.util.concurrent.atomic.AtomicReference<RateLimitSnapshot> rateLimitSnapshot = new java.util.concurrent.atomic.AtomicReference<>();
|
||||||
|
|
||||||
|
/** Internal snapshot of a rate-limit event — captured from send results, read via getRateLimitStatus(). */
|
||||||
|
private record RateLimitSnapshot(long expiresAtEpochMs, String challengeToken) {}
|
||||||
|
|
||||||
public ManagerImpl(
|
public ManagerImpl(
|
||||||
SignalAccount account,
|
SignalAccount account,
|
||||||
@ -468,6 +473,27 @@ public class ManagerImpl implements Manager {
|
|||||||
} catch (org.whispersystems.signalservice.internal.push.exceptions.CaptchaRejectedException ignored) {
|
} catch (org.whispersystems.signalservice.internal.push.exceptions.CaptchaRejectedException ignored) {
|
||||||
throw new CaptchaRejectedException();
|
throw new CaptchaRejectedException();
|
||||||
}
|
}
|
||||||
|
rateLimitSnapshot.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RateLimitStatus getRateLimitStatus() {
|
||||||
|
final var snapshot = rateLimitSnapshot.get();
|
||||||
|
if (snapshot == null) {
|
||||||
|
return RateLimitStatus.inactive();
|
||||||
|
}
|
||||||
|
final var remainingMs = snapshot.expiresAtEpochMs() - System.currentTimeMillis();
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
rateLimitSnapshot.compareAndSet(snapshot, null);
|
||||||
|
return RateLimitStatus.inactive();
|
||||||
|
}
|
||||||
|
final var retryAfterSeconds = (remainingMs + 999L) / 1000L;
|
||||||
|
final var expiresAtEpochSeconds = (snapshot.expiresAtEpochMs() + 999L) / 1000L;
|
||||||
|
return new RateLimitStatus(true,
|
||||||
|
snapshot.challengeToken() != null,
|
||||||
|
retryAfterSeconds,
|
||||||
|
snapshot.challengeToken(),
|
||||||
|
expiresAtEpochSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -720,7 +746,21 @@ public class ManagerImpl implements Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private SendMessageResult toSendMessageResult(final org.whispersystems.signalservice.api.messages.SendMessageResult result) {
|
private SendMessageResult toSendMessageResult(final org.whispersystems.signalservice.api.messages.SendMessageResult result) {
|
||||||
return SendMessageResult.from(result, account.getRecipientResolver(), account.getRecipientAddressResolver());
|
final var apiResult = SendMessageResult.from(result,
|
||||||
|
account.getRecipientResolver(),
|
||||||
|
account.getRecipientAddressResolver());
|
||||||
|
recordRateLimitState(apiResult);
|
||||||
|
return apiResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordRateLimitState(final SendMessageResult apiResult) {
|
||||||
|
if (!apiResult.isRateLimitFailure() || apiResult.rateLimitRetryAfterMilliseconds() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final var proofRequired = apiResult.proofRequiredFailure();
|
||||||
|
final var token = proofRequired == null ? null : proofRequired.getToken();
|
||||||
|
final var expiresAt = System.currentTimeMillis() + apiResult.rateLimitRetryAfterMilliseconds();
|
||||||
|
rateLimitSnapshot.set(new RateLimitSnapshot(expiresAt, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
private SendMessageResults sendTypingMessage(
|
private SendMessageResults sendTypingMessage(
|
||||||
|
|||||||
@ -1119,6 +1119,20 @@ The challenge token from the failed send attempt.
|
|||||||
*--captcha* CAPTCHA::
|
*--captcha* CAPTCHA::
|
||||||
The captcha result, starting with signalcaptcha://
|
The captcha result, starting with signalcaptcha://
|
||||||
|
|
||||||
|
=== getRateLimitStatus
|
||||||
|
|
||||||
|
Return the current rate-limit state for this account, or an inactive status if no rate limit is active.
|
||||||
|
|
||||||
|
State is tracked from observed send results: the status becomes active when a send fails with a rate-limit error, clears after a successful `submitRateLimitChallenge`, and auto-expires when the server-advised retry-after deadline passes.
|
||||||
|
|
||||||
|
With JSON output the response has the following fields:
|
||||||
|
|
||||||
|
- `active`: boolean, true if a rate-limit window is currently active
|
||||||
|
- `proofRequired`: boolean, true if a captcha challenge must be solved via `submitRateLimitChallenge`
|
||||||
|
- `retryAfterSeconds`: seconds remaining until the limit expires (omitted when `active` is false)
|
||||||
|
- `challengeToken`: the challenge token to pass to `submitRateLimitChallenge` (only present when `proofRequired` is true)
|
||||||
|
- `expiresAtEpochSeconds`: Unix timestamp when the rate-limit window expires (omitted when `active` is false)
|
||||||
|
|
||||||
=== version
|
=== version
|
||||||
|
|
||||||
Show version information.
|
Show version information.
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public class Commands {
|
|||||||
addCommand(new HangupCallCommand());
|
addCommand(new HangupCallCommand());
|
||||||
addCommand(new GetAttachmentCommand());
|
addCommand(new GetAttachmentCommand());
|
||||||
addCommand(new GetAvatarCommand());
|
addCommand(new GetAvatarCommand());
|
||||||
|
addCommand(new GetRateLimitStatusCommand());
|
||||||
addCommand(new GetStickerCommand());
|
addCommand(new GetStickerCommand());
|
||||||
addCommand(new GetUserStatusCommand());
|
addCommand(new GetUserStatusCommand());
|
||||||
addCommand(new AddStickerPackCommand());
|
addCommand(new AddStickerPackCommand());
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
package org.asamk.signal.commands;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import net.sourceforge.argparse4j.inf.Namespace;
|
||||||
|
import net.sourceforge.argparse4j.inf.Subparser;
|
||||||
|
|
||||||
|
import org.asamk.signal.commands.exceptions.CommandException;
|
||||||
|
import org.asamk.signal.manager.Manager;
|
||||||
|
import org.asamk.signal.manager.api.RateLimitStatus;
|
||||||
|
import org.asamk.signal.output.JsonWriter;
|
||||||
|
import org.asamk.signal.output.OutputWriter;
|
||||||
|
import org.asamk.signal.output.PlainTextWriter;
|
||||||
|
|
||||||
|
public class GetRateLimitStatusCommand implements JsonRpcLocalCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "getRateLimitStatus";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void attachToSubparser(final Subparser subparser) {
|
||||||
|
subparser.help(
|
||||||
|
"Return the current rate-limit state for this account, or an inactive status if no rate limit is active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleCommand(
|
||||||
|
final Namespace ns,
|
||||||
|
final Manager m,
|
||||||
|
final OutputWriter outputWriter
|
||||||
|
) throws CommandException {
|
||||||
|
final var status = m.getRateLimitStatus();
|
||||||
|
switch (outputWriter) {
|
||||||
|
case JsonWriter writer -> writer.write(JsonRateLimitStatus.from(status));
|
||||||
|
case PlainTextWriter writer -> {
|
||||||
|
if (!status.active()) {
|
||||||
|
writer.println("Not rate limited");
|
||||||
|
} else if (status.proofRequired()) {
|
||||||
|
writer.println("Rate limited (proof required), retry after {}s, challenge token: {}",
|
||||||
|
status.retryAfterSeconds(),
|
||||||
|
status.challengeToken());
|
||||||
|
} else {
|
||||||
|
writer.println("Rate limited, retry after {}s", status.retryAfterSeconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record JsonRateLimitStatus(
|
||||||
|
boolean active,
|
||||||
|
boolean proofRequired,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) Long retryAfterSeconds,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) String challengeToken,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) Long expiresAtEpochSeconds
|
||||||
|
) {
|
||||||
|
|
||||||
|
static JsonRateLimitStatus from(RateLimitStatus status) {
|
||||||
|
return new JsonRateLimitStatus(status.active(),
|
||||||
|
status.proofRequired(),
|
||||||
|
status.retryAfterSeconds(),
|
||||||
|
status.challengeToken(),
|
||||||
|
status.expiresAtEpochSeconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -237,6 +237,12 @@ public class DbusManagerImpl implements Manager {
|
|||||||
signal.submitRateLimitChallenge(challenge, captcha);
|
signal.submitRateLimitChallenge(challenge, captcha);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public org.asamk.signal.manager.api.RateLimitStatus getRateLimitStatus() {
|
||||||
|
// D-Bus does not currently expose rate-limit state; clients should observe send results instead.
|
||||||
|
return org.asamk.signal.manager.api.RateLimitStatus.inactive();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Device> getLinkedDevices() throws IOException {
|
public List<Device> getLinkedDevices() throws IOException {
|
||||||
final var thisDevice = signal.getThisDevice();
|
final var thisDevice = signal.getThisDevice();
|
||||||
|
|||||||
@ -186,6 +186,11 @@ class SubscribeCallEventsTest {
|
|||||||
public void submitRateLimitRecaptchaChallenge(String c, String cap) {
|
public void submitRateLimitRecaptchaChallenge(String c, String cap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public org.asamk.signal.manager.api.RateLimitStatus getRateLimitStatus() {
|
||||||
|
return org.asamk.signal.manager.api.RateLimitStatus.inactive();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Device> getLinkedDevices() {
|
public List<Device> getLinkedDevices() {
|
||||||
return List.of();
|
return List.of();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user