From: AsamK Date: Mon, 9 Aug 2021 15:42:01 +0000 (+0200) Subject: Implement jsonRpc command X-Git-Tag: v0.9.0~85 X-Git-Url: https://git.nmode.ca/signal-cli/commitdiff_plain/a8bbdb54d006f157a009ece0cae5bf72fb636ced?ds=inline Implement jsonRpc command Co-authored-by: technillogue Closes #668 --- 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 ef0b404b..98b02c7f 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -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 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()); } } diff --git a/src/main/java/org/asamk/signal/JsonWriterImpl.java b/src/main/java/org/asamk/signal/JsonWriterImpl.java index 772e4c7e..f0daaa85 100644 --- a/src/main/java/org/asamk/signal/JsonWriterImpl.java +++ b/src/main/java/org/asamk/signal/JsonWriterImpl.java @@ -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) { diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 2e1d6821..33caf8ba 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -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 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 { diff --git a/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index 7988c8ef..7b6f243d 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -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; } } }); diff --git a/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java b/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java index 01b5a260..91e6e47c 100644 --- a/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java +++ b/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java @@ -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 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 index 00000000..394b0f8b --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/JsonRpcCommand.java @@ -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 extends Command { + + default TypeReference getRequestType() { + return null; + } + + void handleCommand(T request, Manager m) throws CommandException; + + default Set 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 index 00000000..dd0c7bee --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java @@ -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 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 void parseParamsAndRunCommand( + final Manager m, final ObjectMapper objectMapper, final TreeNode params, final JsonRpcCommand 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 index 00000000..3d2cd035 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java @@ -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> { + + void handleCommand(Namespace ns, Manager m) throws CommandException; + + default TypeReference> getRequestType() { + return new TypeReference<>() { + }; + } + + default void handleCommand(Map request, Manager m) throws CommandException { + Namespace commandNamespace = new JsonRpcNamespace(request == null ? Map.of() : request); + handleCommand(commandNamespace, m); + } + + default Set 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 attrs) { + super(attrs); + } + + public 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 List getList(final String dest) { + final List 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; + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java index 1708ade0..e9da8099 100644 --- a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java @@ -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 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(); diff --git a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java index 517d894b..c71225e0 100644 --- a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java +++ b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java @@ -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) { } } } diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index 59ee6915..8459118c 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -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 index 00000000..1b6d6477 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/VersionCommand.java @@ -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 { + + 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 index 00000000..d1b63212 --- /dev/null +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcBulkMessage.java @@ -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 messages; + + public JsonRpcBulkMessage(final List messages) { + this.messages = messages; + } + + public List 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 index 00000000..627a981f --- /dev/null +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcException.java @@ -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 index 00000000..7f8b0a1a --- /dev/null +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcMessage.java @@ -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 index 00000000..67fced0d --- /dev/null +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java @@ -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 lineSupplier; + + public JsonRpcReader( + final JsonRpcSender jsonRpcSender, final Supplier lineSupplier + ) { + this.jsonRpcSender = jsonRpcSender; + this.lineSupplier = lineSupplier; + this.objectMapper = Util.createJsonObjectMapper(); + } + + public void readRequests( + final RequestHandler requestHandler, final Consumer 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 index 00000000..1ae8552a --- /dev/null +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcRequest.java @@ -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 index 00000000..b5279b7d --- /dev/null +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcResponse.java @@ -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 index 00000000..cdacdf28 --- /dev/null +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcSender.java @@ -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 requests) { + jsonWriter.write(requests); + } + + public void sendResponse(JsonRpcResponse response) { + jsonWriter.write(response); + } + + public void sendBulkResponses(List responses) { + jsonWriter.write(responses); + } +} diff --git a/src/main/java/org/asamk/signal/util/Util.java b/src/main/java/org/asamk/signal/util/Util.java index a9d2bb8f..31c6b68e 100644 --- a/src/main/java/org/asamk/signal/util/Util.java +++ b/src/main/java/org/asamk/signal/util/Util.java @@ -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 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; + } }