Sending Signal messages

Introduction

Something I have always wanted is the ability to send messages programmatically to my phone. It is such a useful thing to have - being able to send out notifications myself via code and since it is a text, I generally don’t miss it.

Many many years ago when Orkut was still a thing - there was an app that would send out real sms based text messages of your horoscope daily. I absolutely loved getting my information over text and have wanted to recreate that ever since.

Possible approaches

Twilio/MessageBird SMS APIs

Twilio is synonymous with sending text messages via code. MessageBird is another company that provides a similar service for a slightly lower price.

This is the best option to get text messages but didn’t use this since I don’t want to pay for the service.

Emails

Another adjacent way is to send an email, which is more or less free using either sendgrid or connecting directly to the mail server.

Did not use this option either since I don’t want the message getting buried in my inbox inside gmail or outlook.

Push notification

I could send the message via a push notification using OneSignal or Ntfy.sh - The only thing is I don’t want to install an app that exists solely to receive push notifications. Notifications are also ephemeral and have no history

Facebook/Instagram/Whatsapp

I would have assumed this would be the easiest way to communicate but the APIs are quite gated now

Using Signal

Turns out there exists a repo [ https://github.com/bbernhard/signal-cli-rest-api ] that provides Signal’s messaging rest api as a docker container ready to be deployed. Of course, there is no free lunch and the catch is that you need a spare number lying around to actually register the container as a ‘user’ but at least the messaging is free and it shows up [with history tracked] in my Signal app which is my main communication app.

The docker container is run on a server and exposed [and password protected] via nginx. Whenever a message needs to be sent an HTTP request is made to /v2/send with the following json payload

1
2
3
4
5
6
{
"base64_attachments": [],
"message": "test message from signal",
"number": "+12261234567",
"recipients": [ "+15191234567" ]
}'

Setup

Getting it all setup took some work and tinkering, which is why I am writing this blog post so others can save time, specially around the captcha and registering of a number.

Getting a number

The very first thing needed is a phone number that can be used to register as a user with Signal. I am using the TextNow app to get the number. It is important that this number can receive incoming SMS messages

Deploying the Signal Docker container

Next we need to actually run the Signal Docker container on a server. I run it as part of my docker compose deployment on my DigitalOcean server [Get $200 in credit over 60 days => https://m.do.co/c/b5f565690240 ] using the following config

1
2
3
4
5
6
7
8
9
10
11
signal:
image: bbernhard/signal-cli-rest-api
restart: unless-stopped
container_name: signal
environment:
- MODE=native # keeps it fast and requires low memory
- AUTO_RECEIVE_SCHEDULE="0 22 * * *" # auto calls the receive endpoint to avoid errors
volumes:
- ./signal:/home/.local/share/signal-cli # where to keep local files such as keys
expose:
- 8080

Exposing and password protecting the Signal API server

The server is exposed to the outside world via Nginx - the config I am using is as follows

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# nginx is part of the same docker compose deployment and so can communicate directly via docker dns
upstream signal {
server signal:8080;
}
server {
server_name <your-hostname>;
listen 443 ssl http2;
ssl_certificate /etc/ssl/certs/cert.pem;
ssl_certificate_key /etc/ssl/certs/key.pem;

# Improve HTTPS performance with session resumption
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 30m;
ssl_session_tickets off;
ssl_buffer_size 4k;
client_max_body_size 0;
chunked_transfer_encoding on;
# Enable server-side protection against BEAST attacks
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384";
# Aditional Security Headers
# ref: https://developer.mozilla.org/en-US/docs/Security/HTTP_Strict_Transport_Security
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
# ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
add_header X-Frame-Options DENY always;
# ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
add_header X-Content-Type-Options nosniff always;
# ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
add_header X-Xss-Protection "1; mode=block" always;
resolver 1.1.1.1 1.0.0.1 [2606:4700:4700::1111] [2606:4700:4700::1001] valid=300s;
resolver_timeout 5s;
location / {
auth_basic "auth";
auth_basic_user_file /etc/nginx/password/signal-auth.pwd; # this is where the password comes from
proxy_pass http://signal;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

I am using basic authentication. You can use any website to generate the hash file.

From the kotlin side a request can be made using Unirest and basic auth as follows

1
2
3
4
Unirest.post("https://nginx/v2/send")
.basicAuth("signal", "password") // just a header
.body(body)
.asJson().body.toPrettyString()

Captcha and registering a number

To register a number with signal the number needs to go through a captcha + sms verification process.

The http request to register a number with a captcha token is

1
curl -X POST -H "Content-Type: application/json" -d '{"captcha":"captcha-token-here"}' 'http://localhost:8080/v1/register/<number>'

When this request is made an SMS with a verification code is sent to <number> - that verification code needs to subsequently be sent to the verify endpoint using the following request

1
curl -X POST -H "Content-Type: application/json" 'http://localhost:8080/v1/register/<nunber>/verify/<code>'

Seems easy. All that is required is getting a captcha token and filling that in the first curl request. The official docs say to get the captcha token you need to do

1
2
3
Go to https://signalcaptchas.org/registration/generate.html
Open the developer console
Find the line that looks like this: Prevented navigation to “signalcaptcha://{captcha value}” due to an unknown protocol. Copy the captcha value

What happened next was something that is quite common in software - the docs don’t work.

So how do you get the captcha token?

First, I stumbled upon the repo https://gitlab.com/signald/captcha-helper The code uses vala a programming language? I couldn’t quite get this working or grok how vala works

Next is when I stumbled upon a repo that actually worked. https://gitlab.com/h0h0h0/signalcaptchahelper provides an Xcode Mac project that can be run locally, the captcha can be solved and the token is then printed out.

So I cloned the project - pressed play - solved the captcha that came up in the little GUI window and got my captcha token.

After that, I was able to make the two requests above and get my textnow number registered as a signal user in the container.

Sending messages

Once the number is registered and the server has been exposed - A github actions workflow can be set up that runs every morning on a scheduled cron timer. The workflow runs some kotlin code which makes a request to https://api.aawadia.dev/misc/v1/daily?type=motivation or https://api.aawadia.dev/misc/v1/daily?type=joke or https://api.aawadia.dev/misc/v1/daily?type=quote and sends the parsed result as a signal message to my phone.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name: Send reminder

on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch: # in case I want to run it manually

jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Build with Maven
run: mvn compile package # todo: don't compile it each run
- name: Run jar
run: java -jar target/send.jar