diff --git a/README.md b/README.md index 2827ccf..82c6554 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,114 @@ -# Dockerized Signal Messenger REST API - -This project creates a small dockerized REST API around [signal-cli](https://github.com/AsamK/signal-cli). - - -At the moment, the following functionality is exposed via REST: - -* Register a number -* Verify the number using the code received via SMS -* Send message (+ attachment) to multiple recipients - - -## Examples - -Sample `docker-compose.yml`file: - -``` -version: "3" -services: - signal-cli-rest-api: - image: bbernhard/signal-cli-rest-api:latest - ports: - - "8080:8080" #map docker port 8080 to host port 8080. - network_mode: "host" - volumes: - - "./signal-cli-config:/home/.local/share/signal-cli" #map "signal-cli-config" folder on host system into docker container. the folder contains the password and cryptographic keys when a new number is registered - -``` - -Sample REST API calls: - -* Register a number (with SMS verification) - -```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register/'``` - - e.g: - - ```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register/+431212131491291'``` - -* Register a number (with voice verification) - -```curl -X POST -H "Content-Type: application/json" --data '{"use_voice": true}' 'http://127.0.0.1:8080/v1/register/'``` - - e.g: - - ```curl -X POST -H "Content-Type: application/json" --data '{"use_voice": true}' 'http://127.0.0.1:8080/v1/register/+431212131491291'``` - -* Verify the number using the code received via SMS/voice - - ```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register//verify/'``` - - e.g: - - ```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register/+431212131491291/verify/123-456'``` - -* Send message to multiple recipients - - ```curl -X POST -H "Content-Type: application/json" -d '{"message": "", "number": "", "recipients": ["", ""]}' 'http://127.0.0.1:8080/v1/send'``` - - e.g: - - ```curl -X POST -H "Content-Type: application/json" -d '{"message": "Hello World!", "number": "+431212131491291", "recipients": ["+4354546464654", "+4912812812121"]}' 'http://127.0.0.1:8080/v1/send'``` - -* Send a message (+ base64 encoded attachment) to multiple recipients - - ```curl -X POST -H "Content-Type: application/json" -d '{"message": "", "base64_attachment": "", "number": "", "recipients": ["", ""]}' 'http://127.0.0.1:8080/v1/send'``` - -* Receive messages - - Fetch all new messages in the inbox of the specified number. - - ```curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/receive/'``` - - e.g: - - ```curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/receive/+431212131491291'``` - -In case you need more functionality, please **file a ticket** or **create a PR** +# Dockerized Signal Messenger REST API + +This project creates a small dockerized REST API around [signal-cli](https://github.com/AsamK/signal-cli). + + +At the moment, the following functionality is exposed via REST: + +* Register a number +* Verify the number using the code received via SMS +* Send message (+ attachment) to multiple recipients + + +## Examples + +Sample `docker-compose.yml`file: + +``` +version: "3" +services: + signal-cli-rest-api: + image: bbernhard/signal-cli-rest-api:latest + ports: + - "8080:8080" #map docker port 8080 to host port 8080. + network_mode: "host" + volumes: + - "./signal-cli-config:/home/.local/share/signal-cli" #map "signal-cli-config" folder on host system into docker container. the folder contains the password and cryptographic keys when a new number is registered + +``` + +Sample REST API calls: + +* Register a number (with SMS verification) + +```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register/'``` + + e.g: + + ```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register/+431212131491291'``` + +* Register a number (with voice verification) + +```curl -X POST -H "Content-Type: application/json" --data '{"use_voice": true}' 'http://127.0.0.1:8080/v1/register/'``` + + e.g: + + ```curl -X POST -H "Content-Type: application/json" --data '{"use_voice": true}' 'http://127.0.0.1:8080/v1/register/+431212131491291'``` + +* Verify the number using the code received via SMS/voice + + ```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register//verify/'``` + + e.g: + + ```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register/+431212131491291/verify/123-456'``` + +* Send message to multiple recipients + + ```curl -X POST -H "Content-Type: application/json" -d '{"message": "", "number": "", "recipients": ["", ""]}' 'http://127.0.0.1:8080/v1/send'``` + + e.g: + + ```curl -X POST -H "Content-Type: application/json" -d '{"message": "Hello World!", "number": "+431212131491291", "recipients": ["+4354546464654", "+4912812812121"]}' 'http://127.0.0.1:8080/v1/send'``` + +* Send a message (+ base64 encoded attachment) to multiple recipients + + ```curl -X POST -H "Content-Type: application/json" -d '{"message": "", "base64_attachment": "", "number": "", "recipients": ["", ""]}' 'http://127.0.0.1:8080/v1/send'``` + +* Receive messages + + Fetch all new messages in the inbox of the specified number. + + ```curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/receive/'``` + + e.g: + + ```curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/receive/+431212131491291'``` + +* Create a new group + + Create a new group with the specified name and members. + + ```curl -X POST -H "Content-Type: application/json" -d '{"name": "", "members": ["", ""]}' 'http://127.0.0.1:8080/v1/groups/'``` + + e.g: + + ```curl -X POST -H "Content-Type: application/json" -d '{"name": "my group", "members": ["+4354546464654", "+4912812812121"]}' 'http://127.0.0.1:8080/v1/groups/+431212131491291'``` + +* List groups + + ```curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/groups/'``` + + e.g: + + ```curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/groups/+431212131491291'``` + +* Delete a group + + Delete the group with the given group id. The group id can be obtained via the "List groups" REST call. + + ```curl -X DELETE -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/groups//'``` + + e.g: + + ```curl -X DELETE -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/groups/+431212131491291/ckRzaEd4VmRzNnJaASAEsasa'``` + +* Send a message to a group + + ```curl -X POST -H "Content-Type: application/json" -d '{"message": "Hello World!", "number": "", "recipients": [""], "is_group": true}' 'http://127.0.0.1:8080/v1/send'``` + + e.g: + + ```curl -X POST -H "Content-Type: application/json" -d '{"message": "Hello World!", "number": "+431212131491291", "recipients": ["ckRzaEd4VmRzNnJaASAEsasa"], "is_group": true}' 'http://127.0.0.1:8080/v1/send'``` + +In case you need more functionality, please **file a ticket** or **create a PR** diff --git a/doc/HOMEASSISTANT.md b/doc/HOMEASSISTANT.md index 071085c..cdffbc1 100644 --- a/doc/HOMEASSISTANT.md +++ b/doc/HOMEASSISTANT.md @@ -46,5 +46,33 @@ e.g: ```curl -X POST -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/register/+431212131491291/verify/123-456'``` + +## Sending messages to Signal Messenger groups + +The `signal-cli-rest-api` docker container is also capable of sending messages to a Signal Messenger group. + +Requirements: + + * Home Assistant Version >= 0.109 + * signal-cli-rest-api build-nr >= 2 + The build number can be checked with: `curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/about` + * your phone number needs to be properly registered (see the "Register phone number" section above on how to do that) + +A new Signal Messenger group can be created with the following REST API request: + +```curl -X POST -H "Content-Type: application/json" -d '{"name": "", "members": ["", ""]}' 'http://127.0.0.1:8080/v1/groups/'``` + +e.g: + +This creates a new Signal Messenger group called `my group` with the members `+4354546464654` and `+4912812812121`. + +```curl -X POST -H "Content-Type: application/json" -d '{"name": "my group", "members": ["+4354546464654", "+4912812812121"]}' 'http://127.0.0.1:8080/v1/groups/+431212131491291'``` + +Next, use the following endpoint to obtain the group id: + +```curl -X GET -H "Content-Type: application/json" 'http://127.0.0.1:8080/v1/groups/'``` + +The group id then needs to be added to the Home Assistant `configuration.yaml` file (see [here](https://www.home-assistant.io/integrations/signal_messenger/) for details) + # Troubleshooting In case you've problems with the `signal-cli-rest-api` container, have a look [here](TROUBLESHOOTING.md) diff --git a/publish.sh b/publish.sh index ce30af0..aa832b1 100644 --- a/publish.sh +++ b/publish.sh @@ -1,10 +1,11 @@ #!/bin/bash -while getopts v: option +while getopts v:t: option do case "${option}" in v) VERSION=${OPTARG};; +t) TAG=${OPTARG};; esac done @@ -14,8 +15,20 @@ then exit 1 fi +if [ -z "$TAG" ] +then + echo "Please provide a valid tag with the -t flag. e.g: -t stable (supported tags: dev, stable)" + exit 1 +fi + +if [[ "$TAG" != "dev" && "$TAG" != "stable" ]]; then + echo "Please use either dev or stable as tag" + exit 1 +fi + echo "This will upload a new signal-cli-rest-api to dockerhub" echo "Version: $VERSION" +echo "Tag: $TAG" echo "" read -r -p "Are you sure? [y/N] " response @@ -27,10 +40,18 @@ case "$response" in docker buildx create --name multibuilder docker buildx use multibuilder + + if [[ "$TAG" == "stable" ]]; then + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t bbernhard/signal-cli-rest-api:$VERSION . --push + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t bbernhard/signal-cli-rest-api:latest . --push + fi - docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t bbernhard/signal-cli-rest-api:$VERSION . --push - docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t bbernhard/signal-cli-rest-api:latest . --push - ;; + if [[ "$TAG" == "dev" ]]; then + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t bbernhard/signal-cli-rest-api:${VERSION}-dev . --push + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t bbernhard/signal-cli-rest-api:latest-dev . --push + fi + + ;; *) echo "Aborting" exit 1 diff --git a/src/main.go b/src/main.go index 13ae3b6..2ab0a5c 100644 --- a/src/main.go +++ b/src/main.go @@ -1,32 +1,61 @@ package main import ( - log "github.com/sirupsen/logrus" - "github.com/satori/go.uuid" - "github.com/gin-gonic/gin" - "github.com/h2non/filetype" - "os/exec" - "time" - "errors" - "flag" "bytes" - "os" "encoding/base64" "encoding/json" + "errors" + "flag" + "github.com/gin-gonic/gin" + "github.com/h2non/filetype" + "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" + "os" + "os/exec" "strings" + "time" ) +type GroupEntry struct { + Name string `json:"name"` + Id string `json:"id"` + InternalId string `json:"internal_id"` + Members []string `json:"members"` + Active bool `json:"active"` + Blocked bool `json:"blocked"` +} + func cleanupTmpFiles(paths []string) { for _, path := range paths { os.Remove(path) } } -func send(c *gin.Context, attachmentTmpDir string, signalCliConfig string, number string, message string, recipients []string, base64Attachments []string) { +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", "-m", message} - cmd = append(cmd, recipients...) - + 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 { @@ -35,7 +64,7 @@ func send(c *gin.Context, attachmentTmpDir string, signalCliConfig string, numbe 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()}) @@ -74,7 +103,7 @@ func send(c *gin.Context, attachmentTmpDir string, signalCliConfig string, numbe if len(attachmentTmpPaths) > 0 { cmd = append(cmd, "-a") - cmd = append(cmd , attachmentTmpPaths...) + cmd = append(cmd, attachmentTmpPaths...) } _, err := runSignalCli(cmd) @@ -86,6 +115,66 @@ func send(c *gin.Context, attachmentTmpDir string, signalCliConfig string, numbe c.JSON(201, nil) } +func getGroups(number string, signalCliConfig string) ([]GroupEntry, error) { + groupEntries := []GroupEntry{} + + out, err := runSignalCli([]string{"--config", signalCliConfig, "-u", number, "listGroups", "-d"}) + if err != nil { + return groupEntries, err + } + + lines := strings.Split(out, "\n") + for _, line := range lines { + var groupEntry GroupEntry + if line == "" { + continue + } + + idIdx := strings.Index(line, " Name: ") + idPair := line[:idIdx] + groupEntry.InternalId = strings.TrimPrefix(idPair, "Id: ") + groupEntry.Id = base64.StdEncoding.EncodeToString([]byte(groupEntry.InternalId)) + lineWithoutId := strings.TrimLeft(line[idIdx:], " ") + + nameIdx := strings.Index(lineWithoutId, " Active: ") + namePair := lineWithoutId[:nameIdx] + groupEntry.Name = strings.TrimRight(strings.TrimPrefix(namePair, "Name: "), " ") + lineWithoutName := strings.TrimLeft(lineWithoutId[nameIdx:], " ") + + activeIdx := strings.Index(lineWithoutName, " Blocked: ") + activePair := lineWithoutName[:activeIdx] + active := strings.TrimPrefix(activePair, "Active: ") + if active == "true" { + groupEntry.Active = true + } else { + groupEntry.Active = false + } + lineWithoutActive := strings.TrimLeft(lineWithoutName[activeIdx:], " ") + + blockedIdx := strings.Index(lineWithoutActive, " Members: ") + blockedPair := lineWithoutActive[:blockedIdx] + blocked := strings.TrimPrefix(blockedPair, "Blocked: ") + if blocked == "true" { + groupEntry.Blocked = true + } else { + groupEntry.Blocked = false + } + lineWithoutBlocked := strings.TrimLeft(lineWithoutActive[blockedIdx:], " ") + + membersPair := lineWithoutBlocked + members := strings.TrimPrefix(membersPair, "Members: ") + trimmedMembers := strings.TrimRight(strings.TrimLeft(members, "["), "]") + trimmedMembersList := strings.Split(trimmedMembers, ",") + for _, member := range trimmedMembersList { + groupEntry.Members = append(groupEntry.Members, strings.Trim(member, " ")) + } + + groupEntries = append(groupEntries, groupEntry) + } + + return groupEntries, nil +} + func runSignalCli(args []string) (string, error) { cmd := exec.Command("signal-cli", args...) var errBuffer bytes.Buffer @@ -128,16 +217,17 @@ func main() { router.GET("/v1/about", func(c *gin.Context) { type About struct { SupportedApiVersions []string `json:"versions"` + BuildNr int `json:"build"` } - about := About{SupportedApiVersions: []string{"v1", "v2"}} + about := About{SupportedApiVersions: []string{"v1", "v2"}, BuildNr: 2} c.JSON(200, about) }) router.POST("/v1/register/:number", func(c *gin.Context) { number := c.Param("number") - type Request struct{ + type Request struct { UseVoice bool `json:"use_voice"` } @@ -189,7 +279,6 @@ func main() { return } - _, err := runSignalCli([]string{"--config", *signalCliConfig, "-u", number, "verify", token}) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) @@ -199,11 +288,12 @@ func main() { }) router.POST("/v1/send", func(c *gin.Context) { - type Request struct{ - Number string `json:"number"` - Recipients []string `json:"recipients"` - Message string `json:"message"` - Base64Attachment string `json:"base64_attachment"` + type Request struct { + Number string `json:"number"` + Recipients []string `json:"recipients"` + Message string `json:"message"` + Base64Attachment string `json:"base64_attachment"` + IsGroup bool `json:"is_group"` } var req Request err := c.BindJSON(&req) @@ -217,15 +307,16 @@ func main() { base64Attachments = append(base64Attachments, req.Base64Attachment) } - send(c, *signalCliConfig, *signalCliConfig, req.Number, req.Message, req.Recipients, base64Attachments) + send(c, *signalCliConfig, *signalCliConfig, req.Number, req.Message, req.Recipients, base64Attachments, req.IsGroup) }) router.POST("/v2/send", func(c *gin.Context) { - type Request struct{ - Number string `json:"number"` - Recipients []string `json:"recipients"` - Message string `json:"message"` + type Request struct { + Number string `json:"number"` + Recipients []string `json:"recipients"` + Message string `json:"message"` Base64Attachments []string `json:"base64_attachments"` + IsGroup bool `json:"is_group"` } var req Request err := c.BindJSON(&req) @@ -235,7 +326,7 @@ func main() { return } - send(c, *attachmentTmpDir, *signalCliConfig, req.Number, req.Message, req.Recipients, req.Base64Attachments) + send(c, *attachmentTmpDir, *signalCliConfig, req.Number, req.Message, req.Recipients, req.Base64Attachments, req.IsGroup) }) router.GET("/v1/receive/:number", func(c *gin.Context) { @@ -247,10 +338,10 @@ func main() { c.JSON(400, err.Error()) return } - + out = strings.Trim(out, "\n") lines := strings.Split(out, "\n") - + jsonStr := "[" for i, line := range lines { jsonStr += line @@ -263,5 +354,68 @@ func main() { c.String(200, jsonStr) }) + router.POST("/v1/groups/:number", func(c *gin.Context) { + number := c.Param("number") + + type Request struct { + Name string `json:"name"` + Members []string `json:"members"` + } + + var req Request + err := c.BindJSON(&req) + if err != nil { + c.JSON(400, "Couldn't process request - invalid request") + log.Error(err.Error()) + return + } + + cmd := []string{"--config", *signalCliConfig, "-u", number, "updateGroup", "-n", req.Name, "-m"} + cmd = append(cmd, req.Members...) + log.Info(cmd) + out, err := runSignalCli(cmd) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + log.Info(out) + + }) + + router.GET("/v1/groups/:number", func(c *gin.Context) { + number := c.Param("number") + + groups, err := getGroups(number, *signalCliConfig) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, groups) + }) + + router.DELETE("/v1/groups/:number/:groupid", func(c *gin.Context) { + base64EncodedGroupId := c.Param("groupid") + number := c.Param("number") + + if base64EncodedGroupId == "" { + c.JSON(400, gin.H{"error": "Please specify a group id"}) + return + } + + groupId, err := base64.StdEncoding.DecodeString(base64EncodedGroupId) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid group id"}) + return + } + + _, err = runSignalCli([]string{"--config", *signalCliConfig, "-u", number, "quitGroup", "-g", string(groupId)}) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + }) + router.Run() }