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 183bfdea..f7290cd7 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/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 54bf8671..0e3789e5 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -162,7 +162,7 @@ Send a message to another user or group. RECIPIENT:: Specify the recipients’ phone number. -*-g* GROUP, *--group* GROUP:: +*-g* GROUP, *--group-id* GROUP:: Specify the recipient group ID in base64 encoding. *-m* MESSAGE, *--message* MESSAGE:: @@ -174,7 +174,7 @@ Add one or more files as attachment. *--note-to-self*:: Send the message to self without notification. -*-e*, *--endsession*:: +*-e*, *--end-session*:: Clear session state and send end session message. === sendReaction @@ -184,7 +184,7 @@ Send reaction to a previously received or sent message. RECIPIENT:: Specify the recipients’ phone number. -*-g* GROUP, *--group* GROUP:: +*-g* GROUP, *--group-id* GROUP:: Specify the recipient group ID in base64 encoding. *-e* EMOJI, *--emoji* EMOJI:: @@ -207,7 +207,7 @@ Indicator will be shown for 15seconds unless a typing STOP message is sent first RECIPIENT:: Specify the recipients’ phone number. -*-g* GROUP, *--group* GROUP:: +*-g* GROUP, *--group-id* GROUP:: Specify the recipient group ID in base64 encoding. *-s*, *--stop*:: @@ -220,7 +220,7 @@ Remotely delete a previously sent message. RECIPIENT:: Specify the recipients’ phone number. -*-g* GROUP, *--group* GROUP:: +*-g* GROUP, *--group-id* GROUP:: Specify the recipient group ID in base64 encoding. *-t* TIMESTAMP, *--target-timestamp* TIMESTAMP:: @@ -250,7 +250,7 @@ The invitation link URI (starts with `https://signal.group/#`) Create or update a group. If the user is a pending member, this command will accept the group invitation. -*-g* GROUP, *--group* GROUP:: +*-g* GROUP, *--group-id* GROUP:: Specify the recipient group ID in base64 encoding. If not specified, a new group with a new random ID is generated. @@ -296,7 +296,7 @@ To disable expiration set expiration time to 0. Send a quit group message to all group members and remove self from member list. If the user is a pending member, this command will decline the group invitation. -*-g* GROUP, *--group* GROUP:: +*-g* GROUP, *--group-id* GROUP:: Specify the recipient group ID in base64 encoding. *--delete*:: @@ -385,7 +385,7 @@ This change is only local but can be synchronized to other devices by using `sen [CONTACT [CONTACT ...]]:: Specify the phone numbers of contacts that should be blocked. -*-g* [GROUP [GROUP ...]], *--group* [GROUP [GROUP ...]]:: +*-g* [GROUP [GROUP ...]], *--group-id* [GROUP [GROUP ...]]:: Specify the group IDs that should be blocked in base64 encoding. === unblock @@ -396,7 +396,7 @@ This change is only local but can be synchronized to other devices by using `sen [CONTACT [CONTACT ...]]:: Specify the phone numbers of contacts that should be unblocked. -*-g* [GROUP [GROUP ...]], *--group* [GROUP [GROUP ...]]:: +*-g* [GROUP [GROUP ...]], *--group-id* [GROUP [GROUP ...]]:: Specify the group IDs that should be unblocked in base64 encoding. === sendContacts diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java index a7e69129..e7afe13b 100644 --- a/src/main/java/org/asamk/signal/App.java +++ b/src/main/java/org/asamk/signal/App.java @@ -92,7 +92,7 @@ public class App { public void init() throws CommandException { var outputType = ns.get("output"); var outputWriter = outputType == OutputType.JSON - ? new JsonWriter(System.out) + ? new JsonWriterImpl(System.out) : new PlainTextWriterImpl(System.out); var commandKey = ns.getString("command"); diff --git a/src/main/java/org/asamk/signal/GroupLinkState.java b/src/main/java/org/asamk/signal/GroupLinkState.java deleted file mode 100644 index b0a2510f..00000000 --- a/src/main/java/org/asamk/signal/GroupLinkState.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.asamk.signal; - -public enum GroupLinkState { - ENABLED { - @Override - public String toString() { - return "enabled"; - } - }, - ENABLED_WITH_APPROVAL { - @Override - public String toString() { - return "enabled-with-approval"; - } - }, - DISABLED { - @Override - public String toString() { - return "disabled"; - } - }; - - public org.asamk.signal.manager.groups.GroupLinkState toLinkState() { - switch (this) { - case ENABLED: - return org.asamk.signal.manager.groups.GroupLinkState.ENABLED; - case ENABLED_WITH_APPROVAL: - return org.asamk.signal.manager.groups.GroupLinkState.ENABLED_WITH_APPROVAL; - case DISABLED: - return org.asamk.signal.manager.groups.GroupLinkState.DISABLED; - default: - throw new AssertionError(); - } - } -} diff --git a/src/main/java/org/asamk/signal/GroupPermission.java b/src/main/java/org/asamk/signal/GroupPermission.java deleted file mode 100644 index 08056284..00000000 --- a/src/main/java/org/asamk/signal/GroupPermission.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.asamk.signal; - -public enum GroupPermission { - EVERY_MEMBER { - @Override - public String toString() { - return "every-member"; - } - }, - ONLY_ADMINS { - @Override - public String toString() { - return "only-admins"; - } - }; - - public org.asamk.signal.manager.groups.GroupPermission toManager() { - switch (this) { - case EVERY_MEMBER: - return org.asamk.signal.manager.groups.GroupPermission.EVERY_MEMBER; - case ONLY_ADMINS: - return org.asamk.signal.manager.groups.GroupPermission.ONLY_ADMINS; - default: - throw new AssertionError(); - } - } -} diff --git a/src/main/java/org/asamk/signal/JsonWriter.java b/src/main/java/org/asamk/signal/JsonWriter.java index c48fd095..78a04c62 100644 --- a/src/main/java/org/asamk/signal/JsonWriter.java +++ b/src/main/java/org/asamk/signal/JsonWriter.java @@ -1,43 +1,6 @@ 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; +public interface JsonWriter extends OutputWriter { -import java.io.BufferedWriter; -import java.io.IOException; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.Writer; -import java.nio.charset.StandardCharsets; - -public class JsonWriter implements OutputWriter { - - private final Writer writer; - private final ObjectMapper objectMapper; - - public JsonWriter(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); - } - - public void write(final Object object) { - try { - try { - objectMapper.writeValue(writer, object); - } catch (JsonProcessingException e) { - // Some issue with json serialization, probably caused by a bug - throw new AssertionError(e); - } - writer.write(System.lineSeparator()); - writer.flush(); - } catch (IOException e) { - throw new AssertionError(e); - } - } + void write(final Object object); } diff --git a/src/main/java/org/asamk/signal/JsonWriterImpl.java b/src/main/java/org/asamk/signal/JsonWriterImpl.java new file mode 100644 index 00000000..f0daaa85 --- /dev/null +++ b/src/main/java/org/asamk/signal/JsonWriterImpl.java @@ -0,0 +1,40 @@ +package org.asamk.signal; + +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; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; + +public class JsonWriterImpl implements JsonWriter { + + private final Writer writer; + private final ObjectMapper objectMapper; + + public JsonWriterImpl(final OutputStream outputStream) { + this.writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); + + objectMapper = Util.createJsonObjectMapper(); + } + + public synchronized void write(final Object object) { + try { + try { + objectMapper.writeValue(writer, object); + } catch (JsonProcessingException e) { + // Some issue with json serialization, probably caused by a bug + throw new AssertionError(e); + } + writer.write(System.lineSeparator()); + writer.flush(); + } catch (IOException e) { + throw new AssertionError(e); + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java b/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java index 67ba5b89..31b3c7ef 100644 --- a/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java +++ b/src/main/java/org/asamk/signal/commands/AddDeviceCommand.java @@ -17,7 +17,7 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; -public class AddDeviceCommand implements LocalCommand { +public class AddDeviceCommand implements JsonRpcLocalCommand { private final static Logger logger = LoggerFactory.getLogger(AddDeviceCommand.class); diff --git a/src/main/java/org/asamk/signal/commands/BlockCommand.java b/src/main/java/org/asamk/signal/commands/BlockCommand.java index e62bb79a..7229c2e1 100644 --- a/src/main/java/org/asamk/signal/commands/BlockCommand.java +++ b/src/main/java/org/asamk/signal/commands/BlockCommand.java @@ -15,14 +15,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.util.InvalidNumberException; -public class BlockCommand implements LocalCommand { +public class BlockCommand implements JsonRpcLocalCommand { private final static Logger logger = LoggerFactory.getLogger(BlockCommand.class); public static void attachToSubparser(final Subparser subparser) { subparser.help("Block the given contacts or groups (no messages will be received)"); subparser.addArgument("contact").help("Contact number").nargs("*"); - subparser.addArgument("-g", "--group").help("Group ID").nargs("*"); + subparser.addArgument("-g", "--group-id", "--group").help("Group ID").nargs("*"); } public BlockCommand(final OutputWriter outputWriter) { @@ -40,8 +40,8 @@ public class BlockCommand implements LocalCommand { } } - if (ns.getList("group") != null) { - for (var groupIdString : ns.getList("group")) { + if (ns.getList("group-id") != null) { + for (var groupIdString : ns.getList("group-id")) { try { var groupId = Util.decodeGroupId(groupIdString); m.setGroupBlocked(groupId, true); 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 dfd15c24..ffd20d9e 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -128,14 +128,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 4a35fc11..91e6e47c 100644 --- a/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java +++ b/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java @@ -4,9 +4,8 @@ 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.PlainTextWriterImpl; +import org.asamk.signal.PlainTextWriter; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.manager.Manager; @@ -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 @@ -60,7 +53,7 @@ public class GetUserStatusCommand implements LocalCommand { jsonWriter.write(jsonUserStatuses); } else { - final var writer = (PlainTextWriterImpl) outputWriter; + final var writer = (PlainTextWriter) outputWriter; for (var entry : registered.entrySet()) { writer.println("{}: {}", entry.getKey(), entry.getValue()); diff --git a/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java index 6cb81b7e..e5a872e0 100644 --- a/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java @@ -3,8 +3,9 @@ package org.asamk.signal.commands; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +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.IOErrorException; import org.asamk.signal.commands.exceptions.UnexpectedErrorException; @@ -16,10 +17,11 @@ import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; import java.io.IOException; +import java.util.Map; -import static org.asamk.signal.util.ErrorUtils.handleTimestampAndSendMessageResults; +import static org.asamk.signal.util.ErrorUtils.handleSendMessageResults; -public class JoinGroupCommand implements LocalCommand { +public class JoinGroupCommand implements JsonRpcLocalCommand { private final OutputWriter outputWriter; @@ -49,16 +51,24 @@ public class JoinGroupCommand implements LocalCommand { } try { - final var writer = (PlainTextWriterImpl) outputWriter; - final var results = m.joinGroup(linkUrl); var newGroupId = results.first(); - if (!m.getGroup(newGroupId).isMember(m.getSelfRecipientId())) { - writer.println("Requested to join group \"{}\"", newGroupId.toBase64()); + if (outputWriter instanceof JsonWriter) { + final var writer = (JsonWriter) outputWriter; + if (!m.getGroup(newGroupId).isMember(m.getSelfRecipientId())) { + writer.write(Map.of("groupId", newGroupId.toBase64(), "onlyRequested", true)); + } else { + writer.write(Map.of("groupId", newGroupId.toBase64())); + } } else { - writer.println("Joined group \"{}\"", newGroupId.toBase64()); + final var writer = (PlainTextWriter) outputWriter; + if (!m.getGroup(newGroupId).isMember(m.getSelfRecipientId())) { + writer.println("Requested to join group \"{}\"", newGroupId.toBase64()); + } else { + writer.println("Joined group \"{}\"", newGroupId.toBase64()); + } } - handleTimestampAndSendMessageResults(writer, 0, results.second()); + handleSendMessageResults(results.second()); } catch (GroupPatchNotAcceptedException e) { throw new UserErrorException("Failed to join group, maybe already a member"); } catch (IOException e) { 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..4ca90628 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java @@ -0,0 +1,175 @@ +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.Map; +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 ? Map.of() : 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/LinkCommand.java b/src/main/java/org/asamk/signal/commands/LinkCommand.java index ae496758..f117436c 100644 --- a/src/main/java/org/asamk/signal/commands/LinkCommand.java +++ b/src/main/java/org/asamk/signal/commands/LinkCommand.java @@ -4,7 +4,7 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; 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.IOErrorException; import org.asamk.signal.commands.exceptions.UserErrorException; @@ -32,7 +32,7 @@ public class LinkCommand implements ProvisioningCommand { @Override public void handleCommand(final Namespace ns, final ProvisioningManager m) throws CommandException { - final var writer = (PlainTextWriterImpl) outputWriter; + final var writer = (PlainTextWriter) outputWriter; var deviceName = ns.getString("name"); if (deviceName == null) { diff --git a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java index d0e11528..f9a5e0c2 100644 --- a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java @@ -4,7 +4,7 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.signal.OutputWriter; -import org.asamk.signal.PlainTextWriterImpl; +import org.asamk.signal.PlainTextWriter; import org.asamk.signal.manager.Manager; import static org.asamk.signal.util.Util.getLegacyIdentifier; @@ -23,7 +23,7 @@ public class ListContactsCommand implements LocalCommand { @Override public void handleCommand(final Namespace ns, final Manager m) { - final var writer = (PlainTextWriterImpl) outputWriter; + final var writer = (PlainTextWriter) outputWriter; var contacts = m.getContacts(); for (var c : contacts) { diff --git a/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java b/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java index c0873c15..cb2019e2 100644 --- a/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java @@ -4,7 +4,7 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; 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.IOErrorException; import org.asamk.signal.manager.Manager; @@ -31,7 +31,7 @@ public class ListDevicesCommand implements LocalCommand { @Override public void handleCommand(final Namespace ns, final Manager m) throws CommandException { - final var writer = (PlainTextWriterImpl) outputWriter; + final var writer = (PlainTextWriter) outputWriter; List devices; try { 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/ListIdentitiesCommand.java b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java index 9f476599..2b0b1f96 100644 --- a/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java @@ -5,7 +5,6 @@ import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; -import org.asamk.signal.PlainTextWriterImpl; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; @@ -46,7 +45,7 @@ public class ListIdentitiesCommand implements LocalCommand { @Override public void handleCommand(final Namespace ns, final Manager m) throws CommandException { - final var writer = (PlainTextWriterImpl) outputWriter; + final var writer = (PlainTextWriter) outputWriter; var number = ns.getString("number"); diff --git a/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java b/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java index 3414d304..af5d600c 100644 --- a/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java @@ -4,8 +4,9 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; +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.IOErrorException; import org.asamk.signal.commands.exceptions.UserErrorException; @@ -22,11 +23,12 @@ import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.io.IOException; import java.util.HashSet; +import java.util.Map; import java.util.Set; -import static org.asamk.signal.util.ErrorUtils.handleTimestampAndSendMessageResults; +import static org.asamk.signal.util.ErrorUtils.handleSendMessageResults; -public class QuitGroupCommand implements LocalCommand { +public class QuitGroupCommand implements JsonRpcLocalCommand { private final static Logger logger = LoggerFactory.getLogger(QuitGroupCommand.class); private final OutputWriter outputWriter; @@ -37,7 +39,7 @@ public class QuitGroupCommand implements LocalCommand { public static void attachToSubparser(final Subparser subparser) { subparser.help("Send a quit group message to all group members and remove self from member list."); - subparser.addArgument("-g", "--group").required(true).help("Specify the recipient group ID."); + subparser.addArgument("-g", "--group-id", "--group").required(true).help("Specify the recipient group ID."); subparser.addArgument("--delete") .action(Arguments.storeTrue()) .help("Delete local group data completely after quitting group."); @@ -48,11 +50,9 @@ public class QuitGroupCommand implements LocalCommand { @Override public void handleCommand(final Namespace ns, final Manager m) throws CommandException { - final var writer = (PlainTextWriterImpl) outputWriter; - final GroupId groupId; try { - groupId = Util.decodeGroupId(ns.getString("group")); + groupId = Util.decodeGroupId(ns.getString("group-id")); } catch (GroupIdFormatException e) { throw new UserErrorException("Invalid group id: " + e.getMessage()); } @@ -63,7 +63,9 @@ public class QuitGroupCommand implements LocalCommand { try { final var results = m.sendQuitGroupMessage(groupId, groupAdmins == null ? Set.of() : new HashSet<>(groupAdmins)); - handleTimestampAndSendMessageResults(writer, results.first(), results.second()); + final var timestamp = results.first(); + outputResult(timestamp); + handleSendMessageResults(results.second()); } catch (NotAGroupMemberException e) { logger.info("User is not a group member"); } @@ -81,4 +83,14 @@ public class QuitGroupCommand implements LocalCommand { throw new UserErrorException("You need to specify a new admin with --admin: " + 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)); + } + } } diff --git a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java index 301694e9..c71225e0 100644 --- a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java +++ b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java @@ -10,7 +10,6 @@ import org.asamk.signal.JsonWriter; import org.asamk.signal.OutputType; import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; -import org.asamk.signal.PlainTextWriterImpl; import org.asamk.signal.ReceiveMessageHandler; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.IOErrorException; @@ -79,7 +78,7 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { jsonWriter.write(object); }); } else { - final var writer = (PlainTextWriterImpl) outputWriter; + final var writer = (PlainTextWriter) outputWriter; dbusconnection.addSigHandler(Signal.MessageReceived.class, signal, messageReceived -> { writer.println("Envelope from: {}", messageReceived.getSender()); @@ -156,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/RemoteDeleteCommand.java b/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java index 0ab68a90..37c55fe7 100644 --- a/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java +++ b/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java @@ -4,19 +4,23 @@ 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.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.Util; import org.freedesktop.dbus.errors.UnknownObject; import org.freedesktop.dbus.exceptions.DBusExecutionException; import java.util.List; +import java.util.Map; -public class RemoteDeleteCommand implements DbusCommand { +public class RemoteDeleteCommand implements DbusCommand, JsonRpcLocalCommand { private final OutputWriter outputWriter; @@ -30,14 +34,14 @@ public class RemoteDeleteCommand implements DbusCommand { .required(true) .type(long.class) .help("Specify the timestamp of the message to delete."); - subparser.addArgument("-g", "--group").help("Specify the recipient group ID."); + subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID."); subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*"); } @Override public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { final List recipients = ns.getList("recipient"); - final var groupIdString = ns.getString("group"); + final var groupIdString = ns.getString("group-id"); final var noRecipients = recipients == null || recipients.isEmpty(); if (noRecipients && groupIdString == null) { @@ -49,8 +53,6 @@ public class RemoteDeleteCommand implements DbusCommand { final long targetTimestamp = ns.getLong("target-timestamp"); - final var writer = (PlainTextWriterImpl) outputWriter; - byte[] groupId = null; if (groupIdString != null) { try { @@ -67,7 +69,7 @@ public class RemoteDeleteCommand implements DbusCommand { } else { timestamp = signal.sendRemoteDeleteMessage(targetTimestamp, 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.InvalidNumber e) { @@ -78,4 +80,19 @@ public class RemoteDeleteCommand implements DbusCommand { throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } } + + @Override + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + handleCommand(ns, new DbusSignalImpl(m, null)); + } + + 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)); + } + } } diff --git a/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java b/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java index a4d4ffea..8295acee 100644 --- a/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java +++ b/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java @@ -10,7 +10,7 @@ import org.asamk.signal.manager.Manager; import java.io.IOException; -public class RemoveDeviceCommand implements LocalCommand { +public class RemoveDeviceCommand implements JsonRpcLocalCommand { public RemoveDeviceCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/RemovePinCommand.java b/src/main/java/org/asamk/signal/commands/RemovePinCommand.java index b729a9b9..6f335179 100644 --- a/src/main/java/org/asamk/signal/commands/RemovePinCommand.java +++ b/src/main/java/org/asamk/signal/commands/RemovePinCommand.java @@ -13,7 +13,7 @@ import org.whispersystems.signalservice.internal.contacts.crypto.Unauthenticated import java.io.IOException; -public class RemovePinCommand implements LocalCommand { +public class RemovePinCommand implements JsonRpcLocalCommand { public RemovePinCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index 26b2f543..b6ab0058 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -5,14 +5,16 @@ 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.DbusAttachment; - +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; @@ -26,8 +28,9 @@ import java.io.IOException; import java.nio.charset.Charset; import java.util.ArrayList; 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; @@ -40,14 +43,14 @@ public class SendCommand implements DbusCommand { subparser.help("Send a message to another user or group."); subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*"); final var mutuallyExclusiveGroup = subparser.addMutuallyExclusiveGroup(); - mutuallyExclusiveGroup.addArgument("-g", "--group").help("Specify the recipient group ID."); + mutuallyExclusiveGroup.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID."); mutuallyExclusiveGroup.addArgument("--note-to-self") .help("Send the message to self without notification.") .action(Arguments.storeTrue()); subparser.addArgument("-m", "--message").help("Specify the message, if missing standard input is used."); subparser.addArgument("-a", "--attachment").nargs("*").help("Add file as attachment"); - subparser.addArgument("-e", "--endsession") + subparser.addArgument("-e", "--end-session", "--endsession") .help("Clear session state and send end session message.") .action(Arguments.storeTrue()); } @@ -55,8 +58,8 @@ public class SendCommand implements DbusCommand { @Override public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { final List recipients = ns.getList("recipient"); - final var isEndSession = ns.getBoolean("endsession"); - final var groupIdString = ns.getString("group"); + final var isEndSession = ns.getBoolean("end-session"); + final var groupIdString = ns.getString("group-id"); final var isNoteToSelf = ns.getBoolean("note-to-self"); final var noRecipients = recipients == null || recipients.isEmpty(); @@ -96,8 +99,6 @@ public class SendCommand implements DbusCommand { attachmentNames = List.of(); } - final var writer = (PlainTextWriterImpl) outputWriter; - ArrayList dBusAttachments = new ArrayList<>(); if (!attachmentNames.isEmpty()) { for (var attachmentName : attachmentNames) { @@ -116,7 +117,7 @@ public class SendCommand implements DbusCommand { try { var timestamp = signal.sendGroupMessage(messageText, attachmentNames, groupId); - writer.println("{}", timestamp); + outputResult(timestamp); return; } catch (DBusExecutionException e) { throw new UnexpectedErrorException("Failed to send group message: " + e.getMessage()); @@ -126,7 +127,7 @@ public class SendCommand implements DbusCommand { if (isNoteToSelf) { try { var timestamp = signal.sendNoteToSelfMessage(messageText, attachmentNames); - writer.println("{}", timestamp); + outputResult(timestamp); return; } catch (Signal.Error.UntrustedIdentity e) { throw new UntrustedKeyErrorException("Failed to send note to self message: " + e.getMessage()); @@ -137,8 +138,7 @@ public class SendCommand implements DbusCommand { try { var timestamp = signal.sendMessageWithDBusAttachments(messageText, dBusAttachments, 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) { @@ -147,4 +147,19 @@ public class SendCommand implements DbusCommand { throw new UnexpectedErrorException("Failed to send message, did not find attachment: " + 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/SendContactsCommand.java b/src/main/java/org/asamk/signal/commands/SendContactsCommand.java index 6abf9c6f..edd5db34 100644 --- a/src/main/java/org/asamk/signal/commands/SendContactsCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendContactsCommand.java @@ -12,7 +12,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import java.io.IOException; -public class SendContactsCommand implements LocalCommand { +public class SendContactsCommand implements JsonRpcLocalCommand { public SendContactsCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java index 8ed9ac63..44423749 100644 --- a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java @@ -5,19 +5,23 @@ 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.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.Util; import org.freedesktop.dbus.errors.UnknownObject; import org.freedesktop.dbus.exceptions.DBusExecutionException; import java.util.List; +import java.util.Map; -public class SendReactionCommand implements DbusCommand { +public class SendReactionCommand implements DbusCommand, JsonRpcLocalCommand { private final OutputWriter outputWriter; @@ -27,7 +31,7 @@ public class SendReactionCommand implements DbusCommand { public static void attachToSubparser(final Subparser subparser) { subparser.help("Send reaction to a previously received or sent message."); - subparser.addArgument("-g", "--group").help("Specify the recipient group ID."); + subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID."); subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*"); subparser.addArgument("-e", "--emoji") .required(true) @@ -45,7 +49,7 @@ public class SendReactionCommand implements DbusCommand { @Override public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { final List recipients = ns.getList("recipient"); - final var groupIdString = ns.getString("group"); + final var groupIdString = ns.getString("group-id"); final var noRecipients = recipients == null || recipients.isEmpty(); if (noRecipients && groupIdString == null) { @@ -60,8 +64,6 @@ public class SendReactionCommand implements DbusCommand { final var targetAuthor = ns.getString("target-author"); final long targetTimestamp = ns.getLong("target-timestamp"); - final var writer = (PlainTextWriterImpl) outputWriter; - byte[] groupId = null; if (groupIdString != null) { try { @@ -78,7 +80,7 @@ public class SendReactionCommand implements DbusCommand { } else { timestamp = signal.sendMessageReaction(emoji, isRemove, targetAuthor, targetTimestamp, 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.InvalidNumber e) { @@ -89,4 +91,19 @@ public class SendReactionCommand implements DbusCommand { throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } } + + @Override + public void handleCommand(final Namespace ns, final Manager m) throws CommandException { + handleCommand(ns, new DbusSignalImpl(m, null)); + } + + 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)); + } + } } diff --git a/src/main/java/org/asamk/signal/commands/SendSyncRequestCommand.java b/src/main/java/org/asamk/signal/commands/SendSyncRequestCommand.java index 9ced5189..4072be15 100644 --- a/src/main/java/org/asamk/signal/commands/SendSyncRequestCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendSyncRequestCommand.java @@ -10,7 +10,7 @@ import org.asamk.signal.manager.Manager; import java.io.IOException; -public class SendSyncRequestCommand implements LocalCommand { +public class SendSyncRequestCommand implements JsonRpcLocalCommand { public SendSyncRequestCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/SendTypingCommand.java b/src/main/java/org/asamk/signal/commands/SendTypingCommand.java index cbf34a6d..cb4a4f50 100644 --- a/src/main/java/org/asamk/signal/commands/SendTypingCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendTypingCommand.java @@ -20,7 +20,7 @@ import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.io.IOException; import java.util.HashSet; -public class SendTypingCommand implements LocalCommand { +public class SendTypingCommand implements JsonRpcLocalCommand { public SendTypingCommand(final OutputWriter outputWriter) { } @@ -28,7 +28,7 @@ public class SendTypingCommand implements LocalCommand { public static void attachToSubparser(final Subparser subparser) { subparser.help( "Send typing message to trigger a typing indicator for the recipient. Indicator will be shown for 15seconds unless a typing STOP message is sent first."); - subparser.addArgument("-g", "--group").help("Specify the recipient group ID."); + subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID."); subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*"); subparser.addArgument("-s", "--stop").help("Send a typing STOP message.").action(Arguments.storeTrue()); } @@ -36,7 +36,7 @@ public class SendTypingCommand implements LocalCommand { @Override public void handleCommand(final Namespace ns, final Manager m) throws CommandException { final var recipients = ns.getList("recipient"); - final var groupIdString = ns.getString("group"); + final var groupIdString = ns.getString("group-id"); final var noRecipients = recipients == null || recipients.isEmpty(); if (noRecipients && groupIdString == null) { diff --git a/src/main/java/org/asamk/signal/commands/SetPinCommand.java b/src/main/java/org/asamk/signal/commands/SetPinCommand.java index 6958b16a..3c018cbc 100644 --- a/src/main/java/org/asamk/signal/commands/SetPinCommand.java +++ b/src/main/java/org/asamk/signal/commands/SetPinCommand.java @@ -13,21 +13,21 @@ import org.whispersystems.signalservice.internal.contacts.crypto.Unauthenticated import java.io.IOException; -public class SetPinCommand implements LocalCommand { +public class SetPinCommand implements JsonRpcLocalCommand { public SetPinCommand(final OutputWriter outputWriter) { } public static void attachToSubparser(final Subparser subparser) { subparser.help("Set a registration lock pin, to prevent others from registering this number."); - subparser.addArgument("registrationLockPin") + subparser.addArgument("pin") .help("The registration lock PIN, that will be required for new registrations (resets after 7 days of inactivity)"); } @Override public void handleCommand(final Namespace ns, final Manager m) throws CommandException { try { - var registrationLockPin = ns.getString("registrationLockPin"); + var registrationLockPin = ns.getString("pin"); m.setRegistrationLockPin(Optional.of(registrationLockPin)); } catch (UnauthenticatedResponseException e) { throw new UnexpectedErrorException("Set pin error failed with unauthenticated response: " + e.getMessage()); diff --git a/src/main/java/org/asamk/signal/commands/TrustCommand.java b/src/main/java/org/asamk/signal/commands/TrustCommand.java index eb99386b..62f5190c 100644 --- a/src/main/java/org/asamk/signal/commands/TrustCommand.java +++ b/src/main/java/org/asamk/signal/commands/TrustCommand.java @@ -13,7 +13,7 @@ import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.util.Locale; -public class TrustCommand implements LocalCommand { +public class TrustCommand implements JsonRpcLocalCommand { public TrustCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/UnblockCommand.java b/src/main/java/org/asamk/signal/commands/UnblockCommand.java index 412a904f..6388aeee 100644 --- a/src/main/java/org/asamk/signal/commands/UnblockCommand.java +++ b/src/main/java/org/asamk/signal/commands/UnblockCommand.java @@ -15,7 +15,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.util.InvalidNumberException; -public class UnblockCommand implements LocalCommand { +public class UnblockCommand implements JsonRpcLocalCommand { private final static Logger logger = LoggerFactory.getLogger(UnblockCommand.class); @@ -25,7 +25,7 @@ public class UnblockCommand implements LocalCommand { public static void attachToSubparser(final Subparser subparser) { subparser.help("Unblock the given contacts or groups (messages will be received again)"); subparser.addArgument("contact").help("Contact number").nargs("*"); - subparser.addArgument("-g", "--group").help("Group ID").nargs("*"); + subparser.addArgument("-g", "--group-id", "--group").help("Group ID").nargs("*"); } @Override @@ -40,8 +40,8 @@ public class UnblockCommand implements LocalCommand { } } - if (ns.getList("group") != null) { - for (var groupIdString : ns.getList("group")) { + if (ns.getList("group-id") != null) { + for (var groupIdString : ns.getList("group-id")) { try { var groupId = Util.decodeGroupId(groupIdString); m.setGroupBlocked(groupId, false); diff --git a/src/main/java/org/asamk/signal/commands/UnregisterCommand.java b/src/main/java/org/asamk/signal/commands/UnregisterCommand.java index c8c754ac..dbb8b535 100644 --- a/src/main/java/org/asamk/signal/commands/UnregisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/UnregisterCommand.java @@ -32,7 +32,6 @@ public class UnregisterCommand implements LocalCommand { m.unregister(); } } catch (IOException e) { - e.printStackTrace(); throw new IOErrorException("Unregister error: " + e.getMessage()); } } diff --git a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java index ff5aa27e..96b90e41 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java @@ -10,7 +10,7 @@ import org.asamk.signal.manager.Manager; import java.io.IOException; -public class UpdateAccountCommand implements LocalCommand { +public class UpdateAccountCommand implements JsonRpcLocalCommand { public UpdateAccountCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java b/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java index 931b2a61..687619b0 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java @@ -13,7 +13,7 @@ import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.io.IOException; -public class UpdateContactCommand implements LocalCommand { +public class UpdateContactCommand implements JsonRpcLocalCommand { public UpdateContactCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java b/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java index b392df05..0aa2cf1e 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java @@ -5,10 +5,9 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.Signal; -import org.asamk.signal.GroupLinkState; -import org.asamk.signal.GroupPermission; +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.UserErrorException; @@ -16,7 +15,9 @@ import org.asamk.signal.manager.AttachmentInvalidException; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.groups.GroupId; import org.asamk.signal.manager.groups.GroupIdFormatException; +import org.asamk.signal.manager.groups.GroupLinkState; import org.asamk.signal.manager.groups.GroupNotFoundException; +import org.asamk.signal.manager.groups.GroupPermission; import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.util.ErrorUtils; import org.asamk.signal.util.Util; @@ -28,10 +29,10 @@ import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.io.File; import java.io.IOException; import java.util.ArrayList; -import java.util.Base64; +import java.util.HashMap; import java.util.List; -public class UpdateGroupCommand implements DbusCommand, LocalCommand { +public class UpdateGroupCommand implements DbusCommand, JsonRpcLocalCommand { private final static Logger logger = LoggerFactory.getLogger(UpdateGroupCommand.class); private final OutputWriter outputWriter; @@ -42,7 +43,7 @@ public class UpdateGroupCommand implements DbusCommand, LocalCommand { public static void attachToSubparser(final Subparser subparser) { subparser.help("Create or update a group."); - subparser.addArgument("-g", "--group").help("Specify the recipient group ID."); + subparser.addArgument("-g", "--group-id", "--group").help("Specify the group ID."); subparser.addArgument("-n", "--name").help("Specify the new group name."); subparser.addArgument("-d", "--description").help("Specify the new group description."); subparser.addArgument("-a", "--avatar").help("Specify a new group avatar image file"); @@ -60,23 +61,55 @@ public class UpdateGroupCommand implements DbusCommand, LocalCommand { .help("Reset group link and create new link password"); subparser.addArgument("--link") .help("Set group link state, with or without admin approval") - .type(Arguments.enumStringType(GroupLinkState.class)); + .choices("enabled", "enabled-with-approval", "disabled"); subparser.addArgument("--set-permission-add-member") .help("Set permission to add new group members") - .type(Arguments.enumStringType(GroupPermission.class)); + .choices("every-member", "only-admins"); subparser.addArgument("--set-permission-edit-details") .help("Set permission to edit group details") - .type(Arguments.enumStringType(GroupPermission.class)); + .choices("every-member", "only-admins"); subparser.addArgument("-e", "--expiration").type(int.class).help("Set expiration time of messages (seconds)"); } + GroupLinkState getGroupLinkState(String value) throws UserErrorException { + if (value == null) { + return null; + } + switch (value) { + case "enabled": + return GroupLinkState.ENABLED; + case "enabled-with-approval": + case "enabledWithApproval": + return GroupLinkState.ENABLED_WITH_APPROVAL; + case "disabled": + return GroupLinkState.DISABLED; + default: + throw new UserErrorException("Invalid group link state: " + value); + } + } + + GroupPermission getGroupPermission(String value) throws UserErrorException { + if (value == null) { + return null; + } + switch (value) { + case "every-member": + case "everyMember": + return GroupPermission.EVERY_MEMBER; + case "only-admins": + case "onlyAdmins": + return GroupPermission.ONLY_ADMINS; + default: + throw new UserErrorException("Invalid group permission: " + value); + } + } + @Override public void handleCommand(final Namespace ns, final Manager m) throws CommandException { - final var writer = (PlainTextWriterImpl) outputWriter; GroupId groupId = null; - final var groupIdString = ns.getString("group"); + final var groupIdString = ns.getString("group-id"); if (groupIdString != null) { try { groupId = Util.decodeGroupId(groupIdString); @@ -93,19 +126,20 @@ public class UpdateGroupCommand implements DbusCommand, LocalCommand { var groupRemoveAdmins = ns.getList("remove-admin"); var groupAvatar = ns.getString("avatar"); var groupResetLink = ns.getBoolean("reset-link"); - var groupLinkState = ns.get("link"); + var groupLinkState = getGroupLinkState(ns.getString("link")); var groupExpiration = ns.getInt("expiration"); - var groupAddMemberPermission = ns.get("set-permission-add-member"); - var groupEditDetailsPermission = ns.get("set-permission-edit-details"); + var groupAddMemberPermission = getGroupPermission(ns.getString("set-permission-add-member")); + var groupEditDetailsPermission = getGroupPermission(ns.getString("set-permission-edit-details")); try { + boolean isNewGroup = false; if (groupId == null) { + isNewGroup = true; var results = m.createGroup(groupName, groupMembers, groupAvatar == null ? null : new File(groupAvatar)); - ErrorUtils.handleTimestampAndSendMessageResults(writer, 0, results.second()); + ErrorUtils.handleSendMessageResults(results.second()); groupId = results.first(); - writer.println("Created new group: \"{}\"", groupId.toBase64()); groupName = null; groupMembers = null; groupAvatar = null; @@ -119,14 +153,17 @@ public class UpdateGroupCommand implements DbusCommand, LocalCommand { groupAdmins, groupRemoveAdmins, groupResetLink, - groupLinkState != null ? groupLinkState.toLinkState() : null, - groupAddMemberPermission != null ? groupAddMemberPermission.toManager() : null, - groupEditDetailsPermission != null ? groupEditDetailsPermission.toManager() : null, + groupLinkState, + groupAddMemberPermission, + groupEditDetailsPermission, groupAvatar == null ? null : new File(groupAvatar), groupExpiration); + Long timestamp = null; if (results != null) { - ErrorUtils.handleTimestampAndSendMessageResults(writer, results.first(), results.second()); + timestamp = results.first(); + ErrorUtils.handleSendMessageResults(results.second()); } + outputResult(timestamp, isNewGroup ? groupId : null); } catch (AttachmentInvalidException e) { throw new UserErrorException("Failed to add avatar attachment for group\": " + e.getMessage()); } catch (GroupNotFoundException e) { @@ -142,11 +179,10 @@ public class UpdateGroupCommand implements DbusCommand, LocalCommand { @Override public void handleCommand(final Namespace ns, final Signal signal) throws CommandException { - final var writer = (PlainTextWriterImpl) outputWriter; byte[] groupId = null; - if (ns.getString("group") != null) { + if (ns.getString("group-id") != null) { try { - groupId = Util.decodeGroupId(ns.getString("group")).serialize(); + groupId = Util.decodeGroupId(ns.getString("group-id")).serialize(); } catch (GroupIdFormatException e) { throw new UserErrorException("Invalid group id: " + e.getMessage()); } @@ -173,7 +209,7 @@ public class UpdateGroupCommand implements DbusCommand, LocalCommand { try { var newGroupId = signal.updateGroup(groupId, groupName, groupMembers, groupAvatar); if (groupId.length != newGroupId.length) { - writer.println("Created new group: \"{}\"", Base64.getEncoder().encodeToString(newGroupId)); + outputResult(null, GroupId.unknownVersion(newGroupId)); } } catch (Signal.Error.AttachmentInvalid e) { throw new UserErrorException("Failed to add avatar attachment for group\": " + e.getMessage()); @@ -181,4 +217,26 @@ public class UpdateGroupCommand implements DbusCommand, LocalCommand { throw new UnexpectedErrorException("Failed to send message: " + e.getMessage()); } } + + private void outputResult(final Long timestamp, final GroupId groupId) { + if (outputWriter instanceof PlainTextWriter) { + final var writer = (PlainTextWriter) outputWriter; + if (groupId != null) { + writer.println("Created new group: \"{}\"", groupId.toBase64()); + } + if (timestamp != null) { + writer.println("{}", timestamp); + } + } else { + final var writer = (JsonWriter) outputWriter; + final var result = new HashMap<>(); + if (timestamp != null) { + result.put("timestamp", timestamp); + } + if (groupId != null) { + result.put("groupId", groupId.toBase64()); + } + writer.write(result); + } + } } diff --git a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java index 894b19df..ff9e8996 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java @@ -13,7 +13,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.io.File; import java.io.IOException; -public class UpdateProfileCommand implements LocalCommand { +public class UpdateProfileCommand implements JsonRpcLocalCommand { public UpdateProfileCommand(final OutputWriter outputWriter) { } diff --git a/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java b/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java index e2ba2ff9..993e37c4 100644 --- a/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java +++ b/src/main/java/org/asamk/signal/commands/UploadStickerPackCommand.java @@ -4,7 +4,7 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; 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.IOErrorException; import org.asamk.signal.commands.exceptions.UserErrorException; @@ -33,7 +33,7 @@ public class UploadStickerPackCommand implements LocalCommand { @Override public void handleCommand(final Namespace ns, final Manager m) throws CommandException { - final var writer = (PlainTextWriterImpl) outputWriter; + final var writer = (PlainTextWriter) outputWriter; var path = new File(ns.getString("path")); try { 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/ErrorUtils.java b/src/main/java/org/asamk/signal/util/ErrorUtils.java index fb0509c6..4fd88819 100644 --- a/src/main/java/org/asamk/signal/util/ErrorUtils.java +++ b/src/main/java/org/asamk/signal/util/ErrorUtils.java @@ -1,6 +1,5 @@ package org.asamk.signal.util; -import org.asamk.signal.PlainTextWriter; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.IOErrorException; import org.slf4j.Logger; @@ -21,12 +20,9 @@ public class ErrorUtils { private ErrorUtils() { } - public static void handleTimestampAndSendMessageResults( - PlainTextWriter writer, long timestamp, List results + public static void handleSendMessageResults( + List results ) throws CommandException { - if (timestamp != 0) { - writer.println("{}", timestamp); - } var errors = getErrorMessagesFromSendMessageResults(results); handleSendMessageResultErrors(errors); } diff --git a/src/main/java/org/asamk/signal/util/Util.java b/src/main/java/org/asamk/signal/util/Util.java index 5001c4ae..ced6f9f2 100644 --- a/src/main/java/org/asamk/signal/util/Util.java +++ b/src/main/java/org/asamk/signal/util/Util.java @@ -1,16 +1,22 @@ package org.asamk.signal.util; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +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.dbus.DbusAttachment; import org.asamk.signal.manager.groups.GroupId; import org.asamk.signal.manager.groups.GroupIdFormatException; +import org.asamk.signal.dbus.DbusAttachment; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; 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() { @@ -24,6 +30,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; @@ -41,4 +63,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; + } }