Skip to content

Commit b4a5a1a

Browse files
feat(signal): add Signal notification service support
- Implement complete Signal service with signal-cli-rest-api integration - Add URL parsing for signal:// and signals:// schemes with authentication - Support phone numbers and group IDs as recipients - Include base64 attachment support for media files - Add comprehensive test coverage with 22 test specifications - Create documentation with setup instructions and usage examples - Register service in router for automatic discovery Closes #257
1 parent cb3d7b6 commit b4a5a1a

File tree

7 files changed

+829
-0
lines changed

7 files changed

+829
-0
lines changed

docs/services/signal/index.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Signal
2+
3+
## URL Format
4+
5+
!!! info ""
6+
signal://[__`user`__[:__`password`__]@]__`host`__[:__`port`__]/__`source_phone`__/__`recipient1`__[,__`recipient2`__,...]
7+
8+
signals://[__`user`__[:__`password`__]@]__`host`__[:__`port`__]/__`source_phone`__/__`recipient1`__[,__`recipient2`__,...]
9+
10+
## Setting up Signal API Server
11+
12+
Signal notifications require a Signal API server that can send messages on behalf of a registered Signal account. These implementations are built on top of __[signal-cli](https://github.com/AsamK/signal-cli)__, the unofficial command-line interface for Signal (3.8k+ stars).
13+
14+
Popular open-source implementations include:
15+
16+
- __[signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api)__: Dockerized REST API wrapper for signal-cli (2.1k+ stars)
17+
- __[secured-signal-api](https://github.com/codeshelldev/secured-signal-api)__: Security proxy for signal-cli-rest-api with authentication and access control
18+
19+
Common setup involves:
20+
21+
1. __Phone Number__: A dedicated phone number registered with Signal
22+
2. __API Server__: A server running signal-cli with REST API capabilities
23+
3. __Account Linking__: Linking the server as a secondary device to your Signal account
24+
4. __Optional Security Layer__: Authentication and endpoint restrictions via a proxy
25+
26+
The server must be able to receive SMS verification codes during initial setup and maintain a persistent connection to Signal's servers.
27+
28+
!!! tip "Setup Resources"
29+
See the [signal-cli-rest-api documentation](https://github.com/bbernhard/signal-cli-rest-api) and [secured-signal-api documentation](https://github.com/codeshelldev/secured-signal-api) for detailed setup instructions.
30+
31+
## URL Parameters
32+
33+
### Host and Port
34+
35+
- `host`: The hostname or IP address of your Signal API server (default: localhost)
36+
- `port`: The port number (default: 8080)
37+
38+
### Authentication
39+
40+
The Signal service supports multiple authentication methods:
41+
42+
- `user`: Username for HTTP Basic Authentication (optional)
43+
- `password`: Password for HTTP Basic Authentication (optional)
44+
- `token` or `apikey`: API token for Bearer authentication (optional)
45+
46+
!!! tip "Authentication Priority"
47+
If both token and user/password are provided, the API token takes precedence and uses Bearer authentication. This is useful for [secured-signal-api](https://github.com/codeshelldev/secured-signal-api) which prefers Bearer tokens.
48+
49+
### Source Phone Number
50+
51+
The `source_phone` is your Signal phone number with country code (e.g., +1234567890) that is registered with the API server.
52+
53+
### Recipients
54+
55+
Recipients can be:
56+
57+
- __Phone numbers__: With country code (e.g., +0987654321)
58+
- __Group IDs__: In the format `group.groupId`
59+
60+
### TLS Configuration
61+
62+
- Use `signal://` for HTTPS (default, recommended)
63+
- Use `signals://` for HTTP (insecure, for local testing only)
64+
65+
## Examples
66+
67+
### Send to a single phone number
68+
69+
```
70+
signal://localhost:8080/+1234567890/+0987654321
71+
```
72+
73+
### Send to multiple recipients
74+
75+
```
76+
signal://localhost:8080/+1234567890/+0987654321/+1123456789/group.testgroup
77+
```
78+
79+
### Send to a group
80+
81+
```
82+
signal://localhost:8080/+1234567890/group.abcdefghijklmnop=
83+
```
84+
85+
### With authentication
86+
87+
```
88+
signal://user:password@localhost:8080/+1234567890/+0987654321
89+
```
90+
91+
### With API token (Bearer auth)
92+
93+
```
94+
signal://localhost:8080/+1234567890/+0987654321?token=YOUR_API_TOKEN
95+
```
96+
97+
### Using HTTP instead of HTTPS
98+
99+
```
100+
signals://localhost:8080/+1234567890/+0987654321
101+
```
102+
103+
## Attachments
104+
105+
The Signal service supports sending base64-encoded attachments. Use the `attachments` parameter with comma-separated base64 data:
106+
107+
```bash
108+
# Send with attachments via CLI
109+
shoutrrr send "signal://localhost:8080/+1234567890/+0987654321" \
110+
"Message with attachment" \
111+
--attachments "base64data1,base64data2"
112+
```
113+
114+
!!! note "Attachment Format"
115+
Attachments must be provided as base64-encoded data. The API server handles the MIME type detection and file handling.
116+
117+
## Optional Parameters
118+
119+
You can specify additional parameters in the URL query string:
120+
121+
- `disabletls=yes`: Force HTTP instead of HTTPS (same as using `signals://`)
122+
123+
## Implementation Notes
124+
125+
The Signal service sends messages using HTTP POST requests to the API server's send endpoint with JSON payloads containing the message, source number, and recipient list. The server handles the actual Signal protocol communication.

pkg/router/servicemap.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushbullet"
1818
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushover"
1919
"github.com/nicholas-fedor/shoutrrr/pkg/services/rocketchat"
20+
"github.com/nicholas-fedor/shoutrrr/pkg/services/signal"
2021
"github.com/nicholas-fedor/shoutrrr/pkg/services/slack"
2122
"github.com/nicholas-fedor/shoutrrr/pkg/services/smtp"
2223
"github.com/nicholas-fedor/shoutrrr/pkg/services/teams"
@@ -44,6 +45,7 @@ var serviceMap = map[string]func() types.Service{
4445
"pushbullet": func() types.Service { return &pushbullet.Service{} },
4546
"pushover": func() types.Service { return &pushover.Service{} },
4647
"rocketchat": func() types.Service { return &rocketchat.Service{} },
48+
"signal": func() types.Service { return &signal.Service{} },
4749
"slack": func() types.Service { return &slack.Service{} },
4850
"smtp": func() types.Service { return &smtp.Service{} },
4951
"teams": func() types.Service { return &teams.Service{} },

pkg/services/signal/doc.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Package signal provides functionality to send notifications via Signal Messenger
2+
// through REST API servers that wrap the signal-cli command-line interface.
3+
//
4+
// This service supports sending text messages and base64-encoded attachments to
5+
// individual phone numbers and Signal groups. Authentication supports both HTTP
6+
// Basic Auth and Bearer tokens for compatibility with different API servers.
7+
//
8+
// It requires a Signal API server (such as signal-cli-rest-api or secured-signal-api)
9+
// to be running and configured with a registered Signal account.
10+
//
11+
// URL format: signal://[user:pass@]host:port/source_phone/recipient1/recipient2
12+
// URL format: signal://host:port/source_phone/recipient1/recipient2?token=apikey
13+
//
14+
// For setup instructions and API server options, see the service documentation.
15+
package signal

pkg/services/signal/signal.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package signal
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"time"
13+
14+
"github.com/nicholas-fedor/shoutrrr/pkg/format"
15+
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
16+
"github.com/nicholas-fedor/shoutrrr/pkg/types"
17+
)
18+
19+
// HTTP request timeout duration.
20+
const (
21+
defaultHTTPTimeout = 30 * time.Second
22+
)
23+
24+
// ErrSendFailed indicates a failure to send a Signal message.
25+
var (
26+
ErrSendFailed = errors.New("failed to send Signal message")
27+
)
28+
29+
// Service sends notifications to Signal recipients via signal-cli-rest-api.
30+
type Service struct {
31+
standard.Standard
32+
Config *Config
33+
pkr format.PropKeyResolver
34+
}
35+
36+
// Send delivers a notification message to Signal recipients.
37+
func (service *Service) Send(message string, params *types.Params) error {
38+
config := *service.Config
39+
40+
// Separate config params from message params (like attachments)
41+
var (
42+
configParams *types.Params
43+
messageParams *types.Params
44+
)
45+
46+
if params != nil {
47+
configParams = &types.Params{}
48+
messageParams = &types.Params{}
49+
50+
for key, value := range *params {
51+
// Check if this is a config parameter
52+
if _, err := service.pkr.Get(key); err == nil {
53+
// It's a valid config key
54+
(*configParams)[key] = value
55+
} else {
56+
// It's a message parameter (like attachments)
57+
(*messageParams)[key] = value
58+
}
59+
}
60+
61+
if err := service.pkr.UpdateConfigFromParams(&config, configParams); err != nil {
62+
return fmt.Errorf("updating config from params: %w", err)
63+
}
64+
}
65+
66+
return service.sendMessage(message, &config, messageParams)
67+
}
68+
69+
// Initialize configures the service with a URL and logger.
70+
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
71+
service.SetLogger(logger)
72+
service.Config = &Config{}
73+
service.pkr = format.NewPropKeyResolver(service.Config)
74+
75+
if err := service.Config.setURL(&service.pkr, configURL); err != nil {
76+
return err
77+
}
78+
79+
return nil
80+
}
81+
82+
// GetID returns the identifier for this service.
83+
func (service *Service) GetID() string {
84+
return Scheme
85+
}
86+
87+
// sendMessage sends a message to all configured recipients.
88+
func (service *Service) sendMessage(message string, config *Config, params *types.Params) error {
89+
if len(config.Recipients) == 0 {
90+
return ErrNoRecipients
91+
}
92+
93+
payload := service.createPayload(message, config, params)
94+
95+
req, cancel, err := service.createRequest(config, payload)
96+
if err != nil {
97+
return err
98+
}
99+
defer cancel()
100+
101+
return service.sendRequest(req)
102+
}
103+
104+
// createPayload builds the JSON payload for the Signal API request.
105+
func (service *Service) createPayload(
106+
message string,
107+
config *Config,
108+
params *types.Params,
109+
) sendMessagePayload {
110+
payload := sendMessagePayload{
111+
Message: message,
112+
Number: config.Source,
113+
Recipients: config.Recipients,
114+
}
115+
116+
// Check for attachments in params (passed during Send call)
117+
// Note: Shoutrrr doesn't have a standard attachment interface,
118+
// so we check for "attachments" parameter with base64 data
119+
if params != nil {
120+
if attachments, ok := (*params)["attachments"]; ok && attachments != "" {
121+
// Parse comma-separated base64 attachments
122+
attachmentList := strings.Split(attachments, ",")
123+
for i, attachment := range attachmentList {
124+
attachmentList[i] = strings.TrimSpace(attachment)
125+
}
126+
127+
payload.Base64Attachments = attachmentList
128+
}
129+
}
130+
131+
return payload
132+
}
133+
134+
// createRequest builds the HTTP request for the Signal API.
135+
func (service *Service) createRequest(
136+
config *Config,
137+
payload sendMessagePayload,
138+
) (*http.Request, context.CancelFunc, error) {
139+
apiURL := service.buildAPIURL(config)
140+
141+
jsonData, err := json.Marshal(payload)
142+
if err != nil {
143+
return nil, nil, fmt.Errorf("marshaling payload to JSON: %w", err)
144+
}
145+
146+
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
147+
148+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
149+
if err != nil {
150+
cancel()
151+
152+
return nil, nil, fmt.Errorf("creating HTTP request: %w", err)
153+
}
154+
155+
req.Header.Set("Content-Type", "application/json")
156+
service.setAuthentication(req, config)
157+
158+
return req, cancel, nil
159+
}
160+
161+
// buildAPIURL constructs the Signal API endpoint URL.
162+
func (service *Service) buildAPIURL(config *Config) string {
163+
scheme := "https"
164+
if config.DisableTLS {
165+
scheme = "http"
166+
}
167+
168+
return fmt.Sprintf("%s://%s:%d/v2/send", scheme, config.Host, config.Port)
169+
}
170+
171+
// setAuthentication configures HTTP authentication headers.
172+
func (service *Service) setAuthentication(req *http.Request, config *Config) {
173+
// Add authentication - prefer Bearer token over Basic Auth
174+
if config.Token != "" {
175+
req.Header.Set("Authorization", "Bearer "+config.Token)
176+
} else if config.User != "" {
177+
req.SetBasicAuth(config.User, config.Password)
178+
}
179+
}
180+
181+
// sendRequest executes the HTTP request and handles the response.
182+
func (service *Service) sendRequest(req *http.Request) error {
183+
client := &http.Client{}
184+
185+
resp, err := client.Do(req)
186+
if err != nil {
187+
return fmt.Errorf("sending HTTP request: %w", err)
188+
}
189+
defer resp.Body.Close()
190+
191+
// Check response status
192+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
193+
return fmt.Errorf("%w: server returned status %d", ErrSendFailed, resp.StatusCode)
194+
}
195+
196+
// Parse response (optional, for logging)
197+
service.parseResponse(resp)
198+
199+
return nil
200+
}
201+
202+
// parseResponse extracts and logs response information.
203+
func (service *Service) parseResponse(resp *http.Response) {
204+
var response sendMessageResponse
205+
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
206+
service.Logf("Warning: failed to parse response: %v", err)
207+
} else {
208+
service.Logf("Message sent successfully at timestamp %d", response.Timestamp)
209+
}
210+
}

0 commit comments

Comments
 (0)