Notizen erstellen mit Signal

In diesem Beitrag schreiben wir uns ein Skript, welches es uns ermöglicht, mit dem Signal-Messenger eine Notiz im Kirby-CMS zu erstellen.

Das hier ist ein Experiment. Alles, was ich in diesem Artikel beschreibe, ist mit Vorsicht zu genießen und als Proof of Concept zu verstehen.

In diesem Beitrag habe ich bereits erklärt, wie wir mit Kirby und Raycast eine neue Notiz anlegen und veröffentlichen können. Diesen Code nehmen wir nun als Grundlage und treiben das Ganze auf die Spitze.

Das Ziel unseres Experiments: Wir möchten uns in Signal eine Nachricht schicken und diese Nachricht soll als Notiz im Kirby-Blog online gehen. Dazu nutzen wir den bereits beschriebenen POSSE-Endpunkt.

Signal CLI

Damit wir Nachrichten abgreifen und etwas mit ihnen machen können, benötigen wir irgendwie Zugriff. Hier hilft uns das Signal-CLI-Projekt, für das es dankenswerterweise einen Wrapper gibt, der uns eine REST-API bereitstellt.

Einmal gestartet, können wir dieses Tool als neues Gerät in unserem Account registrieren. Deshalb hiermit noch einmal die Warnung: Das hier ist ein Experiment! Falsch konfiguriert, kann dieses Setup im schlimmsten Fall möglichen Angreifern Zugriff auf alle eure Nachrichten verschaffen!

Wir lassen Signal-CLI in einem Docker-Container laufen. Dazu legen wir ein docker-compose.yaml an:

services:
  signal:
    image: bbernhard/signal-cli-rest-api:latest
    container_name: signal-api
    environment:
      - MODE=native #supported modes: json-rpc, native, normal
    ports:
      - "8080:8080" #map docker port 8080 to host port 8080.
    volumes:
      - "./signal-cli-config:/home/.local/share/signal-cli"

Als Basis dient uns das Signal-CLI-Rest-Api Docker-Image. Viel müssen wir gar nicht tun, wir setzen ein paar Grundeinstellungen. Wichtig ist das Volume, hier werden die Metadaten abgelegt, wie beispielsweise der Hinterlegte Account. Das Verzeichnis sollte also unbedingt erhalten bleiben, damit wir nicht ständig ein neues Gerät hinzufügen müssen.

Mit folgendem Befehl können wir das Setup testen:

docker-compose up

Danach können wir im Browser folgende URL aufrufen:

http://localhost:8080/v1/qrcodelink?device_name=signal-api

Wir müssen unsere laufende CLI-Instanz nun zu den vertrauten Geräten hinzufügen. In der Signal App gehen wir dazu auf den eigenen Avatar und dann "Gekoppelte Geräte" → "Neues Gerät hinzufügen". Sobald wir den QR-Code im Browser gescannt haben, sind wir startklar.

Signal-CLI kann nun auf alle Nachrichten zugreifen. Damit können wir aber noch nichts anfangen. Wirbenötigenn noch eine Verknüpfung zwischen Signal-CLI und unserem POSSE-Endpunkt. Dazu schreiben wir uns ein kleines Typescript.

Das Skript

Dazu legen wir das Verzeichnis bot an und erstellen dort eine package.json, damit wir alle Abhängigkeiten parat haben:

{
  "name": "signal-bot",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "dotenv": "^16.3.1"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "@types/node": "^22.0.0"
  }
}

Wie man sieht, hält sich der Umfang in Grenzen. Im nächsten Schritt legen wir eine tsconfig.json Datei an.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "types": ["node"]
  },
  "include": ["index.ts"]
}

Zuerst fangen wir damit an, die wichtigesten Eckdaten auszulesen. Wir verwenden Environment-Variablen.

import axios from "axios";

const SIGNAL_API = process.env.SIGNAL_API!;
const SIGNAL_NUMBER = process.env.SIGNAL_NUMBER!;
const TARGET_API = process.env.TARGET_API!;
const KIRBY_SECRET = process.env.KIRBY_SECRET!;

Wir benötigen den API-Endpunkt, den uns unser Docker-Container bereitstellt, unsere eigene Telefonnummer, den Kirby-POSSE-Endpunkt und das Kirby-Secret.

async function poll() {
  try {
    const res = await axios.get(
      `${SIGNAL_API}/v1/receive/${encodeURIComponent(SIGNAL_NUMBER)}`,
    );
    const messages = res.data;

    for (const msg of messages) {
      const sender = msg.envelope?.source;
      const destination =
        msg.envelope?.syncMessage?.sentMessage?.destinationNumber;
      const text =
        msg.envelope?.dataMessage?.message ||
        msg.envelope?.syncMessage?.sentMessage?.message;

      if (sender === SIGNAL_NUMBER && destination === SIGNAL_NUMBER && text) {
        console.log(`Forwarding message: ${text}`);
      }
    }
  } catch (error) {
    if (error instanceof Error) {
      console.error("Error polling messages:", error.message);
    } else {
      console.error("Unknown error:", error);
    }
  }
}

Wir beginnen damit, den Signal-Endpunkt aufzurufen. Er liefert uns eine Liste ungelesener Nachrichten zurück, die wir durchlaufen. Wir brauchen ein paar Daten, die wir uns holen:

  • Die Absenderin der Nachricht
  • Die Empfängerin der Nachricht
  • Den Text der Nachricht

Beim Text gibt es eine kleine Besonderheit. Da wir den Text an uns selbst schicken werden (Notiz an mich), wird dieser Text sofort als gelesen markiert werden und wir finden den Text dann nicht mehr unter envelope?.dataMessage?.message wie es normalerweise der Fall wäre, sondern unter envelope?.syncMessage?.sentMessage?.message. Um ganz sicher zu sein, fragen wir beide Werte ab, ist ersterer leer, fallen wir auf die verschickten Daten zurück.

Da wir auf jede Nachricht reagieren und damit jede Nachricht in jedem Chat eine Notiz in Kirby erzeugen würde, müssen wir zunächst die passenden Nachrichten filtern:

if (sender === SIGNAL_NUMBER && destination === SIGNAL_NUMBER && text)

Diese Abfrage sorgt dafür, dass wir nur auf Nachrichten reagieren, die von unserer eigenen Nummer an unsere eigene Nummer gehen und die einen Text haben. Damit sind wir einigermaßen abgesichert.

Jetzt füllen wir den if-Block. Darin wollen wir den API-Request in Richtung Kirby absetzen:

let skipUrl = false;
let ext = false;
let autopublish = true;

// Find and remove hashtags, set flags
let parsedText = text
  .replace(/#skipUrl\b/gi, (_match: any) => {
    skipUrl = true;
    return "";
  })
  .replace(/#ext\b/gi, (_match: any) => {
    ext = true;
    return "";
  })
  .replace(/#draft\b/gi, (_match: any) => {
    autopublish = false;
    return "";
  })
  .trim();

await axios.post(
  TARGET_API,
  {
    posttext: parsedText,
    externalPost: ext,
    skipUrl,
    autopublish,
  },
  {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${KIRBY_SECRET}`,
    },
  },
);

Zunächst setzen wir ein paar Standardwerte. Die haben wir im bereits erwähnten POSSE-Beitrag festgelegt. Sie ermöglichen es uns, Kirby zu steuern.

  • skipUrl legt fest, ob beim Posten zu Mastodon ein Link zur Notiz angehangen werden soll.
  • ext legt fest, ob überhaupt zu Mastodon oder Bluesky gepostet werden soll.
  • autopublish legt fest, ob die Notiz sofort veröffentlicht oder als Entwurf abgelegt werden soll.

Da wir im Signal-Chat keine passenden Buttons haben, verwenden wir Hashtags: #skipUrl, #ext und #draft.

Wir suchen nach diesen Hashtags im Text, ignorieren dabei Groß- und Kleinschreibung und setzen die entsprechenden Variablen. Schließlich entfernen wir die Hashtags aus dem Text, so dass sie in der Notiz nicht mehr vorkommen.

Ich habe autopublishing aktiviert und deaktivieren es gezielt mit dem Hashtag #draft. Das lässt sich bei Bedarf natürlich auch umkehren.

Schließlich setzen wir den API-Request in Richtung Kirby ab. Wie man sieht, enthält er alle notwendigen Felder und unser Secrect im Header.

Damit sind wir fast fertig. Wir müssen unsere Methode nur noch regelmäßig aufrufen, um nach neuen Nachrichten zu schauen:

setInterval(poll, 10_000);

Docker Compose

Bevor wir fertig sind, müssen wir unser docker-compose.yml noch anpassen. Wir wollen unseren Bot zusammen mit Signal-CLI hochfahren. Dazu braucht unser Bot zunächst ein Dockerfile:

FROM node:22-alpine

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

CMD ["node", "dist/index.js"]

Wir benutzen das node-alpine Image als Grundlage, installieren alle notwendigen Pakete, bauen unser Skript und starten es.

Hier lässt sich sicherlich noch optimieren und Platz sparen, aber für unser Experiment passt das.

Nun müssen wir noch unser docker-compose.yml erweitern. Unter dem bereits vorhanden Signal-Code ergänzen wir:

services:
    signal:
        # see code above
    bot:
        build: ./bot
        container_name: signal-bot
        environment:
          - SIGNAL_API=http://signal:8080
          - SIGNAL_NUMBER=+491234567890
          - TARGET_API=https://my-blog.tld/posse
          - KIRBY_SECRET=abc-def-ghi
        depends_on:
          - signal
        restart: unless-stopped

Hier setzen wir unsere Environment-Variablen. Für den Signal-Endpunkt benutzen wir den Containernamen. Wir müssen noch unsere Telefonnummer mit Ländervorwahl angeben und natürlich unseren Kirby-POSSE-Endpunkt und -Secret.

Unser Bot wartet darauf, dass die Signal-CLI vorhanden ist und fährt erst dann hoch.

Damit unser Bot gebaut wird, müssen wir bei Code-Änderungen diesen Befehl aufrufen:

docker compose up --build

Danach langt es, docker-compose aufzurufen:

docker-compose up

Sollen die Container im Hintergrund laufen, können wir einfach -d ergänzen:

docker-compose up -d

Fertig!

Jetzt können wir eine Nachricht an uns selbst schicken:

Unser Skript greift diese Nachricht auf:

Sendet sie an Kirby und publiziert die Notiz:

Und das IndieConnector-Plugin schickt sie weiter an Mastodon, weil wir externes Posten aktiviert haben:

Jetzt können wir in alter Twitter-Manier vom Telefon aus in die eigenen Notizen posten und nach Belieben weiter verteilen.

Abschließend sei noch angemerkt, dass alle Nachrichten, die wir an uns selbst schicken, veröffentlicht werden. Wer die Notiz an mich Funktion von Signal anderweitig nutzt, sollte die Logik etwas erweitern und einen weiteren Hashtag fürs Posten einfügen. So könnte #posse den Ablauf auslösen und alle anderen Nachrichten ignoriert werden. Vielleicht eine gute Idee, um versehentliche Notizen zu vermeiden.

Es sei noch einmal erwähnt, dass es sich hier um ein Experiment handelt, das zeigen soll, was wir alles mit unseren Blogs anstellen können. Wer das Skript in der Form betreiben will, sollte sich im Klaren darüber sein, welche Sicherheitsrisiken durch den Betrieb von Signal-CLI entstehen, besonders wenn der API-Endpunkt öffentlich verfügbar gemacht wird.

Dennoch: Viel Spaß beim Ausprobieren!