From 9173ed7dec7206d2c41f8e638870ff1555af90fc Mon Sep 17 00:00:00 2001 From: tonycpsu Date: Tue, 13 Oct 2026 23:27:45 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + .../org/asamk/signal/manager/api/Message.java | 3 +- .../signal/manager/internal/ManagerImpl.java | 22 +++- man/signal-cli.1.adoc | 5 + .../asamk/signal/commands/SendCommand.java | 40 +++++- .../asamk/signal/dbus/DbusManagerImpl.java | 3 + .../org/asamk/signal/dbus/DbusSignalImpl.java | 9 +- .../commands/SendCommandParsingTest.java | 120 ++++++++++++++++++ 8 files changed, 195 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e99fee7..39417d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Message.java b/lib/src/main/java/org/asamk/signal/manager/api/Message.java index ccb7cba8..9c1b96f7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/Message.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/Message.java @@ -16,7 +16,8 @@ public record Message( List previews, Optional storyReply, List textStyles, - boolean urgent + boolean urgent, + Optional timestamp ) { public record AttachmentDimensions(int width, int height) {} diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 8658f365..686bb3b0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -692,7 +692,7 @@ public class ManagerImpl implements Manager { Set 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 recipients, boolean notifySelf, Optional editTargetTimestamp, + Optional messageTimestamp, boolean urgent ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { var results = new HashMap>(); - 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 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 diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index c2a8d7c6..e997ba9e 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -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. diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index dc6be9a4..1839ddfd 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -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 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 parseMentions( final String selfNumber, final List mentionStrings diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 091816cc..cfdd3522 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -466,6 +466,9 @@ public class DbusManagerImpl implements Manager { final Set 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()), diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 71a6e69b..70b927eb 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -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(); diff --git a/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java index e206dbf7..abea0ed7 100644 --- a/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java +++ b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java @@ -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 blurHashes) { return new JsonRpcNamespace(Map.of("noteToSelf", true,