diff --git a/.gitignore b/.gitignore index 8fa9c8bd..59c6ff79 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ local.properties .settings/ out/ .DS_Store +bin/ +config/ \ No newline at end of file diff --git a/json_socket.md b/json_socket.md new file mode 100644 index 00000000..66ecc774 --- /dev/null +++ b/json_socket.md @@ -0,0 +1,159 @@ +# TCP daemon mode + +signal-cli can run in daemon mode and listen to incoming TCP connections. +Once a client connected, the daemon is accepting commands wrapped in JSON objects. +Each requested command results in a corresponding response. + +Multiple commands can be send using the same TCP connection. Invalid commands - +e.g. invalid JSON syntax - will result in error responses, but the connection +will not be terminated. + +## send message + +Sending a message to one recipient or one group. Attachments can be attached as +as base64 encoded string. +`recipient` and `groupId` must not be provided at the same time. + +### Request `send_message` +```JSON +{ + "command": "send_message", + "reqId": "[request ID (optional)]", + + "recipient": "[phone number]", + "groupId": "[base64 group ID]", + + "dataMessage": { + "message": "[text message (optional)]", + "attachments": [{ + "base64Data": "[base64 encoded data]", + "filename": "[filename (optional)]" + }] + } +} +``` + +### Response `send_message` +```JSON +{ + "command": "send_message", + "reqId": "[referencing request ID]", + + "statusCode": 0, + "timestamp": 1234567 +} +``` + +## send reaction + +Reacting to an existing message. +`recipient` and `groupId` must not be provided at the same time. + +### Request `send_reaction` +```JSON +{ + "command": "send_reaction", + "reqId": "[request ID (optional)]", + + "recipient": "[phone number]", + "groupId": "[base64 group ID]", + + "reaction": { + "emoji": "😀", + "author": "[phone number of original message]", + "remove": false, + "timestamp": 1234567 + } +} +``` + +### Response `send_reaction` +```JSON +{ + "command": "send_reaction", + "reqId": "[referencing request ID]", + + "statusCode": 0, + "timestamp": 1234567 +} +``` + +## receive messages + +Receiving incoming messages. Command waits for `timeout` milliseconds for new messages. Default `1000`. + +Attachments can be omitted in the command response with `ignoreAttachments`. Default `false`. + +### Request `receive_messages` +```JSON +{ + "command": "receive_messages", + "reqId": "[request ID (optional)]", + + "timeout": 1000, + "ignoreAttachments": false +} +``` + +### Response `receive_messages` +```JSON +{ + "command": "receive_messages", + "reqId": "[referencing request ID]", + + "statusCode": 0, + "messages": [{ + "timestamp": 1234567, + "sender": "[senders phone number]", + "body": "[text message]", + "attachments": [{ + "base64Data": "[base64 encoded data]", + "filename": "[filename (optional)]" + }] + },{ + "statusCode": -1, + "errorMessage": "[message parsing error]" + }] +} +``` + +## Error response +```JSON +{ + "command": "[requested command]", + "reqId": "[referencing request ID]", + + "statusCode": -1, + "errorMessage": "[additional information]" +} +``` + +# Example +Running the client +``` +signal-cli --username +436XXYYYZZZZ socket +``` + +Sending command with e.g. `socat` +``` +echo "{command:'receive_messages'}" | socat -t 2 TCP:localhost:6789 +``` + + +# Status codes + +| Code | Value | Description | +| ----:| ----------------------- | ----------------------------------------------------------- | +| -1 | UNKNOWN | Unknown error. see `errorMessage` | +| 0 | SUCCESS | Command successfully executed | +| 1 | UNKNOWN_COMMAND | Unknown or not implemented command | +| 2 | INVALID_NUMBER | Invalid `recipient` number | +| 3 | INVALID_ATTACHMENT | Error while parsing attachment | +| 4 | INVALID_JSON | Invalid JSON received | +| 5 | INVALID_RECIPIENT | None or both of `recipient` and `groupId` are set | +| 6 | GROUP_NOT_FOUND | Invalid `groupId` provided | +| 7 | NOT_A_GROUP_MEMBER | User isn't a member of provided `groupId` | +| 8 | MESSAGE_ERROR | Received error while fetching messages | +| 9 | MISSING_MESSAGE_CONTENT | Missing message content. Can't parse message | +| 10 | MESSAGE_PARSING_ERROR | General error while parsing the message. see `errorMessage` | +| 11 | MISSING_REACTION | Incomplete reaction request | diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 16b684ea..0fdddaf3 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -368,6 +368,16 @@ Use DBus system bus instead of user bus. *--ignore-attachments*:: Don’t download attachments of received messages. +=== TCP daemon + +Run the signal-cli in daemon mode accepting commands in JSON format via a TCP socket. +Detailed information about the JSON format can be found here + +*-p*, *--port*:: +Specify the listening port. Default is `6789` +*-a*, *--address*:: +Bind socket to specified IP address. Default is `127.0.0.1` + == Examples Register a number (with SMS verification):: diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 4bc17930..0434fabc 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -36,6 +36,7 @@ public class Commands { addCommand("updateProfile", new UpdateProfileCommand()); addCommand("verify", new VerifyCommand()); addCommand("uploadStickerPack", new UploadStickerPackCommand()); + addCommand("socket", new SocketCommand()); } public static Map getCommands() { diff --git a/src/main/java/org/asamk/signal/commands/SocketCommand.java b/src/main/java/org/asamk/signal/commands/SocketCommand.java new file mode 100644 index 00000000..d1cfa54a --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/SocketCommand.java @@ -0,0 +1,71 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.commands; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.UnknownHostException; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.socket.JsonSocketHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +public class SocketCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(SocketCommand.class); + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.addArgument("-p", "--port").type(Integer.class).setDefault(6789).help("Port to bind"); + subparser.addArgument("-a", "--address").setDefault("127.0.0.1").help("Address to bind"); + } + + @Override + public int handleCommand(final Namespace ns, final Manager m) { + final Integer port = ns.getInt("port"); + InetAddress address = null; + final String addressParam = ns.getString("address"); + try { + address = InetAddress.getByName(addressParam); + } catch (final UnknownHostException e1) { + logger.error("Invalid bind address: %s\n", addressParam); + return 1; + } + try (ServerSocket serverSocket = new ServerSocket(port, 0, address)) { + while (true) { + try { + final Socket socket = serverSocket.accept(); + final InetSocketAddress remote = (InetSocketAddress) socket.getRemoteSocketAddress(); + logger.debug("Client connected from {}:{}", remote.getHostName(), remote.getPort()); + new Thread(new JsonSocketHandler(m, socket)).start(); + } catch (final IOException ioe) { + logger.error("Client connection failed with '{}'", ioe.getMessage(), ioe); + } + } + } catch (final IOException e) { + logger.error("Cannot open socket ({}:{}): {}", addressParam, port, e.getMessage()); + return 1; + } + } + +} diff --git a/src/main/java/org/asamk/signal/manager/Manager.java b/src/main/java/org/asamk/signal/manager/Manager.java index 96dbe212..a4c0a92c 100644 --- a/src/main/java/org/asamk/signal/manager/Manager.java +++ b/src/main/java/org/asamk/signal/manager/Manager.java @@ -484,7 +484,7 @@ public class Manager implements Closeable { return unidentifiedMessagePipe; } - private SignalServiceMessageSender createMessageSender() { + public SignalServiceMessageSender createMessageSender() { final ExecutorService executor = null; return new SignalServiceMessageSender(serviceConfiguration, account.getUuid(), @@ -1208,7 +1208,7 @@ public class Manager implements Closeable { } } - private Collection getSignalServiceAddresses(Collection numbers) throws InvalidNumberException { + public Collection getSignalServiceAddresses(Collection numbers) throws InvalidNumberException { final Set signalServiceAddresses = new HashSet<>(numbers.size()); final Set addressesMissingUuid = new HashSet<>(); @@ -1255,7 +1255,7 @@ public class Manager implements Closeable { } } - private Pair> sendMessage( + public Pair> sendMessage( SignalServiceDataMessage.Builder messageBuilder, Collection recipients ) throws IOException { recipients = recipients.stream().map(this::resolveSignalServiceAddress).collect(Collectors.toSet()); @@ -2186,7 +2186,7 @@ public class Manager implements Closeable { } } - private InputStream retrieveAttachmentAsStream( + public InputStream retrieveAttachmentAsStream( SignalServiceAttachmentPointer pointer, File tmpFile ) throws IOException, InvalidMessageException, MissingConfigurationException { return messageReceiver.retrieveAttachment(pointer, tmpFile, ServiceConfig.MAX_ATTACHMENT_SIZE); diff --git a/src/main/java/org/asamk/signal/socket/JsonCommandDeserializer.java b/src/main/java/org/asamk/signal/socket/JsonCommandDeserializer.java new file mode 100644 index 00000000..e1092ab2 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/JsonCommandDeserializer.java @@ -0,0 +1,64 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.socket; + +import java.io.IOException; +import java.util.Optional; + +import org.asamk.signal.socket.commands.AbstractCommand; +import org.asamk.signal.socket.commands.JsonReceiveMessagesCommand; +import org.asamk.signal.socket.commands.JsonSendMessageCommand; +import org.asamk.signal.socket.commands.JsonSendReactionCommand; +import org.asamk.signal.socket.commands.JsonUnknownCommand; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class JsonCommandDeserializer extends JsonDeserializer { + + @Override + public AbstractCommand deserialize(final JsonParser jp, final DeserializationContext ctxt) + throws IOException, JsonProcessingException { + final ObjectMapper mapper = (ObjectMapper) jp.getCodec(); + final ObjectNode root = mapper.readTree(jp); + final Optional command = Optional.ofNullable(root).map(r -> r.get("command")).map(JsonNode::asText); + if (command.isEmpty()) { + return new JsonUnknownCommand(); + } + Class instanceClass; + switch (command.get()) { + case "send_message": + instanceClass = JsonSendMessageCommand.class; + break; + case "receive_messages": + instanceClass = JsonReceiveMessagesCommand.class; + break; + case "send_reaction": + instanceClass = JsonSendReactionCommand.class; + break; + default: + instanceClass = JsonUnknownCommand.class; + break; + } + return mapper.treeToValue(root, instanceClass); + } +} diff --git a/src/main/java/org/asamk/signal/socket/JsonSocketHandler.java b/src/main/java/org/asamk/signal/socket/JsonSocketHandler.java new file mode 100644 index 00000000..35abdea0 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/JsonSocketHandler.java @@ -0,0 +1,104 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.socket; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.socket.commands.AbstractCommand; +import org.asamk.signal.socket.json.JsonErrorResponse; +import org.asamk.signal.socket.json.JsonResponse; +import org.asamk.signal.socket.json.JsonResponse.StatusCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonParser.Feature; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.io.JsonEOFException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MappingJsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class JsonSocketHandler implements Runnable { + private final static Logger logger = LoggerFactory.getLogger(JsonSocketHandler.class); + + private final Manager manager; + private final Socket socket; + private final InputStream inputStream; + private final OutputStream outputStream; + private final JsonParser parser; + private final JsonGenerator writer; + + public JsonSocketHandler(final Manager manager, final Socket socket) throws IOException { + this.manager = manager; + this.socket = socket; + inputStream = socket.getInputStream(); + outputStream = socket.getOutputStream(); + final JsonFactory factory = new MappingJsonFactory(); + final ObjectMapper objectMapper = new ObjectMapper(factory).enable(Feature.AUTO_CLOSE_SOURCE) + .enable(Feature.ALLOW_SINGLE_QUOTES).enable(Feature.ALLOW_UNQUOTED_FIELD_NAMES) + .enable(Feature.ALLOW_TRAILING_COMMA).enable(Feature.ALLOW_UNQUOTED_FIELD_NAMES) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).disable(Feature.AUTO_CLOSE_SOURCE); + factory.setCodec(objectMapper); + parser = factory.createParser(inputStream); + writer = factory.createGenerator(outputStream); + } + + @Override + public void run() { + try { + do { + try { + final JsonToken curToken = parser.nextToken(); + if (curToken == null) { + break; + } + switch (curToken) { + case START_OBJECT: + final AbstractCommand command = parser.readValueAs(AbstractCommand.class); + final JsonResponse result = command.apply(manager); + writer.writeObject(result); + break; + case START_ARRAY: + break; + case END_ARRAY: + break; + default: + writer.writeObject(new JsonErrorResponse(null, StatusCode.INVALID_JSON, curToken.asString())); + } + } catch (final JsonEOFException eof) { + break; + } catch (final JsonParseException e) { + writer.writeObject(new JsonErrorResponse(null, StatusCode.INVALID_JSON, e.getMessage())); + } + } while (true); + + writer.flush(); + socket.close(); + } catch (final IOException e) { + logger.error("Connection failed with '{}'", e.getMessage(), e); + } + } + +} diff --git a/src/main/java/org/asamk/signal/socket/commands/AbstractCommand.java b/src/main/java/org/asamk/signal/socket/commands/AbstractCommand.java new file mode 100644 index 00000000..ee116714 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/commands/AbstractCommand.java @@ -0,0 +1,30 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.socket.commands; + +import java.util.function.Function; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.socket.JsonCommandDeserializer; +import org.asamk.signal.socket.json.JsonEnvelope; +import org.asamk.signal.socket.json.JsonResponse; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(using = JsonCommandDeserializer.class) +public abstract class AbstractCommand extends JsonEnvelope implements Function { +} diff --git a/src/main/java/org/asamk/signal/socket/commands/AbstractSendCommand.java b/src/main/java/org/asamk/signal/socket/commands/AbstractSendCommand.java new file mode 100644 index 00000000..d1823cac --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/commands/AbstractSendCommand.java @@ -0,0 +1,28 @@ +package org.asamk.signal.socket.commands; + +public abstract class AbstractSendCommand extends AbstractCommand { + + protected String recipient; + protected String groupId; + + public AbstractSendCommand() { + super(); + } + + public void setRecipient(final String recipient) { + this.recipient = recipient; + } + + public String getRecipient() { + return recipient; + } + + public void setGroupId(final String groupId) { + this.groupId = groupId; + } + + public String getGroupId() { + return groupId; + } + +} \ No newline at end of file diff --git a/src/main/java/org/asamk/signal/socket/commands/JsonReceiveMessagesCommand.java b/src/main/java/org/asamk/signal/socket/commands/JsonReceiveMessagesCommand.java new file mode 100644 index 00000000..b4cc1a97 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/commands/JsonReceiveMessagesCommand.java @@ -0,0 +1,130 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.socket.commands; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.groups.GroupUtils; +import org.asamk.signal.manager.util.IOUtils; +import org.asamk.signal.socket.json.JsonErrorResponse; +import org.asamk.signal.socket.json.JsonMessageAttachment; +import org.asamk.signal.socket.json.JsonReceivedMessage; +import org.asamk.signal.socket.json.JsonReceivedMessageResponse; +import org.asamk.signal.socket.json.JsonResponse; +import org.asamk.signal.socket.json.JsonResponse.StatusCode; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(as = JsonReceiveMessagesCommand.class) +public class JsonReceiveMessagesCommand extends AbstractCommand { + private long timeout = 1000; + private boolean ignoreAttachments = false; + + private final FileAttribute tempFileAttributes = PosixFilePermissions + .asFileAttribute(PosixFilePermissions.fromString("rw-------")); + + @Override + public JsonResponse apply(final Manager manager) { + final JsonReceivedMessageResponse result = new JsonReceivedMessageResponse(this); + try { + manager.receiveMessages(getTimeout(), TimeUnit.MILLISECONDS, true, false, + (final SignalServiceEnvelope envelope, final SignalServiceContent content, final Throwable e) -> { + if (content != null) { + try { + final JsonReceivedMessage msg = new JsonReceivedMessage(content.getTimestamp(), + content.getSender().getNumber().orNull()); + if (content.getDataMessage().isPresent()) { + final SignalServiceDataMessage message = content.getDataMessage().get(); + msg.withBody(message.getBody().orNull()); + + Optional.ofNullable(message.getGroupContext().orNull()).map(GroupUtils::getGroupId) + .map(GroupId::toBase64).ifPresent(msg::withGroupId); + + if (!isIgnoreAttachments() && message.getAttachments().isPresent()) { + final List attachments = message.getAttachments() + .get(); + for (final SignalServiceAttachment a : attachments) { + final JsonMessageAttachment jsonAttachment = new JsonMessageAttachment(); + if (a.isPointer()) { + final SignalServiceAttachmentPointer pointer = a.asPointer(); + final File tempFile = Files + .createTempFile(null, null, tempFileAttributes).toFile(); + jsonAttachment.setData(IOUtils.readFully( + manager.retrieveAttachmentAsStream(pointer, tempFile))); + tempFile.delete(); + jsonAttachment.setFilename(pointer.getFileName().orNull()); + } else if (a.isStream()) { + final SignalServiceAttachmentStream stream = a.asStream(); + jsonAttachment.setData(IOUtils.readFully(stream.getInputStream())); + jsonAttachment.setFilename(stream.getFileName().orNull()); + } + msg.withAttachment(jsonAttachment); + } + } + } + result.addMessage(msg); + } catch (final IOException | InvalidMessageException | MissingConfigurationException ex) { + result.addMessage( + new JsonErrorResponse(StatusCode.MESSAGE_PARSING_ERROR, ex.getMessage())); + } + } else { + result.addMessage( + new JsonErrorResponse(StatusCode.MISSING_MESSAGE_CONTENT, "missing content")); + } + if (e != null) { + result.addMessage(new JsonErrorResponse(StatusCode.MESSAGE_ERROR, e.getMessage())); + } + }); + return result; + } catch (final IOException e) { + return new JsonErrorResponse(this, StatusCode.UNKNOWN, e.getMessage()); + } + } + + public long getTimeout() { + return timeout; + } + + public void setTimeout(final long timeout) { + this.timeout = timeout; + } + + public boolean isIgnoreAttachments() { + return ignoreAttachments; + } + + public void setIgnoreAttachments(final boolean ignoreAttachments) { + this.ignoreAttachments = ignoreAttachments; + } +} diff --git a/src/main/java/org/asamk/signal/socket/commands/JsonSendMessageCommand.java b/src/main/java/org/asamk/signal/socket/commands/JsonSendMessageCommand.java new file mode 100644 index 00000000..e03fa731 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/commands/JsonSendMessageCommand.java @@ -0,0 +1,115 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.socket.commands; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URLConnection; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +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.GroupNotFoundException; +import org.asamk.signal.manager.groups.NotAGroupMemberException; +import org.asamk.signal.manager.util.AttachmentUtils; +import org.asamk.signal.socket.json.JsonErrorResponse; +import org.asamk.signal.socket.json.JsonMessageAttachment; +import org.asamk.signal.socket.json.JsonResponse; +import org.asamk.signal.socket.json.JsonResponse.StatusCode; +import org.asamk.signal.socket.json.JsonSendMessageData; +import org.asamk.signal.socket.json.JsonSendMessageResponse; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.util.InvalidNumberException; +import org.whispersystems.signalservice.api.util.StreamDetails; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(as = JsonSendMessageCommand.class) +public class JsonSendMessageCommand extends AbstractSendCommand { + + private JsonSendMessageData dataMessage; + + @Override + public JsonResponse apply(final Manager manager) { + final SignalServiceDataMessage.Builder messageBuilder = SignalServiceDataMessage.newBuilder() + .withBody(dataMessage.getMessage()); + + if (dataMessage.getAttachments() != null) { + final SignalServiceMessageSender messageSender = manager.createMessageSender(); + final List attachmentPointers = new ArrayList<>(); + try { + for (final JsonMessageAttachment a : dataMessage.getAttachments()) { + final ByteArrayInputStream stream = new ByteArrayInputStream(a.getData()); + final int size = stream.available(); + final String mime = URLConnection.guessContentTypeFromStream(stream); + + final SignalServiceAttachmentStream attachment = AttachmentUtils.createAttachment( + new StreamDetails(stream, mime, size), Optional.fromNullable(a.getFilename())); + + if (attachment.isStream()) { + attachmentPointers.add(messageSender.uploadAttachment(attachment.asStream())); + } else if (attachment.isPointer()) { + attachmentPointers.add(attachment.asPointer()); + } + } + } catch (final IOException e) { + return new JsonErrorResponse(this, StatusCode.INVALID_ATTACHMENT, e.getMessage()); + } + messageBuilder.withAttachments(attachmentPointers); + } + try { + Pair> result; + if (recipient != null && groupId == null) { + result = manager.sendMessage(messageBuilder, + manager.getSignalServiceAddresses(Arrays.asList(recipient))); + } else if (groupId != null && recipient == null) { + result = manager.sendGroupMessage(messageBuilder, GroupId.fromBase64(groupId)); + } else { + return new JsonErrorResponse(this, StatusCode.INVALID_RECIPIENT, + "'recipient' or 'groupId' must be set"); + } + return new JsonSendMessageResponse(this, StatusCode.SUCCESS, result.first()); + } catch (final InvalidNumberException e) { + return new JsonErrorResponse(this, StatusCode.INVALID_NUMBER, e.getMessage()); + } catch (final GroupNotFoundException e) { + return new JsonErrorResponse(this, StatusCode.GROUP_NOT_FOUND, e.getMessage()); + } catch (final NotAGroupMemberException e) { + return new JsonErrorResponse(this, StatusCode.NOT_A_GROUP_MEMBER, e.getMessage()); + } catch (final GroupIdFormatException e) { + return new JsonErrorResponse(this, StatusCode.INVALID_NUMBER, e.getMessage()); + } catch (final IOException e) { + return new JsonErrorResponse(this, StatusCode.UNKNOWN, e.getMessage()); + } + } + + public JsonSendMessageData getDataMessage() { + return dataMessage; + } + + public void setDataMessage(final JsonSendMessageData dataMessage) { + this.dataMessage = dataMessage; + } +} diff --git a/src/main/java/org/asamk/signal/socket/commands/JsonSendReactionCommand.java b/src/main/java/org/asamk/signal/socket/commands/JsonSendReactionCommand.java new file mode 100644 index 00000000..375917de --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/commands/JsonSendReactionCommand.java @@ -0,0 +1,82 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.socket.commands; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +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.GroupNotFoundException; +import org.asamk.signal.manager.groups.NotAGroupMemberException; +import org.asamk.signal.socket.json.JsonErrorResponse; +import org.asamk.signal.socket.json.JsonResponse; +import org.asamk.signal.socket.json.JsonResponse.StatusCode; +import org.asamk.signal.socket.json.JsonSendMessageResponse; +import org.asamk.signal.socket.json.JsonSendReaction; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.util.InvalidNumberException; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(as = JsonSendReactionCommand.class) +public class JsonSendReactionCommand extends AbstractSendCommand { + private JsonSendReaction reaction; + + @Override + public JsonResponse apply(final Manager manager) { + final JsonSendReaction r = getReaction(); + if (r == null) { + return new JsonErrorResponse(this, StatusCode.MISSING_REACTION, "incomplete request"); + } + try { + Pair> result; + if (recipient != null && groupId == null) { + result = manager.sendMessageReaction(r.getEmoji(), r.isRemove(), r.getAuthor(), r.getTimestamp(), + Arrays.asList(getRecipient())); + } else if (groupId != null && recipient == null) { + result = manager.sendGroupMessageReaction(r.getEmoji(), r.isRemove(), r.getAuthor(), r.getTimestamp(), + GroupId.fromBase64(groupId)); + } else { + return new JsonErrorResponse(this, StatusCode.INVALID_RECIPIENT, + "'recipient' xor 'groupId' must be set"); + } + return new JsonSendMessageResponse(this, StatusCode.SUCCESS, result.first()); + } catch (final InvalidNumberException e) { + return new JsonErrorResponse(this, StatusCode.INVALID_NUMBER, e.getMessage()); + } catch (final GroupNotFoundException e) { + return new JsonErrorResponse(this, StatusCode.GROUP_NOT_FOUND, e.getMessage()); + } catch (final NotAGroupMemberException e) { + return new JsonErrorResponse(this, StatusCode.NOT_A_GROUP_MEMBER, e.getMessage()); + } catch (final GroupIdFormatException e) { + return new JsonErrorResponse(this, StatusCode.INVALID_NUMBER, e.getMessage()); + } catch (final IOException e) { + return new JsonErrorResponse(this, StatusCode.UNKNOWN, e.getMessage()); + } + } + + public JsonSendReaction getReaction() { + return reaction; + } + + public void setReaction(final JsonSendReaction reaction) { + this.reaction = reaction; + } +} diff --git a/src/main/java/org/asamk/signal/socket/commands/JsonUnknownCommand.java b/src/main/java/org/asamk/signal/socket/commands/JsonUnknownCommand.java new file mode 100644 index 00000000..a5c37a47 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/commands/JsonUnknownCommand.java @@ -0,0 +1,33 @@ +/* + Copyright (C) 2021 sehaas and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.socket.commands; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.socket.json.JsonResponse; +import org.asamk.signal.socket.json.JsonResponse.StatusCode; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(as = JsonUnknownCommand.class) +public class JsonUnknownCommand extends AbstractCommand { + + @Override + public JsonResponse apply(final Manager manager) { + return new JsonResponse(this, StatusCode.UNKNOWN_COMMAND); + } + +} diff --git a/src/main/java/org/asamk/signal/socket/json/IJsonReceiveableMessage.java b/src/main/java/org/asamk/signal/socket/json/IJsonReceiveableMessage.java new file mode 100644 index 00000000..2fe77e58 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/IJsonReceiveableMessage.java @@ -0,0 +1,5 @@ +package org.asamk.signal.socket.json; + +public interface IJsonReceiveableMessage { + +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonEnvelope.java b/src/main/java/org/asamk/signal/socket/json/JsonEnvelope.java new file mode 100644 index 00000000..ece7b8bd --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonEnvelope.java @@ -0,0 +1,27 @@ +package org.asamk.signal.socket.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +public class JsonEnvelope { + @JsonInclude(Include.NON_EMPTY) + private String command; + @JsonInclude(Include.NON_EMPTY) + private String reqId; + + public String getCommand() { + return command; + } + + public void setCommand(final String command) { + this.command = command; + } + + public String getReqId() { + return reqId; + } + + public void setReqId(final String reqId) { + this.reqId = reqId; + } +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonErrorResponse.java b/src/main/java/org/asamk/signal/socket/json/JsonErrorResponse.java new file mode 100644 index 00000000..3d445721 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonErrorResponse.java @@ -0,0 +1,23 @@ +package org.asamk.signal.socket.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +public class JsonErrorResponse extends JsonResponse implements IJsonReceiveableMessage { + @JsonInclude(Include.NON_EMPTY) + private final String errorMessage; + + public JsonErrorResponse(final StatusCode code, final String errorMsg) { + this(null, code, errorMsg); + } + + public JsonErrorResponse(final JsonEnvelope req, final StatusCode code, final String errorMsg) { + super(req, code); + this.errorMessage = errorMsg; + } + + public String getErrorMessage() { + return errorMessage; + } + +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonMessageAttachment.java b/src/main/java/org/asamk/signal/socket/json/JsonMessageAttachment.java new file mode 100644 index 00000000..1692702e --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonMessageAttachment.java @@ -0,0 +1,38 @@ +package org.asamk.signal.socket.json; + +import java.util.Base64; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class JsonMessageAttachment { + @JsonIgnore + private byte[] data; + private String filename; + private String base64Data; + + public String getFilename() { + return filename; + } + + public void setFilename(final String filename) { + this.filename = filename; + } + + public String getBase64Data() { + return base64Data; + } + + public void setBase64Data(final String base64Data) { + this.base64Data = base64Data; + data = base64Data != null ? Base64.getDecoder().decode(base64Data) : null; + } + + public byte[] getData() { + return data; + } + + public void setData(final byte[] data) { + this.data = data; + this.base64Data = data != null ? Base64.getEncoder().encodeToString(data) : null; + } +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonReceivedMessage.java b/src/main/java/org/asamk/signal/socket/json/JsonReceivedMessage.java new file mode 100644 index 00000000..bbcf9613 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonReceivedMessage.java @@ -0,0 +1,60 @@ +package org.asamk.signal.socket.json; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +public class JsonReceivedMessage extends JsonResponse implements IJsonReceiveableMessage { + private final Long timestamp; + private final String sender; + @JsonInclude(Include.NON_EMPTY) + private String body; + private final List attachments; + + @JsonInclude(Include.NON_EMPTY) + private String groupId; + + public JsonReceivedMessage(final Long timestamp, final String sender) { + super(null, StatusCode.SUCCESS); + this.timestamp = timestamp; + this.sender = sender; + this.attachments = new ArrayList<>(); + } + + public JsonReceivedMessage withBody(final String body) { + this.body = body; + return this; + } + + public JsonReceivedMessage withGroupId(final String groupId) { + this.groupId = groupId; + return this; + } + + public JsonReceivedMessage withAttachment(final JsonMessageAttachment attach) { + attachments.add(attach); + return this; + } + + public String getSender() { + return sender; + } + + public String getBody() { + return body; + } + + public String getGroupId() { + return groupId; + } + + public Long getTimestamp() { + return timestamp; + } + + public List getAttachments() { + return attachments; + } +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonReceivedMessageResponse.java b/src/main/java/org/asamk/signal/socket/json/JsonReceivedMessageResponse.java new file mode 100644 index 00000000..280c5832 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonReceivedMessageResponse.java @@ -0,0 +1,23 @@ +package org.asamk.signal.socket.json; + +import java.util.ArrayList; +import java.util.List; + +public class JsonReceivedMessageResponse extends JsonResponse { + private final List messages; + + public JsonReceivedMessageResponse(final JsonEnvelope req) { + super(req, StatusCode.SUCCESS); + messages = new ArrayList<>(); + } + + public List getMessages() { + return messages; + } + + public void addMessage(final IJsonReceiveableMessage msg) { + if (msg != null) { + messages.add(msg); + } + } +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonResponse.java b/src/main/java/org/asamk/signal/socket/json/JsonResponse.java new file mode 100644 index 00000000..c3fabd1d --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonResponse.java @@ -0,0 +1,43 @@ +package org.asamk.signal.socket.json; + +public class JsonResponse extends JsonEnvelope { + private final StatusCode statusCode; + + public static enum StatusCode { + UNKNOWN(-1), + SUCCESS(0), + UNKNOWN_COMMAND(1), + INVALID_NUMBER(2), + INVALID_ATTACHMENT(3), + INVALID_JSON(4), + INVALID_RECIPIENT(5), + GROUP_NOT_FOUND(6), + NOT_A_GROUP_MEMBER(7), + MESSAGE_ERROR(8), + MISSING_MESSAGE_CONTENT(9), + MESSAGE_PARSING_ERROR(10), + MISSING_REACTION(11); + + private int code; + + private StatusCode(final int c) { + code = c; + } + + public int getCode() { + return code; + } + } + + public JsonResponse(final JsonEnvelope req, final StatusCode code) { + if (req != null) { + setReqId(req.getReqId()); + setCommand(req.getCommand()); + } + statusCode = code != null ? code : StatusCode.UNKNOWN; + } + + public int getStatusCode() { + return statusCode.getCode(); + } +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonSendMessageData.java b/src/main/java/org/asamk/signal/socket/json/JsonSendMessageData.java new file mode 100644 index 00000000..32693dbe --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonSendMessageData.java @@ -0,0 +1,24 @@ +package org.asamk.signal.socket.json; + +import java.util.List; + +public class JsonSendMessageData { + private String message; + private List attachments; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonSendMessageResponse.java b/src/main/java/org/asamk/signal/socket/json/JsonSendMessageResponse.java new file mode 100644 index 00000000..00c936b7 --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonSendMessageResponse.java @@ -0,0 +1,14 @@ +package org.asamk.signal.socket.json; + +public class JsonSendMessageResponse extends JsonResponse { + private final Long timestamp; + + public JsonSendMessageResponse(final JsonEnvelope req, final StatusCode code, final Long timestamp) { + super(req, code); + this.timestamp = timestamp; + } + + public Long getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/org/asamk/signal/socket/json/JsonSendReaction.java b/src/main/java/org/asamk/signal/socket/json/JsonSendReaction.java new file mode 100644 index 00000000..ac8b2e7c --- /dev/null +++ b/src/main/java/org/asamk/signal/socket/json/JsonSendReaction.java @@ -0,0 +1,40 @@ +package org.asamk.signal.socket.json; + +public class JsonSendReaction { + private String emoji; + private String author; + private boolean remove = false; + private long timestamp; + + public String getEmoji() { + return emoji; + } + + public void setEmoji(final String emoji) { + this.emoji = emoji; + } + + public void setAuthor(final String author) { + this.author = author; + } + + public String getAuthor() { + return author; + } + + public boolean isRemove() { + return remove; + } + + public void setRemove(final boolean remove) { + this.remove = remove; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(final long timestamp) { + this.timestamp = timestamp; + } +}