diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index 3d4695e..115a9e6 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -39,10 +39,11 @@ body:
required: true
attributes:
label: In which mode are you using the docker container?
- description: If you have `USE_NATIVE=1` set in your `docker-compose.yml` file then you are using the native mode.
+ description: Please have a look at the `MODE` parameter in you `docker-compose.yml` file. If you do not have the `MODE` parameter explicitly set, then you are running in normal mode.
options:
- Normal Mode
- Native Mode
+ - JSON-RPC Mode
- type: dropdown
validations:
required: true
diff --git a/Dockerfile b/Dockerfile
index 274e6b7..93db634 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,12 +1,12 @@
-ARG SIGNAL_CLI_VERSION=0.8.5
+ARG SIGNAL_CLI_VERSION=0.9.0
ARG ZKGROUP_VERSION=0.7.0
-ARG LIBSIGNAL_CLIENT_VERSION=0.8.1
+ARG LIBSIGNAL_CLIENT_VERSION=0.9.0
ARG SWAG_VERSION=1.6.7
ARG GRAALVM_JAVA_VERSION=11
-ARG GRAALVM_VERSION=21.0.0
+ARG GRAALVM_VERSION=21.2.0
-FROM golang:1.14-buster AS buildcontainer
+FROM golang:1.17-bullseye AS buildcontainer
ARG SIGNAL_CLI_VERSION
ARG ZKGROUP_VERSION
@@ -125,16 +125,22 @@ RUN cd /tmp/signal-cli-${SIGNAL_CLI_VERSION}/build/distributions/ \
COPY src/api /tmp/signal-cli-rest-api-src/api
+COPY src/client /tmp/signal-cli-rest-api-src/client
COPY src/utils /tmp/signal-cli-rest-api-src/utils
+COPY src/scripts /tmp/signal-cli-rest-api-src/scripts
COPY src/main.go /tmp/signal-cli-rest-api-src/
COPY src/go.mod /tmp/signal-cli-rest-api-src/
COPY src/go.sum /tmp/signal-cli-rest-api-src/
+# build signal-cli-rest-api
RUN cd /tmp/signal-cli-rest-api-src && swag init && go build
+# build supervisorctl_config_creator
+RUN cd /tmp/signal-cli-rest-api-src/scripts && go build -o jsonrpc2-helper
+
# Start a fresh container for release container
-FROM adoptopenjdk:11-jre-hotspot-bionic
+FROM eclipse-temurin:11-jre-focal
ENV GIN_MODE=release
@@ -143,12 +149,13 @@ ENV PORT=8080
ARG SIGNAL_CLI_VERSION
RUN apt-get update \
- && apt-get install -y --no-install-recommends setpriv \
+ && apt-get install -y --no-install-recommends util-linux supervisor netcat \
&& rm -rf /var/lib/apt/lists/*
COPY --from=buildcontainer /tmp/signal-cli-rest-api-src/signal-cli-rest-api /usr/bin/signal-cli-rest-api
COPY --from=buildcontainer /tmp/signal-cli-${SIGNAL_CLI_VERSION}/build/distributions/signal-cli-${SIGNAL_CLI_VERSION}.tar /tmp/signal-cli-${SIGNAL_CLI_VERSION}.tar
COPY --from=buildcontainer /tmp/signal-cli-${SIGNAL_CLI_VERSION}/build/native-image/signal-cli /tmp/signal-cli-native
+COPY --from=buildcontainer /tmp/signal-cli-rest-api-src/scripts/jsonrpc2-helper /usr/bin/jsonrpc2-helper
COPY entrypoint.sh /entrypoint.sh
RUN tar xf /tmp/signal-cli-${SIGNAL_CLI_VERSION}.tar -C /opt
diff --git a/README.md b/README.md
index 50504b2..2e1d6a2 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,41 @@ At the moment, the following functionality is exposed via REST:
and [many more](https://bbernhard.github.io/signal-cli-rest-api/)
-## Examples
+## Modes
+
+The `signal-cli-rest-api` supports three different modes:
+
+* Normal Mode: In normal mode, the `signal-cli` executable is invoked for every REST API request. As `signal-cli` is a Java application, a significant amount of time is spent in the JVM (Java Virtual Machine) startup - which makes this mode pretty slow.
+* Native Mode: Instead of calling a Java executable for every REST API request, a native image (compiled with GraalVM) is called. This mode therefore usually performs better than the normal mode.
+* JSON-RPC Mode: In JSON-RPC mode, a single `signal-cli` instance is spawned in daemon mode. The communication happens via JSON-RPC. This mode is usually the fastest.
+
+
+| architecture | normal mode | native mode | json-rpc mode |
+|--------------|:-----------:|:-----------:|---------------|
+| x86-64 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+| armv7 | :heavy_check_mark: | ❌ 1 | :heavy_check_mark: |
+| arm64 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
+
+
+| mode | speed |
+|-------------:|:------------|
+| json-rpc | :heavy_check_mark: :heavy_check_mark: :heavy_check_mark: |
+| native | :heavy_check_mark: :heavy_check_mark: |
+| normal | :heavy_check_mark: |
+
+
+Notes:
+1. If the signal-cli-rest-api docker container is started on an armv7 system in native mode, it automatically falls back to the normal mode.
+
+## Auto Receive Schedule
+
+> :warning: This setting is only needed in normal/native mode!
+
+[signal-cli](https://github.com/AsamK/signal-cli), which this REST API wrapper is based on, recommends to call `receive` on a regular basis. So, if you are not already calling the `receive` endpoint regularily, it is recommended to set the `AUTO_RECEIVE_SCHEDULE` parameter in the docker-compose.yml file. The `AUTO_RECEIVE_SCHEDULE` accepts cron schedule expressions and automatically calls the `receive` endpoint at the given time. e.g: `0 22 * * *` calls `receive` daily at 10pm. If you are not familiar with cron schedule expressions, you can use this [website](https://crontab.guru).
+
+**WARNING** Calling `receive` will fetch all the messages for the registered Signal number from the Signal Server! So, if you are using the REST API for receiving messages, it's _not_ a good idea to use the `AUTO_RECEIVE_SCHEDULE` parameter, as you might lose some messages that way.
+
+## Example
Sample `docker-compose.yml`file:
@@ -25,7 +59,7 @@ services:
signal-cli-rest-api:
image: bbernhard/signal-cli-rest-api:latest
environment:
- - USE_NATIVE=0
+ - MODE=normal #supported modes: json-rpc, native, normal
#- AUTO_RECEIVE_SCHEDULE=0 22 * * * #enable this parameter on demand (see description below)
ports:
- "8080:8080" #map docker port 8080 to host port 8080.
@@ -34,21 +68,6 @@ services:
```
-## Auto Receive Schedule
-
-[signal-cli](https://github.com/AsamK/signal-cli), which this REST API wrapper is based on, recommends to call `receive` on a regular basis. So, if you are not already calling the `receive` endpoint regularily, it is recommended to set the `AUTO_RECEIVE_SCHEDULE` parameter in the docker-compose.yml file. The `AUTO_RECEIVE_SCHEDULE` accepts cron schedule expressions and automatically calls the `receive` endpoint at the given time. e.g: `0 22 * * *` calls `receive` daily at 10pm. If you are not familiar with cron schedule expressions, you can use this [website](https://crontab.guru).
-
-**WARNING** Calling `receive` will fetch all the messages for the registered Signal number from the Signal Server! So, if you are using the REST API for receiving messages, it's _not_ a good idea to use the `AUTO_RECEIVE_SCHEDULE` parameter, as you might lose some messages that way.
-
-
-## Native Image (EXPERIMENTAL)
-
-On Systems like the Raspberry Pi, some operations like sending messages can take quite a while. That's because signal-cli is a Java application and a significant amount of time is spent in the JVM (Java Virtual Machine) startup. signal-cli recently added the possibility to compile the Java application to a native binary (done via GraalVM).
-
-By adding `USE_NATIVE=1` as environmental variable to the `docker-compose.yml` file the native mode will be enabled.
-
-In case there's no native binary available (e.g on a 32 bit Raspian OS), it will fall back to the signal-cli Java application. The native mode only works on a 64bit system, when the native mode is enabled on a 32bit system, it falls back to the Java application.
-
## Documentation
### API Reference
diff --git a/docker-compose.yml b/docker-compose.yml
index ae752bb..194f112 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,9 +3,8 @@ services:
signal-cli-rest-api:
image: bbernhard/signal-cli-rest-api:latest
environment:
- - USE_NATIVE=0
- #- AUTO_RECEIVE_SCHEDULE=0 22 * * *
- - PORT=8080
+ - MODE=normal #supported modes: json-rpc, native, normal
+ #- AUTO_RECEIVE_SCHEDULE=0 22 * * * #enable this parameter on demand (see description below)
ports:
- "8080:8080" #map docker port 8080 to host port 8080.
volumes:
diff --git a/entrypoint.sh b/entrypoint.sh
index 41b4b68..3037044 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -18,5 +18,13 @@ EOF
cap_prefix="-cap_"
caps="$cap_prefix$(seq -s ",$cap_prefix" 0 $(cat /proc/sys/kernel/cap_last_cap))"
+# TODO: check mode
+if [ "$MODE" = "json-rpc" ]
+then
+/usr/bin/jsonrpc2-helper
+service supervisor start
+supervisorctl start all
+fi
+
# Start API as signal-api user
exec setpriv --reuid=1000 --regid=1000 --init-groups --inh-caps=$caps signal-cli-rest-api -signal-cli-config=${SIGNAL_CLI_CONFIG_DIR}
diff --git a/ext/libraries/libsignal-client/v0.8.4/arm64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.8.4/arm64/libsignal_jni.so
new file mode 100644
index 0000000..03f66ba
Binary files /dev/null and b/ext/libraries/libsignal-client/v0.8.4/arm64/libsignal_jni.so differ
diff --git a/ext/libraries/libsignal-client/v0.8.4/armv7/libsignal_jni.so b/ext/libraries/libsignal-client/v0.8.4/armv7/libsignal_jni.so
new file mode 100644
index 0000000..3386b3e
Binary files /dev/null and b/ext/libraries/libsignal-client/v0.8.4/armv7/libsignal_jni.so differ
diff --git a/ext/libraries/libsignal-client/v0.8.4/x86-64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.8.4/x86-64/libsignal_jni.so
new file mode 100644
index 0000000..de3b033
Binary files /dev/null and b/ext/libraries/libsignal-client/v0.8.4/x86-64/libsignal_jni.so differ
diff --git a/ext/libraries/libsignal-client/v0.9.0/arm64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.9.0/arm64/libsignal_jni.so
new file mode 100644
index 0000000..50ad8f9
Binary files /dev/null and b/ext/libraries/libsignal-client/v0.9.0/arm64/libsignal_jni.so differ
diff --git a/ext/libraries/libsignal-client/v0.9.0/armv7/libsignal_jni.so b/ext/libraries/libsignal-client/v0.9.0/armv7/libsignal_jni.so
new file mode 100644
index 0000000..9eed8cc
Binary files /dev/null and b/ext/libraries/libsignal-client/v0.9.0/armv7/libsignal_jni.so differ
diff --git a/ext/libraries/libsignal-client/v0.9.0/x86-64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.9.0/x86-64/libsignal_jni.so
new file mode 100644
index 0000000..e99af29
Binary files /dev/null and b/ext/libraries/libsignal-client/v0.9.0/x86-64/libsignal_jni.so differ
diff --git a/src/api/api.go b/src/api/api.go
index d88587e..e7ef1ee 100644
--- a/src/api/api.go
+++ b/src/api/api.go
@@ -1,44 +1,31 @@
package api
import (
- "bufio"
"bytes"
- "encoding/base64"
"encoding/json"
- "errors"
- "io/ioutil"
"net/http"
- "os"
- "os/exec"
- "path/filepath"
"strconv"
- "strings"
"time"
- "github.com/cyphar/filepath-securejoin"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
- uuid "github.com/gofrs/uuid"
- "github.com/h2non/filetype"
log "github.com/sirupsen/logrus"
- qrcode "github.com/skip2/go-qrcode"
+ "github.com/gorilla/websocket"
+
+ "github.com/bbernhard/signal-cli-rest-api/client"
utils "github.com/bbernhard/signal-cli-rest-api/utils"
)
-const signalCliV2GroupError = "Cannot create a V2 group as self does not have a versioned profile"
+const (
+ // Time allowed to write the file to the client.
+ writeWait = 10 * time.Second
-const groupPrefix = "group."
+ // Time allowed to read the next pong message from the client.
+ pongWait = 60 * time.Second
-type GroupEntry struct {
- Name string `json:"name"`
- Id string `json:"id"`
- InternalId string `json:"internal_id"`
- Members []string `json:"members"`
- Blocked bool `json:"blocked"`
- PendingInvites []string `json:"pending_invites"`
- PendingRequests []string `json:"pending_requests"`
- InviteLink string `json:"invite_link"`
-}
+ // Send pings to client with this period. Must be less than pongWait.
+ pingPeriod = (pongWait * 9) / 10
+)
type GroupPermissions struct {
AddMembers string `json:"add_members" enums:"only-admins,every-member"`
@@ -61,25 +48,6 @@ type Configuration struct {
Logging LoggingConfiguration `json:"logging"`
}
-type SignalCliGroupEntry struct {
- Name string `json:"name"`
- Id string `json:"id"`
- IsMember bool `json:"isMember"`
- IsBlocked bool `json:"isBlocked"`
- Members []string `json:"members"`
- PendingMembers []string `json:"pendingMembers"`
- RequestingMembers []string `json:"requestingMembers"`
- GroupInviteLink string `json:"groupInviteLink"`
-}
-
-type IdentityEntry struct {
- Number string `json:"number"`
- Status string `json:"status"`
- Fingerprint string `json:"fingerprint"`
- Added string `json:"added"`
- SafetyNumber string `json:"safety_number"`
-}
-
type RegisterNumberRequest struct {
UseVoice bool `json:"use_voice"`
Captcha string `json:"captcha"`
@@ -108,10 +76,7 @@ type Error struct {
Msg string `json:"error"`
}
-type About struct {
- SupportedApiVersions []string `json:"versions"`
- BuildNr int `json:"build"`
-}
+
type CreateGroupResponse struct {
Id string `json:"id"`
@@ -130,293 +95,19 @@ type SendMessageResponse struct {
Timestamp string `json:"timestamp"`
}
-func convertInternalGroupIdToGroupId(internalId string) string {
- return groupPrefix + base64.StdEncoding.EncodeToString([]byte(internalId))
-}
-
-func convertGroupIdToInternalGroupId(id string) (string, error) {
- groupIdWithoutPrefix := strings.TrimPrefix(id, groupPrefix)
- internalGroupId, err := base64.StdEncoding.DecodeString(groupIdWithoutPrefix)
- if err != nil {
- return "", errors.New("Invalid group id")
- }
-
- return string(internalGroupId), err
-}
-
-func getStringInBetween(str string, start string, end string) (result string) {
- i := strings.Index(str, start)
- if i == -1 {
- return
- }
- i += len(start)
- j := strings.Index(str[i:], end)
- if j == -1 {
- return
- }
- return str[i : i+j]
-}
-
-func cleanupTmpFiles(paths []string) {
- for _, path := range paths {
- os.Remove(path)
- }
-}
-
-func getContainerId() (string, error) {
- data, err := ioutil.ReadFile("/proc/1/cpuset")
- if err != nil {
- return "", err
- }
- lines := strings.Split(string(data), "\n")
- if len(lines) == 0 {
- return "", errors.New("Couldn't get docker container id (empty)")
- }
- containerId := strings.Replace(lines[0], "/docker/", "", -1)
- return containerId, nil
-}
-
-func send(c *gin.Context, attachmentTmpDir string, signalCliConfig string, number string, message string,
- recipients []string, base64Attachments []string, isGroup bool) {
- cmd := []string{"--config", signalCliConfig, "-u", number, "send"}
-
- if len(recipients) == 0 {
- c.JSON(400, gin.H{"error": "Please specify at least one recipient"})
- return
- }
-
- if !isGroup {
- cmd = append(cmd, recipients...)
- } else {
- if len(recipients) > 1 {
- c.JSON(400, gin.H{"error": "More than one recipient is currently not allowed"})
- return
- }
-
- groupId, err := base64.StdEncoding.DecodeString(recipients[0])
- if err != nil {
- c.JSON(400, gin.H{"error": "Invalid group id"})
- return
- }
-
- cmd = append(cmd, []string{"-g", string(groupId)}...)
- }
-
- attachmentTmpPaths := []string{}
- for _, base64Attachment := range base64Attachments {
- u, err := uuid.NewV4()
- if err != nil {
- c.JSON(400, gin.H{"error": err.Error()})
- return
- }
-
- dec, err := base64.StdEncoding.DecodeString(base64Attachment)
- if err != nil {
- c.JSON(400, gin.H{"error": err.Error()})
- return
- }
-
- mimeType := mimetype.Detect(dec)
-
- attachmentTmpPath := attachmentTmpDir + u.String() + mimeType.Extension()
- attachmentTmpPaths = append(attachmentTmpPaths, attachmentTmpPath)
-
- f, err := os.Create(attachmentTmpPath)
- if err != nil {
- c.JSON(400, gin.H{"error": err.Error()})
- return
- }
- defer f.Close()
-
- if _, err := f.Write(dec); err != nil {
- cleanupTmpFiles(attachmentTmpPaths)
- c.JSON(400, gin.H{"error": err.Error()})
- return
- }
- if err := f.Sync(); err != nil {
- cleanupTmpFiles(attachmentTmpPaths)
- c.JSON(400, gin.H{"error": err.Error()})
- return
- }
-
- f.Close()
- }
-
- if len(attachmentTmpPaths) > 0 {
- cmd = append(cmd, "-a")
- cmd = append(cmd, attachmentTmpPaths...)
- }
-
- resp, err := runSignalCli(true, cmd, message)
- if err != nil {
- cleanupTmpFiles(attachmentTmpPaths)
- if strings.Contains(err.Error(), signalCliV2GroupError) {
- c.JSON(400, Error{Msg: "Cannot send message to group - please first update your profile."})
- } else {
- c.JSON(400, Error{Msg: err.Error()})
- }
- return
- }
-
- sendMessageResponse := SendMessageResponse{Timestamp: strings.TrimSuffix(resp, "\n")}
-
- cleanupTmpFiles(attachmentTmpPaths)
- c.JSON(201, sendMessageResponse)
-}
-
-func parseWhitespaceDelimitedKeyValueStringList(in string, keys []string) []map[string]string {
- l := []map[string]string{}
- lines := strings.Split(in, "\n")
- for _, line := range lines {
- if line == "" {
- continue
- }
-
- m := make(map[string]string)
-
- temp := line
- for i, key := range keys {
- if i == 0 {
- continue
- }
-
- idx := strings.Index(temp, " "+key+": ")
- pair := temp[:idx]
- value := strings.TrimPrefix(pair, key+": ")
- temp = strings.TrimLeft(temp[idx:], " "+key+": ")
-
- m[keys[i-1]] = value
- }
- m[keys[len(keys)-1]] = temp
-
- l = append(l, m)
- }
- return l
-}
-
-func getGroups(number string, signalCliConfig string) ([]GroupEntry, error) {
- groupEntries := []GroupEntry{}
-
- out, err := runSignalCli(true, []string{"--config", signalCliConfig, "--output", "json", "-u", number, "listGroups", "-d"}, "")
- if err != nil {
- return groupEntries, err
- }
-
- var signalCliGroupEntries []SignalCliGroupEntry
-
- err = json.Unmarshal([]byte(out), &signalCliGroupEntries)
- if err != nil {
- return groupEntries, err
- }
-
- for _, signalCliGroupEntry := range signalCliGroupEntries {
- var groupEntry GroupEntry
- groupEntry.InternalId = signalCliGroupEntry.Id
- groupEntry.Name = signalCliGroupEntry.Name
- groupEntry.Id = convertInternalGroupIdToGroupId(signalCliGroupEntry.Id)
- groupEntry.Blocked = signalCliGroupEntry.IsBlocked
- groupEntry.Members = signalCliGroupEntry.Members
- groupEntry.PendingRequests = signalCliGroupEntry.PendingMembers
- groupEntry.PendingInvites = signalCliGroupEntry.RequestingMembers
- groupEntry.InviteLink = signalCliGroupEntry.GroupInviteLink
-
- groupEntries = append(groupEntries, groupEntry)
- }
-
- return groupEntries, nil
-}
-
-func runSignalCli(wait bool, args []string, stdin string) (string, error) {
- containerId, err := getContainerId()
-
- log.Debug("If you want to run this command manually, run the following steps on your host system:")
- if err == nil {
- log.Debug("*) docker exec -it ", containerId, " /bin/bash")
- } else {
- log.Debug("*) docker exec -it /bin/bash")
- }
-
- signalCliBinary := "signal-cli"
- if utils.GetEnv("USE_NATIVE", "0") == "1" {
- if utils.GetEnv("SUPPORTS_NATIVE", "0") == "1" {
- signalCliBinary = "signal-cli-native"
- } else {
- log.Error("signal-cli-native is not support on this system...falling back to signal-cli")
- signalCliBinary = "signal-cli"
- }
- }
-
- fullCmd := ""
- if(stdin != "") {
- fullCmd += "echo '" + stdin + "' | "
- }
- fullCmd += signalCliBinary + " " + strings.Join(args, " ")
-
- log.Debug("*) su signal-api")
- log.Debug("*) ", fullCmd)
-
- cmdTimeout, err := utils.GetIntEnv("SIGNAL_CLI_CMD_TIMEOUT", 120)
- if err != nil {
- log.Error("Env variable 'SIGNAL_CLI_CMD_TIMEOUT' contains an invalid timeout...falling back to default timeout (120 seconds)")
- cmdTimeout = 120
- }
-
- cmd := exec.Command(signalCliBinary, args...)
- if stdin != "" {
- cmd.Stdin = strings.NewReader(stdin)
- }
- if wait {
- var errBuffer bytes.Buffer
- var outBuffer bytes.Buffer
- cmd.Stderr = &errBuffer
- cmd.Stdout = &outBuffer
-
- err := cmd.Start()
- if err != nil {
- return "", err
- }
-
- done := make(chan error, 1)
- go func() {
- done <- cmd.Wait()
- }()
- select {
- case <-time.After(time.Duration(cmdTimeout) * time.Second):
- err := cmd.Process.Kill()
- if err != nil {
- return "", err
- }
- return "", errors.New("process killed as timeout reached")
- case err := <-done:
- if err != nil {
- return "", errors.New(errBuffer.String())
- }
- }
-
- return outBuffer.String(), nil
- } else {
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return "", err
- }
- cmd.Start()
- buf := bufio.NewReader(stdout) // Notice that this is not in a loop
- line, _, _ := buf.ReadLine()
- return string(line), nil
- }
+var connectionUpgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool {
+ return true
+ },
}
type Api struct {
- signalCliConfig string
- attachmentTmpDir string
- avatarTmpDir string
+ signalClient *client.SignalClient
}
-func NewApi(signalCliConfig string, attachmentTmpDir string, avatarTmpDir string) *Api {
+func NewApi(signalClient *client.SignalClient) *Api {
return &Api{
- signalCliConfig: signalCliConfig,
- attachmentTmpDir: attachmentTmpDir,
- avatarTmpDir: avatarTmpDir,
+ signalClient: signalClient,
}
}
@@ -424,12 +115,10 @@ func NewApi(signalCliConfig string, attachmentTmpDir string, avatarTmpDir string
// @Tags General
// @Description Returns the supported API versions and the internal build nr
// @Produce json
-// @Success 200 {object} About
+// @Success 200 {object} client.About
// @Router /v1/about [get]
func (a *Api) About(c *gin.Context) {
-
- about := About{SupportedApiVersions: []string{"v1", "v2"}, BuildNr: 2}
- c.JSON(200, about)
+ c.JSON(200, a.signalClient.About())
}
// @Summary Register a phone number.
@@ -466,17 +155,7 @@ func (a *Api) RegisterNumber(c *gin.Context) {
return
}
- command := []string{"--config", a.signalCliConfig, "-u", number, "register"}
-
- if req.UseVoice == true {
- command = append(command, "--voice")
- }
-
- if req.Captcha != "" {
- command = append(command, []string{"--captcha", req.Captcha}...)
- }
-
- _, err := runSignalCli(true, command, "")
+ err := a.signalClient.RegisterNumber(number, req.UseVoice, req.Captcha)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
@@ -523,13 +202,7 @@ func (a *Api) VerifyRegisteredNumber(c *gin.Context) {
return
}
- cmd := []string{"--config", a.signalCliConfig, "-u", number, "verify", token}
- if pin != "" {
- cmd = append(cmd, "--pin")
- cmd = append(cmd, pin)
- }
-
- _, err := runSignalCli(true, cmd, "")
+ err := a.signalClient.VerifyRegisteredNumber(number, token, pin)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
@@ -552,7 +225,7 @@ func (a *Api) Send(c *gin.Context) {
var req SendMessageV1
err := c.BindJSON(&req)
if err != nil {
- c.JSON(400, gin.H{"error": "Couldn't process request - invalid request"})
+ c.JSON(400, Error{Msg: "Couldn't process request - invalid request"})
return
}
@@ -561,7 +234,12 @@ func (a *Api) Send(c *gin.Context) {
base64Attachments = append(base64Attachments, req.Base64Attachment)
}
- send(c, a.signalCliConfig, a.signalCliConfig, req.Number, req.Message, req.Recipients, base64Attachments, req.IsGroup)
+ timestamp, err := a.signalClient.SendV1(req.Number, req.Message, req.Recipients, base64Attachments, req.IsGroup)
+ if err != nil {
+ c.JSON(400, Error{Msg: err.Error()})
+ return
+ }
+ c.JSON(201, SendMessageResponse{Timestamp: strconv.FormatInt(timestamp.Timestamp, 10)})
}
// @Summary Send a signal message.
@@ -587,39 +265,72 @@ func (a *Api) SendV2(c *gin.Context) {
return
}
- groups := []string{}
- recipients := []string{}
+ if req.Number == "" {
+ c.JSON(400, gin.H{"error": "Couldn't process request - please provide a valid number"})
+ return
+ }
- for _, recipient := range req.Recipients {
- if strings.HasPrefix(recipient, groupPrefix) {
- groups = append(groups, strings.TrimPrefix(recipient, groupPrefix))
+ timestamps, err := a.signalClient.SendV2(req.Number, req.Message, req.Recipients, req.Base64Attachments)
+ if err != nil {
+ c.JSON(400, Error{Msg: err.Error()})
+ return
+ }
+
+ c.JSON(201, SendMessageResponse{Timestamp: strconv.FormatInt((*timestamps)[0].Timestamp, 10)})
+}
+
+
+func (a *Api) handleSignalReceive(ws *websocket.Conn, number string) {
+ for {
+ data, err := a.signalClient.Receive(number, 0)
+ if err == nil {
+ err = ws.WriteMessage(websocket.TextMessage, []byte(data))
+ if err != nil {
+ log.Error("Couldn't write message: " + err.Error())
+ return
+ }
} else {
- recipients = append(recipients, recipient)
+ errorMsg := Error{Msg: err.Error()}
+ errorMsgBytes, err := json.Marshal(errorMsg)
+ if err != nil {
+ log.Error("Couldn't serialize error message: " + err.Error())
+ return
+ }
+ err = ws.WriteMessage(websocket.TextMessage, errorMsgBytes)
+ if err != nil {
+ log.Error("Couldn't write message: " + err.Error())
+ return
+ }
}
}
+}
- if len(recipients) > 0 && len(groups) > 0 {
- c.JSON(400, gin.H{"error": "Signal Messenger Groups and phone numbers cannot be specified together in one request! Please split them up into multiple REST API calls."})
- return
+func wsPong(ws *websocket.Conn) {
+ ws.SetReadLimit(512)
+ ws.SetPongHandler(func(string) error { log.Debug("Received pong"); return nil })
+ for {
+ _, _, err := ws.ReadMessage()
+ if err != nil {
+ break
+ }
}
+}
- if len(groups) > 1 {
- c.JSON(400, gin.H{"error": "A signal message cannot be sent to more than one group at once! Please use multiple REST API calls for that."})
- return
- }
-
- for _, group := range groups {
- send(c, a.attachmentTmpDir, a.signalCliConfig, req.Number, req.Message, []string{group}, req.Base64Attachments, true)
- }
-
- if len(recipients) > 0 {
- send(c, a.attachmentTmpDir, a.signalCliConfig, req.Number, req.Message, recipients, req.Base64Attachments, false)
+func wsPing(ws *websocket.Conn) {
+ pingTicker := time.NewTicker(pingPeriod)
+ for {
+ select {
+ case <-pingTicker.C:
+ if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
+ return
+ }
+ }
}
}
// @Summary Receive Signal Messages.
// @Tags Messages
-// @Description Receives Signal Messages from the Signal Network.
+// @Description Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint.
// @Accept json
// @Produce json
// @Success 200 {object} []string
@@ -630,28 +341,32 @@ func (a *Api) SendV2(c *gin.Context) {
func (a *Api) Receive(c *gin.Context) {
number := c.Param("number")
- timeout := c.DefaultQuery("timeout", "1")
-
- command := []string{"--config", a.signalCliConfig, "--output", "json", "-u", number, "receive", "-t", timeout}
- out, err := runSignalCli(true, command, "")
- if err != nil {
- c.JSON(400, gin.H{"error": err.Error()})
- return
- }
-
- out = strings.Trim(out, "\n")
- lines := strings.Split(out, "\n")
-
- jsonStr := "["
- for i, line := range lines {
- jsonStr += line
- if i != (len(lines) - 1) {
- jsonStr += ","
+ if a.signalClient.GetSignalCliMode() == client.JsonRpc {
+ ws, err := connectionUpgrader.Upgrade(c.Writer, c.Request, nil)
+ if err != nil {
+ c.JSON(400, Error{Msg: err.Error()})
+ return
+ }
+ defer ws.Close()
+ go a.handleSignalReceive(ws, number)
+ go wsPing(ws)
+ wsPong(ws)
+ } else {
+ timeout := c.DefaultQuery("timeout", "1")
+ timeoutInt, err := strconv.ParseInt(timeout, 10, 32)
+ if err != nil {
+ c.JSON(400, Error{Msg: "Couldn't process request - timeout needs to be numeric!"})
+ return
}
- }
- jsonStr += "]"
- c.String(200, jsonStr)
+ jsonStr, err := a.signalClient.Receive(number, timeoutInt)
+ if err != nil {
+ c.JSON(400, Error{Msg: err.Error()})
+ return
+ }
+
+ c.String(200, jsonStr)
+ }
}
// @Summary Create a new Signal Group.
@@ -670,57 +385,36 @@ func (a *Api) CreateGroup(c *gin.Context) {
var req CreateGroupRequest
err := c.BindJSON(&req)
if err != nil {
- c.JSON(400, gin.H{"error": "Couldn't process request - invalid request"})
- log.Error(err.Error())
+ c.JSON(400, Error{Msg: "Couldn't process request - invalid request"})
return
}
if req.Permissions.AddMembers != "" && !utils.StringInSlice(req.Permissions.AddMembers, []string{"every-member", "only-admins"}) {
- c.JSON(400, gin.H{"error": "Invalid add members permission provided - only 'every-member' and 'only-admins' allowed!"})
+ c.JSON(400, Error{Msg: "Invalid add members permission provided - only 'every-member' and 'only-admins' allowed!"})
return
}
if req.Permissions.EditGroup != "" && !utils.StringInSlice(req.Permissions.EditGroup, []string{"every-member", "only-admins"}) {
- c.JSON(400, gin.H{"error": "Invalid edit group permissions provided - only 'every-member' and 'only-admins' allowed!"})
+ c.JSON(400, Error{Msg: "Invalid edit group permissions provided - only 'every-member' and 'only-admins' allowed!"})
return
}
if req.GroupLinkState != "" && !utils.StringInSlice(req.GroupLinkState, []string{"enabled", "enabled-with-approval", "disabled"}) {
- c.JSON(400, gin.H{"error": "Invalid group link provided - only 'enabled', 'enabled-with-approval' and 'disabled' allowed!" })
+ c.JSON(400, Error{Msg: "Invalid group link provided - only 'enabled', 'enabled-with-approval' and 'disabled' allowed!" })
return
}
- cmd := []string{"--config", a.signalCliConfig, "-u", number, "updateGroup", "-n", req.Name, "-m"}
- cmd = append(cmd, req.Members...)
+ editGroupPermission := client.DefaultGroupPermission
+ addMembersPermission := client.DefaultGroupPermission
+ groupLinkState := client.DefaultGroupLinkState
- if req.Permissions.AddMembers != "" {
- cmd = append(cmd, []string{"--set-permission-add-member", req.Permissions.AddMembers}...)
- }
-
- if req.Permissions.EditGroup != "" {
- cmd = append(cmd, []string{"--set-permission-edit-details", req.Permissions.EditGroup}...)
- }
-
- if req.GroupLinkState != "" {
- cmd = append(cmd, []string{"--link", req.GroupLinkState}...)
- }
-
- if req.Description != "" {
- cmd = append(cmd, []string{"--description", req.Description}...)
- }
-
- out, err := runSignalCli(true, cmd, "")
+ groupId, err := a.signalClient.CreateGroup(number, req.Name, req.Members, req.Description, editGroupPermission, addMembersPermission, groupLinkState)
if err != nil {
- if strings.Contains(err.Error(), signalCliV2GroupError) {
- c.JSON(400, Error{Msg: "Cannot create group - please first update your profile."})
- } else {
- c.JSON(400, Error{Msg: err.Error()})
- }
+ c.JSON(400, Error{Msg: err.Error()})
return
}
- internalGroupId := getStringInBetween(out, `"`, `"`)
- c.JSON(201, CreateGroupResponse{Id: convertInternalGroupIdToGroupId(internalGroupId)})
+ c.JSON(201, CreateGroupResponse{Id: groupId})
}
// @Summary List all Signal Groups.
@@ -728,16 +422,16 @@ func (a *Api) CreateGroup(c *gin.Context) {
// @Description List all Signal Groups.
// @Accept json
// @Produce json
-// @Success 200 {object} []GroupEntry
+// @Success 200 {object} []client.GroupEntry
// @Failure 400 {object} Error
// @Param number path string true "Registered Phone Number"
// @Router /v1/groups/{number} [get]
func (a *Api) GetGroups(c *gin.Context) {
number := c.Param("number")
- groups, err := getGroups(number, a.signalCliConfig)
+ groups, err := a.signalClient.GetGroups(number)
if err != nil {
- c.JSON(400, gin.H{"error": err.Error()})
+ c.JSON(400, Error{Msg: err.Error()})
return
}
@@ -749,7 +443,7 @@ func (a *Api) GetGroups(c *gin.Context) {
// @Description List a specific Signal Group.
// @Accept json
// @Produce json
-// @Success 200 {object} GroupEntry
+// @Success 200 {object} client.GroupEntry
// @Failure 400 {object} Error
// @Param number path string true "Registered Phone Number"
// @Param groupid path string true "Group ID"
@@ -758,20 +452,17 @@ func (a *Api) GetGroup(c *gin.Context) {
number := c.Param("number")
groupId := c.Param("groupid")
- groups, err := getGroups(number, a.signalCliConfig)
+ groupEntry, err := a.signalClient.GetGroup(number, groupId)
if err != nil {
- c.JSON(400, gin.H{"error": err.Error()})
+ c.JSON(400, Error{Msg: err.Error()})
return
}
- for _, group := range groups {
- if group.Id == groupId {
- c.JSON(200, group)
- return
- }
+ if groupEntry != nil {
+ c.JSON(200, groupEntry)
+ } else {
+ c.JSON(404, Error{Msg: "No group with that id found"})
}
-
- c.JSON(404, Error{Msg: "No group with that id found"})
}
// @Summary Delete a Signal Group.
@@ -789,19 +480,19 @@ func (a *Api) DeleteGroup(c *gin.Context) {
number := c.Param("number")
if base64EncodedGroupId == "" {
- c.JSON(400, gin.H{"error": "Please specify a group id"})
+ c.JSON(400, Error{Msg: "Please specify a group id"})
return
}
- groupId, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(base64EncodedGroupId, groupPrefix))
+ groupId, err := client.ConvertGroupIdToInternalGroupId(base64EncodedGroupId)
if err != nil {
- c.JSON(400, gin.H{"error": "Invalid group id"})
+ c.JSON(400, Error{Msg: err.Error()})
return
}
- _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "quitGroup", "-g", string(groupId)}, "")
+ err = a.signalClient.DeleteGroup(number, groupId)
if err != nil {
- c.JSON(400, gin.H{"error": err.Error()})
+ c.JSON(400, Error{Msg: err.Error()})
return
}
}
@@ -818,32 +509,13 @@ func (a *Api) GetQrCodeLink(c *gin.Context) {
deviceName := c.Query("device_name")
if deviceName == "" {
- c.JSON(400, gin.H{"error": "Please provide a name for the device"})
+ c.JSON(400, Error{Msg: "Please provide a name for the device"})
return
}
- command := []string{"--config", a.signalCliConfig, "link", "-n", deviceName}
-
- tsdeviceLink, err := runSignalCli(false, command, "")
+ png, err := a.signalClient.GetQrCodeLink(deviceName)
if err != nil {
- log.Error("Couldn't create QR code: ", err.Error())
- c.JSON(400, Error{Msg: "Couldn't create QR code: " + err.Error()})
- return
- }
-
- q, err := qrcode.New(string(tsdeviceLink), qrcode.Medium)
- if err != nil {
- log.Error("Couldn't create QR code: ", err.Error())
- c.JSON(400, Error{Msg: "Couldn't create QR code: " + err.Error()})
- return
- }
-
- q.DisableBorder = false
- var png []byte
- png, err = q.PNG(256)
- if err != nil {
- log.Error("Couldn't create QR code: ", err.Error())
- c.JSON(400, Error{Msg: "Couldn't create QR code: " + err.Error()})
+ c.JSON(400, Error{Msg: err.Error()})
return
}
@@ -858,15 +530,7 @@ func (a *Api) GetQrCodeLink(c *gin.Context) {
// @Failure 400 {object} Error
// @Router /v1/attachments [get]
func (a *Api) GetAttachments(c *gin.Context) {
- files := []string{}
- err := filepath.Walk(a.signalCliConfig+"/attachments/", func(path string, info os.FileInfo, err error) error {
- if info.IsDir() {
- return nil
- }
- files = append(files, filepath.Base(path))
- return nil
- })
-
+ files, err := a.signalClient.GetAttachments()
if err != nil {
c.JSON(500, Error{Msg: "Couldn't get list of attachments: " + err.Error()})
return
@@ -885,21 +549,25 @@ func (a *Api) GetAttachments(c *gin.Context) {
// @Router /v1/attachments/{attachment} [delete]
func (a *Api) RemoveAttachment(c *gin.Context) {
attachment := c.Param("attachment")
- path, err := securejoin.SecureJoin(a.signalCliConfig+"/attachments/", attachment)
+
+ err := a.signalClient.RemoveAttachment(attachment)
if err != nil {
- c.JSON(400, Error{Msg: "Please provide a valid attachment name"})
- return
+ switch err.(type) {
+ case *client.InvalidNameError:
+ c.JSON(400, Error{Msg: err.Error()})
+ return
+ case *client.NotFoundError:
+ c.JSON(404, Error{Msg: err.Error()})
+ return
+ case *client.InternalError:
+ c.JSON(500, Error{Msg: err.Error()})
+ return
+ default:
+ c.JSON(500, Error{Msg: err.Error()})
+ return
+ }
}
- if _, err := os.Stat(path); os.IsNotExist(err) {
- c.JSON(404, Error{Msg: "No attachment with that name found"})
- return
- }
- err = os.Remove(path)
- if err != nil {
- c.JSON(500, Error{Msg: "Couldn't delete attachment - please try again later"})
- return
- }
c.Status(http.StatusNoContent)
}
@@ -913,32 +581,34 @@ func (a *Api) RemoveAttachment(c *gin.Context) {
// @Router /v1/attachments/{attachment} [get]
func (a *Api) ServeAttachment(c *gin.Context) {
attachment := c.Param("attachment")
- path, err := securejoin.SecureJoin(a.signalCliConfig+"/attachments/", attachment)
+
+ attachmentBytes, err := a.signalClient.GetAttachment(attachment)
if err != nil {
- c.JSON(400, Error{Msg: "Please provide a valid attachment name"})
- return
+ switch err.(type) {
+ case *client.InvalidNameError:
+ c.JSON(400, Error{Msg: err.Error()})
+ return
+ case *client.NotFoundError:
+ c.JSON(404, Error{Msg: err.Error()})
+ return
+ case *client.InternalError:
+ c.JSON(500, Error{Msg: err.Error()})
+ return
+ default:
+ c.JSON(500, Error{Msg: err.Error()})
+ return
+ }
}
- if _, err := os.Stat(path); os.IsNotExist(err) {
- c.JSON(404, Error{Msg: "No attachment with that name found"})
- return
- }
-
- imgBytes, err := ioutil.ReadFile(path)
- if err != nil {
- c.JSON(500, Error{Msg: "Couldn't read attachment - please try again later"})
- return
- }
-
- mime, err := mimetype.DetectReader(bytes.NewReader(imgBytes))
+ mime, err := mimetype.DetectReader(bytes.NewReader(attachmentBytes))
if err != nil {
c.JSON(500, Error{Msg: "Couldn't detect MIME type for attachment"})
return
}
c.Writer.Header().Set("Content-Type", mime.String())
- c.Writer.Header().Set("Content-Length", strconv.Itoa(len(imgBytes)))
- _, err = c.Writer.Write(imgBytes)
+ c.Writer.Header().Set("Content-Length", strconv.Itoa(len(attachmentBytes)))
+ _, err = c.Writer.Write(attachmentBytes)
if err != nil {
c.JSON(500, Error{Msg: "Couldn't serve attachment - please try again later"})
return
@@ -974,63 +644,13 @@ func (a *Api) UpdateProfile(c *gin.Context) {
c.JSON(400, Error{Msg: "Couldn't process request - profile name missing"})
return
}
- cmd := []string{"--config", a.signalCliConfig, "-u", number, "updateProfile", "--name", req.Name}
- avatarTmpPaths := []string{}
- if req.Base64Avatar == "" {
- cmd = append(cmd, "--remove-avatar")
- } else {
- u, err := uuid.NewV4()
- if err != nil {
- c.JSON(400, Error{Msg: err.Error()})
- return
- }
-
- avatarBytes, err := base64.StdEncoding.DecodeString(req.Base64Avatar)
- if err != nil {
- c.JSON(400, Error{Msg: "Couldn't decode base64 encoded avatar"})
- return
- }
-
- fType, err := filetype.Get(avatarBytes)
- if err != nil {
- c.JSON(400, Error{Msg: err.Error()})
- return
- }
-
- avatarTmpPath := a.avatarTmpDir + u.String() + "." + fType.Extension
-
- f, err := os.Create(avatarTmpPath)
- if err != nil {
- c.JSON(400, Error{Msg: err.Error()})
- return
- }
- defer f.Close()
-
- if _, err := f.Write(avatarBytes); err != nil {
- cleanupTmpFiles(avatarTmpPaths)
- c.JSON(400, Error{Msg: err.Error()})
- return
- }
- if err := f.Sync(); err != nil {
- cleanupTmpFiles(avatarTmpPaths)
- c.JSON(400, Error{Msg: err.Error()})
- return
- }
- f.Close()
-
- cmd = append(cmd, []string{"--avatar", avatarTmpPath}...)
- avatarTmpPaths = append(avatarTmpPaths, avatarTmpPath)
- }
-
- _, err = runSignalCli(true, cmd, "")
+ err = a.signalClient.UpdateProfile(number, req.Name, req.Base64Avatar)
if err != nil {
- cleanupTmpFiles(avatarTmpPaths)
c.JSON(400, Error{Msg: err.Error()})
return
}
- cleanupTmpFiles(avatarTmpPaths)
c.Status(http.StatusNoContent)
}
@@ -1048,7 +668,7 @@ func (a *Api) Health(c *gin.Context) {
// @Tags Identities
// @Description List all identities for the given number.
// @Produce json
-// @Success 200 {object} []IdentityEntry
+// @Success 200 {object} []client.IdentityEntry
// @Param number path string true "Registered Phone Number"
// @Router /v1/identities/{number} [get]
func (a *Api) ListIdentities(c *gin.Context) {
@@ -1059,27 +679,12 @@ func (a *Api) ListIdentities(c *gin.Context) {
return
}
- out, err := runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "listIdentities"}, "")
+ identityEntries, err := a.signalClient.ListIdentities(number)
if err != nil {
c.JSON(500, Error{Msg: err.Error()})
return
}
- identityEntries := []IdentityEntry{}
- keyValuePairs := parseWhitespaceDelimitedKeyValueStringList(out, []string{"NumberAndTrustStatus", "Added", "Fingerprint", "Safety Number"})
- for _, keyValuePair := range keyValuePairs {
- numberAndTrustStatus := keyValuePair["NumberAndTrustStatus"]
- numberAndTrustStatusSplitted := strings.Split(numberAndTrustStatus, ":")
-
- identityEntry := IdentityEntry{Number: strings.Trim(numberAndTrustStatusSplitted[0], " "),
- Status: strings.Trim(numberAndTrustStatusSplitted[1], " "),
- Added: keyValuePair["Added"],
- Fingerprint: strings.Trim(keyValuePair["Fingerprint"], " "),
- SafetyNumber: strings.Trim(keyValuePair["Safety Number"], " "),
- }
- identityEntries = append(identityEntries, identityEntry)
- }
-
c.JSON(200, identityEntries)
}
@@ -1119,12 +724,12 @@ func (a *Api) TrustIdentity(c *gin.Context) {
return
}
- cmd := []string{"--config", a.signalCliConfig, "-u", number, "trust", numberToTrust, "--verified-safety-number", req.VerifiedSafetyNumber}
- _, err = runSignalCli(true, cmd, "")
+ err = a.signalClient.TrustIdentity(number, numberToTrust, req.VerifiedSafetyNumber)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
+
c.Status(http.StatusNoContent)
}
@@ -1201,17 +806,18 @@ func (a *Api) BlockGroup(c *gin.Context) {
}
groupId := c.Param("groupid")
- internalGroupId, err := convertGroupIdToInternalGroupId(groupId)
+ internalGroupId, err := client.ConvertGroupIdToInternalGroupId(groupId)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
- _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "block", "-g", internalGroupId}, "")
+ err = a.signalClient.BlockGroup(number, internalGroupId)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
+
c.Status(http.StatusNoContent)
}
@@ -1233,17 +839,18 @@ func (a *Api) JoinGroup(c *gin.Context) {
}
groupId := c.Param("groupid")
- internalGroupId, err := convertGroupIdToInternalGroupId(groupId)
+ internalGroupId, err := client.ConvertGroupIdToInternalGroupId(groupId)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
- _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "updateGroup", "-g", internalGroupId}, "")
+ err = a.signalClient.JoinGroup(number, internalGroupId)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
+
c.Status(http.StatusNoContent)
}
@@ -1265,13 +872,13 @@ func (a *Api) QuitGroup(c *gin.Context) {
}
groupId := c.Param("groupid")
- internalGroupId, err := convertGroupIdToInternalGroupId(groupId)
+ internalGroupId, err := client.ConvertGroupIdToInternalGroupId(groupId)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
- _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "quitGroup", "-g", internalGroupId}, "")
+ err = a.signalClient.QuitGroup(number, internalGroupId)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
diff --git a/src/client/client.go b/src/client/client.go
new file mode 100644
index 0000000..6dc2147
--- /dev/null
+++ b/src/client/client.go
@@ -0,0 +1,974 @@
+package client
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/cyphar/filepath-securejoin"
+ "github.com/gabriel-vasile/mimetype"
+ "github.com/h2non/filetype"
+ //"github.com/sourcegraph/jsonrpc2"//"net/rpc/jsonrpc"
+ log "github.com/sirupsen/logrus"
+
+ uuid "github.com/gofrs/uuid"
+ qrcode "github.com/skip2/go-qrcode"
+
+ utils "github.com/bbernhard/signal-cli-rest-api/utils"
+)
+
+const groupPrefix = "group."
+
+const signalCliV2GroupError = "Cannot create a V2 group as self does not have a versioned profile"
+
+const endpointNotSupportedInJsonRpcMode = "This endpoint is not supported in JSON-RCP mode."
+
+type GroupPermission int
+
+const (
+ DefaultGroupPermission GroupPermission = iota + 1
+ EveryMember
+ OnlyAdmins
+)
+
+type SignalCliMode int
+
+const (
+ Normal SignalCliMode = iota + 1
+ Native
+ JsonRpc
+)
+
+type GroupLinkState int
+
+const (
+ DefaultGroupLinkState GroupLinkState = iota + 1
+ Enabled
+ EnabledWithApproval
+ Disabled
+)
+
+func (g GroupPermission) String() string {
+ return []string{"", "default", "every-member", "only-admins"}[g]
+}
+
+func (g GroupLinkState) String() string {
+ return []string{"", "enabled", "enabled-with-approval", "disabled"}[g]
+}
+
+type GroupEntry struct {
+ Name string `json:"name"`
+ Id string `json:"id"`
+ InternalId string `json:"internal_id"`
+ Members []string `json:"members"`
+ Blocked bool `json:"blocked"`
+ PendingInvites []string `json:"pending_invites"`
+ PendingRequests []string `json:"pending_requests"`
+ InviteLink string `json:"invite_link"`
+}
+
+type IdentityEntry struct {
+ Number string `json:"number"`
+ Status string `json:"status"`
+ Fingerprint string `json:"fingerprint"`
+ Added string `json:"added"`
+ SafetyNumber string `json:"safety_number"`
+}
+
+type SignalCliGroupMember struct {
+ Number string `json:"number"`
+ Uuid string `json:"uuid"`
+}
+
+type SignalCliGroupEntry struct {
+ Name string `json:"name"`
+ Id string `json:"id"`
+ IsMember bool `json:"isMember"`
+ IsBlocked bool `json:"isBlocked"`
+ Members []SignalCliGroupMember `json:"members"`
+ PendingMembers []string `json:"pendingMembers"`
+ RequestingMembers []string `json:"requestingMembers"`
+ GroupInviteLink string `json:"groupInviteLink"`
+}
+
+type SignalCliIdentityEntry struct {
+ Number string `json:"number"`
+ Uuid string `json:"uuid"`
+ Fingerprint string `json:"fingerprint"`
+ SafetyNumber string `json:"safetyNumber"`
+ ScannableSafetyNumber string `json:"scannableSafetyNumber"`
+ TrustLevel string `json:"trustLevel"`
+ AddedTimestamp int64 `json:"addedTimestamp"`
+}
+
+type SendResponse struct {
+ Timestamp int64 `json:"timestamp"`
+}
+
+type About struct {
+ SupportedApiVersions []string `json:"versions"`
+ BuildNr int `json:"build"`
+}
+
+func cleanupTmpFiles(paths []string) {
+ for _, path := range paths {
+ os.Remove(path)
+ }
+}
+
+func convertInternalGroupIdToGroupId(internalId string) string {
+ return groupPrefix + base64.StdEncoding.EncodeToString([]byte(internalId))
+}
+
+func getStringInBetween(str string, start string, end string) (result string) {
+ i := strings.Index(str, start)
+ if i == -1 {
+ return
+ }
+ i += len(start)
+ j := strings.Index(str[i:], end)
+ if j == -1 {
+ return
+ }
+ return str[i : i+j]
+}
+
+func parseWhitespaceDelimitedKeyValueStringList(in string, keys []string) []map[string]string {
+ l := []map[string]string{}
+ lines := strings.Split(in, "\n")
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+
+ m := make(map[string]string)
+
+ temp := line
+ for i, key := range keys {
+ if i == 0 {
+ continue
+ }
+
+ idx := strings.Index(temp, " "+key+": ")
+ pair := temp[:idx]
+ value := strings.TrimPrefix(pair, key+": ")
+ temp = strings.TrimLeft(temp[idx:], " "+key+": ")
+
+ m[keys[i-1]] = value
+ }
+ m[keys[len(keys)-1]] = temp
+
+ l = append(l, m)
+ }
+ return l
+}
+
+func getContainerId() (string, error) {
+ data, err := ioutil.ReadFile("/proc/1/cpuset")
+ if err != nil {
+ return "", err
+ }
+ lines := strings.Split(string(data), "\n")
+ if len(lines) == 0 {
+ return "", errors.New("Couldn't get docker container id (empty)")
+ }
+ containerId := strings.Replace(lines[0], "/docker/", "", -1)
+ return containerId, nil
+}
+
+func runSignalCli(wait bool, args []string, stdin string, signalCliMode SignalCliMode) (string, error) {
+ containerId, err := getContainerId()
+
+ log.Debug("If you want to run this command manually, run the following steps on your host system:")
+ if err == nil {
+ log.Debug("*) docker exec -it ", containerId, " /bin/bash")
+ } else {
+ log.Debug("*) docker exec -it /bin/bash")
+ }
+
+ signalCliBinary := ""
+ if signalCliMode == Normal {
+ signalCliBinary = "signal-cli"
+ } else if signalCliMode == Native {
+ signalCliBinary = "signal-cli-native"
+ } else {
+ return "", errors.New("Invalid signal-cli mode")
+ }
+
+ fullCmd := ""
+ if stdin != "" {
+ fullCmd += "echo '" + stdin + "' | "
+ }
+ fullCmd += signalCliBinary + " " + strings.Join(args, " ")
+
+ log.Debug("*) su signal-api")
+ log.Debug("*) ", fullCmd)
+
+ cmdTimeout, err := utils.GetIntEnv("SIGNAL_CLI_CMD_TIMEOUT", 120)
+ if err != nil {
+ log.Error("Env variable 'SIGNAL_CLI_CMD_TIMEOUT' contains an invalid timeout...falling back to default timeout (120 seconds)")
+ cmdTimeout = 120
+ }
+
+ cmd := exec.Command(signalCliBinary, args...)
+ if stdin != "" {
+ cmd.Stdin = strings.NewReader(stdin)
+ }
+ if wait {
+ var errBuffer bytes.Buffer
+ var outBuffer bytes.Buffer
+ cmd.Stderr = &errBuffer
+ cmd.Stdout = &outBuffer
+
+ err := cmd.Start()
+ if err != nil {
+ return "", err
+ }
+
+ done := make(chan error, 1)
+ go func() {
+ done <- cmd.Wait()
+ }()
+ select {
+ case <-time.After(time.Duration(cmdTimeout) * time.Second):
+ err := cmd.Process.Kill()
+ if err != nil {
+ return "", err
+ }
+ return "", errors.New("process killed as timeout reached")
+ case err := <-done:
+ if err != nil {
+ return "", errors.New(errBuffer.String())
+ }
+ }
+
+ return outBuffer.String(), nil
+ } else {
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", err
+ }
+ cmd.Start()
+ buf := bufio.NewReader(stdout) // Notice that this is not in a loop
+ line, _, _ := buf.ReadLine()
+ return string(line), nil
+ }
+}
+
+func ConvertGroupIdToInternalGroupId(id string) (string, error) {
+
+ groupIdWithoutPrefix := strings.TrimPrefix(id, groupPrefix)
+ internalGroupId, err := base64.StdEncoding.DecodeString(groupIdWithoutPrefix)
+ if err != nil {
+ return "", errors.New("Invalid group id")
+ }
+
+ return string(internalGroupId), err
+}
+
+type SignalClient struct {
+ signalCliConfig string
+ attachmentTmpDir string
+ avatarTmpDir string
+ signalCliMode SignalCliMode
+ jsonRpc2ClientConfig *utils.JsonRpc2ClientConfig
+ jsonRpc2ClientConfigPath string
+ jsonRpc2Clients map[string]*JsonRpc2Client
+}
+
+func NewSignalClient(signalCliConfig string, attachmentTmpDir string, avatarTmpDir string, signalCliMode SignalCliMode,
+ jsonRpc2ClientConfigPath string) *SignalClient {
+ return &SignalClient{
+ signalCliConfig: signalCliConfig,
+ attachmentTmpDir: attachmentTmpDir,
+ avatarTmpDir: avatarTmpDir,
+ signalCliMode: signalCliMode,
+ jsonRpc2ClientConfigPath: jsonRpc2ClientConfigPath,
+ jsonRpc2Clients: make(map[string]*JsonRpc2Client),
+ }
+}
+
+func (s *SignalClient) GetSignalCliMode() SignalCliMode {
+ return s.signalCliMode
+}
+
+func (s *SignalClient) Init() error {
+ if s.signalCliMode == JsonRpc {
+ s.jsonRpc2ClientConfig = utils.NewJsonRpc2ClientConfig()
+ err := s.jsonRpc2ClientConfig.Load(s.jsonRpc2ClientConfigPath)
+ if err != nil {
+ return err
+ }
+
+ tcpPortsNumberMapping := s.jsonRpc2ClientConfig.GetTcpPortsForNumbers()
+ for number, tcpPort := range tcpPortsNumberMapping {
+ s.jsonRpc2Clients[number] = NewJsonRpc2Client()
+ err := s.jsonRpc2Clients[number].Dial("127.0.0.1:" + strconv.FormatInt(tcpPort, 10))
+ if err != nil {
+ return err
+ }
+
+ go s.jsonRpc2Clients[number].ReceiveData(number) //receive messages in goroutine
+ }
+ }
+ return nil
+}
+
+func (s *SignalClient) send(number string, message string,
+ recipients []string, base64Attachments []string, isGroup bool) (*SendResponse, error) {
+
+ var resp SendResponse
+
+ if len(recipients) == 0 {
+ return nil, errors.New("Please specify at least one recipient")
+ }
+
+ var groupId string = ""
+ if isGroup {
+ if len(recipients) > 1 {
+ return nil, errors.New("More than one recipient is currently not allowed")
+ }
+
+ grpId, err := base64.StdEncoding.DecodeString(recipients[0])
+ if err != nil {
+ return nil, errors.New("Invalid group id")
+ }
+ groupId = string(grpId)
+ }
+
+ attachmentTmpPaths := []string{}
+ for _, base64Attachment := range base64Attachments {
+ u, err := uuid.NewV4()
+ if err != nil {
+ return nil, err
+ }
+
+ dec, err := base64.StdEncoding.DecodeString(base64Attachment)
+ if err != nil {
+ return nil, err
+ }
+
+ mimeType := mimetype.Detect(dec)
+
+ attachmentTmpPath := s.attachmentTmpDir + u.String() + mimeType.Extension()
+ attachmentTmpPaths = append(attachmentTmpPaths, attachmentTmpPath)
+
+ f, err := os.Create(attachmentTmpPath)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ if _, err := f.Write(dec); err != nil {
+ cleanupTmpFiles(attachmentTmpPaths)
+ return nil, err
+ }
+ if err := f.Sync(); err != nil {
+ cleanupTmpFiles(attachmentTmpPaths)
+ return nil, err
+ }
+
+ f.Close()
+ }
+
+ if s.signalCliMode == JsonRpc {
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return nil, err
+ }
+
+ type Request struct {
+ Recipients []string `json:"recipient,omitempty"`
+ Message string `json:"message"`
+ GroupId string `json:"group-id,omitempty"`
+ Attachments []string `json:"attachment,omitempty"`
+ }
+
+ request := Request{Message: message}
+ if isGroup {
+ request.GroupId = groupId
+ } else {
+ request.Recipients = recipients
+ }
+ if len(attachmentTmpPaths) > 0 {
+ request.Attachments = append(request.Attachments, attachmentTmpPaths...)
+ }
+
+ rawData, err := jsonRpc2Client.getRaw("send", request)
+ if err != nil {
+ cleanupTmpFiles(attachmentTmpPaths)
+ return nil, err
+ }
+
+ err = json.Unmarshal([]byte(rawData), &resp)
+ if err != nil {
+ if strings.Contains(err.Error(), signalCliV2GroupError) {
+ return nil, errors.New("Cannot send message to group - please first update your profile.")
+ }
+ return nil, err
+ }
+ } else {
+ cmd := []string{"--config", s.signalCliConfig, "-u", number, "send"}
+ if !isGroup {
+ cmd = append(cmd, recipients...)
+ } else {
+ cmd = append(cmd, []string{"-g", groupId}...)
+ }
+
+ if len(attachmentTmpPaths) > 0 {
+ cmd = append(cmd, "-a")
+ cmd = append(cmd, attachmentTmpPaths...)
+ }
+
+ rawData, err := runSignalCli(true, cmd, message, s.signalCliMode)
+ if err != nil {
+ cleanupTmpFiles(attachmentTmpPaths)
+ if strings.Contains(err.Error(), signalCliV2GroupError) {
+ return nil, errors.New("Cannot send message to group - please first update your profile.")
+ }
+ return nil, err
+ }
+ resp.Timestamp, err = strconv.ParseInt(strings.TrimSuffix(rawData, "\n"), 10, 64)
+ if err != nil {
+ cleanupTmpFiles(attachmentTmpPaths)
+ return nil, err
+ }
+ }
+
+ cleanupTmpFiles(attachmentTmpPaths)
+
+ return &resp, nil
+}
+
+func (s *SignalClient) About() About {
+ about := About{SupportedApiVersions: []string{"v1", "v2"}, BuildNr: 2}
+ return about
+}
+
+func (s *SignalClient) RegisterNumber(number string, useVoice bool, captcha string) error {
+ if s.signalCliMode == JsonRpc {
+ return errors.New(endpointNotSupportedInJsonRpcMode)
+ }
+ command := []string{"--config", s.signalCliConfig, "-u", number, "register"}
+
+ if useVoice {
+ command = append(command, "--voice")
+ }
+
+ if captcha != "" {
+ command = append(command, []string{"--captcha", captcha}...)
+ }
+
+ _, err := runSignalCli(true, command, "", s.signalCliMode)
+ return err
+}
+
+func (s *SignalClient) VerifyRegisteredNumber(number string, token string, pin string) error {
+ if s.signalCliMode == JsonRpc {
+ return errors.New(endpointNotSupportedInJsonRpcMode)
+ }
+
+ cmd := []string{"--config", s.signalCliConfig, "-u", number, "verify", token}
+ if pin != "" {
+ cmd = append(cmd, "--pin")
+ cmd = append(cmd, pin)
+ }
+
+ _, err := runSignalCli(true, cmd, "", s.signalCliMode)
+ return err
+}
+
+func (s *SignalClient) SendV1(number string, message string, recipients []string, base64Attachments []string, isGroup bool) (*SendResponse, error) {
+ timestamp, err := s.send(number, message, recipients, base64Attachments, isGroup)
+ return timestamp, err
+}
+
+func (s *SignalClient) getJsonRpc2Client(number string) (*JsonRpc2Client, error) {
+ if val, ok := s.jsonRpc2Clients[number]; ok {
+ return val, nil
+ }
+ return nil, errors.New("Number not registered with JSON-RPC")
+}
+
+func (s *SignalClient) SendV2(number string, message string, recps []string, base64Attachments []string) (*[]SendResponse, error) {
+ if len(recps) == 0 {
+ return nil, errors.New("Please provide at least one recipient")
+ }
+
+ if number == "" {
+ return nil, errors.New("Please provide a valid number")
+ }
+
+ groups := []string{}
+ recipients := []string{}
+
+ for _, recipient := range recps {
+ if strings.HasPrefix(recipient, groupPrefix) {
+ groups = append(groups, strings.TrimPrefix(recipient, groupPrefix))
+ } else {
+ recipients = append(recipients, recipient)
+ }
+ }
+
+ if len(recipients) > 0 && len(groups) > 0 {
+ return nil, errors.New("Signal Messenger Groups and phone numbers cannot be specified together in one request! Please split them up into multiple REST API calls.")
+ }
+
+ if len(groups) > 1 {
+ return nil, errors.New("A signal message cannot be sent to more than one group at once! Please use multiple REST API calls for that.")
+ }
+
+ timestamps := []SendResponse{}
+ for _, group := range groups {
+ timestamp, err := s.send(number, message, []string{group}, base64Attachments, true)
+ if err != nil {
+ return nil, err
+ }
+ timestamps = append(timestamps, *timestamp)
+ }
+
+ if len(recipients) > 0 {
+ timestamp, err := s.send(number, message, recipients, base64Attachments, false)
+ if err != nil {
+ return nil, err
+ }
+ timestamps = append(timestamps, *timestamp)
+ }
+
+ return ×tamps, nil
+}
+
+func (s *SignalClient) Receive(number string, timeout int64) (string, error) {
+ if s.signalCliMode == JsonRpc {
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return "", err
+ }
+ msg := jsonRpc2Client.ReceiveMessage()
+ if msg.Err.Code != 0 {
+ return "", errors.New(msg.Err.Message)
+ }
+ return string(msg.Params), nil
+ } else {
+ command := []string{"--config", s.signalCliConfig, "--output", "json", "-u", number, "receive", "-t", strconv.FormatInt(timeout, 10)}
+
+ out, err := runSignalCli(true, command, "", s.signalCliMode)
+ if err != nil {
+ return "", err
+ }
+
+ out = strings.Trim(out, "\n")
+ lines := strings.Split(out, "\n")
+
+ jsonStr := "["
+ for i, line := range lines {
+ jsonStr += line
+ if i != (len(lines) - 1) {
+ jsonStr += ","
+ }
+ }
+ jsonStr += "]"
+
+ return jsonStr, nil
+ }
+}
+
+func (s *SignalClient) CreateGroup(number string, name string, members []string, description string, editGroupPermission GroupPermission, addMembersPermission GroupPermission, groupLinkState GroupLinkState) (string, error) {
+ var err error
+ var rawData string
+ if s.signalCliMode == JsonRpc {
+ type Request struct {
+ Name string `json:"name"`
+ Members []string `json:"members"`
+ }
+ request := Request{Name: name, Members: members}
+
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return "", err
+ }
+ rawData, err = jsonRpc2Client.getRaw("updateGroup", request)
+ if err != nil {
+ return "", err
+ }
+ } else {
+ cmd := []string{"--config", s.signalCliConfig, "-u", number, "updateGroup", "-n", name, "-m"}
+ cmd = append(cmd, members...)
+
+ if addMembersPermission != DefaultGroupPermission {
+ cmd = append(cmd, []string{"--set-permission-add-member", addMembersPermission.String()}...)
+ }
+
+ if editGroupPermission != DefaultGroupPermission {
+ cmd = append(cmd, []string{"--set-permission-edit-details", editGroupPermission.String()}...)
+ }
+
+ if groupLinkState != DefaultGroupLinkState {
+ cmd = append(cmd, []string{"--link", groupLinkState.String()}...)
+ }
+
+ if description != "" {
+ cmd = append(cmd, []string{"--description", description}...)
+ }
+
+ rawData, err = runSignalCli(true, cmd, "", s.signalCliMode)
+ if err != nil {
+ if strings.Contains(err.Error(), signalCliV2GroupError) {
+ return "", errors.New("Cannot create group - please first update your profile.")
+ }
+ return "", err
+ }
+ }
+ internalGroupId := getStringInBetween(rawData, `"`, `"`)
+ groupId := convertInternalGroupIdToGroupId(internalGroupId)
+
+ return groupId, nil
+}
+
+func (s *SignalClient) GetGroups(number string) ([]GroupEntry, error) {
+ groupEntries := []GroupEntry{}
+
+ var signalCliGroupEntries []SignalCliGroupEntry
+ var err error
+ var rawData string
+
+ if s.signalCliMode == JsonRpc {
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return groupEntries, err
+ }
+ rawData, err = jsonRpc2Client.getRaw("listGroups", nil)
+ if err != nil {
+ return groupEntries, err
+ }
+ } else {
+ rawData, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "--output", "json", "-u", number, "listGroups", "-d"}, "", s.signalCliMode)
+ if err != nil {
+ return groupEntries, err
+ }
+ }
+
+ err = json.Unmarshal([]byte(rawData), &signalCliGroupEntries)
+ if err != nil {
+ return groupEntries, err
+ }
+
+ for _, signalCliGroupEntry := range signalCliGroupEntries {
+ var groupEntry GroupEntry
+ groupEntry.InternalId = signalCliGroupEntry.Id
+ groupEntry.Name = signalCliGroupEntry.Name
+ groupEntry.Id = convertInternalGroupIdToGroupId(signalCliGroupEntry.Id)
+ groupEntry.Blocked = signalCliGroupEntry.IsBlocked
+
+ members := []string{}
+ for _, val := range signalCliGroupEntry.Members {
+ members = append(members, val.Number)
+ }
+ groupEntry.Members = members
+
+ groupEntry.PendingRequests = signalCliGroupEntry.PendingMembers
+ groupEntry.PendingInvites = signalCliGroupEntry.RequestingMembers
+ groupEntry.InviteLink = signalCliGroupEntry.GroupInviteLink
+
+ groupEntries = append(groupEntries, groupEntry)
+ }
+
+ return groupEntries, nil
+}
+
+func (s *SignalClient) GetGroup(number string, groupId string) (*GroupEntry, error) {
+ groupEntry := GroupEntry{}
+ groups, err := s.GetGroups(number)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, group := range groups {
+ if group.Id == groupId {
+ groupEntry = group
+ return &groupEntry, nil
+ }
+ }
+
+ return nil, nil
+}
+
+func (s *SignalClient) DeleteGroup(number string, groupId string) error {
+ _, err := runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "quitGroup", "-g", string(groupId)}, "", s.signalCliMode)
+ return err
+}
+
+func (s *SignalClient) GetQrCodeLink(deviceName string) ([]byte, error) {
+ if s.signalCliMode == JsonRpc {
+ return []byte{}, errors.New(endpointNotSupportedInJsonRpcMode)
+ }
+ command := []string{"--config", s.signalCliConfig, "link", "-n", deviceName}
+
+ tsdeviceLink, err := runSignalCli(false, command, "", s.signalCliMode)
+ if err != nil {
+ return []byte{}, errors.New("Couldn't create QR code: " + err.Error())
+ }
+
+ q, err := qrcode.New(string(tsdeviceLink), qrcode.Medium)
+ if err != nil {
+ return []byte{}, errors.New("Couldn't create QR code: " + err.Error())
+ }
+
+ q.DisableBorder = false
+ var png []byte
+ png, err = q.PNG(256)
+ if err != nil {
+ return []byte{}, errors.New("Couldn't create QR code: " + err.Error())
+ }
+ return png, nil
+}
+
+func (s *SignalClient) GetAttachments() ([]string, error) {
+ files := []string{}
+
+ err := filepath.Walk(s.signalCliConfig+"/attachments/", func(path string, info os.FileInfo, err error) error {
+ if info.IsDir() {
+ return nil
+ }
+ files = append(files, filepath.Base(path))
+ return nil
+ })
+
+ return files, err
+}
+
+func (s *SignalClient) RemoveAttachment(attachment string) error {
+ path, err := securejoin.SecureJoin(s.signalCliConfig+"/attachments/", attachment)
+ if err != nil {
+ return &InvalidNameError{Description: "Please provide a valid attachment name"}
+ }
+
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ return &NotFoundError{Description: "No attachment with that name found"}
+ }
+ err = os.Remove(path)
+ if err != nil {
+ return &InternalError{Description: "Couldn't delete attachment - please try again later"}
+ }
+
+ return nil
+}
+
+func (s *SignalClient) GetAttachment(attachment string) ([]byte, error) {
+ path, err := securejoin.SecureJoin(s.signalCliConfig+"/attachments/", attachment)
+ if err != nil {
+ return []byte{}, &InvalidNameError{Description: "Please provide a valid attachment name"}
+ }
+
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ return []byte{}, &NotFoundError{Description: "No attachment with that name found"}
+ }
+
+ attachmentBytes, err := ioutil.ReadFile(path)
+ if err != nil {
+ return []byte{}, &InternalError{Description: "Couldn't read attachment - please try again later"}
+ }
+
+ return attachmentBytes, nil
+}
+
+func (s *SignalClient) UpdateProfile(number string, profileName string, base64Avatar string) error {
+ var err error
+ var avatarTmpPath string
+ if base64Avatar != "" {
+ u, err := uuid.NewV4()
+ if err != nil {
+ return err
+ }
+
+ avatarBytes, err := base64.StdEncoding.DecodeString(base64Avatar)
+ if err != nil {
+ return errors.New("Couldn't decode base64 encoded avatar: " + err.Error())
+ }
+
+ fType, err := filetype.Get(avatarBytes)
+ if err != nil {
+ return err
+ }
+
+ avatarTmpPath := s.avatarTmpDir + u.String() + "." + fType.Extension
+
+ f, err := os.Create(avatarTmpPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ if _, err := f.Write(avatarBytes); err != nil {
+ cleanupTmpFiles([]string{avatarTmpPath})
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ cleanupTmpFiles([]string{avatarTmpPath})
+ return err
+ }
+ f.Close()
+ }
+
+ if s.signalCliMode == JsonRpc {
+ type Request struct {
+ Name string `json:"name"`
+ Avatar string `json:"avatar,omitempty"`
+ RemoveAvatar bool `json:"remove-avatar"`
+ }
+ request := Request{Name: profileName}
+ if base64Avatar == "" {
+ request.RemoveAvatar = true
+ } else {
+ request.Avatar = avatarTmpPath
+ request.RemoveAvatar = false
+ }
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return err
+ }
+ _, err = jsonRpc2Client.getRaw("updateProfile", request)
+ } else {
+ cmd := []string{"--config", s.signalCliConfig, "-u", number, "updateProfile", "--name", profileName}
+ if base64Avatar == "" {
+ cmd = append(cmd, "--remove-avatar")
+ } else {
+ cmd = append(cmd, []string{"--avatar", avatarTmpPath}...)
+ }
+
+ _, err = runSignalCli(true, cmd, "", s.signalCliMode)
+ }
+
+ cleanupTmpFiles([]string{avatarTmpPath})
+ return err
+}
+
+func (s *SignalClient) ListIdentities(number string) (*[]IdentityEntry, error) {
+ identityEntries := []IdentityEntry{}
+ if s.signalCliMode == JsonRpc {
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return nil, err
+ }
+ rawData, err := jsonRpc2Client.getRaw("listIdentities", nil)
+ signalCliIdentityEntries := []SignalCliIdentityEntry{}
+ err = json.Unmarshal([]byte(rawData), &signalCliIdentityEntries)
+ if err != nil {
+ return nil, err
+ }
+ for _, signalCliIdentityEntry := range signalCliIdentityEntries {
+ identityEntry := IdentityEntry{
+ Number: signalCliIdentityEntry.Number,
+ Status: signalCliIdentityEntry.TrustLevel,
+ Added: strconv.FormatInt(signalCliIdentityEntry.AddedTimestamp, 10),
+ Fingerprint: signalCliIdentityEntry.Fingerprint,
+ SafetyNumber: signalCliIdentityEntry.SafetyNumber,
+ }
+ identityEntries = append(identityEntries, identityEntry)
+ }
+ } else {
+ rawData, err := runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "listIdentities"}, "", s.signalCliMode)
+ if err != nil {
+ return nil, err
+ }
+
+ keyValuePairs := parseWhitespaceDelimitedKeyValueStringList(rawData, []string{"NumberAndTrustStatus", "Added", "Fingerprint", "Safety Number"})
+ for _, keyValuePair := range keyValuePairs {
+ numberAndTrustStatus := keyValuePair["NumberAndTrustStatus"]
+ numberAndTrustStatusSplitted := strings.Split(numberAndTrustStatus, ":")
+
+ identityEntry := IdentityEntry{Number: strings.Trim(numberAndTrustStatusSplitted[0], " "),
+ Status: strings.Trim(numberAndTrustStatusSplitted[1], " "),
+ Added: keyValuePair["Added"],
+ Fingerprint: strings.Trim(keyValuePair["Fingerprint"], " "),
+ SafetyNumber: strings.Trim(keyValuePair["Safety Number"], " "),
+ }
+ identityEntries = append(identityEntries, identityEntry)
+ }
+ }
+
+ return &identityEntries, nil
+}
+
+func (s *SignalClient) TrustIdentity(number string, numberToTrust string, verifiedSafetyNumber string) error {
+ var err error
+ if s.signalCliMode == JsonRpc {
+ type Request struct {
+ VerifiedSafetyNumber string `json:"verified-safety-number"`
+ Recipient string `json:"recipient"`
+ }
+ request := Request{VerifiedSafetyNumber: verifiedSafetyNumber, Recipient: numberToTrust}
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return err
+ }
+ _, err = jsonRpc2Client.getRaw("trust", request)
+ } else {
+ cmd := []string{"--config", s.signalCliConfig, "-u", number, "trust", numberToTrust, "--verified-safety-number", verifiedSafetyNumber}
+ _, err = runSignalCli(true, cmd, "", s.signalCliMode)
+ }
+ return err
+}
+
+func (s *SignalClient) BlockGroup(number string, groupId string) error {
+ var err error
+ if s.signalCliMode == JsonRpc {
+ type Request struct {
+ GroupId string `json:"groupId"`
+ }
+ request := Request{GroupId: groupId}
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return err
+ }
+ _, err = jsonRpc2Client.getRaw("updateGroup", request)
+ } else {
+ _, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "block", "-g", groupId}, "", s.signalCliMode)
+ }
+ return err
+}
+
+func (s *SignalClient) JoinGroup(number string, groupId string) error {
+ var err error
+ if s.signalCliMode == JsonRpc {
+ type Request struct {
+ GroupId string `json:"groupId"`
+ }
+ request := Request{GroupId: groupId}
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return err
+ }
+ _, err = jsonRpc2Client.getRaw("updateGroup", request)
+ } else {
+ _, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "updateGroup", "-g", groupId}, "", s.signalCliMode)
+ }
+ return err
+}
+
+func (s *SignalClient) QuitGroup(number string, groupId string) error {
+ var err error
+ if s.signalCliMode == JsonRpc {
+ type Request struct {
+ GroupId string `json:"groupId"`
+ }
+ request := Request{GroupId: groupId}
+ jsonRpc2Client, err := s.getJsonRpc2Client(number)
+ if err != nil {
+ return err
+ }
+ _, err = jsonRpc2Client.getRaw("quitGroup", request)
+ } else {
+ _, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "quitGroup", "-g", groupId}, "", s.signalCliMode)
+ }
+ return err
+}
diff --git a/src/client/errors.go b/src/client/errors.go
new file mode 100644
index 0000000..5344334
--- /dev/null
+++ b/src/client/errors.go
@@ -0,0 +1,25 @@
+package client
+
+type InvalidNameError struct {
+ Description string
+}
+
+func (e *InvalidNameError) Error() string {
+ return e.Description
+}
+
+type NotFoundError struct {
+ Description string
+}
+
+func (e *NotFoundError) Error() string {
+ return e.Description
+}
+
+type InternalError struct {
+ Description string
+}
+
+func (e *InternalError) Error() string {
+ return e.Description
+}
diff --git a/src/client/jsonrpc2.go b/src/client/jsonrpc2.go
new file mode 100644
index 0000000..c78d368
--- /dev/null
+++ b/src/client/jsonrpc2.go
@@ -0,0 +1,134 @@
+package client
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ uuid "github.com/gofrs/uuid"
+ log "github.com/sirupsen/logrus"
+ "net"
+)
+
+type Error struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+type JsonRpc2MessageResponse struct {
+ Id string `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Err Error `json:"error"`
+}
+
+type JsonRpc2ReceivedMessage struct {
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+ Err Error `json:"error"`
+}
+
+type JsonRpc2Client struct {
+ conn net.Conn
+ receivedMessageResponses chan JsonRpc2MessageResponse
+ receivedMessages chan JsonRpc2ReceivedMessage
+}
+
+func NewJsonRpc2Client() *JsonRpc2Client {
+ return &JsonRpc2Client{}
+}
+
+func (r *JsonRpc2Client) Dial(address string) error {
+ var err error
+ r.conn, err = net.Dial("tcp", address)
+ if err != nil {
+ return err
+ }
+
+ r.receivedMessageResponses = make(chan JsonRpc2MessageResponse)
+ r.receivedMessages = make(chan JsonRpc2ReceivedMessage)
+
+ return nil
+}
+
+func (r *JsonRpc2Client) getRaw(command string, args interface{}) (string, error) {
+ type Request struct {
+ JsonRpc string `json:"jsonrpc"`
+ Method string `json:"method"`
+ Id string `json:"id"`
+ Params interface{} `json:"params,omitempty"`
+ }
+
+ u, err := uuid.NewV4()
+ if err != nil {
+ return "", err
+ }
+
+ fullCommand := Request{JsonRpc: "2.0", Method: command, Id: u.String()}
+ if args != nil {
+ fullCommand.Params = args
+ }
+
+ fullCommandBytes, err := json.Marshal(fullCommand)
+ if err != nil {
+ return "", err
+ }
+
+ log.Debug("full command: ", string(fullCommandBytes))
+
+ _, err = r.conn.Write([]byte(string(fullCommandBytes) + "\n"))
+ if err != nil {
+ return "", err
+ }
+
+ var resp JsonRpc2MessageResponse
+ for {
+ resp = <-r.receivedMessageResponses
+ if resp.Id == u.String() {
+ break
+ }
+ }
+
+ if resp.Err.Code != 0 {
+ return "", errors.New(resp.Err.Message)
+ }
+ return string(resp.Result), nil
+}
+
+func (r *JsonRpc2Client) ReceiveData(number string) {
+ connbuf := bufio.NewReader(r.conn)
+ for {
+ str, err := connbuf.ReadString('\n')
+ if err != nil {
+ log.Error("Couldn't read data for number ", number, ": ", err.Error(), ". Is the number properly registered?")
+ continue
+ }
+ //log.Info("Received data = ", str)
+
+ var resp1 JsonRpc2ReceivedMessage
+ json.Unmarshal([]byte(str), &resp1)
+ if resp1.Method == "receive" {
+ select {
+ case r.receivedMessages <- resp1:
+ log.Debug("Message sent to golang channel")
+ default:
+ log.Debug("Couldn't send message to golang channel, as there's no receiver")
+ }
+ continue
+ }
+
+ var resp2 JsonRpc2MessageResponse
+ err = json.Unmarshal([]byte(str), &resp2)
+ if err == nil {
+ if resp2.Id != "" {
+ r.receivedMessageResponses <- resp2
+ }
+ } else {
+ log.Error("Received unparsable message: ", str)
+ }
+ }
+}
+
+//blocks until message a message is received
+func (r *JsonRpc2Client) ReceiveMessage() JsonRpc2ReceivedMessage {
+ resp := <-r.receivedMessages
+ return resp
+}
diff --git a/src/docs/docs.go b/src/docs/docs.go
index 4793a65..4279001 100644
--- a/src/docs/docs.go
+++ b/src/docs/docs.go
@@ -39,7 +39,7 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.About"
+ "$ref": "#/definitions/client.About"
}
}
}
@@ -237,7 +237,7 @@ var doc = `{
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/api.GroupEntry"
+ "$ref": "#/definitions/client.GroupEntry"
}
}
},
@@ -328,7 +328,7 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.GroupEntry"
+ "$ref": "#/definitions/client.GroupEntry"
}
},
"400": {
@@ -563,7 +563,7 @@ var doc = `{
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/api.IdentityEntry"
+ "$ref": "#/definitions/client.IdentityEntry"
}
}
}
@@ -696,7 +696,7 @@ var doc = `{
},
"/v1/receive/{number}": {
"get": {
- "description": "Receives Signal Messages from the Signal Network.",
+ "description": "Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint.",
"consumes": [
"application/json"
],
@@ -918,20 +918,6 @@ var doc = `{
}
},
"definitions": {
- "api.About": {
- "type": "object",
- "properties": {
- "build": {
- "type": "integer"
- },
- "versions": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
"api.Configuration": {
"type": "object",
"properties": {
@@ -986,44 +972,6 @@ var doc = `{
}
}
},
- "api.GroupEntry": {
- "type": "object",
- "properties": {
- "blocked": {
- "type": "boolean"
- },
- "id": {
- "type": "string"
- },
- "internal_id": {
- "type": "string"
- },
- "invite_link": {
- "type": "string"
- },
- "members": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "name": {
- "type": "string"
- },
- "pending_invites": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "pending_requests": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
"api.GroupPermissions": {
"type": "object",
"properties": {
@@ -1043,26 +991,6 @@ var doc = `{
}
}
},
- "api.IdentityEntry": {
- "type": "object",
- "properties": {
- "added": {
- "type": "string"
- },
- "fingerprint": {
- "type": "string"
- },
- "number": {
- "type": "string"
- },
- "safety_number": {
- "type": "string"
- },
- "status": {
- "type": "string"
- }
- }
- },
"api.LoggingConfiguration": {
"type": "object",
"properties": {
@@ -1162,6 +1090,78 @@ var doc = `{
"type": "string"
}
}
+ },
+ "client.About": {
+ "type": "object",
+ "properties": {
+ "build": {
+ "type": "integer"
+ },
+ "versions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "client.GroupEntry": {
+ "type": "object",
+ "properties": {
+ "blocked": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "string"
+ },
+ "internal_id": {
+ "type": "string"
+ },
+ "invite_link": {
+ "type": "string"
+ },
+ "members": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "name": {
+ "type": "string"
+ },
+ "pending_invites": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "pending_requests": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "client.IdentityEntry": {
+ "type": "object",
+ "properties": {
+ "added": {
+ "type": "string"
+ },
+ "fingerprint": {
+ "type": "string"
+ },
+ "number": {
+ "type": "string"
+ },
+ "safety_number": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ }
}
},
"tags": [
diff --git a/src/docs/swagger.json b/src/docs/swagger.json
index 28b735c..b7fcad0 100644
--- a/src/docs/swagger.json
+++ b/src/docs/swagger.json
@@ -24,7 +24,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.About"
+ "$ref": "#/definitions/client.About"
}
}
}
@@ -222,7 +222,7 @@
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/api.GroupEntry"
+ "$ref": "#/definitions/client.GroupEntry"
}
}
},
@@ -313,7 +313,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.GroupEntry"
+ "$ref": "#/definitions/client.GroupEntry"
}
},
"400": {
@@ -548,7 +548,7 @@
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/api.IdentityEntry"
+ "$ref": "#/definitions/client.IdentityEntry"
}
}
}
@@ -681,7 +681,7 @@
},
"/v1/receive/{number}": {
"get": {
- "description": "Receives Signal Messages from the Signal Network.",
+ "description": "Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint.",
"consumes": [
"application/json"
],
@@ -903,20 +903,6 @@
}
},
"definitions": {
- "api.About": {
- "type": "object",
- "properties": {
- "build": {
- "type": "integer"
- },
- "versions": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
"api.Configuration": {
"type": "object",
"properties": {
@@ -971,44 +957,6 @@
}
}
},
- "api.GroupEntry": {
- "type": "object",
- "properties": {
- "blocked": {
- "type": "boolean"
- },
- "id": {
- "type": "string"
- },
- "internal_id": {
- "type": "string"
- },
- "invite_link": {
- "type": "string"
- },
- "members": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "name": {
- "type": "string"
- },
- "pending_invites": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "pending_requests": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
"api.GroupPermissions": {
"type": "object",
"properties": {
@@ -1028,26 +976,6 @@
}
}
},
- "api.IdentityEntry": {
- "type": "object",
- "properties": {
- "added": {
- "type": "string"
- },
- "fingerprint": {
- "type": "string"
- },
- "number": {
- "type": "string"
- },
- "safety_number": {
- "type": "string"
- },
- "status": {
- "type": "string"
- }
- }
- },
"api.LoggingConfiguration": {
"type": "object",
"properties": {
@@ -1147,6 +1075,78 @@
"type": "string"
}
}
+ },
+ "client.About": {
+ "type": "object",
+ "properties": {
+ "build": {
+ "type": "integer"
+ },
+ "versions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "client.GroupEntry": {
+ "type": "object",
+ "properties": {
+ "blocked": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "string"
+ },
+ "internal_id": {
+ "type": "string"
+ },
+ "invite_link": {
+ "type": "string"
+ },
+ "members": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "name": {
+ "type": "string"
+ },
+ "pending_invites": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "pending_requests": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "client.IdentityEntry": {
+ "type": "object",
+ "properties": {
+ "added": {
+ "type": "string"
+ },
+ "fingerprint": {
+ "type": "string"
+ },
+ "number": {
+ "type": "string"
+ },
+ "safety_number": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ }
}
},
"tags": [
diff --git a/src/docs/swagger.yaml b/src/docs/swagger.yaml
index f5fdc6a..139e792 100644
--- a/src/docs/swagger.yaml
+++ b/src/docs/swagger.yaml
@@ -1,14 +1,5 @@
basePath: /
definitions:
- api.About:
- properties:
- build:
- type: integer
- versions:
- items:
- type: string
- type: array
- type: object
api.Configuration:
properties:
logging:
@@ -45,31 +36,6 @@ definitions:
error:
type: string
type: object
- api.GroupEntry:
- properties:
- blocked:
- type: boolean
- id:
- type: string
- internal_id:
- type: string
- invite_link:
- type: string
- members:
- items:
- type: string
- type: array
- name:
- type: string
- pending_invites:
- items:
- type: string
- type: array
- pending_requests:
- items:
- type: string
- type: array
- type: object
api.GroupPermissions:
properties:
add_members:
@@ -83,19 +49,6 @@ definitions:
- every-member
type: string
type: object
- api.IdentityEntry:
- properties:
- added:
- type: string
- fingerprint:
- type: string
- number:
- type: string
- safety_number:
- type: string
- status:
- type: string
- type: object
api.LoggingConfiguration:
properties:
Level:
@@ -160,6 +113,53 @@ definitions:
pin:
type: string
type: object
+ client.About:
+ properties:
+ build:
+ type: integer
+ versions:
+ items:
+ type: string
+ type: array
+ type: object
+ client.GroupEntry:
+ properties:
+ blocked:
+ type: boolean
+ id:
+ type: string
+ internal_id:
+ type: string
+ invite_link:
+ type: string
+ members:
+ items:
+ type: string
+ type: array
+ name:
+ type: string
+ pending_invites:
+ items:
+ type: string
+ type: array
+ pending_requests:
+ items:
+ type: string
+ type: array
+ type: object
+ client.IdentityEntry:
+ properties:
+ added:
+ type: string
+ fingerprint:
+ type: string
+ number:
+ type: string
+ safety_number:
+ type: string
+ status:
+ type: string
+ type: object
host: 127.0.0.1:8080
info:
contact: {}
@@ -177,7 +177,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.About'
+ $ref: '#/definitions/client.About'
summary: Lists general information about the API
tags:
- General
@@ -307,7 +307,7 @@ paths:
description: OK
schema:
items:
- $ref: '#/definitions/api.GroupEntry'
+ $ref: '#/definitions/client.GroupEntry'
type: array
"400":
description: Bad Request
@@ -397,7 +397,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.GroupEntry'
+ $ref: '#/definitions/client.GroupEntry'
"400":
description: Bad Request
schema:
@@ -524,7 +524,7 @@ paths:
description: OK
schema:
items:
- $ref: '#/definitions/api.IdentityEntry'
+ $ref: '#/definitions/client.IdentityEntry'
type: array
summary: List Identities
tags:
@@ -615,7 +615,7 @@ paths:
get:
consumes:
- application/json
- description: Receives Signal Messages from the Signal Network.
+ description: Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint.
parameters:
- description: Registered Phone Number
in: path
diff --git a/src/go.mod b/src/go.mod
index 3bc5b8d..56b5303 100644
--- a/src/go.mod
+++ b/src/go.mod
@@ -11,6 +11,7 @@ require (
github.com/go-openapi/spec v0.19.8 // indirect
github.com/go-openapi/swag v0.19.9 // indirect
github.com/gofrs/uuid v3.3.0+incompatible
+ github.com/gorilla/websocket v1.4.2
github.com/h2non/filetype v1.1.0
github.com/mailru/easyjson v0.7.1 // indirect
github.com/robfig/cron/v3 v3.0.1
@@ -23,5 +24,5 @@ require (
golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect
golang.org/x/text v0.3.3 // indirect
golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f // indirect
- gopkg.in/yaml.v2 v2.3.0 // indirect
+ gopkg.in/yaml.v2 v2.3.0
)
diff --git a/src/go.sum b/src/go.sum
index 0199f7c..236835e 100644
--- a/src/go.sum
+++ b/src/go.sum
@@ -16,6 +16,7 @@ github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrP
github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.1.2 h1:gaPnPcNor5aZSVCJVSGipcpbgMWiAAj9z182ocSGbHU=
github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
@@ -68,8 +69,11 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA=
github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -95,6 +99,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -143,6 +149,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -156,9 +163,11 @@ golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -188,8 +197,10 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/src/main.go b/src/main.go
index f712e47..3c1480c 100644
--- a/src/main.go
+++ b/src/main.go
@@ -3,13 +3,15 @@ package main
import (
"encoding/json"
"flag"
- "strings"
- "strconv"
+ "io/ioutil"
"net/http"
"os"
"path/filepath"
- "io/ioutil"
+ "strconv"
+ "strings"
+
"github.com/bbernhard/signal-cli-rest-api/api"
+ "github.com/bbernhard/signal-cli-rest-api/client"
_ "github.com/bbernhard/signal-cli-rest-api/docs"
"github.com/bbernhard/signal-cli-rest-api/utils"
"github.com/gin-gonic/gin"
@@ -71,7 +73,56 @@ func main() {
log.Fatal("Couldn't set env variable: ", err.Error())
}
- api := api.NewApi(*signalCliConfig, *attachmentTmpDir, *avatarTmpDir)
+ useNative := utils.GetEnv("USE_NATIVE", "")
+ if useNative != "" {
+ log.Warning("The env variable USE_NATIVE is deprecated. Please use the env variable MODE instead")
+ }
+
+ signalCliMode := client.Normal
+ mode := utils.GetEnv("MODE", "normal")
+ if mode == "normal" {
+ signalCliMode = client.Normal
+ } else if mode == "json-rpc" {
+ signalCliMode = client.JsonRpc
+ } else if mode == "native" {
+ signalCliMode = client.Native
+ }
+
+ if useNative != "" {
+ _, modeEnvVariableSet := os.LookupEnv("MODE")
+ if modeEnvVariableSet {
+ log.Fatal("You have both the USE_NATIVE and the MODE env variable set. Please remove the deprecated env variable USE_NATIVE!")
+ }
+ }
+
+ if useNative == "1" || signalCliMode == client.Native {
+ if supportsSignalCliNative == "0" {
+ log.Error("signal-cli-native is not support on this system...falling back to signal-cli")
+ signalCliMode = client.Normal
+ }
+ }
+
+ if signalCliMode == client.JsonRpc {
+ _, autoReceiveScheduleEnvVariableSet := os.LookupEnv("AUTO_RECEIVE_SCHEDULE")
+ if autoReceiveScheduleEnvVariableSet {
+ log.Fatal("Env variable AUTO_RECEIVE_SCHEDULE can't be used with mode json-rpc")
+ }
+
+ _, signalCliCommandTimeoutEnvVariableSet := os.LookupEnv("SIGNAL_CLI_CMD_TIMEOUT")
+ if signalCliCommandTimeoutEnvVariableSet {
+ log.Fatal("Env variable SIGNAL_CLI_CMD_TIMEOUT can't be used with mode json-rpc")
+ }
+ }
+
+
+ jsonRpc2ClientConfigPathPath := *signalCliConfig + "/jsonrpc2.yml"
+ signalClient := client.NewSignalClient(*signalCliConfig, *attachmentTmpDir, *avatarTmpDir, signalCliMode, jsonRpc2ClientConfigPathPath)
+ err = signalClient.Init()
+ if err != nil {
+ log.Fatal("Couldn't init Signal Client: ", err.Error())
+ }
+
+ api := api.NewApi(signalClient)
v1 := router.Group("/v1")
{
about := v1.Group("/about")
diff --git a/src/scripts/jsonrpc2-helper.go b/src/scripts/jsonrpc2-helper.go
new file mode 100644
index 0000000..759b371
--- /dev/null
+++ b/src/scripts/jsonrpc2-helper.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "github.com/bbernhard/signal-cli-rest-api/utils"
+ log "github.com/sirupsen/logrus"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+const supervisorctlConfigTemplate = `
+[program:%s]
+environment=JAVA_HOME=/opt/java/openjdk
+process_name=%s
+command=bash -c "nc -l -p %d <%s | signal-cli --output=json -u %s --config /home/.local/share/signal-cli/ jsonRpc >%s"
+autostart=true
+autorestart=true
+startretries=10
+user=signal-api
+directory=/usr/bin/
+redirect_stderr=true
+stdout_logfile=/var/log/%s/out.log
+stderr_logfile=/var/log/%s/err.log
+stdout_logfile_maxbytes=50MB
+stdout_logfile_backups=10
+numprocs=1
+`
+
+func main() {
+ signalCliConfigDir := flag.String("signal-cli-config-dir", "/home/.local/share/signal-cli/", "Path to signal-cli config directory")
+ signalCliConfigDataDir := *signalCliConfigDir + "data"
+
+ jsonRpc2ClientConfig := utils.NewJsonRpc2ClientConfig()
+
+ var tcpBasePort int64 = 6000
+ fifoBasePathName := "/tmp/sigsocket"
+ var ctr int64 = 0
+
+ items, err := ioutil.ReadDir(signalCliConfigDataDir)
+ if err != nil {
+ log.Fatal("Couldn't read contents of ", signalCliConfigDataDir)
+ }
+ for _, item := range items {
+ if item.IsDir() {
+ continue
+ }
+ filename := filepath.Base(item.Name())
+ if strings.HasPrefix(filename, "+") {
+ if utils.IsPhoneNumber(filename) {
+ number := filename
+ fifoPathname := fifoBasePathName + strconv.FormatInt(ctr, 10)
+ tcpPort := tcpBasePort + ctr
+ jsonRpc2ClientConfig.AddEntry(number, utils.ConfigEntry{TcpPort: tcpPort, FifoPathname: fifoPathname})
+ ctr += 1
+
+ os.Remove(fifoPathname) //remove any existing named pipe
+
+ _, err = exec.Command("mkfifo", fifoPathname).Output()
+ if err != nil {
+ log.Fatal("Couldn't create fifo with name ", fifoPathname, ": ", err.Error())
+ }
+
+ _, err = exec.Command("chown", "1000:1000", fifoPathname).Output()
+ if err != nil {
+ log.Fatal("Couldn't change permissions of fifo with name ", fifoPathname, ": ", err.Error())
+ }
+
+ supervisorctlProgramName := "signal-cli-json-rpc-" + strconv.FormatInt(ctr, 10)
+ supervisorctlLogFolder := "/var/log/" + supervisorctlProgramName
+ _, err = exec.Command("mkdir", "-p", supervisorctlLogFolder).Output()
+ if err != nil {
+ log.Fatal("Couldn't create log folder ", supervisorctlLogFolder, ": ", err.Error())
+ }
+
+ log.Info("Found number ", number, " and added it to jsonrpc2.yml")
+
+ //write supervisorctl config
+ supervisorctlConfigFilename := "/etc/supervisor/conf.d/" + "signal-cli-json-rpc-" + strconv.FormatInt(ctr, 10) + ".conf"
+ supervisorctlConfig := fmt.Sprintf(supervisorctlConfigTemplate, supervisorctlProgramName, supervisorctlProgramName,
+ tcpPort, fifoPathname, number, fifoPathname, supervisorctlProgramName, supervisorctlProgramName)
+ err = ioutil.WriteFile(supervisorctlConfigFilename, []byte(supervisorctlConfig), 0644)
+ if err != nil {
+ log.Fatal("Couldn't write ", supervisorctlConfigFilename, ": ", err.Error())
+ }
+ } else {
+ log.Error("Skipping ", filename, " as it is not a valid phone number!")
+ }
+ }
+ }
+
+ // write jsonrpc.yml config file
+ err = jsonRpc2ClientConfig.Persist(*signalCliConfigDir + "jsonrpc2.yml")
+ if err != nil {
+ log.Fatal("Couldn't persist jsonrpc2.yaml: ", err.Error())
+ }
+}
diff --git a/src/utils/config.go b/src/utils/config.go
new file mode 100644
index 0000000..dff209e
--- /dev/null
+++ b/src/utils/config.go
@@ -0,0 +1,79 @@
+package utils
+
+import (
+ "errors"
+ "gopkg.in/yaml.v2"
+ "io/ioutil"
+)
+
+type ConfigEntry struct {
+ TcpPort int64 `yaml:"tcp_port"`
+ FifoPathname string `yaml:"fifo_pathname"`
+}
+
+type Config struct {
+ Entries map[string]ConfigEntry `yaml:"config,omitempty"`
+}
+
+type JsonRpc2ClientConfig struct {
+ config Config
+}
+
+func NewJsonRpc2ClientConfig() *JsonRpc2ClientConfig {
+ return &JsonRpc2ClientConfig{}
+}
+
+func (c *JsonRpc2ClientConfig) Load(path string) error {
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ err = yaml.Unmarshal(data, &c.config)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *JsonRpc2ClientConfig) GetTcpPortForNumber(number string) (int64, error) {
+ if val, ok := c.config.Entries[number]; ok {
+ return val.TcpPort, nil
+ }
+
+ return 0, errors.New("Number " + number + " not found in local map")
+}
+
+func (c *JsonRpc2ClientConfig) GetFifoPathnameForNumber(number string) (string, error) {
+ if val, ok := c.config.Entries[number]; ok {
+ return val.FifoPathname, nil
+ }
+
+ return "", errors.New("Number " + number + " not found in local map")
+}
+
+func (c *JsonRpc2ClientConfig) GetTcpPortsForNumbers() map[string]int64 {
+ mapping := make(map[string]int64)
+ for number, val := range c.config.Entries {
+ mapping[number] = val.TcpPort
+ }
+
+ return mapping
+}
+
+func (c *JsonRpc2ClientConfig) AddEntry(number string, configEntry ConfigEntry) {
+ if c.config.Entries == nil {
+ c.config.Entries = make(map[string]ConfigEntry)
+ }
+ c.config.Entries[number] = configEntry
+}
+
+func (c *JsonRpc2ClientConfig) Persist(path string) error {
+ out, err := yaml.Marshal(&c.config)
+ if err != nil {
+ return err
+ }
+
+ return ioutil.WriteFile(path, out, 0644)
+}
diff --git a/src/utils/utils.go b/src/utils/utils.go
index c534ee3..70612bd 100644
--- a/src/utils/utils.go
+++ b/src/utils/utils.go
@@ -31,3 +31,18 @@ func StringInSlice(a string, list []string) bool {
}
return false
}
+
+func IsPhoneNumber(s string) bool {
+ for index, c := range s {
+ if index == 0 {
+ if c != '+' {
+ return false
+ }
+ } else {
+ if c < '0' || c > '9' {
+ return false
+ }
+ }
+ }
+ return true
+}