try {
action.execute(this);
} catch (Throwable e) {
+ if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
logger.warn("Message action failed.", e);
}
}
boolean returnOnTimeout,
boolean ignoreAttachments,
ReceiveMessageHandler handler
- ) throws IOException {
+ ) throws IOException, InterruptedException {
retryFailedReceivedMessages(handler, ignoreAttachments);
Set<HandleAction> queuedActions = null;
try {
action.execute(this);
} catch (Throwable e) {
+ if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
logger.warn("Message action failed.", e);
}
}
// 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;
try {
action.execute(this);
} catch (Throwable e) {
+ if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
logger.warn("Message action failed.", e);
}
}
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());
}
}
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;
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) {
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);
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() {
String name, CommandConstructor commandConstructor, SubparserAttacher subparserAttacher
) {
commands.put(name, commandConstructor);
- commandSubparserAttacher.put(name, subparserAttacher);
+ if (subparserAttacher != null) {
+ commandSubparserAttacher.put(name, subparserAttacher);
+ }
}
private interface CommandConstructor {
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;
}
}
});
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;
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;
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
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+ }
+}
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;
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);
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();
handler);
} catch (IOException e) {
throw new IOErrorException("Error while receiving messages: " + e.getMessage());
+ } catch (InterruptedException ignored) {
}
}
}
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;
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;
attachments = List.of();
}
- final var writer = (PlainTextWriterImpl) outputWriter;
-
if (groupIdString != null) {
byte[] groupId;
try {
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());
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());
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) {
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));
+ }
}
--- /dev/null
+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));
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+package org.asamk.signal.jsonrpc;
+
+/**
+ * Represents a JSON-RPC (bulk) request or (bulk) response.
+ * https://www.jsonrpc.org/specification
+ */
+public abstract class JsonRpcMessage {
+
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+ }
+}
--- /dev/null
+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);
+ }
+}
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() {
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;
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;
+ }
}