Accept JSON formatted command via sockets

- Start ServerSocket and listen for clients
- Accept multiple JSON objects as commands
	* Send messages (single recipient / group)
	* Receive messages (minimal support)
	* Send reactions
- Support attachments embedded in JSON (base64 encoded)
This commit is contained in:
Sebastian Haas 2021-01-13 22:52:48 +01:00
parent c3c1802b4d
commit 4f3e3f9a24
No known key found for this signature in database
GPG key ID: DE43FCD0565E3C77
24 changed files with 1130 additions and 4 deletions

2
.gitignore vendored
View file

@ -11,3 +11,5 @@ local.properties
.settings/ .settings/
out/ out/
.DS_Store .DS_Store
bin/
config/

159
json_socket.md Normal file
View file

@ -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 |

View file

@ -368,6 +368,16 @@ Use DBus system bus instead of user bus.
*--ignore-attachments*:: *--ignore-attachments*::
Dont download attachments of received messages. Dont 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 <https://github.com/AsamK/signal-cli/json_socket.md>
*-p*, *--port*::
Specify the listening port. Default is `6789`
*-a*, *--address*::
Bind socket to specified IP address. Default is `127.0.0.1`
== Examples == Examples
Register a number (with SMS verification):: Register a number (with SMS verification)::

View file

@ -36,6 +36,7 @@ public class Commands {
addCommand("updateProfile", new UpdateProfileCommand()); addCommand("updateProfile", new UpdateProfileCommand());
addCommand("verify", new VerifyCommand()); addCommand("verify", new VerifyCommand());
addCommand("uploadStickerPack", new UploadStickerPackCommand()); addCommand("uploadStickerPack", new UploadStickerPackCommand());
addCommand("socket", new SocketCommand());
} }
public static Map<String, Command> getCommands() { public static Map<String, Command> getCommands() {

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}
}
}

View file

@ -484,7 +484,7 @@ public class Manager implements Closeable {
return unidentifiedMessagePipe; return unidentifiedMessagePipe;
} }
private SignalServiceMessageSender createMessageSender() { public SignalServiceMessageSender createMessageSender() {
final ExecutorService executor = null; final ExecutorService executor = null;
return new SignalServiceMessageSender(serviceConfiguration, return new SignalServiceMessageSender(serviceConfiguration,
account.getUuid(), account.getUuid(),
@ -1208,7 +1208,7 @@ public class Manager implements Closeable {
} }
} }
private Collection<SignalServiceAddress> getSignalServiceAddresses(Collection<String> numbers) throws InvalidNumberException { public Collection<SignalServiceAddress> getSignalServiceAddresses(Collection<String> numbers) throws InvalidNumberException {
final Set<SignalServiceAddress> signalServiceAddresses = new HashSet<>(numbers.size()); final Set<SignalServiceAddress> signalServiceAddresses = new HashSet<>(numbers.size());
final Set<SignalServiceAddress> addressesMissingUuid = new HashSet<>(); final Set<SignalServiceAddress> addressesMissingUuid = new HashSet<>();
@ -1255,7 +1255,7 @@ public class Manager implements Closeable {
} }
} }
private Pair<Long, List<SendMessageResult>> sendMessage( public Pair<Long, List<SendMessageResult>> sendMessage(
SignalServiceDataMessage.Builder messageBuilder, Collection<SignalServiceAddress> recipients SignalServiceDataMessage.Builder messageBuilder, Collection<SignalServiceAddress> recipients
) throws IOException { ) throws IOException {
recipients = recipients.stream().map(this::resolveSignalServiceAddress).collect(Collectors.toSet()); 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 SignalServiceAttachmentPointer pointer, File tmpFile
) throws IOException, InvalidMessageException, MissingConfigurationException { ) throws IOException, InvalidMessageException, MissingConfigurationException {
return messageReceiver.retrieveAttachment(pointer, tmpFile, ServiceConfig.MAX_ATTACHMENT_SIZE); return messageReceiver.retrieveAttachment(pointer, tmpFile, ServiceConfig.MAX_ATTACHMENT_SIZE);

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<AbstractCommand> {
@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<String> command = Optional.ofNullable(root).map(r -> r.get("command")).map(JsonNode::asText);
if (command.isEmpty()) {
return new JsonUnknownCommand();
}
Class<? extends AbstractCommand> 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);
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<Manager, JsonResponse> {
}

View file

@ -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;
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<SignalServiceAttachment> 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;
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<SignalServiceAttachment> 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<Long, List<SendMessageResult>> 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;
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<Long, List<SendMessageResult>> 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;
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
}

View file

@ -0,0 +1,5 @@
package org.asamk.signal.socket.json;
public interface IJsonReceiveableMessage {
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<JsonMessageAttachment> 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<JsonMessageAttachment> getAttachments() {
return attachments;
}
}

View file

@ -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<IJsonReceiveableMessage> messages;
public JsonReceivedMessageResponse(final JsonEnvelope req) {
super(req, StatusCode.SUCCESS);
messages = new ArrayList<>();
}
public List<IJsonReceiveableMessage> getMessages() {
return messages;
}
public void addMessage(final IJsonReceiveableMessage msg) {
if (msg != null) {
messages.add(msg);
}
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,24 @@
package org.asamk.signal.socket.json;
import java.util.List;
public class JsonSendMessageData {
private String message;
private List<JsonMessageAttachment> attachments;
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public List<JsonMessageAttachment> getAttachments() {
return attachments;
}
public void setAttachments(List<JsonMessageAttachment> attachments) {
this.attachments = attachments;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}