mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-10-26 08:49:38 +00:00
Add --timestamp option to send command
Let the caller choose the timestamp of a sent message instead of using the current time. Signal clients identify a message by its author and timestamp, so this lets a caller know the timestamp before the send returns, or reuse the timestamp of an earlier attempt. The value must be positive and strictly in the past. A supplied timestamp does not update the last generated timestamp, so a value equal to the current time could be the same as the one generated for a concurrent send. The option is rejected together with --edit-timestamp or --end-session, and over D-Bus, which has no way to pass it. Closes #1390 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1da2a51c1e
commit
9173ed7dec
@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `send --timestamp` to set the timestamp of the sent message instead of using the current time
|
||||
|
||||
## [0.14.9] - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
@ -16,7 +16,8 @@ public record Message(
|
||||
List<Preview> previews,
|
||||
Optional<StoryReply> storyReply,
|
||||
List<TextStyle> textStyles,
|
||||
boolean urgent
|
||||
boolean urgent,
|
||||
Optional<Long> timestamp
|
||||
) {
|
||||
|
||||
public record AttachmentDimensions(int width, int height) {}
|
||||
|
||||
@ -692,7 +692,7 @@ public class ManagerImpl implements Manager {
|
||||
Set<RecipientIdentifier> recipients,
|
||||
boolean notifySelf
|
||||
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
|
||||
return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty(), true);
|
||||
return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty(), Optional.empty(), true);
|
||||
}
|
||||
|
||||
private SendMessageResults sendMessage(
|
||||
@ -700,10 +700,11 @@ public class ManagerImpl implements Manager {
|
||||
Set<RecipientIdentifier> recipients,
|
||||
boolean notifySelf,
|
||||
Optional<Long> editTargetTimestamp,
|
||||
Optional<Long> messageTimestamp,
|
||||
boolean urgent
|
||||
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
|
||||
var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
|
||||
long timestamp = getNextMessageTimestamp();
|
||||
long timestamp = messageTimestamp.orElseGet(this::getNextMessageTimestamp);
|
||||
messageBuilder.withTimestamp(timestamp);
|
||||
for (final var recipient : recipients) {
|
||||
if (recipient instanceof RecipientIdentifier.NoteToSelf || (
|
||||
@ -833,7 +834,12 @@ public class ManagerImpl implements Manager {
|
||||
}
|
||||
final var messageBuilder = SignalServiceDataMessage.newBuilder();
|
||||
applyMessage(messageBuilder, message);
|
||||
return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty(), message.urgent());
|
||||
return sendMessage(messageBuilder,
|
||||
recipients,
|
||||
notifySelf,
|
||||
Optional.empty(),
|
||||
message.timestamp(),
|
||||
message.urgent());
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -842,9 +848,17 @@ public class ManagerImpl implements Manager {
|
||||
Set<RecipientIdentifier> recipients,
|
||||
long editTargetTimestamp
|
||||
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException {
|
||||
if (message.timestamp().isPresent()) {
|
||||
throw new IllegalArgumentException("A message timestamp cannot be combined with editing a message");
|
||||
}
|
||||
final var messageBuilder = SignalServiceDataMessage.newBuilder();
|
||||
applyMessage(messageBuilder, message);
|
||||
return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp), message.urgent());
|
||||
return sendMessage(messageBuilder,
|
||||
recipients,
|
||||
false,
|
||||
Optional.of(editTargetTimestamp),
|
||||
Optional.empty(),
|
||||
message.urgent());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -440,6 +440,11 @@ Clear session state and send end session message.
|
||||
*--edit-timestamp*::
|
||||
Specify the timestamp of a previous message with the recipient or group to send an edited message.
|
||||
|
||||
*--timestamp*::
|
||||
Specify the timestamp of the message in milliseconds since the epoch, instead of using the current time.
|
||||
Must be positive and in the past.
|
||||
Cannot be combined with *--edit-timestamp* or *--end-session*.
|
||||
|
||||
*--no-urgent*::
|
||||
Send the message without the urgent flag, so no push notification is triggered for the recipient.
|
||||
The message will still be delivered in real-time if the recipient's app is active.
|
||||
|
||||
@ -106,6 +106,12 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
subparser.addArgument("--edit-timestamp")
|
||||
.type(long.class)
|
||||
.help("Specify the timestamp of a previous message with the recipient or group to send an edited message.");
|
||||
subparser.addArgument("--timestamp")
|
||||
.type(long.class)
|
||||
.help("Specify the timestamp of the message in milliseconds since the epoch, "
|
||||
+ "instead of using the current time. "
|
||||
+ "Must be positive and in the past. "
|
||||
+ "Cannot be combined with --edit-timestamp or --end-session.");
|
||||
subparser.addArgument("--no-urgent")
|
||||
.action(Arguments.storeTrue())
|
||||
.help("Send the message without the urgent flag, so no push notification is triggered for the recipient. "
|
||||
@ -141,8 +147,19 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
groupIdStrings,
|
||||
usernameStrings);
|
||||
|
||||
final var messageTimestamp = parseMessageTimestamp(ns.get("timestamp"), System.currentTimeMillis());
|
||||
|
||||
final var editTimestamp = ns.getLong("edit-timestamp");
|
||||
if (editTimestamp != null && messageTimestamp.isPresent()) {
|
||||
throw new UserErrorException("--timestamp cannot be combined with --edit-timestamp");
|
||||
}
|
||||
|
||||
final var isEndSession = Boolean.TRUE.equals(ns.getBoolean("end-session"));
|
||||
if (isEndSession) {
|
||||
if (messageTimestamp.isPresent()) {
|
||||
throw new UserErrorException("--timestamp cannot be combined with --end-session");
|
||||
}
|
||||
|
||||
final var singleRecipients = recipientIdentifiers.stream()
|
||||
.filter(r -> r instanceof RecipientIdentifier.Single)
|
||||
.map(RecipientIdentifier.Single.class::cast)
|
||||
@ -259,8 +276,6 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
"Sending empty message is not allowed, either a message, attachment or sticker must be given.");
|
||||
}
|
||||
|
||||
final var editTimestamp = ns.getLong("edit-timestamp");
|
||||
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
@ -274,7 +289,8 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
previews,
|
||||
Optional.ofNullable((storyReply)),
|
||||
textStyles,
|
||||
!noUrgent);
|
||||
!noUrgent,
|
||||
messageTimestamp);
|
||||
var results = editTimestamp != null
|
||||
? m.sendEditMessage(message, recipientIdentifiers, editTimestamp)
|
||||
: m.sendMessage(message, recipientIdentifiers, notifySelf);
|
||||
@ -337,6 +353,24 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
return blurHash.length() == 4 + 2 * (sizeFlag % 9 + 1) * (sizeFlag / 9 + 1);
|
||||
}
|
||||
|
||||
static Optional<Long> parseMessageTimestamp(final Object raw, final long now) throws UserErrorException {
|
||||
if (raw == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final long timestamp = switch (raw) {
|
||||
case Integer i -> i.longValue();
|
||||
case Long l -> l;
|
||||
default -> throw new UserErrorException("Timestamp must be an integer number of milliseconds");
|
||||
};
|
||||
if (timestamp <= 0) {
|
||||
throw new UserErrorException("Timestamp must be positive");
|
||||
}
|
||||
if (timestamp >= now) {
|
||||
throw new UserErrorException("Timestamp must be in the past");
|
||||
}
|
||||
return Optional.of(timestamp);
|
||||
}
|
||||
|
||||
private List<Message.Mention> parseMentions(
|
||||
final String selfNumber,
|
||||
final List<String> mentionStrings
|
||||
|
||||
@ -466,6 +466,9 @@ public class DbusManagerImpl implements Manager {
|
||||
final Set<RecipientIdentifier> recipients,
|
||||
final boolean notifySelf
|
||||
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
|
||||
if (message.timestamp().isPresent()) {
|
||||
throw new UnsupportedOperationException("Custom message timestamps are not supported over D-Bus");
|
||||
}
|
||||
return handleMessage(recipients,
|
||||
numbers -> signal.sendMessage(message.messageText(), message.attachments(), numbers),
|
||||
() -> signal.sendNoteToSelfMessage(message.messageText(), message.attachments()),
|
||||
|
||||
@ -252,7 +252,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
List.of(),
|
||||
Optional.empty(),
|
||||
List.of(),
|
||||
true);
|
||||
true,
|
||||
Optional.empty());
|
||||
final var recipientIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber()).stream()
|
||||
.map(RecipientIdentifier.class::cast)
|
||||
.collect(Collectors.toSet());
|
||||
@ -421,7 +422,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
List.of(),
|
||||
Optional.empty(),
|
||||
List.of(),
|
||||
true);
|
||||
true,
|
||||
Optional.empty());
|
||||
final var results = m.sendMessage(message, Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), false);
|
||||
checkSendMessageResults(results);
|
||||
return results.timestamp();
|
||||
@ -470,7 +472,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
List.of(),
|
||||
Optional.empty(),
|
||||
List.of(),
|
||||
true);
|
||||
true,
|
||||
Optional.empty());
|
||||
var results = m.sendMessage(message, Set.of(getGroupRecipientIdentifier(groupId)), false);
|
||||
checkSendMessageResults(results);
|
||||
return results.timestamp();
|
||||
|
||||
@ -13,13 +13,16 @@ import org.asamk.signal.testutil.ManagerMock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SendCommandParsingTest {
|
||||
|
||||
@ -113,6 +116,123 @@ class SendCommandParsingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cliPassesTimestampIntoTheSentMessage() throws Exception {
|
||||
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
|
||||
.includeArgumentNamesAsKeysInResult(true)
|
||||
.build();
|
||||
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
|
||||
final var timestamp = System.currentTimeMillis() - 5000;
|
||||
final var message = send(parser.parseArgs(new String[]{
|
||||
"send",
|
||||
"--note-to-self",
|
||||
"-m",
|
||||
"hello",
|
||||
"--timestamp",
|
||||
String.valueOf(timestamp)
|
||||
}));
|
||||
|
||||
assertEquals(Optional.of(timestamp), message.timestamp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcPassesTimestampIntoTheSentMessage() throws Exception {
|
||||
final var timestamp = System.currentTimeMillis() - 5000;
|
||||
final var message = send(timestampNamespace(timestamp));
|
||||
assertEquals(Optional.of(timestamp), message.timestamp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcAcceptsIntegerTimestamp() throws Exception {
|
||||
// JSON-RPC delivers small numbers as Integer
|
||||
final var message = send(timestampNamespace(12345));
|
||||
assertEquals(Optional.of(12345L), message.timestamp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void omittedTimestampKeepsExistingBehavior() throws Exception {
|
||||
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "message", "hello")));
|
||||
assertEquals(Optional.empty(), message.timestamp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidTimestampsBeforeSending() {
|
||||
final var now = System.currentTimeMillis();
|
||||
for (final Object value : new Object[]{0L, -1L, now + 86_400_000L, 1.7548e12, "12345"}) {
|
||||
assertThrows(UserErrorException.class, () -> send(timestampNamespace(value)), String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTimestampWithEditTimestamp() {
|
||||
final var e = assertThrows(UserErrorException.class,
|
||||
() -> send(new JsonRpcNamespace(Map.of("noteToSelf",
|
||||
true,
|
||||
"message",
|
||||
"hello",
|
||||
"timestamp",
|
||||
System.currentTimeMillis() - 5000,
|
||||
"editTimestamp",
|
||||
System.currentTimeMillis() - 10000))));
|
||||
assertTrue(e.getMessage().contains("--edit-timestamp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTimestampWithEndSession() {
|
||||
final var e = assertThrows(UserErrorException.class,
|
||||
() -> send(new JsonRpcNamespace(Map.of("recipient",
|
||||
"+15550000000",
|
||||
"timestamp",
|
||||
System.currentTimeMillis() - 5000,
|
||||
"endSession",
|
||||
true))));
|
||||
assertTrue(e.getMessage().contains("--end-session"));
|
||||
}
|
||||
|
||||
// parseMessageTimestamp takes the current time as a parameter, so the bounds can be tested deterministically.
|
||||
|
||||
@Test
|
||||
void parseMessageTimestampReturnsEmptyForNull() throws Exception {
|
||||
assertEquals(Optional.empty(), SendCommand.parseMessageTimestamp(null, 1000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseMessageTimestampAcceptsTimestampJustBeforeNow() throws Exception {
|
||||
assertEquals(Optional.of(999L), SendCommand.parseMessageTimestamp(999L, 1000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseMessageTimestampRejectsTimestampNotInThePast() {
|
||||
for (final var value : List.of(1000L, 1001L)) {
|
||||
final var e = assertThrows(UserErrorException.class,
|
||||
() -> SendCommand.parseMessageTimestamp(value, 1000L));
|
||||
assertTrue(e.getMessage().contains("past"), String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseMessageTimestampRejectsZeroAndNegative() {
|
||||
for (final var value : List.of(0L, -1L)) {
|
||||
final var e = assertThrows(UserErrorException.class,
|
||||
() -> SendCommand.parseMessageTimestamp(value, 1000L));
|
||||
assertTrue(e.getMessage().contains("positive"), String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseMessageTimestampRejectsNonIntegerTypes() {
|
||||
final var outOfRange = BigInteger.valueOf(2).pow(64).add(BigInteger.ONE);
|
||||
for (final Object value : new Object[]{outOfRange, 1.7548e12, "12345"}) {
|
||||
final var e = assertThrows(UserErrorException.class,
|
||||
() -> SendCommand.parseMessageTimestamp(value, 1000000L));
|
||||
assertTrue(e.getMessage().contains("integer"), String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
private JsonRpcNamespace timestampNamespace(Object timestamp) {
|
||||
return new JsonRpcNamespace(Map.of("noteToSelf", true, "message", "hello", "timestamp", timestamp));
|
||||
}
|
||||
|
||||
private JsonRpcNamespace blurHashNamespace(List<String> blurHashes) {
|
||||
return new JsonRpcNamespace(Map.of("noteToSelf",
|
||||
true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user