From 4747a6ddfc420bafa9dbd75c0f3a95ebbc3e9e82 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Tue, 22 Sep 2026 12:41:11 +0300 Subject: [PATCH] Add REST endpoint for joining a group by invitation link. signal-cli joinGroup --uri had no HTTP route. POST /v1/groups/{number}/join accepts the invite link. Joining by group id is unchanged. Co-authored-by: Cursor --- src/api/api.go | 45 ++++++++++++++++++++ src/client/client.go | 20 +++++++++ src/docs/docs.go | 96 +++++++++++++++++++++++++++++++++++++++++++ src/docs/swagger.json | 96 +++++++++++++++++++++++++++++++++++++++++++ src/main.go | 1 + 5 files changed, 258 insertions(+) diff --git a/src/api/api.go b/src/api/api.go index 210660b..b79cdc2 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -75,6 +75,10 @@ type ChangeGroupAdminsRequest struct { Admins []string `json:"admins"` } +type JoinGroupByLinkRequest struct { + Uri string `json:"uri"` +} + type LoggingConfiguration struct { Level string `json:"Level"` } @@ -1739,6 +1743,47 @@ func (a *Api) JoinGroup(c *gin.Context) { c.Status(http.StatusNoContent) } +// @Summary Join a Signal Group via an invitation link. +// @Tags Groups +// @Description Join a Signal Group using an invitation link. The uri starts with https://signal.group/# +// @Accept json +// @Produce json +// @Success 204 {string} OK +// @Failure 400 {object} Error +// @Param number path string true "Registered Phone Number" +// @Param data body JoinGroupByLinkRequest true "Invitation link" +// @Router /v1/groups/{number}/join [post] +func (a *Api) JoinGroupByLink(c *gin.Context) { + number, err := url.PathUnescape(c.Param("number")) + if err != nil { + c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + return + } + if number == "" { + c.JSON(400, Error{Msg: "Couldn't process request - number missing"}) + return + } + + var req JoinGroupByLinkRequest + err = c.BindJSON(&req) + if err != nil { + c.JSON(400, Error{Msg: "Couldn't process request - invalid request"}) + return + } + if req.Uri == "" { + c.JSON(400, Error{Msg: "Couldn't process request - uri missing"}) + return + } + + err = a.signalClient.JoinGroupByLink(number, req.Uri) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + + c.Status(http.StatusNoContent) +} + // @Summary Quit a Signal Group. // @Tags Groups // @Description Quit the specified Signal Group. diff --git a/src/client/client.go b/src/client/client.go index 18d9049..3fd25a6 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -2054,6 +2054,26 @@ func (s *SignalClient) JoinGroup(number string, groupId string) error { return err } +func (s *SignalClient) JoinGroupByLink(number string, uri string) error { + var err error + var jsonRpc2Client *JsonRpc2Client + if s.signalCliMode == JsonRpc { + type Request struct { + Uri string `json:"uri"` + } + request := Request{Uri: uri} + jsonRpc2Client, err = s.getJsonRpc2Client() + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("joinGroup", &number, request) + } else { + cmd := []string{"--config", s.signalCliConfig, "-a", number, "joinGroup", "--uri", uri} + _, err = s.cliClient.Execute(true, cmd, "") + } + return err +} + func (s *SignalClient) QuitGroup(number string, groupId string) error { var err error var jsonRpc2Client *JsonRpc2Client diff --git a/src/docs/docs.go b/src/docs/docs.go index 7eccd61..aa30094 100644 --- a/src/docs/docs.go +++ b/src/docs/docs.go @@ -208,6 +208,17 @@ const docTemplate = `{ ], "type": "object" }, + "api.JoinGroupByLinkRequest": { + "properties": { + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, "api.LoggingConfiguration": { "properties": { "Level": { @@ -1176,6 +1187,9 @@ const docTemplate = `{ "id": { "type": "string" }, + "isVoiceNote": { + "type": "boolean" + }, "size": { "type": "integer" }, @@ -1186,6 +1200,9 @@ const docTemplate = `{ "type": "integer" } }, + "required": [ + "isVoiceNote" + ], "type": "object" }, "receive.AttachmentData": { @@ -1452,12 +1469,24 @@ const docTemplate = `{ "expiresInSeconds": { "type": "integer" }, + "groupCallUpdate": { + "$ref": "#/definitions/receive.GroupCallUpdate" + }, "groupInfo": { "$ref": "#/definitions/receive.GroupInfo" }, + "hasProfileKey": { + "type": "boolean" + }, + "isEndSession": { + "type": "boolean" + }, "isExpirationUpdate": { "type": "boolean" }, + "isProfileKeyUpdate": { + "type": "boolean" + }, "mentions": { "items": { "$ref": "#/definitions/receive.Mention" @@ -1549,6 +1578,14 @@ const docTemplate = `{ }, "type": "object" }, + "receive.GroupCallUpdate": { + "properties": { + "eraId": { + "type": "string" + } + }, + "type": "object" + }, "receive.GroupInfo": { "properties": { "groupId": { @@ -2139,12 +2176,24 @@ const docTemplate = `{ "expiresInSeconds": { "type": "integer" }, + "groupCallUpdate": { + "$ref": "#/definitions/receive.GroupCallUpdate" + }, "groupInfo": { "$ref": "#/definitions/receive.GroupInfo" }, + "hasProfileKey": { + "type": "boolean" + }, + "isEndSession": { + "type": "boolean" + }, "isExpirationUpdate": { "type": "boolean" }, + "isProfileKeyUpdate": { + "type": "boolean" + }, "mentions": { "items": { "$ref": "#/definitions/receive.Mention" @@ -3372,6 +3421,53 @@ const docTemplate = `{ ] } }, + "/v1/groups/{number}/join": { + "post": { + "consumes": [ + "application/json" + ], + "description": "Join a Signal Group using an invitation link. The uri starts with https://signal.group/#", + "parameters": [ + { + "description": "Registered Phone Number", + "in": "path", + "name": "number", + "required": true, + "type": "string" + }, + { + "description": "Invitation link", + "in": "body", + "name": "data", + "required": true, + "schema": { + "$ref": "#/definitions/api.JoinGroupByLinkRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "summary": "Join a Signal Group via an invitation link.", + "tags": [ + "Groups" + ] + } + }, "/v1/groups/{number}/{groupid}": { "delete": { "consumes": [ diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 5eee8ec..687e55b 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -203,6 +203,17 @@ ], "type": "object" }, + "api.JoinGroupByLinkRequest": { + "properties": { + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, "api.LoggingConfiguration": { "properties": { "Level": { @@ -1171,6 +1182,9 @@ "id": { "type": "string" }, + "isVoiceNote": { + "type": "boolean" + }, "size": { "type": "integer" }, @@ -1181,6 +1195,9 @@ "type": "integer" } }, + "required": [ + "isVoiceNote" + ], "type": "object" }, "receive.AttachmentData": { @@ -1447,12 +1464,24 @@ "expiresInSeconds": { "type": "integer" }, + "groupCallUpdate": { + "$ref": "#/definitions/receive.GroupCallUpdate" + }, "groupInfo": { "$ref": "#/definitions/receive.GroupInfo" }, + "hasProfileKey": { + "type": "boolean" + }, + "isEndSession": { + "type": "boolean" + }, "isExpirationUpdate": { "type": "boolean" }, + "isProfileKeyUpdate": { + "type": "boolean" + }, "mentions": { "items": { "$ref": "#/definitions/receive.Mention" @@ -1544,6 +1573,14 @@ }, "type": "object" }, + "receive.GroupCallUpdate": { + "properties": { + "eraId": { + "type": "string" + } + }, + "type": "object" + }, "receive.GroupInfo": { "properties": { "groupId": { @@ -2134,12 +2171,24 @@ "expiresInSeconds": { "type": "integer" }, + "groupCallUpdate": { + "$ref": "#/definitions/receive.GroupCallUpdate" + }, "groupInfo": { "$ref": "#/definitions/receive.GroupInfo" }, + "hasProfileKey": { + "type": "boolean" + }, + "isEndSession": { + "type": "boolean" + }, "isExpirationUpdate": { "type": "boolean" }, + "isProfileKeyUpdate": { + "type": "boolean" + }, "mentions": { "items": { "$ref": "#/definitions/receive.Mention" @@ -3367,6 +3416,53 @@ ] } }, + "/v1/groups/{number}/join": { + "post": { + "consumes": [ + "application/json" + ], + "description": "Join a Signal Group using an invitation link. The uri starts with https://signal.group/#", + "parameters": [ + { + "description": "Registered Phone Number", + "in": "path", + "name": "number", + "required": true, + "type": "string" + }, + { + "description": "Invitation link", + "in": "body", + "name": "data", + "required": true, + "schema": { + "$ref": "#/definitions/api.JoinGroupByLinkRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "summary": "Join a Signal Group via an invitation link.", + "tags": [ + "Groups" + ] + } + }, "/v1/groups/{number}/{groupid}": { "delete": { "consumes": [ diff --git a/src/main.go b/src/main.go index ede0cc1..8659ec6 100644 --- a/src/main.go +++ b/src/main.go @@ -220,6 +220,7 @@ func main() { groups.DELETE(":number/:groupid", api.DeleteGroup) groups.POST(":number/:groupid/block", api.BlockGroup) groups.POST(":number/:groupid/join", api.JoinGroup) + groups.POST(":number/join", api.JoinGroupByLink) groups.POST(":number/:groupid/quit", api.QuitGroup) groups.PUT(":number/:groupid", api.UpdateGroup) groups.POST(":number/:groupid/members", api.AddMembersToGroup)