]> nmode's Git Repositories - signal-cli/commitdiff
Implement jsonRpc command
authorAsamK <asamk@gmx.de>
Mon, 9 Aug 2021 15:42:01 +0000 (17:42 +0200)
committerAsamK <asamk@gmx.de>
Tue, 10 Aug 2021 16:16:50 +0000 (18:16 +0200)
Co-authored-by: technillogue <technillogue@gmail.com>
Closes #668

20 files changed:
lib/src/main/java/org/asamk/signal/manager/Manager.java
src/main/java/org/asamk/signal/JsonWriterImpl.java
src/main/java/org/asamk/signal/commands/Commands.java
src/main/java/org/asamk/signal/commands/DaemonCommand.java
src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java
src/main/java/org/asamk/signal/commands/JsonRpcCommand.java [new file with mode: 0644]
src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java [new file with mode: 0644]
src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java [new file with mode: 0644]
src/main/java/org/asamk/signal/commands/ListGroupsCommand.java
src/main/java/org/asamk/signal/commands/ReceiveCommand.java
src/main/java/org/asamk/signal/commands/SendCommand.java
src/main/java/org/asamk/signal/commands/VersionCommand.java [new file with mode: 0644]
src/main/java/org/asamk/signal/jsonrpc/JsonRpcBulkMessage.java [new file with mode: 0644]
src/main/java/org/asamk/signal/jsonrpc/JsonRpcException.java [new file with mode: 0644]
src/main/java/org/asamk/signal/jsonrpc/JsonRpcMessage.java [new file with mode: 0644]
src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java [new file with mode: 0644]
src/main/java/org/asamk/signal/jsonrpc/JsonRpcRequest.java [new file with mode: 0644]
src/main/java/org/asamk/signal/jsonrpc/JsonRpcResponse.java [new file with mode: 0644]
src/main/java/org/asamk/signal/jsonrpc/JsonRpcSender.java [new file with mode: 0644]
src/main/java/org/asamk/signal/util/Util.java

index ef0b404b98ef38d0af3b0df770fcf3f0828c61ff..98b02c7f4e3cd45af04eaa06642e7bb85e06f415 100644 (file)
@@ -2028,6 +2028,9 @@ public class Manager implements Closeable {
             try {
                 action.execute(this);
             } catch (Throwable e) {
+                if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                    Thread.currentThread().interrupt();
+                }
                 logger.warn("Message action failed.", e);
             }
         }
@@ -2074,7 +2077,7 @@ public class Manager implements Closeable {
             boolean returnOnTimeout,
             boolean ignoreAttachments,
             ReceiveMessageHandler handler
-    ) throws IOException {
+    ) throws IOException, InterruptedException {
         retryFailedReceivedMessages(handler, ignoreAttachments);
 
         Set<HandleAction> queuedActions = null;
@@ -2110,6 +2113,9 @@ public class Manager implements Closeable {
                             try {
                                 action.execute(this);
                             } catch (Throwable e) {
+                                if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                                    Thread.currentThread().interrupt();
+                                }
                                 logger.warn("Message action failed.", e);
                             }
                         }
@@ -2120,6 +2126,12 @@ public class Manager implements Closeable {
                     // Continue to wait another timeout for new messages
                     continue;
                 }
+            } catch (AssertionError e) {
+                if (e.getCause() instanceof InterruptedException) {
+                    throw (InterruptedException) e.getCause();
+                } else {
+                    throw e;
+                }
             } catch (TimeoutException e) {
                 if (returnOnTimeout) return;
                 continue;
@@ -2153,6 +2165,9 @@ public class Manager implements Closeable {
                         try {
                             action.execute(this);
                         } catch (Throwable e) {
+                            if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                                Thread.currentThread().interrupt();
+                            }
                             logger.warn("Message action failed.", e);
                         }
                     }
@@ -2549,6 +2564,9 @@ public class Manager implements Closeable {
             avatarStore.storeProfileAvatar(address,
                     outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
         } catch (Throwable e) {
+            if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
             logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
         }
     }
index 772e4c7e87e1a5307568c9105e3888e7e91735ee..f0daaa85dbafb632c1cd674473857b54cee6366d 100644 (file)
@@ -1,11 +1,10 @@
 package org.asamk.signal;
 
-import com.fasterxml.jackson.annotation.JsonAutoDetect;
-import com.fasterxml.jackson.annotation.PropertyAccessor;
-import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
+import org.asamk.signal.util.Util;
+
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -21,9 +20,7 @@ public class JsonWriterImpl implements JsonWriter {
     public JsonWriterImpl(final OutputStream outputStream) {
         this.writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
 
-        objectMapper = new ObjectMapper();
-        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.PUBLIC_ONLY);
-        objectMapper.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
+        objectMapper = Util.createJsonObjectMapper();
     }
 
     public synchronized void write(final Object object) {
index 2e1d682181a3ae2c285791281cf23fa56153ed9e..33caf8ba3caec1d4fc9a393c1798929aec8d7bc4 100644 (file)
@@ -16,6 +16,7 @@ public class Commands {
         addCommand("block", BlockCommand::new, BlockCommand::attachToSubparser);
         addCommand("daemon", DaemonCommand::new, DaemonCommand::attachToSubparser);
         addCommand("getUserStatus", GetUserStatusCommand::new, GetUserStatusCommand::attachToSubparser);
+        addCommand("jsonRpc", JsonRpcDispatcherCommand::new, JsonRpcDispatcherCommand::attachToSubparser);
         addCommand("link", LinkCommand::new, LinkCommand::attachToSubparser);
         addCommand("listContacts", ListContactsCommand::new, ListContactsCommand::attachToSubparser);
         addCommand("listDevices", ListDevicesCommand::new, ListDevicesCommand::attachToSubparser);
@@ -43,6 +44,7 @@ public class Commands {
         addCommand("updateProfile", UpdateProfileCommand::new, UpdateProfileCommand::attachToSubparser);
         addCommand("uploadStickerPack", UploadStickerPackCommand::new, UploadStickerPackCommand::attachToSubparser);
         addCommand("verify", VerifyCommand::new, VerifyCommand::attachToSubparser);
+        addCommand("version", VersionCommand::new, null);
     }
 
     public static Map<String, SubparserAttacher> getCommandSubparserAttachers() {
@@ -60,7 +62,9 @@ public class Commands {
             String name, CommandConstructor commandConstructor, SubparserAttacher subparserAttacher
     ) {
         commands.put(name, commandConstructor);
-        commandSubparserAttacher.put(name, subparserAttacher);
+        if (subparserAttacher != null) {
+            commandSubparserAttacher.put(name, subparserAttacher);
+        }
     }
 
     private interface CommandConstructor {
index 7988c8efcdc44d1faff1a09a91d05e8c51e771d9..7b6f243d74c24cdc66d6378b3ebd4cd7217c25c9 100644 (file)
@@ -123,14 +123,17 @@ public class DaemonCommand implements MultiLocalCommand {
         logger.info("Exported dbus object: " + objectPath);
 
         final var thread = new Thread(() -> {
-            while (true) {
+            while (!Thread.interrupted()) {
                 try {
                     final var receiveMessageHandler = outputWriter instanceof JsonWriter
                             ? new JsonDbusReceiveMessageHandler(m, (JsonWriter) outputWriter, conn, objectPath)
                             : new DbusReceiveMessageHandler(m, (PlainTextWriter) outputWriter, conn, objectPath);
                     m.receiveMessages(1, TimeUnit.HOURS, false, ignoreAttachments, receiveMessageHandler);
+                    break;
                 } catch (IOException e) {
                     logger.warn("Receiving messages failed, retrying", e);
+                } catch (InterruptedException ignored) {
+                    break;
                 }
             }
         });
index 01b5a2600498268465c4794f911a412376c76193..91e6e47cfad1fca658c2c7a08db1d52977d75260 100644 (file)
@@ -4,7 +4,6 @@ import net.sourceforge.argparse4j.inf.Namespace;
 import net.sourceforge.argparse4j.inf.Subparser;
 
 import org.asamk.signal.JsonWriter;
-import org.asamk.signal.OutputType;
 import org.asamk.signal.OutputWriter;
 import org.asamk.signal.PlainTextWriter;
 import org.asamk.signal.commands.exceptions.CommandException;
@@ -16,10 +15,9 @@ import org.slf4j.LoggerFactory;
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.Map;
-import java.util.Set;
 import java.util.stream.Collectors;
 
-public class GetUserStatusCommand implements LocalCommand {
+public class GetUserStatusCommand implements JsonRpcLocalCommand {
 
     private final static Logger logger = LoggerFactory.getLogger(GetUserStatusCommand.class);
     private final OutputWriter outputWriter;
@@ -33,11 +31,6 @@ public class GetUserStatusCommand implements LocalCommand {
         this.outputWriter = outputWriter;
     }
 
-    @Override
-    public Set<OutputType> getSupportedOutputTypes() {
-        return Set.of(OutputType.PLAIN_TEXT, OutputType.JSON);
-    }
-
     @Override
     public void handleCommand(final Namespace ns, final Manager m) throws CommandException {
         // Get a map of registration statuses
diff --git a/src/main/java/org/asamk/signal/commands/JsonRpcCommand.java b/src/main/java/org/asamk/signal/commands/JsonRpcCommand.java
new file mode 100644 (file)
index 0000000..394b0f8
--- /dev/null
@@ -0,0 +1,22 @@
+package org.asamk.signal.commands;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+
+import org.asamk.signal.OutputType;
+import org.asamk.signal.commands.exceptions.CommandException;
+import org.asamk.signal.manager.Manager;
+
+import java.util.Set;
+
+public interface JsonRpcCommand<T> extends Command {
+
+    default TypeReference<T> getRequestType() {
+        return null;
+    }
+
+    void handleCommand(T request, Manager m) throws CommandException;
+
+    default Set<OutputType> getSupportedOutputTypes() {
+        return Set.of(OutputType.JSON);
+    }
+}
diff --git a/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java b/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java
new file mode 100644 (file)
index 0000000..dd0c7be
--- /dev/null
@@ -0,0 +1,174 @@
+package org.asamk.signal.commands;
+
+import com.fasterxml.jackson.core.TreeNode;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ContainerNode;
+
+import net.sourceforge.argparse4j.impl.Arguments;
+import net.sourceforge.argparse4j.inf.Namespace;
+import net.sourceforge.argparse4j.inf.Subparser;
+
+import org.asamk.signal.JsonReceiveMessageHandler;
+import org.asamk.signal.JsonWriter;
+import org.asamk.signal.OutputType;
+import org.asamk.signal.OutputWriter;
+import org.asamk.signal.commands.exceptions.CommandException;
+import org.asamk.signal.commands.exceptions.IOErrorException;
+import org.asamk.signal.commands.exceptions.UntrustedKeyErrorException;
+import org.asamk.signal.commands.exceptions.UserErrorException;
+import org.asamk.signal.jsonrpc.JsonRpcException;
+import org.asamk.signal.jsonrpc.JsonRpcReader;
+import org.asamk.signal.jsonrpc.JsonRpcRequest;
+import org.asamk.signal.jsonrpc.JsonRpcResponse;
+import org.asamk.signal.jsonrpc.JsonRpcSender;
+import org.asamk.signal.manager.Manager;
+import org.asamk.signal.util.Util;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+public class JsonRpcDispatcherCommand implements LocalCommand {
+
+    private final static Logger logger = LoggerFactory.getLogger(JsonRpcDispatcherCommand.class);
+
+    private static final int USER_ERROR = -1;
+    private static final int IO_ERROR = -3;
+    private static final int UNTRUSTED_KEY_ERROR = -4;
+
+    private final OutputWriter outputWriter;
+
+    public static void attachToSubparser(final Subparser subparser) {
+        subparser.help("Take commands from standard input as line-delimited JSON RPC while receiving messages.");
+        subparser.addArgument("--ignore-attachments")
+                .help("Don’t download attachments of received messages.")
+                .action(Arguments.storeTrue());
+    }
+
+    public JsonRpcDispatcherCommand(final OutputWriter outputWriter) {
+        this.outputWriter = outputWriter;
+    }
+
+    @Override
+    public Set<OutputType> getSupportedOutputTypes() {
+        return Set.of(OutputType.JSON);
+    }
+
+    @Override
+    public void handleCommand(final Namespace ns, final Manager m) throws CommandException {
+        final boolean ignoreAttachments = ns.getBoolean("ignore-attachments");
+
+        final var objectMapper = Util.createJsonObjectMapper();
+        final var jsonRpcSender = new JsonRpcSender((JsonWriter) outputWriter);
+
+        final var receiveThread = receiveMessages(s -> jsonRpcSender.sendRequest(JsonRpcRequest.forNotification(
+                "receive",
+                objectMapper.valueToTree(s),
+                null)), m, ignoreAttachments);
+
+        final BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
+
+        final var jsonRpcReader = new JsonRpcReader(jsonRpcSender, () -> {
+            try {
+                return reader.readLine();
+            } catch (IOException e) {
+                throw new AssertionError(e);
+            }
+        });
+        jsonRpcReader.readRequests((method, params) -> handleRequest(m, objectMapper, method, params),
+                response -> logger.debug("Received unexpected response for id {}", response.getId()));
+
+        receiveThread.interrupt();
+        try {
+            receiveThread.join();
+        } catch (InterruptedException ignored) {
+        }
+    }
+
+    private JsonNode handleRequest(
+            final Manager m, final ObjectMapper objectMapper, final String method, ContainerNode<?> params
+    ) throws JsonRpcException {
+        final Object[] result = {null};
+        final JsonWriter commandOutputWriter = s -> {
+            if (result[0] != null) {
+                throw new AssertionError("Command may only write one json result");
+            }
+
+            result[0] = s;
+        };
+
+        var command = Commands.getCommand(method, commandOutputWriter);
+        if (!(command instanceof JsonRpcCommand)) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.METHOD_NOT_FOUND,
+                    "Method not implemented",
+                    null));
+        }
+
+        try {
+            parseParamsAndRunCommand(m, objectMapper, params, (JsonRpcCommand<?>) command);
+        } catch (JsonMappingException e) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_REQUEST,
+                    e.getMessage(),
+                    null));
+        } catch (UserErrorException e) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(USER_ERROR, e.getMessage(), null));
+        } catch (IOErrorException e) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(IO_ERROR, e.getMessage(), null));
+        } catch (UntrustedKeyErrorException e) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(UNTRUSTED_KEY_ERROR, e.getMessage(), null));
+        } catch (Throwable e) {
+            logger.error("Command execution failed", e);
+            throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INTERNAL_ERROR,
+                    e.getMessage(),
+                    null));
+        }
+
+        Object output = result[0] == null ? new Object() : result[0];
+        return objectMapper.valueToTree(output);
+    }
+
+    private <T> void parseParamsAndRunCommand(
+            final Manager m, final ObjectMapper objectMapper, final TreeNode params, final JsonRpcCommand<T> command
+    ) throws CommandException, JsonMappingException {
+        T requestParams = null;
+        final var requestType = command.getRequestType();
+        if (params != null && requestType != null) {
+            try {
+                requestParams = objectMapper.readValue(objectMapper.treeAsTokens(params), requestType);
+            } catch (JsonMappingException e) {
+                throw e;
+            } catch (IOException e) {
+                throw new AssertionError(e);
+            }
+        }
+        command.handleCommand(requestParams, m);
+    }
+
+    private Thread receiveMessages(
+            JsonWriter jsonWriter, Manager m, boolean ignoreAttachments
+    ) {
+        final var thread = new Thread(() -> {
+            while (!Thread.interrupted()) {
+                try {
+                    final var receiveMessageHandler = new JsonReceiveMessageHandler(m, jsonWriter);
+                    m.receiveMessages(1, TimeUnit.HOURS, false, ignoreAttachments, receiveMessageHandler);
+                    break;
+                } catch (IOException e) {
+                    logger.warn("Receiving messages failed, retrying", e);
+                } catch (InterruptedException e) {
+                    break;
+                }
+            }
+        });
+
+        thread.start();
+
+        return thread;
+    }
+}
diff --git a/src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java b/src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java
new file mode 100644 (file)
index 0000000..3d2cd03
--- /dev/null
@@ -0,0 +1,72 @@
+package org.asamk.signal.commands;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+
+import net.sourceforge.argparse4j.inf.Namespace;
+
+import org.asamk.signal.OutputType;
+import org.asamk.signal.commands.exceptions.CommandException;
+import org.asamk.signal.manager.Manager;
+import org.asamk.signal.util.Util;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public interface JsonRpcLocalCommand extends JsonRpcCommand<Map<String, Object>> {
+
+    void handleCommand(Namespace ns, Manager m) throws CommandException;
+
+    default TypeReference<Map<String, Object>> getRequestType() {
+        return new TypeReference<>() {
+        };
+    }
+
+    default void handleCommand(Map<String, Object> request, Manager m) throws CommandException {
+        Namespace commandNamespace = new JsonRpcNamespace(request == null ? Map.of() : request);
+        handleCommand(commandNamespace, m);
+    }
+
+    default Set<OutputType> getSupportedOutputTypes() {
+        return Set.of(OutputType.PLAIN_TEXT, OutputType.JSON);
+    }
+
+    /**
+     * Namepace implementation, that defaults booleans to false and converts camel case keys to dashed strings
+     */
+    final class JsonRpcNamespace extends Namespace {
+
+        public JsonRpcNamespace(final Map<String, Object> attrs) {
+            super(attrs);
+        }
+
+        public <T> T get(String dest) {
+            final T value = super.get(dest);
+            if (value != null) {
+                return value;
+            }
+
+            final var camelCaseString = Util.dashSeparatedToCamelCaseString(dest);
+            return super.get(camelCaseString);
+        }
+
+        @Override
+        public <E> List<E> getList(final String dest) {
+            final List<E> value = super.getList(dest);
+            if (value != null) {
+                return value;
+            }
+
+            return super.getList(dest + "s");
+        }
+
+        @Override
+        public Boolean getBoolean(String dest) {
+            Boolean maybeGotten = this.get(dest);
+            if (maybeGotten == null) {
+                maybeGotten = false;
+            }
+            return maybeGotten;
+        }
+    }
+}
index 1708ade0458bd009af2d59c64d4e9fa3b2c6ee86..e9da8099ee7c483f4ca45f16a213d08694abc461 100644 (file)
@@ -5,7 +5,6 @@ import net.sourceforge.argparse4j.inf.Namespace;
 import net.sourceforge.argparse4j.inf.Subparser;
 
 import org.asamk.signal.JsonWriter;
-import org.asamk.signal.OutputType;
 import org.asamk.signal.OutputWriter;
 import org.asamk.signal.PlainTextWriter;
 import org.asamk.signal.commands.exceptions.CommandException;
@@ -19,7 +18,7 @@ import org.slf4j.LoggerFactory;
 import java.util.Set;
 import java.util.stream.Collectors;
 
-public class ListGroupsCommand implements LocalCommand {
+public class ListGroupsCommand implements JsonRpcLocalCommand {
 
     private final static Logger logger = LoggerFactory.getLogger(ListGroupsCommand.class);
 
@@ -70,11 +69,6 @@ public class ListGroupsCommand implements LocalCommand {
         this.outputWriter = outputWriter;
     }
 
-    @Override
-    public Set<OutputType> getSupportedOutputTypes() {
-        return Set.of(OutputType.PLAIN_TEXT, OutputType.JSON);
-    }
-
     @Override
     public void handleCommand(final Namespace ns, final Manager m) throws CommandException {
         final var groups = m.getGroups();
index 517d894b1c0a4063cd2975b8a9774a8e1a616375..c71225e07456d76d8e8148aaf0c83a8234b003f5 100644 (file)
@@ -155,6 +155,7 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand {
                     handler);
         } catch (IOException e) {
             throw new IOErrorException("Error while receiving messages: " + e.getMessage());
+        } catch (InterruptedException ignored) {
         }
     }
 }
index 59ee691518128d762f0a61d7f0b5a27b7a0b8d9f..8459118cf28044686ceaff92734a4a0c7a8a802d 100644 (file)
@@ -5,12 +5,15 @@ import net.sourceforge.argparse4j.inf.Namespace;
 import net.sourceforge.argparse4j.inf.Subparser;
 
 import org.asamk.Signal;
+import org.asamk.signal.JsonWriter;
 import org.asamk.signal.OutputWriter;
-import org.asamk.signal.PlainTextWriterImpl;
+import org.asamk.signal.PlainTextWriter;
 import org.asamk.signal.commands.exceptions.CommandException;
 import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
 import org.asamk.signal.commands.exceptions.UntrustedKeyErrorException;
 import org.asamk.signal.commands.exceptions.UserErrorException;
+import org.asamk.signal.dbus.DbusSignalImpl;
+import org.asamk.signal.manager.Manager;
 import org.asamk.signal.manager.groups.GroupIdFormatException;
 import org.asamk.signal.util.IOUtils;
 import org.asamk.signal.util.Util;
@@ -22,8 +25,9 @@ import org.slf4j.LoggerFactory;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.util.List;
+import java.util.Map;
 
-public class SendCommand implements DbusCommand {
+public class SendCommand implements DbusCommand, JsonRpcLocalCommand {
 
     private final static Logger logger = LoggerFactory.getLogger(SendCommand.class);
     private final OutputWriter outputWriter;
@@ -92,8 +96,6 @@ public class SendCommand implements DbusCommand {
             attachments = List.of();
         }
 
-        final var writer = (PlainTextWriterImpl) outputWriter;
-
         if (groupIdString != null) {
             byte[] groupId;
             try {
@@ -104,7 +106,7 @@ public class SendCommand implements DbusCommand {
 
             try {
                 var timestamp = signal.sendGroupMessage(messageText, attachments, groupId);
-                writer.println("{}", timestamp);
+                outputResult(timestamp);
                 return;
             } catch (DBusExecutionException e) {
                 throw new UnexpectedErrorException("Failed to send group message: " + e.getMessage());
@@ -114,7 +116,7 @@ public class SendCommand implements DbusCommand {
         if (isNoteToSelf) {
             try {
                 var timestamp = signal.sendNoteToSelfMessage(messageText, attachments);
-                writer.println("{}", timestamp);
+                outputResult(timestamp);
                 return;
             } catch (Signal.Error.UntrustedIdentity e) {
                 throw new UntrustedKeyErrorException("Failed to send message: " + e.getMessage());
@@ -125,7 +127,7 @@ public class SendCommand implements DbusCommand {
 
         try {
             var timestamp = signal.sendMessage(messageText, attachments, recipients);
-            writer.println("{}", timestamp);
+            outputResult(timestamp);
         } catch (UnknownObject e) {
             throw new UserErrorException("Failed to find dbus object, maybe missing the -u flag: " + e.getMessage());
         } catch (Signal.Error.UntrustedIdentity e) {
@@ -134,4 +136,19 @@ public class SendCommand implements DbusCommand {
             throw new UnexpectedErrorException("Failed to send message: " + e.getMessage());
         }
     }
+
+    private void outputResult(final long timestamp) {
+        if (outputWriter instanceof PlainTextWriter) {
+            final var writer = (PlainTextWriter) outputWriter;
+            writer.println("{}", timestamp);
+        } else {
+            final var writer = (JsonWriter) outputWriter;
+            writer.write(Map.of("timestamp", timestamp));
+        }
+    }
+
+    @Override
+    public void handleCommand(final Namespace ns, final Manager m) throws CommandException {
+        handleCommand(ns, new DbusSignalImpl(m, null));
+    }
 }
diff --git a/src/main/java/org/asamk/signal/commands/VersionCommand.java b/src/main/java/org/asamk/signal/commands/VersionCommand.java
new file mode 100644 (file)
index 0000000..1b6d647
--- /dev/null
@@ -0,0 +1,24 @@
+package org.asamk.signal.commands;
+
+import org.asamk.signal.BaseConfig;
+import org.asamk.signal.JsonWriter;
+import org.asamk.signal.OutputWriter;
+import org.asamk.signal.commands.exceptions.CommandException;
+import org.asamk.signal.manager.Manager;
+
+import java.util.Map;
+
+public class VersionCommand implements JsonRpcCommand<Void> {
+
+    private final OutputWriter outputWriter;
+
+    public VersionCommand(final OutputWriter outputWriter) {
+        this.outputWriter = outputWriter;
+    }
+
+    @Override
+    public void handleCommand(final Void request, final Manager m) throws CommandException {
+        final var jsonWriter = (JsonWriter) outputWriter;
+        jsonWriter.write(Map.of("version", BaseConfig.PROJECT_VERSION));
+    }
+}
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcBulkMessage.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcBulkMessage.java
new file mode 100644 (file)
index 0000000..d1b6321
--- /dev/null
@@ -0,0 +1,18 @@
+package org.asamk.signal.jsonrpc;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.util.List;
+
+public class JsonRpcBulkMessage extends JsonRpcMessage {
+
+    List<JsonNode> messages;
+
+    public JsonRpcBulkMessage(final List<JsonNode> messages) {
+        this.messages = messages;
+    }
+
+    public List<JsonNode> getMessages() {
+        return messages;
+    }
+}
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcException.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcException.java
new file mode 100644 (file)
index 0000000..627a981
--- /dev/null
@@ -0,0 +1,14 @@
+package org.asamk.signal.jsonrpc;
+
+public class JsonRpcException extends Exception {
+
+    private final JsonRpcResponse.Error error;
+
+    public JsonRpcException(final JsonRpcResponse.Error error) {
+        this.error = error;
+    }
+
+    public JsonRpcResponse.Error getError() {
+        return error;
+    }
+}
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcMessage.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcMessage.java
new file mode 100644 (file)
index 0000000..7f8b0a1
--- /dev/null
@@ -0,0 +1,9 @@
+package org.asamk.signal.jsonrpc;
+
+/**
+ * Represents a JSON-RPC (bulk) request or (bulk) response.
+ * https://www.jsonrpc.org/specification
+ */
+public abstract class JsonRpcMessage {
+
+}
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java
new file mode 100644 (file)
index 0000000..67fced0
--- /dev/null
@@ -0,0 +1,216 @@
+package org.asamk.signal.jsonrpc;
+
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ContainerNode;
+import com.fasterxml.jackson.databind.node.ValueNode;
+
+import org.asamk.signal.util.Util;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+public class JsonRpcReader {
+
+    private final static Logger logger = LoggerFactory.getLogger(JsonRpcReader.class);
+
+    private final JsonRpcSender jsonRpcSender;
+    private final ObjectMapper objectMapper;
+    private final Supplier<String> lineSupplier;
+
+    public JsonRpcReader(
+            final JsonRpcSender jsonRpcSender, final Supplier<String> lineSupplier
+    ) {
+        this.jsonRpcSender = jsonRpcSender;
+        this.lineSupplier = lineSupplier;
+        this.objectMapper = Util.createJsonObjectMapper();
+    }
+
+    public void readRequests(
+            final RequestHandler requestHandler, final Consumer<JsonRpcResponse> responseHandler
+    ) {
+        while (!Thread.interrupted()) {
+            JsonRpcMessage message = readMessage();
+            if (message == null) break;
+
+            if (message instanceof JsonRpcRequest) {
+                final var response = handleRequest(requestHandler, (JsonRpcRequest) message);
+                if (response != null) {
+                    jsonRpcSender.sendResponse(response);
+                }
+            } else if (message instanceof JsonRpcResponse) {
+                responseHandler.accept((JsonRpcResponse) message);
+            } else {
+                final var responseList = ((JsonRpcBulkMessage) message).getMessages().stream().map(jsonNode -> {
+                    final JsonRpcRequest request;
+                    try {
+                        request = parseJsonRpcRequest(jsonNode);
+                    } catch (JsonRpcException e) {
+                        return JsonRpcResponse.forError(e.getError(), getId(jsonNode));
+                    }
+
+                    return handleRequest(requestHandler, request);
+                }).filter(Objects::nonNull).collect(Collectors.toList());
+
+                jsonRpcSender.sendBulkResponses(responseList);
+            }
+        }
+    }
+
+    private JsonRpcResponse handleRequest(final RequestHandler requestHandler, final JsonRpcRequest request) {
+        try {
+            final var result = requestHandler.apply(request.getMethod(), request.getParams());
+            if (request.getId() != null) {
+                return JsonRpcResponse.forSuccess(result, request.getId());
+            }
+        } catch (JsonRpcException e) {
+            if (request.getId() != null) {
+                return JsonRpcResponse.forError(e.getError(), request.getId());
+            }
+        }
+        return null;
+    }
+
+    private JsonRpcMessage readMessage() {
+        while (!Thread.interrupted()) {
+            String input = lineSupplier.get();
+
+            if (input == null) {
+                // Reached end of input stream
+                break;
+            }
+
+            JsonRpcMessage message = parseJsonRpcMessage(input);
+            if (message == null) continue;
+
+            return message;
+        }
+
+        return null;
+    }
+
+    private JsonRpcMessage parseJsonRpcMessage(final String input) {
+        final JsonNode jsonNode;
+        try {
+            jsonNode = objectMapper.readTree(input);
+        } catch (JsonParseException e) {
+            jsonRpcSender.sendResponse(JsonRpcResponse.forError(new JsonRpcResponse.Error(JsonRpcResponse.Error.PARSE_ERROR,
+                    e.getMessage(),
+                    null), null));
+            return null;
+        } catch (IOException e) {
+            throw new AssertionError(e);
+        }
+
+        if (jsonNode == null) {
+            jsonRpcSender.sendResponse(JsonRpcResponse.forError(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_REQUEST,
+                    "invalid request",
+                    null), null));
+            return null;
+        } else if (jsonNode.isArray()) {
+            if (jsonNode.size() == 0) {
+                jsonRpcSender.sendResponse(JsonRpcResponse.forError(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_REQUEST,
+                        "invalid request",
+                        null), null));
+                return null;
+            }
+            return new JsonRpcBulkMessage(StreamSupport.stream(jsonNode.spliterator(), false)
+                    .collect(Collectors.toList()));
+        } else if (jsonNode.isObject()) {
+            if (jsonNode.has("result") || jsonNode.has("error")) {
+                return parseJsonRpcResponse(jsonNode);
+            } else {
+                try {
+                    return parseJsonRpcRequest(jsonNode);
+                } catch (JsonRpcException e) {
+                    jsonRpcSender.sendResponse(JsonRpcResponse.forError(e.getError(), getId(jsonNode)));
+                    return null;
+                }
+            }
+        } else {
+            jsonRpcSender.sendResponse(JsonRpcResponse.forError(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_REQUEST,
+                    "unexpected type: " + jsonNode.getNodeType().name(),
+                    null), null));
+            return null;
+        }
+    }
+
+    private ValueNode getId(JsonNode jsonNode) {
+        final var id = jsonNode.get("id");
+        return id instanceof ValueNode ? (ValueNode) id : null;
+    }
+
+    private JsonRpcRequest parseJsonRpcRequest(final JsonNode input) throws JsonRpcException {
+        JsonRpcRequest request;
+        try {
+            request = objectMapper.treeToValue(input, JsonRpcRequest.class);
+        } catch (JsonMappingException e) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_REQUEST,
+                    e.getMessage(),
+                    null));
+        } catch (IOException e) {
+            throw new AssertionError(e);
+        }
+
+        if (!"2.0".equals(request.getJsonrpc())) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_REQUEST,
+                    "only jsonrpc version 2.0 is supported",
+                    null));
+        }
+
+        if (request.getMethod() == null) {
+            throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_REQUEST,
+                    "method field must be set",
+                    null));
+        }
+
+        return request;
+    }
+
+    private JsonRpcResponse parseJsonRpcResponse(final JsonNode input) {
+        JsonRpcResponse response;
+        try {
+            response = objectMapper.treeToValue(input, JsonRpcResponse.class);
+        } catch (JsonParseException | JsonMappingException e) {
+            logger.debug("Received invalid jsonrpc response {}", e.getMessage());
+            return null;
+        } catch (IOException e) {
+            throw new AssertionError(e);
+        }
+
+        if (!"2.0".equals(response.getJsonrpc())) {
+            logger.debug("Received invalid jsonrpc response with invalid version {}", response.getJsonrpc());
+            return null;
+        }
+
+        if (response.getResult() != null && response.getError() != null) {
+            logger.debug("Received invalid jsonrpc response with both result and error");
+            return null;
+        }
+
+        if (response.getResult() == null && response.getError() == null) {
+            logger.debug("Received invalid jsonrpc response without result and error");
+            return null;
+        }
+
+        if (response.getId() == null || response.getId().isNull()) {
+            logger.debug("Received invalid jsonrpc response without id");
+            return null;
+        }
+
+        return response;
+    }
+
+    public interface RequestHandler {
+
+        JsonNode apply(String method, ContainerNode<?> params) throws JsonRpcException;
+    }
+}
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcRequest.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcRequest.java
new file mode 100644 (file)
index 0000000..1ae8552
--- /dev/null
@@ -0,0 +1,73 @@
+package org.asamk.signal.jsonrpc;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.node.ContainerNode;
+import com.fasterxml.jackson.databind.node.ValueNode;
+
+/**
+ * Represents a JSON-RPC request.
+ * https://www.jsonrpc.org/specification#request_object
+ */
+public class JsonRpcRequest extends JsonRpcMessage {
+
+    /**
+     * A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0".
+     */
+    String jsonrpc;
+
+    /**
+     * A String containing the name of the method to be invoked.
+     * Method names that begin with the word rpc followed by a period character (U+002E or ASCII 46)
+     * are reserved for rpc-internal methods and extensions and MUST NOT be used for anything else.
+     */
+    String method;
+
+    /**
+     * A Structured value that holds the parameter values to be used during the invocation of the method.
+     * This member MAY be omitted.
+     */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    ContainerNode<?> params;
+
+    /**
+     * An identifier established by the Client that MUST contain a String, Number, or NULL value if included.
+     * If it is not included it is assumed to be a notification.
+     * The value SHOULD normally not be Null and Numbers SHOULD NOT contain fractional parts
+     */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    ValueNode id;
+
+    public static JsonRpcRequest forNotification(
+            final String method, final ContainerNode<?> params, final ValueNode id
+    ) {
+        return new JsonRpcRequest("2.0", method, params, id);
+    }
+
+    private JsonRpcRequest() {
+    }
+
+    private JsonRpcRequest(
+            final String jsonrpc, final String method, final ContainerNode<?> params, final ValueNode id
+    ) {
+        this.jsonrpc = jsonrpc;
+        this.method = method;
+        this.params = params;
+        this.id = id;
+    }
+
+    public String getJsonrpc() {
+        return jsonrpc;
+    }
+
+    public String getMethod() {
+        return method;
+    }
+
+    public ContainerNode<?> getParams() {
+        return params;
+    }
+
+    public ValueNode getId() {
+        return id;
+    }
+}
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcResponse.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcResponse.java
new file mode 100644 (file)
index 0000000..b5279b7
--- /dev/null
@@ -0,0 +1,120 @@
+package org.asamk.signal.jsonrpc;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ValueNode;
+
+/**
+ * Represents a JSON-RPC response.
+ * https://www.jsonrpc.org/specification#response_object
+ */
+public class JsonRpcResponse extends JsonRpcMessage {
+
+    /**
+     * A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0".
+     */
+    String jsonrpc;
+
+    /**
+     * This member is REQUIRED on success.
+     * This member MUST NOT exist if there was an error invoking the method.
+     * The value of this member is determined by the method invoked on the Server.
+     */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    JsonNode result;
+
+    /**
+     * This member is REQUIRED on error.
+     * This member MUST NOT exist if there was no error triggered during invocation.
+     * The value for this member MUST be an Object as defined in section 5.1.
+     */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    Error error;
+
+    /**
+     * This member is REQUIRED.
+     * It MUST be the same as the value of the id member in the Request Object.
+     * If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.
+     */
+    ValueNode id;
+
+    public static JsonRpcResponse forSuccess(JsonNode result, ValueNode id) {
+        return new JsonRpcResponse("2.0", result, null, id);
+    }
+
+    public static JsonRpcResponse forError(Error error, ValueNode id) {
+        return new JsonRpcResponse("2.0", null, error, id);
+    }
+
+    private JsonRpcResponse() {
+    }
+
+    private JsonRpcResponse(final String jsonrpc, final JsonNode result, final Error error, final ValueNode id) {
+        this.jsonrpc = jsonrpc;
+        this.result = result;
+        this.error = error;
+        this.id = id;
+    }
+
+    public String getJsonrpc() {
+        return jsonrpc;
+    }
+
+    public JsonNode getResult() {
+        return result;
+    }
+
+    public Error getError() {
+        return error;
+    }
+
+    public ValueNode getId() {
+        return id;
+    }
+
+    public static class Error {
+
+        public static final int PARSE_ERROR = -32700;
+        public static final int INVALID_REQUEST = -32600;
+        public static final int METHOD_NOT_FOUND = -32601;
+        public static final int INVALID_PARAMS = -32602;
+        public static final int INTERNAL_ERROR = -32603;
+
+        /**
+         * A Number that indicates the error type that occurred.
+         * This MUST be an integer.
+         */
+        int code;
+
+        /**
+         * A String providing a short description of the error.
+         * The message SHOULD be limited to a concise single sentence.
+         */
+        String message;
+
+        /**
+         * A Primitive or Structured value that contains additional information about the error.
+         * This may be omitted.
+         * The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.).
+         */
+        JsonNode data;
+
+        public Error(final int code, final String message, final JsonNode data) {
+            this.code = code;
+            this.message = message;
+            this.data = data;
+        }
+
+        public int getCode() {
+            return code;
+        }
+
+        public String getMessage() {
+            return message;
+        }
+
+        public JsonNode getData() {
+            return data;
+        }
+    }
+}
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcSender.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcSender.java
new file mode 100644 (file)
index 0000000..cdacdf2
--- /dev/null
@@ -0,0 +1,30 @@
+package org.asamk.signal.jsonrpc;
+
+import org.asamk.signal.JsonWriter;
+
+import java.util.List;
+
+public class JsonRpcSender {
+
+    private final JsonWriter jsonWriter;
+
+    public JsonRpcSender(final JsonWriter jsonWriter) {
+        this.jsonWriter = jsonWriter;
+    }
+
+    public void sendRequest(JsonRpcRequest request) {
+        jsonWriter.write(request);
+    }
+
+    public void sendBulkRequests(List<JsonRpcRequest> requests) {
+        jsonWriter.write(requests);
+    }
+
+    public void sendResponse(JsonRpcResponse response) {
+        jsonWriter.write(response);
+    }
+
+    public void sendBulkResponses(List<JsonRpcResponse> responses) {
+        jsonWriter.write(responses);
+    }
+}
index a9d2bb8fa14533b6c34bcab2c6c006f68ba53efe..31c6b68e987316ed9bdac52db7655abd01eb1bee 100644 (file)
@@ -1,10 +1,20 @@
 package org.asamk.signal.util;
 
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupIdFormatException;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+
 public class Util {
 
     private Util() {
@@ -18,6 +28,22 @@ public class Util {
         return string;
     }
 
+    public static String dashSeparatedToCamelCaseString(String s) {
+        var parts = s.split("-");
+        return toCamelCaseString(Arrays.asList(parts));
+    }
+
+    private static String toCamelCaseString(List<String> strings) {
+        if (strings.size() == 0) {
+            return "";
+        }
+        return strings.get(0) + strings.stream()
+                .skip(1)
+                .filter(s -> s.length() > 0)
+                .map(s -> Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase(Locale.ROOT))
+                .collect(Collectors.joining());
+    }
+
     public static String formatSafetyNumber(String digits) {
         final var partCount = 12;
         var partSize = digits.length() / partCount;
@@ -35,4 +61,11 @@ public class Util {
     public static String getLegacyIdentifier(final SignalServiceAddress address) {
         return address.getNumber().or(() -> address.getUuid().get().toString());
     }
+
+    public static ObjectMapper createJsonObjectMapper() {
+        var objectMapper = new ObjectMapper();
+        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.PUBLIC_ONLY);
+        objectMapper.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
+        return objectMapper;
+    }
 }