diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f336c480..5a1fd42a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: with: distribution: 'adopt' java-version: ${{ matrix.java }} + cache: 'gradle' - name: Build with Gradle run: ./gradlew --no-daemon build - name: Compress archive @@ -37,6 +38,7 @@ jobs: with: version: 'latest' java-version: '17' + cache: 'gradle' components: 'native-image' github-token: ${{ secrets.GITHUB_TOKEN }} - name: Build with Gradle diff --git a/.github/workflows/repackage-native-libs.yml b/.github/workflows/repackage-native-libs.yml index 6d334ecf..ac75b926 100644 --- a/.github/workflows/repackage-native-libs.yml +++ b/.github/workflows/repackage-native-libs.yml @@ -179,6 +179,7 @@ jobs: with: distribution: 'adopt' java-version: ${{ env.JAVA_VERSION }} + java-package: 'jre" - name: Run signal-cli run: | diff --git a/client/src/cli.rs b/client/src/cli.rs index 01910001..ca809290 100644 --- a/client/src/cli.rs +++ b/client/src/cli.rs @@ -162,6 +162,12 @@ pub enum CliCommands { #[arg(long)] sticker: Option, + + #[arg(long)] + story_timestamp: Option, + + #[arg(long)] + story_author: Option, }, SendContacts, SendPaymentNotification { @@ -193,6 +199,9 @@ pub enum CliCommands { #[arg(short = 'r', long)] remove: bool, + + #[arg(long)] + story: bool, }, SendReceipt { recipient: String, diff --git a/client/src/jsonrpc.rs b/client/src/jsonrpc.rs index 3c8abbbd..51e41c19 100644 --- a/client/src/jsonrpc.rs +++ b/client/src/jsonrpc.rs @@ -130,6 +130,8 @@ pub trait Rpc { #[allow(non_snake_case)] quoteMessage: Option, #[allow(non_snake_case)] quoteMention: Vec, sticker: Option, + #[allow(non_snake_case)] storyTimestamp: Option, + #[allow(non_snake_case)] storyAuthor: Option, ) -> Result; #[rpc(name = "sendContacts", params = "named")] @@ -155,6 +157,7 @@ pub trait Rpc { #[allow(non_snake_case)] targetAuthor: String, #[allow(non_snake_case)] targetTimestamp: u64, remove: bool, + story: bool, ) -> Result; #[rpc(name = "sendReceipt", params = "named")] diff --git a/client/src/main.rs b/client/src/main.rs index 50a0f4cb..ea9cb9c8 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -127,6 +127,8 @@ async fn main() -> Result<(), anyhow::Error> { quote_message, quote_mention, sticker, + story_timestamp, + story_author, } => { client .send( @@ -143,6 +145,8 @@ async fn main() -> Result<(), anyhow::Error> { quote_message, quote_mention, sticker, + story_timestamp, + story_author, ) .await } @@ -164,6 +168,7 @@ async fn main() -> Result<(), anyhow::Error> { target_author, target_timestamp, remove, + story, } => { client .send_reaction( @@ -175,6 +180,7 @@ async fn main() -> Result<(), anyhow::Error> { target_author, target_timestamp, remove, + story, ) .await } diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json index 78f1d5e4..268952db 100644 --- a/graalvm-config-dir/reflect-config.json +++ b/graalvm-config-dir/reflect-config.json @@ -605,6 +605,17 @@ {"name":"id","parameterTypes":[] } ] }, +{ + "name":"org.asamk.signal.commands.ReceiveCommand$ReceiveParams", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[ + {"name":"","parameterTypes":["java.lang.Double","java.lang.Integer"] }, + {"name":"maxMessages","parameterTypes":[] }, + {"name":"timeout","parameterTypes":[] } + ] +}, { "name":"org.asamk.signal.commands.RegisterCommand$RegistrationParams", "allDeclaredFields":true, diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 007a783e..ec168a4c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -136,7 +136,8 @@ public interface Manager extends Closeable { boolean remove, RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, - Set recipients + Set recipients, + final boolean isStory ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException; SendMessageResults sendPaymentNotificationMessage( @@ -201,12 +202,9 @@ public interface Manager extends Closeable { /** * Receive new messages from server, returns if no new message arrive in a timespan of timeout. */ - void receiveMessages(Duration timeout, ReceiveMessageHandler handler) throws IOException; - - /** - * Receive new messages from server, returns only if the thread is interrupted. - */ - void receiveMessages(ReceiveMessageHandler handler) throws IOException; + public void receiveMessages( + Optional timeout, Optional maxMessages, ReceiveMessageHandler handler + ) throws IOException; void setReceiveConfig(ReceiveConfig receiveConfig); diff --git a/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java index 58863d08..95f5bde4 100644 --- a/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java @@ -624,6 +624,14 @@ class ManagerImpl implements Manager { } messageBuilder.withPreviews(previews); } + if (message.storyReply().isPresent()) { + final var storyReply = message.storyReply().get(); + final var authorServiceId = context.getRecipientHelper() + .resolveSignalServiceAddress(context.getRecipientHelper().resolveRecipient(storyReply.author())) + .getServiceId(); + messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId, + storyReply.timestamp())); + } } private ArrayList resolveMentions(final List mentionList) throws UnregisteredRecipientException { @@ -667,14 +675,19 @@ class ManagerImpl implements Manager { boolean remove, RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, - Set recipients + Set recipients, + final boolean isStory ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor); - var reaction = new SignalServiceDataMessage.Reaction(emoji, - remove, - context.getRecipientHelper().resolveSignalServiceAddress(targetAuthorRecipientId).getServiceId(), - targetSentTimestamp); + final var authorServiceId = context.getRecipientHelper() + .resolveSignalServiceAddress(targetAuthorRecipientId) + .getServiceId(); + var reaction = new SignalServiceDataMessage.Reaction(emoji, remove, authorServiceId, targetSentTimestamp); final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction); + if (isStory) { + messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId, + targetSentTimestamp)); + } return sendMessage(messageBuilder, recipients); } @@ -948,17 +961,16 @@ class ManagerImpl implements Manager { } @Override - public void receiveMessages(Duration timeout, ReceiveMessageHandler handler) throws IOException { - receiveMessages(timeout, true, handler); - } - - @Override - public void receiveMessages(ReceiveMessageHandler handler) throws IOException { - receiveMessages(Duration.ofMinutes(1), false, handler); + public void receiveMessages( + Optional timeout, + Optional maxMessages, + ReceiveMessageHandler handler + ) throws IOException { + receiveMessages(timeout.orElse(Duration.ofMinutes(1)), timeout.isPresent(), maxMessages.orElse(null), handler); } private void receiveMessages( - Duration timeout, boolean returnOnTimeout, ReceiveMessageHandler handler + Duration timeout, boolean returnOnTimeout, Integer maxMessages, ReceiveMessageHandler handler ) throws IOException { if (isReceiving()) { throw new IllegalStateException("Already receiving message."); @@ -966,7 +978,7 @@ class ManagerImpl implements Manager { isReceivingSynchronous = true; receiveThread = Thread.currentThread(); try { - context.getReceiveHelper().receiveMessages(timeout, returnOnTimeout, handler); + context.getReceiveHelper().receiveMessages(timeout, returnOnTimeout, maxMessages, handler); } finally { receiveThread = null; isReceivingSynchronous = false; diff --git a/lib/src/main/java/org/asamk/signal/manager/ProvisioningManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManagerImpl.java index 17569401..c8df9774 100644 --- a/lib/src/main/java/org/asamk/signal/manager/ProvisioningManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManagerImpl.java @@ -21,7 +21,6 @@ import org.asamk.signal.manager.config.ServiceConfig; import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.accounts.AccountsStore; -import org.asamk.signal.manager.storage.identities.TrustNewIdentity; import org.asamk.signal.manager.util.KeyUtils; import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.util.KeyHelper; @@ -147,7 +146,7 @@ class ProvisioningManagerImpl implements ProvisioningManager { registrationId, pniRegistrationId, profileKey, - TrustNewIdentity.ON_FIRST_USE); + Settings.DEFAULT); ManagerImpl m = null; try { @@ -194,10 +193,7 @@ class ProvisioningManagerImpl implements ProvisioningManager { private boolean canRelinkExistingAccount(final String accountPath) throws IOException { final SignalAccount signalAccount; try { - signalAccount = SignalAccount.load(pathConfig.dataPath(), - accountPath, - false, - TrustNewIdentity.ON_FIRST_USE); + signalAccount = SignalAccount.load(pathConfig.dataPath(), accountPath, false, Settings.DEFAULT); } catch (IOException e) { logger.debug("Account in use or failed to load.", e); return false; diff --git a/lib/src/main/java/org/asamk/signal/manager/Settings.java b/lib/src/main/java/org/asamk/signal/manager/Settings.java new file mode 100644 index 00000000..e4f4554b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/Settings.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager; + +import org.asamk.signal.manager.storage.identities.TrustNewIdentity; + +public record Settings(TrustNewIdentity trustNewIdentity, boolean disableMessageSendLog) { + + public static Settings DEFAULT = new Settings(TrustNewIdentity.ON_FIRST_USE, false); +} diff --git a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java index 9d0b344a..12eb3d99 100644 --- a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java +++ b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java @@ -7,7 +7,6 @@ import org.asamk.signal.manager.config.ServiceEnvironment; import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.accounts.AccountsStore; -import org.asamk.signal.manager.storage.identities.TrustNewIdentity; import org.asamk.signal.manager.util.KeyUtils; import org.signal.libsignal.protocol.util.KeyHelper; import org.slf4j.Logger; @@ -28,27 +27,27 @@ public class SignalAccountFiles { private final ServiceEnvironment serviceEnvironment; private final ServiceEnvironmentConfig serviceEnvironmentConfig; private final String userAgent; - private final TrustNewIdentity trustNewIdentity; + private final Settings settings; private final AccountsStore accountsStore; public SignalAccountFiles( final File settingsPath, final ServiceEnvironment serviceEnvironment, final String userAgent, - final TrustNewIdentity trustNewIdentity + final Settings settings ) throws IOException { this.pathConfig = PathConfig.createDefault(settingsPath); this.serviceEnvironment = serviceEnvironment; this.serviceEnvironmentConfig = ServiceConfig.getServiceEnvironmentConfig(this.serviceEnvironment, userAgent); this.userAgent = userAgent; - this.trustNewIdentity = trustNewIdentity; + this.settings = settings; this.accountsStore = new AccountsStore(pathConfig.dataPath(), serviceEnvironment, accountPath -> { if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) { return null; } try { - return SignalAccount.load(pathConfig.dataPath(), accountPath, false, trustNewIdentity); + return SignalAccount.load(pathConfig.dataPath(), accountPath, false, settings); } catch (Exception e) { return null; } @@ -90,7 +89,7 @@ public class SignalAccountFiles { throw new NotRegisteredException(); } - var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, trustNewIdentity); + var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings); if (!number.equals(account.getNumber())) { account.close(); throw new IOException("Number in account file doesn't match expected number: " + account.getNumber()); @@ -168,7 +167,7 @@ public class SignalAccountFiles { registrationId, pniRegistrationId, profileKey, - trustNewIdentity); + settings); return new RegistrationManagerImpl(account, pathConfig, @@ -178,7 +177,7 @@ public class SignalAccountFiles { new AccountFileUpdaterImpl(accountsStore, newAccountPath)); } - var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, trustNewIdentity); + var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings); if (!number.equals(account.getNumber())) { account.close(); throw new IOException("Number in account file doesn't match expected number: " + account.getNumber()); 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 1e76faea..aba79cc5 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 @@ -9,7 +9,8 @@ public record Message( List mentions, Optional quote, Optional sticker, - List previews + List previews, + Optional storyReply ) { public record Mention(RecipientIdentifier.Single recipient, int start, int length) {} @@ -19,4 +20,6 @@ public record Message( public record Sticker(byte[] packId, int stickerId) {} public record Preview(String url, String title, String description, Optional image) {} + + public record StoryReply(long timestamp, RecipientIdentifier.Single author) {} } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java index 9fe1bf54..c15f4f94 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java @@ -80,7 +80,7 @@ public class ReceiveHelper { public void receiveMessagesContinuously(Manager.ReceiveMessageHandler handler) { while (!shouldStop) { try { - receiveMessages(Duration.ofMinutes(1), false, handler); + receiveMessages(Duration.ofMinutes(1), false, null, handler); break; } catch (IOException e) { logger.warn("Receiving messages failed, retrying", e); @@ -89,7 +89,7 @@ public class ReceiveHelper { } public void receiveMessages( - Duration timeout, boolean returnOnTimeout, Manager.ReceiveMessageHandler handler + Duration timeout, boolean returnOnTimeout, Integer maxMessages, Manager.ReceiveMessageHandler handler ) throws IOException { needsToRetryFailedMessages = true; hasCaughtUpWithOldMessages = false; @@ -107,7 +107,7 @@ public class ReceiveHelper { signalWebSocket.connect(); try { - receiveMessagesInternal(signalWebSocket, timeout, returnOnTimeout, handler, queuedActions); + receiveMessagesInternal(signalWebSocket, timeout, returnOnTimeout, maxMessages, handler, queuedActions); } finally { hasCaughtUpWithOldMessages = false; handleQueuedActions(queuedActions.keySet()); @@ -122,13 +122,15 @@ public class ReceiveHelper { final SignalWebSocket signalWebSocket, Duration timeout, boolean returnOnTimeout, + Integer maxMessages, Manager.ReceiveMessageHandler handler, final Map queuedActions ) throws IOException { + int remainingMessages = maxMessages == null ? -1 : maxMessages; var backOffCounter = 0; isWaitingForMessage = false; - while (!shouldStop) { + while (!shouldStop && remainingMessages != 0) { if (needsToRetryFailedMessages) { retryFailedReceivedMessages(handler); needsToRetryFailedMessages = false; @@ -154,6 +156,9 @@ public class ReceiveHelper { backOffCounter = 0; if (result.isPresent()) { + if (remainingMessages > 0) { + remainingMessages -= 1; + } envelope = result.get(); logger.debug("New message received from server"); } else { diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 1759649e..dac8de94 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -3,6 +3,7 @@ package org.asamk.signal.manager.storage; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.asamk.signal.manager.Settings; import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.TrustLevel; import org.asamk.signal.manager.config.ServiceEnvironment; @@ -17,7 +18,6 @@ import org.asamk.signal.manager.storage.groups.LegacyGroupStore; import org.asamk.signal.manager.storage.identities.IdentityKeyStore; import org.asamk.signal.manager.storage.identities.LegacyIdentityKeyStore; import org.asamk.signal.manager.storage.identities.SignalIdentityKeyStore; -import org.asamk.signal.manager.storage.identities.TrustNewIdentity; import org.asamk.signal.manager.storage.messageCache.MessageCache; import org.asamk.signal.manager.storage.prekeys.LegacyPreKeyStore; import org.asamk.signal.manager.storage.prekeys.LegacySignedPreKeyStore; @@ -136,7 +136,7 @@ public class SignalAccount implements Closeable { private IdentityKeyPair pniIdentityKeyPair; private int localRegistrationId; private int localPniRegistrationId; - private TrustNewIdentity trustNewIdentity; + private Settings settings; private long lastReceiveTimestamp = 0; private boolean registered = false; @@ -170,7 +170,7 @@ public class SignalAccount implements Closeable { } public static SignalAccount load( - File dataPath, String accountPath, boolean waitForLock, final TrustNewIdentity trustNewIdentity + File dataPath, String accountPath, boolean waitForLock, final Settings settings ) throws IOException { logger.trace("Opening account file"); final var fileName = getFileName(dataPath, accountPath); @@ -178,7 +178,7 @@ public class SignalAccount implements Closeable { try { var signalAccount = new SignalAccount(pair.first(), pair.second()); logger.trace("Loading account file"); - signalAccount.load(dataPath, accountPath, trustNewIdentity); + signalAccount.load(dataPath, accountPath, settings); logger.trace("Migrating legacy parts of account file"); signalAccount.migrateLegacyConfigs(); @@ -200,7 +200,7 @@ public class SignalAccount implements Closeable { int registrationId, int pniRegistrationId, ProfileKey profileKey, - final TrustNewIdentity trustNewIdentity + final Settings settings ) throws IOException { IOUtils.createPrivateDirectories(dataPath); var fileName = getFileName(dataPath, accountPath); @@ -221,7 +221,7 @@ public class SignalAccount implements Closeable { signalAccount.pniIdentityKeyPair = pniIdentityKey; signalAccount.localRegistrationId = registrationId; signalAccount.localPniRegistrationId = pniRegistrationId; - signalAccount.trustNewIdentity = trustNewIdentity; + signalAccount.settings = settings; signalAccount.configurationStore = new ConfigurationStore(signalAccount::saveConfigurationStore); signalAccount.registered = false; @@ -248,7 +248,7 @@ public class SignalAccount implements Closeable { int registrationId, int pniRegistrationId, ProfileKey profileKey, - final TrustNewIdentity trustNewIdentity + final Settings settings ) throws IOException { IOUtils.createPrivateDirectories(dataPath); var fileName = getFileName(dataPath, accountPath); @@ -267,10 +267,10 @@ public class SignalAccount implements Closeable { registrationId, pniRegistrationId, profileKey, - trustNewIdentity); + settings); } - final var signalAccount = load(dataPath, accountPath, true, trustNewIdentity); + final var signalAccount = load(dataPath, accountPath, true, settings); signalAccount.setProvisioningData(number, aci, pni, @@ -318,7 +318,7 @@ public class SignalAccount implements Closeable { int registrationId, int pniRegistrationId, ProfileKey profileKey, - final TrustNewIdentity trustNewIdentity + final Settings settings ) throws IOException { var fileName = getFileName(dataPath, accountPath); IOUtils.createPrivateFile(fileName); @@ -331,7 +331,7 @@ public class SignalAccount implements Closeable { signalAccount.serviceEnvironment = serviceEnvironment; signalAccount.localRegistrationId = registrationId; signalAccount.localPniRegistrationId = pniRegistrationId; - signalAccount.trustNewIdentity = trustNewIdentity; + signalAccount.settings = settings; signalAccount.setProvisioningData(number, aci, pni, @@ -502,7 +502,7 @@ public class SignalAccount implements Closeable { } private void load( - File dataPath, String accountPath, final TrustNewIdentity trustNewIdentity + File dataPath, String accountPath, final Settings settings ) throws IOException { this.dataPath = dataPath; this.accountPath = accountPath; @@ -685,7 +685,7 @@ public class SignalAccount implements Closeable { this.aciIdentityKeyPair = aciIdentityKeyPair; this.localRegistrationId = registrationId; - this.trustNewIdentity = trustNewIdentity; + this.settings = settings; migratedLegacyConfig = loadLegacyStores(rootNode, legacySignalProtocolStore) || migratedLegacyConfig; @@ -1156,7 +1156,7 @@ public class SignalAccount implements Closeable { public IdentityKeyStore getIdentityKeyStore() { return getOrCreate(() -> identityKeyStore, - () -> identityKeyStore = new IdentityKeyStore(getAccountDatabase(), trustNewIdentity)); + () -> identityKeyStore = new IdentityKeyStore(getAccountDatabase(), settings.trustNewIdentity())); } public SignalIdentityKeyStore getAciIdentityKeyStore() { @@ -1242,7 +1242,8 @@ public class SignalAccount implements Closeable { public MessageSendLogStore getMessageSendLogStore() { return getOrCreate(() -> messageSendLogStore, - () -> messageSendLogStore = new MessageSendLogStore(getAccountDatabase())); + () -> messageSendLogStore = new MessageSendLogStore(getAccountDatabase(), + settings.disableMessageSendLog())); } public CredentialsProvider getCredentialsProvider() { diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/sendLog/MessageSendLogStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/sendLog/MessageSendLogStore.java index f672667d..bab0aa4f 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/sendLog/MessageSendLogStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/sendLog/MessageSendLogStore.java @@ -33,9 +33,11 @@ public class MessageSendLogStore implements AutoCloseable { private final Database database; private final Thread cleanupThread; + private final boolean sendLogDisabled; - public MessageSendLogStore(final Database database) { + public MessageSendLogStore(final Database database, final boolean disableMessageSendLog) { this.database = database; + this.sendLogDisabled = disableMessageSendLog; this.cleanupThread = new Thread(() -> { try { final var interval = Duration.ofHours(1).toMillis(); @@ -43,6 +45,7 @@ public class MessageSendLogStore implements AutoCloseable { try (final var connection = database.getConnection()) { deleteOutdatedEntries(connection); } catch (SQLException e) { + logger.debug("MSL", e); logger.warn("Deleting outdated entries failed"); break; } @@ -113,6 +116,9 @@ public class MessageSendLogStore implements AutoCloseable { public long insertIfPossible( long sentTimestamp, SendMessageResult sendMessageResult, ContentHint contentHint, boolean urgent ) { + if (sendLogDisabled) { + return -1; + } final RecipientDevices recipientDevice = getRecipientDevices(sendMessageResult); if (recipientDevice == null) { return -1; @@ -128,6 +134,9 @@ public class MessageSendLogStore implements AutoCloseable { public long insertIfPossible( long sentTimestamp, List sendMessageResults, ContentHint contentHint, boolean urgent ) { + if (sendLogDisabled) { + return -1; + } final var recipientDevices = sendMessageResults.stream() .map(this::getRecipientDevices) .filter(Objects::nonNull) @@ -146,6 +155,9 @@ public class MessageSendLogStore implements AutoCloseable { } public void addRecipientToExistingEntryIfPossible(final long contentId, final SendMessageResult sendMessageResult) { + if (sendLogDisabled) { + return; + } final RecipientDevices recipientDevice = getRecipientDevices(sendMessageResult); if (recipientDevice == null) { return; @@ -157,6 +169,9 @@ public class MessageSendLogStore implements AutoCloseable { public void addRecipientToExistingEntryIfPossible( final long contentId, final List sendMessageResults ) { + if (sendLogDisabled) { + return; + } final var recipientDevices = sendMessageResults.stream() .map(this::getRecipientDevices) .filter(Objects::nonNull) diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 9b6ec2db..4d34582a 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -79,6 +79,9 @@ Choose when to trust new identities: - `always`: Trust any new identity key without verification - `never`: Don't trust any unknown identity key, every key must be verified manually +*--disable-send-log*:: +Disable message send log (for resending messages that recipient couldn't decrypt). + == Commands === register @@ -272,6 +275,12 @@ Specify the description for the link preview (optional). *--preview-image*:: Specify the image file for the link preview (optional). +*--story-timestamp*:: +Specify the timestamp of a story to reply to. + +*--story-author*:: +Specify the number of the author of the story. + *-e*, *--end-session*:: Clear session state and send end session message. @@ -310,6 +319,9 @@ Specify the timestamp of the message to which to react. *-r*, *--remove*:: Remove a reaction. +*--story*:: +React to a story instead of a normal message + === sendReceipt Send a read or viewed receipt to a previously received message. @@ -360,6 +372,9 @@ In json mode this is outputted as one json object per line. Number of seconds to wait for new messages (negative values disable timeout). Default is 5 seconds. +*--max-messages*:: +Maximum number of messages to receive, before returning. + *--ignore-attachments*:: Don’t download attachments of received messages. diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java index 4f045345..058ae6bb 100644 --- a/src/main/java/org/asamk/signal/App.java +++ b/src/main/java/org/asamk/signal/App.java @@ -23,6 +23,7 @@ import org.asamk.signal.dbus.DbusProvisioningManagerImpl; import org.asamk.signal.dbus.DbusRegistrationManagerImpl; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.RegistrationManager; +import org.asamk.signal.manager.Settings; import org.asamk.signal.manager.SignalAccountFiles; import org.asamk.signal.manager.api.AccountCheckException; import org.asamk.signal.manager.api.NotRegisteredException; @@ -101,6 +102,10 @@ public class App { .type(Arguments.enumStringType(TrustNewIdentityCli.class)) .setDefault(TrustNewIdentityCli.ON_FIRST_USE); + parser.addArgument("--disable-send-log") + .help("Disable message send log (for resending messages that recipient couldn't decrypt)") + .action(Arguments.storeTrue()); + var subparsers = parser.addSubparsers().title("subcommands").dest("command"); Commands.getCommandSubparserAttachers().forEach((key, value) -> { @@ -167,12 +172,14 @@ public class App { ? TrustNewIdentity.ON_FIRST_USE : trustNewIdentityCli == TrustNewIdentityCli.ALWAYS ? TrustNewIdentity.ALWAYS : TrustNewIdentity.NEVER; + final var disableSendLog = Boolean.TRUE.equals(ns.getBoolean("disable-send-log")); + final SignalAccountFiles signalAccountFiles; try { signalAccountFiles = new SignalAccountFiles(configPath, serviceEnvironment, BaseConfig.USER_AGENT, - trustNewIdentity); + new Settings(trustNewIdentity, disableSendLog)); } catch (IOException e) { throw new IOErrorException("Failed to read local accounts list", e); } diff --git a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java index 4d5bdff0..79d0ee1d 100644 --- a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java +++ b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java @@ -1,5 +1,7 @@ package org.asamk.signal.commands; +import com.fasterxml.jackson.core.type.TypeReference; + import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; @@ -19,9 +21,11 @@ import org.slf4j.LoggerFactory; import java.io.IOException; import java.time.Duration; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; -public class ReceiveCommand implements LocalCommand { +public class ReceiveCommand implements LocalCommand, JsonRpcSingleCommand { private final static Logger logger = LoggerFactory.getLogger(ReceiveCommand.class); @@ -37,6 +41,10 @@ public class ReceiveCommand implements LocalCommand { .type(double.class) .setDefault(3.0) .help("Number of seconds to wait for new messages (negative values disable timeout)"); + subparser.addArgument("--max-messages") + .type(int.class) + .setDefault(-1) + .help("Maximum number of messages to receive, before returning."); subparser.addArgument("--ignore-attachments") .help("Don’t download attachments of received messages.") .action(Arguments.storeTrue()); @@ -58,6 +66,7 @@ public class ReceiveCommand implements LocalCommand { final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { final var timeout = ns.getDouble("timeout"); + final var maxMessagesRaw = ns.getInt("max-messages"); final var ignoreAttachments = Boolean.TRUE.equals(ns.getBoolean("ignore-attachments")); final var ignoreStories = Boolean.TRUE.equals(ns.getBoolean("ignore-stories")); final var sendReadReceipts = Boolean.TRUE.equals(ns.getBoolean("send-read-receipts")); @@ -65,13 +74,37 @@ public class ReceiveCommand implements LocalCommand { try { final var handler = outputWriter instanceof JsonWriter ? new JsonReceiveMessageHandler(m, (JsonWriter) outputWriter) : new ReceiveMessageHandler(m, (PlainTextWriter) outputWriter); - if (timeout < 0) { - m.receiveMessages(handler); - } else { - m.receiveMessages(Duration.ofMillis((long) (timeout * 1000)), handler); - } + final var duration = timeout < 0 ? null : Duration.ofMillis((long) (timeout * 1000)); + final var maxMessages = maxMessagesRaw < 0 ? null : maxMessagesRaw; + m.receiveMessages(Optional.ofNullable(duration), Optional.ofNullable(maxMessages), handler); } catch (IOException e) { throw new IOErrorException("Error while receiving messages: " + e.getMessage(), e); } } + + @Override + public TypeReference getRequestType() { + return new TypeReference<>() {}; + } + + @Override + public void handleCommand( + final ReceiveParams request, final Manager m, final JsonWriter jsonWriter + ) throws CommandException { + final var timeout = request.timeout() == null ? 3.0 : request.timeout(); + final var maxMessagesRaw = request.maxMessages() == null ? -1 : request.maxMessages(); + + try { + final var messages = new ArrayList<>(); + final var handler = new JsonReceiveMessageHandler(m, messages::add); + final var duration = timeout < 0 ? null : Duration.ofMillis((long) (timeout * 1000)); + final var maxMessages = maxMessagesRaw < 0 ? null : maxMessagesRaw; + m.receiveMessages(Optional.ofNullable(duration), Optional.ofNullable(maxMessages), handler); + jsonWriter.write(messages); + } catch (IOException e) { + throw new IOErrorException("Error while receiving messages: " + e.getMessage(), e); + } + } + + record ReceiveParams(Double timeout, Integer maxMessages) {} } diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index 3fd00eaf..2a47ab8d 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -80,6 +80,10 @@ public class SendCommand implements JsonRpcLocalCommand { subparser.addArgument("--preview-title").help("Specify the title for the link preview (mandatory)."); subparser.addArgument("--preview-description").help("Specify the description for the link preview (optional)."); subparser.addArgument("--preview-image").help("Specify the image file for the link preview (optional)."); + subparser.addArgument("--story-timestamp") + .type(long.class) + .help("Specify the timestamp of a story to reply to."); + subparser.addArgument("--story-author").help("Specify the number of the author of the story."); } @Override @@ -170,18 +174,30 @@ public class SendCommand implements JsonRpcLocalCommand { previews = List.of(); } + final Message.StoryReply storyReply; + final var storyReplyTimestamp = ns.getLong("story-timestamp"); + if (storyReplyTimestamp != null) { + final var storyAuthor = ns.getString("story-author"); + storyReply = new Message.StoryReply(storyReplyTimestamp, + CommandUtil.getSingleRecipientIdentifier(storyAuthor, m.getSelfNumber())); + } else { + storyReply = null; + } + if (messageText.isEmpty() && attachments.isEmpty() && sticker == null && quote == null) { throw new UserErrorException( "Sending empty message is not allowed, either a message, attachment or sticker must be given."); } try { - var results = m.sendMessage(new Message(messageText, + final var message = new Message(messageText, attachments, mentions, Optional.ofNullable(quote), Optional.ofNullable(sticker), - previews), recipientIdentifiers); + previews, + Optional.ofNullable((storyReply))); + var results = m.sendMessage(message, recipientIdentifiers); outputResult(outputWriter, results); } catch (AttachmentInvalidException | IOException e) { throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() diff --git a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java index f5b6a89d..a844fabf 100644 --- a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java @@ -45,6 +45,9 @@ public class SendReactionCommand implements JsonRpcLocalCommand { .type(long.class) .help("Specify the timestamp of the message to which to react."); subparser.addArgument("-r", "--remove").help("Remove a reaction.").action(Arguments.storeTrue()); + subparser.addArgument("--story") + .help("React to a story instead of a normal message") + .action(Arguments.storeTrue()); } @Override @@ -64,13 +67,15 @@ public class SendReactionCommand implements JsonRpcLocalCommand { final var isRemove = Boolean.TRUE.equals(ns.getBoolean("remove")); final var targetAuthor = ns.getString("target-author"); final var targetTimestamp = ns.getLong("target-timestamp"); + final var isStory = Boolean.TRUE.equals(ns.getBoolean("story")); try { final var results = m.sendMessageReaction(emoji, isRemove, CommandUtil.getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber()), targetTimestamp, - recipientIdentifiers); + recipientIdentifiers, + isStory); outputResult(outputWriter, results); } catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) { throw new UserErrorException(e.getMessage()); diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index bbfa4f1c..b59be923 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -58,6 +58,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.Supplier; @@ -364,7 +365,8 @@ public class DbusManagerImpl implements Manager { final boolean remove, final RecipientIdentifier.Single targetAuthor, final long targetSentTimestamp, - final Set recipients + final Set recipients, + final boolean isStory ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { return handleMessage(recipients, numbers -> signal.sendMessageReaction(emoji, @@ -496,39 +498,46 @@ public class DbusManagerImpl implements Manager { } } - @Override - public void receiveMessages(final ReceiveMessageHandler handler) throws IOException { - addReceiveHandler(handler); - try { - synchronized (this) { - this.wait(); - } - } catch (InterruptedException ignored) { - } - removeReceiveHandler(handler); - } - @Override public void receiveMessages( - final Duration timeout, final ReceiveMessageHandler handler + Optional timeout, Optional maxMessages, ReceiveMessageHandler handler ) throws IOException { + final var remainingMessages = new AtomicInteger(maxMessages.orElse(-1)); final var lastMessage = new AtomicLong(System.currentTimeMillis()); + final var thread = Thread.currentThread(); final ReceiveMessageHandler receiveHandler = (envelope, e) -> { lastMessage.set(System.currentTimeMillis()); handler.handleMessage(envelope, e); + if (remainingMessages.get() > 0) { + if (remainingMessages.decrementAndGet() <= 0) { + remainingMessages.set(0); + thread.interrupt(); + } + } }; addReceiveHandler(receiveHandler); - while (true) { - try { - final var sleepTimeRemaining = timeout.toMillis() - (System.currentTimeMillis() - lastMessage.get()); - if (sleepTimeRemaining < 0) { - break; + if (timeout.isPresent()) { + while (remainingMessages.get() != 0) { + try { + final var passedTime = System.currentTimeMillis() - lastMessage.get(); + final var sleepTimeRemaining = timeout.get().toMillis() - passedTime; + if (sleepTimeRemaining < 0) { + break; + } + Thread.sleep(sleepTimeRemaining); + } catch (InterruptedException ignored) { + } + } + } else { + try { + synchronized (this) { + this.wait(); } - Thread.sleep(sleepTimeRemaining); } catch (InterruptedException ignored) { } } + removeReceiveHandler(receiveHandler); } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 92e92157..1a9118be 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -218,7 +218,8 @@ public class DbusSignalImpl implements Signal { List.of(), Optional.empty(), Optional.empty(), - List.of()), + List.of(), + Optional.empty()), getSingleRecipientIdentifiers(recipients, m.getSelfNumber()).stream() .map(RecipientIdentifier.class::cast) .collect(Collectors.toSet())); @@ -287,7 +288,8 @@ public class DbusSignalImpl implements Signal { targetSentTimestamp, getSingleRecipientIdentifiers(recipients, m.getSelfNumber()).stream() .map(RecipientIdentifier.class::cast) - .collect(Collectors.toSet())); + .collect(Collectors.toSet()), + false); checkSendMessageResults(results); return results.timestamp(); } catch (IOException e) { @@ -385,7 +387,8 @@ public class DbusSignalImpl implements Signal { List.of(), Optional.empty(), Optional.empty(), - List.of()), Set.of(RecipientIdentifier.NoteToSelf.INSTANCE)); + List.of(), + Optional.empty()), Set.of(RecipientIdentifier.NoteToSelf.INSTANCE)); checkSendMessageResults(results); return results.timestamp(); } catch (AttachmentInvalidException e) { @@ -427,7 +430,8 @@ public class DbusSignalImpl implements Signal { List.of(), Optional.empty(), Optional.empty(), - List.of()), Set.of(getGroupRecipientIdentifier(groupId))); + List.of(), + Optional.empty()), Set.of(getGroupRecipientIdentifier(groupId))); checkSendMessageResults(results); return results.timestamp(); } catch (IOException | InvalidStickerException e) { @@ -485,7 +489,8 @@ public class DbusSignalImpl implements Signal { remove, getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber()), targetSentTimestamp, - Set.of(getGroupRecipientIdentifier(groupId))); + Set.of(getGroupRecipientIdentifier(groupId)), + false); checkSendMessageResults(results); return results.timestamp(); } catch (IOException e) {