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:
tonycpsu 2026-10-13 23:27:45 -04:00
parent 1da2a51c1e
commit 9173ed7dec
8 changed files with 195 additions and 11 deletions

View File

@ -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

View File

@ -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) {}

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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()),

View File

@ -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();

View File

@ -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,