Compare commits

...

5 Commits

Author SHA1 Message Date
Hugo
46bb0da9d5
Merge 8ea7456812d6f8b53d11c2aa26f6a2645b83322f into 0650451a02e19befc66ddfc7c9aeaa1ebda730e8 2026-01-12 17:12:46 +01:00
Bernhard B.
0650451a02
Merge pull request #772 from Kamaradas/master
New/changes endpoints for linking devices
2026-01-09 21:10:54 +01:00
Samuel Rodrigues
2e507699c5 Changed the ListDevices endpoint to return the device id, created three new endpoints, one to return the device link to permit new device links without the qrcode, one to remove the linked devices and another to delete local-data 2026-01-08 15:46:13 +00:00
Hugo
8ea7456812
Add files via upload
added 2 API registrations :

/api/v2/sendalertmanager
/api/v2/sendgraylognotification
2024-12-24 08:45:50 +01:00
Hugo
95c14a5f2b
Add files via upload
Additional functions to process calls from Graylog or Grafana. Grafana uses the AlertManager structure, so all tools using this structure can use this.
2024-12-24 08:43:15 +01:00
4 changed files with 424 additions and 19 deletions

View File

@ -220,6 +220,14 @@ type RemoteDeleteRequest struct {
Timestamp int64 `json:"timestamp"`
}
type DeleteLocalAccountDataRequest struct {
IgnoreRegistered bool `json:"ignore_registered" example:"false"`
}
type DeviceLinkUriResponse struct {
DeviceLinkUri string `json:"device_link_uri"`
}
type Api struct {
signalClient *client.SignalClient
wsMutex sync.Mutex
@ -328,6 +336,43 @@ func (a *Api) UnregisterNumber(c *gin.Context) {
c.Writer.WriteHeader(204)
}
// @Summary Delete local account data
// @Tags Devices
// @Description Delete all local data for the specified account. Only use this after unregistering the account or after removing a linked device.
// @Accept json
// @Produce json
// @Param number path string true "Registered Phone Number"
// @Param data body DeleteLocalAccountDataRequest false "Cleanup options"
// @Success 204
// @Failure 400 {object} Error
// @Router /v1/devices/{number}/local-data [delete]
func (a *Api) DeleteLocalAccountData(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
}
req := DeleteLocalAccountDataRequest{}
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.BindJSON(&req); err != nil {
c.JSON(400, Error{Msg: "Couldn't process request - invalid request"})
return
}
}
if err := a.signalClient.DeleteLocalAccountData(number, req.IgnoreRegistered); err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// @Summary Verify a registered phone number.
// @Tags Devices
// @Description Verify a registered phone number with the signal network.
@ -1103,6 +1148,30 @@ func (a *Api) GetQrCodeLink(c *gin.Context) {
c.Data(200, "image/png", png)
}
// @Summary Get raw device link URI
// @Tags Devices
// @Description Generate the deviceLinkUri string for linking without scanning a QR code.
// @Produce json
// @Param device_name query string true "Device Name"
// @Success 200 {object} DeviceLinkUriResponse
// @Failure 400 {object} Error
// @Router /v1/qrcodelink/raw [get]
func (a *Api) GetQrCodeLinkUri(c *gin.Context) {
deviceName := c.Query("device_name")
if deviceName == "" {
c.JSON(400, Error{Msg: "Please provide a name for the device"})
return
}
deviceLinkUri, err := a.signalClient.GetDeviceLinkUri(deviceName)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
c.JSON(200, DeviceLinkUriResponse{DeviceLinkUri: deviceLinkUri})
}
// @Summary List all accounts
// @Tags Accounts
// @Description Lists all of the accounts linked or registered
@ -1974,6 +2043,40 @@ func (a *Api) ListDevices(c *gin.Context) {
c.JSON(200, devices)
}
// @Summary Remove linked device
// @Tags Devices
// @Description Remove a linked device from the primary account.
// @Param number path string true "Registered Phone Number"
// @Param deviceId path int true "Device ID from listDevices"
// @Success 204
// @Failure 400 {object} Error
// @Router /v1/devices/{number}/{deviceId} [delete]
func (a *Api) RemoveDevice(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
}
deviceIdStr := c.Param("deviceId")
deviceId, err := strconv.ParseInt(deviceIdStr, 10, 64)
if err != nil {
c.JSON(400, Error{Msg: "deviceId must be numeric"})
return
}
err = a.signalClient.RemoveDevice(number, deviceId)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// @Summary Set account specific settings.
// @Tags General
// @Description Set account specific settings.

211
src/api/graylogapi.go Normal file
View File

@ -0,0 +1,211 @@
package api
import (
"encoding/json"
"errors"
"strconv"
"strings"
"fmt"
_ "runtime/debug"
_ "github.com/yassinebenaid/godump"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/bbernhard/signal-cli-rest-api/client"
)
type AlertManagerNotification struct {
Receiver string `json:"receiver"`
Status string `json:"status"`
Alerts []Alert `json:"alerts"`
GroupLabels Labels `json:"groupLabels"`
CommonLabels Labels `json:"commonLabels"`
CommonAnnotations Annotations `json:"commonAnnotations"`
ExternalURL string `json:"externalURL"`
Version string `json:"version"`
GroupKey string `json:"groupKey"`
TruncatedAlerts int64 `json:"truncatedAlerts"`
OrgID int64 `json:"orgId"`
Title string `json:"title"`
State string `json:"state"`
Message string `json:"message"`
}
type Alert struct {
Status string `json:"status"`
Labels Labels `json:"labels"`
Annotations Annotations `json:"annotations"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
GeneratorURL string `json:"generatorURL"`
Fingerprint string `json:"fingerprint"`
SilenceURL string `json:"silenceURL"`
DashboardURL string `json:"dashboardURL"`
PanelURL string `json:"panelURL"`
Values interface{} `json:"values"`
ValueString string `json:"valueString"`
}
type Annotations struct {
Summary string `json:"summary"`
}
type Labels struct {
Alertname string `json:"alertname"`
Instance string `json:"instance"`
}
type GrafanaMessage struct {
Number string `json:"number"`
Recipients string `json:"recipients"`
Message string `json:"message"`
}
type GraylogNotification struct {
EventDefinitionID string `json:"event_definition_id"`
EventDefinitionType string `json:"event_definition_type"`
EventDefinitionTitle string `json:"event_definition_title"`
EventDefinitionDescription string `json:"event_definition_description"`
JobDefinitionID string `json:"job_definition_id"`
JobTriggerID string `json:"job_trigger_id"`
Event GraylogEvent `json:"event"`
Backlog []interface{} `json:"backlog"`
}
type GraylogEvent struct {
ID string `json:"id"`
EventDefinitionType string `json:"event_definition_type"`
EventDefinitionID string `json:"event_definition_id"`
OriginContext string `json:"origin_context"`
Timestamp string `json:"timestamp"`
TimestampProcessing string `json:"timestamp_processing"`
TimerangeStart interface{} `json:"timerange_start"`
TimerangeEnd interface{} `json:"timerange_end"`
Streams []string `json:"streams"`
SourceStreams []interface{} `json:"source_streams"`
Message string `json:"message"`
Source string `json:"source"`
KeyTuple []string `json:"key_tuple"`
Key string `json:"key"`
Priority int64 `json:"priority"`
Alert bool `json:"alert"`
Fields Fields `json:"fields"`
}
type Fields struct {
Recipients string `json:"recipients"`
FromNumber string `json:"fromnumber"`
Message string `json:"message"`
}
// @Summary Send a signal message.
// @Tags Messages
// @Description Send a signal message. Set the text_mode to 'styled' in case you want to add formatting to your text message. Styling Options: *italic text*, **bold text**, ~strikethrough text~.
// @Accept json
// @Produce json
// @Success 201 {object} SendMessageResponse
// @Failure 400 {object} SendMessageError
// @Param data body SendMessageV2 true "Input Data"
// @Router /v2/send [post]
func (a *Api) SendAlertManagerV2(c *gin.Context) {
var req AlertManagerNotification
var msg GrafanaMessage
base64Attachments := []string{}
err := c.BindJSON(&req)
if err != nil {
c.JSON(400, gin.H{"error": "Couldn't process request - invalid request"})
log.Error(err.Error())
return
}
//fmt.Printf(">>>%s\n",[]byte(req.Message))
// Unmarshal or Decode the JSON to the interface.
json.Unmarshal([]byte(req.Message), &msg)
// timestamp, err := a.signalClient.SendV1(msg.Number, msg.Message, msg.Recipients, base64Attachments, msg.IsGroup)
// if err != nil {
// c.JSON(400, Error{Msg: err.Error()})
// return
// }
// c.JSON(201, SendMessageResponse{Timestamp: strconv.FormatInt(timestamp.Timestamp, 10)})
data, err := a.signalClient.SendV2(msg.Number, msg.Message, strings.Split(msg.Recipients,","), base64Attachments, "", nil, nil, nil, nil, nil, nil, nil, nil)
if err != nil {
switch err.(type) {
case *client.RateLimitErrorType:
if rateLimitError, ok := err.(*client.RateLimitErrorType); ok {
extendedError := errors.New(err.Error() + ". Use the attached challenge tokens to lift the rate limit restrictions via the '/v1/accounts/{number}/rate-limit-challenge' endpoint.")
c.JSON(429, SendMessageError{Msg: extendedError.Error(), ChallengeTokens: rateLimitError.ChallengeTokens, Account: msg.Number})
return
} else {
c.JSON(400, Error{Msg: err.Error()})
return
}
default:
c.JSON(400, Error{Msg: err.Error()})
return
}
c.JSON(400, Error{Msg: err.Error()})
return
}
c.JSON(201, SendMessageResponse{Timestamp: strconv.FormatInt((*data)[0].Timestamp, 10)})
}
// @Summary Send a signal message.
// @Tags Messages
// @Description Send a signal message. Set the text_mode to 'styled' in case you want to add formatting to your text message. Styling Options: *italic text*, **bold text**, ~strikethrough text~.
// @Accept json
// @Produce json
// @Success 201 {object} SendMessageResponse
// @Failure 400 {object} SendMessageError
// @Param data body SendMessageV2 true "Input Data"
// @Router /v2/send [post]
func (a *Api) SendGraylogNotificationV2(c *gin.Context) {
var req GraylogNotification
base64Attachments := []string{}
// jsonData,err2 := io.ReadAll(c.Request.Body)
//if err2 != nil {
// log.Error(err2.Error())
//}
//fmt.Printf("<<<%s\n",jsonData)
err := c.BindJSON(&req)
if err != nil {
c.JSON(400, gin.H{"error": "Couldn't process request - invalid requestttttt"})
log.Error(err.Error())
fmt.Printf("<<<%s\n",c.Request.Body)
return
}
data, err := a.signalClient.SendV2(req.Event.Fields.FromNumber, req.Event.Fields.Message, strings.Split(req.Event.Fields.Recipients,","), base64Attachments, "", nil, nil, nil, nil, nil, nil, nil, nil)
if err != nil {
switch err.(type) {
case *client.RateLimitErrorType:
if rateLimitError, ok := err.(*client.RateLimitErrorType); ok {
extendedError := errors.New(err.Error() + ". Use the attached challenge tokens to lift the rate limit restrictions via the '/v1/accounts/{number}/rate-limit-challenge' endpoint.")
c.JSON(429, SendMessageError{Msg: extendedError.Error(), ChallengeTokens: rateLimitError.ChallengeTokens, Account: req.Event.Fields.FromNumber})
return
} else {
c.JSON(400, Error{Msg: err.Error()})
return
}
default:
c.JSON(400, Error{Msg: err.Error()})
return
}
c.JSON(400, Error{Msg: err.Error()})
return
}
c.JSON(201, SendMessageResponse{Timestamp: strconv.FormatInt((*data)[0].Timestamp, 10)})
}

View File

@ -232,6 +232,7 @@ type ListContactsResponse struct {
}
type ListDevicesResponse struct {
Id int64 `json:"id"`
Name string `json:"name"`
LastSeenTimestamp int64 `json:"last_seen_timestamp"`
CreationTimestamp int64 `json:"creation_timestamp"`
@ -783,6 +784,32 @@ func (s *SignalClient) UnregisterNumber(number string, deleteAccount bool, delet
return err
}
func (s *SignalClient) DeleteLocalAccountData(number string, ignoreRegistered bool) error {
if s.signalCliMode == JsonRpc {
type Request struct {
IgnoreRegistered bool `json:"ignore-registered,omitempty"`
}
req := Request{}
if ignoreRegistered {
req.IgnoreRegistered = true
}
jsonRpc2Client, err := s.getJsonRpc2Client()
if err != nil {
return err
}
_, err = jsonRpc2Client.getRaw("deleteLocalAccountData", &number, req)
return err
} else {
cmd := []string{"--config", s.signalCliConfig, "-a", number, "deleteLocalAccountData"}
if ignoreRegistered {
cmd = append(cmd, "--ignore-registered")
}
_, err := s.cliClient.Execute(true, cmd, "")
return err
}
}
func (s *SignalClient) VerifyRegisteredNumber(number string, token string, pin string) error {
if s.signalCliMode == JsonRpc {
type Request struct {
@ -1463,25 +1490,7 @@ func (s *SignalClient) GetQrCodeLink(deviceName string, qrCodeVersion int) ([]by
return []byte{}, errors.New("Couldn't create QR code: " + err.Error())
}
go (func() {
type FinishRequest struct {
DeviceLinkUri string `json:"deviceLinkUri"`
DeviceName string `json:"deviceName"`
}
req := FinishRequest{
DeviceLinkUri: resp.DeviceLinkUri,
DeviceName: deviceName,
}
result, err := jsonRpc2Client.getRaw("finishLink", nil, &req)
if err != nil {
log.Debug("Error linking device: ", err.Error())
return
}
log.Debug("Linking device result: ", result)
s.signalCliApiConfig.Load(s.signalCliApiConfigPath)
})()
s.finishLinkAsync(jsonRpc2Client, deviceName, resp.DeviceLinkUri)
return png, nil
}
@ -1506,6 +1515,57 @@ func (s *SignalClient) GetQrCodeLink(deviceName string, qrCodeVersion int) ([]by
return png, nil
}
func (s *SignalClient) GetDeviceLinkUri(deviceName string) (string, error) {
if s.signalCliMode == JsonRpc {
type StartResponse struct {
DeviceLinkUri string `json:"deviceLinkUri"`
}
jsonRpc2Client, err := s.getJsonRpc2Client()
if err != nil {
return "", err
}
raw, err := jsonRpc2Client.getRaw("startLink", nil, struct{}{})
if err != nil {
return "", errors.New("Couldn't start link: " + err.Error())
}
var resp StartResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
return "", errors.New("Couldn't parse startLink response: " + err.Error())
}
// Complete the linking handshake in the background, just like GetQrCodeLink does.
s.finishLinkAsync(jsonRpc2Client, deviceName, resp.DeviceLinkUri)
return resp.DeviceLinkUri, nil
}
cmd := []string{"--config", s.signalCliConfig, "link", "-n", deviceName}
deviceLinkUri, err := s.cliClient.Execute(false, cmd, "")
if err != nil {
return "", errors.New("Couldn't create link URI: " + err.Error())
}
return strings.TrimSpace(deviceLinkUri), nil
}
func (s *SignalClient) finishLinkAsync(jsonRpc2Client *JsonRpc2Client, deviceName string, deviceLinkUri string) {
type finishRequest struct {
DeviceLinkUri string `json:"deviceLinkUri"`
DeviceName string `json:"deviceName"`
}
go func() {
req := finishRequest{DeviceLinkUri: deviceLinkUri, DeviceName: deviceName}
result, err := jsonRpc2Client.getRaw("finishLink", nil, &req)
if err != nil {
log.Debug("Error linking device: ", err.Error())
return
}
log.Debug("Linking device result: ", result)
s.signalCliApiConfig.Load(s.signalCliApiConfigPath)
}()
}
func (s *SignalClient) GetAccounts() ([]string, error) {
accounts := make([]string, 0)
var rawData string
@ -2285,6 +2345,7 @@ func (s *SignalClient) ListDevices(number string) ([]ListDevicesResponse, error)
for _, entry := range signalCliResp {
deviceEntry := ListDevicesResponse{
Id: entry.Id,
Name: entry.Name,
CreationTimestamp: entry.CreatedTimestamp,
LastSeenTimestamp: entry.LastSeenTimestamp,
@ -2295,6 +2356,25 @@ func (s *SignalClient) ListDevices(number string) ([]ListDevicesResponse, error)
return resp, nil
}
func (s *SignalClient) RemoveDevice(number string, deviceId int64) error {
var err error
if s.signalCliMode == JsonRpc {
type Request struct {
DeviceId int64 `json:"deviceId"`
}
request := Request{DeviceId: deviceId}
jsonRpc2Client, err := s.getJsonRpc2Client()
if err != nil {
return err
}
_, err = jsonRpc2Client.getRaw("removeDevice", &number, request)
} else {
cmd := []string{"--config", s.signalCliConfig, "-a", number, "removeDevice", "--deviceId", strconv.FormatInt(deviceId, 10)}
_, err = s.cliClient.Execute(true, cmd, "")
}
return err
}
func (s *SignalClient) SetTrustMode(number string, trustMode utils.SignalCliTrustMode) error {
if s.signalCliMode == JsonRpc {
return errors.New("Not supported in json-rpc mode, use the environment variable JSON_RPC_TRUST_NEW_IDENTITIES instead!")

View File

@ -230,6 +230,7 @@ func main() {
link := v1.Group("qrcodelink")
{
link.GET("", api.GetQrCodeLink)
link.GET("/raw", api.GetQrCodeLinkUri)
}
accounts := v1.Group("accounts")
@ -247,6 +248,8 @@ func main() {
{
devices.POST(":number", api.AddDevice)
devices.GET(":number", api.ListDevices)
devices.DELETE(":number/:deviceId", api.RemoveDevice)
devices.DELETE(":number/local-data", api.DeleteLocalAccountData)
}
attachments := v1.Group("attachments")
@ -356,6 +359,14 @@ func main() {
{
sendV2.POST("", api.SendV2)
}
sendalertmanagerV2 := v2.Group("/sendalertmanager")
{
sendalertmanagerV2.POST("", api.SendAlertManagerV2)
}
sendgraylognotificationV2 := v2.Group("/sendgraylognotification")
{
sendgraylognotificationV2.POST("", api.SendGraylogNotificationV2)
}
}
protocol := "http"