Miau, Miau (Linux-Magazin, April 2026)

Um seinen WhatsApp-Freunden zu imponieren, baut sich Mike Schilli einen Chatbot in Go, der auf Abruf OpenAI kontaktiert und Antworten gibt.

Es war vor vielen vielen Jahr, anno 2009. Ich hatte schon einige Jahre bei dem Unternehmen Yahoo! in Sunnyvale, Kalifornien als Software-Ingenieur gearbeiet, da kam unter den Kollegen meiner Gruppe die Idee auf, eine Messenger App zu bauen, dafür den gutbezahlten Job bei Yahoo samt Krankenversicherung aufzugeben und einer neu gegründeten Startup-Firma beizutreten. Ich lachte und lehnte dankend ab, weil mir das Unternehmen zum Scheitern verurteilt schien, obwohl ich wusste, dass die Kollegen schwer auf Zack waren. Um es kurz zu machen, die neue Firma hieß WhatsApp, schlug ein wie eine Bombe und wurde nur fünf Jahre später für 19 Milliarden an Facebook (heute Meta) verkauft, was meine ehemaligen Kollegen in die Riege der reichsten Menschen Kaliforniens katapultierte. Hätte ich damals zugesagt, würde ich heute nur noch als Hauptsponsor von Benefizveranstaltungen fungieren, und das wäre auch kein Leben. Außerdem, soviel zu meiner Fähigkeit, die Zukunft vorherzusagen!

Ende-Zu-Ende verschlüsselt

Heute hat WhatsApp die gute alte Email im Privatleben fast gänzlich abgelöst. Leider stellt Meta aber keine API-Lösung für den Nachrichtenservice bereit, doch die Open-Source-Welt hat sich unter [2] mit whatsmeow eine Go-Bibliothek gebaut, mit der sich basierend auf der WhatsApp-Web-Technologie (WhatsApp im Webbrowser) beliebige Applikationen bauen lassen, die sich in offzielle Chats einhängen und Nachrichten senden und empfangen können. Das ist gar nicht so trivial, denn WhatsApp-Nachrichten werden immer noch Ende-zu-Ende-verschlüsselt. Einzige Ausnahme: Falls ein Gesprächsteilnehmer "@Meta AI" sagt, dann darf sich der Facebook-Bot zuschalten, mitlesen und per künstlicher Intelligenz Anworten geben.

Der in dieser Ausgabe gezeigte handgestrickte Bot liest ebenfalls mit, funkt aber dabei nicht an die Meta-Zentrale, sondern behält erstmal alles für sich. So lassen sich lustige Apps schreiben, die zum Beispiel auf bestimmte Texte reagieren, und auch passende Antworten schicken können. Als Idee schwebt mir vor, den Bot auf unsere Pickup-Fußball-WhatsApp-Gruppe loszulassen und mich sofort anzumelden, sobald der Organisator das Formular für die Spielwilligen herausgibt. Der würde Augen machen, wenn ich mich binnen zwei Sekunden als Erster eingetragen hätte!

Frag das Orakel

Als Beispielapplikation soll aber heute ein Bot dienen, der Fragen aus dem Chat entgegen nimmt, sie an OpenAI schickt und die Antwort des Orakels zurück in den Chat schickt. So kann zum Beispiel ein Teilnehmer fragen "ai: was ist 2 * 2?" und der Bot würde "2 * 2 ist 4" antworten, nachdem er die Antwort bei OpenAI per API abgeholt hätte.

Whatsapp-User-Accounts hängen an dezidierten Telefonnummern. Bei der Erstinstallation der App auf dem Mobiltelefon prüft sie, ob der User Zugang zur angegebenen Nummer hat, indem sie einen Code dorthin schickt. Läuft WhatsApp schon auf einem Gerät, üblicherweise dem Smartphone, erlaubt die App dort die Registrierung weiterer Geräte über den Settings-Dialog und den Eintrag "Linked Devices". Wer auf "+ Link a device" klickt, sieht eine Kamera hochschnellen, die den QR-Code fotografieren soll, den ein neues Gerät, zum Beispiel ein auf web.whatsapp.com eingenordeter Web-Browser, als erstes anzeigt (Abbildung 1).

Abbildung 1: WhatsApp im Browser bringt zunächst einen QR-Code zur Registrierung.

Bot als Doppelgänger

Fotografiert dann die bereits registrierte Telefon-App den QR-Code des neuen Geräts ab, wird dieses beim WhatsApp-Server angemeldet, und schon kann die neue App unter dem Account des Users zukünftig nach Gusto schalten und walten. Genauso wird später unser WhatsApp-Bot funktionieren. Er repräsentiert lediglich ein weiteres Gerät des angemeldeten Nutzers, das gleichzeitig online ist und sogar im gleichen Chat mitmischen darf. Die von ihm gesendeten Nachrichten sehen aus als kämen sie von Seiten des Nutzers, der eventuell ebenfalls im Chat weilt.

Abbildung 2: Der neue WhatsApp-Client muss per QR-Code registriert werden.

Abbildung 3: Die registrierte App fotografiert den QR-Code eines neu einzubindenden Geräts.

Abbildung 4: WhatsApp akzeptiert den Whatsmeow-Client als offiziell eingebundenes Gerät.

Abbildung 2 zeigt, wie die vorgestellte Linux-App ebenfalls einen QR-Code hochbringt, mit dem sie sich Zugang zum User-Account verschafft. Ist dies einmal erledigt, speichert sie die Zugangsdaten auf der Festplatte und beim nächsten Hochfahren geht sie nahtlos Online.

Abbildung 5: Die Fyne-GUI des Whatsmeow-Clients protokolliert den Chat mit.

Eingehende an den User gerichtete Nachrichten zeigt die App in einem Scrollfenster an (Abbildung 5), und beginnt eine Nachricht mit dem Text "ai:", schickt der Bot die Frage an die openAI-API, um die Antwort anschließend in den Chat zu pusten, ganz so als käme sie vom User selbst. Abbildung 6 zeigt den Chat-Dialog in der Orignal-App auf dem Smartphone. Den Inhalt der grün unterlegten Sprechblasen habe ich ins Telefon getippt, die Antworten in weiß kamen vom Bot.

Abbildung 6: In der Original-App auf dem Smartphone erscheint der Bot unter dem Kürzel des angemeldeten Users.

Eintauchen in WhatsApp

Die Zusammenarbeit mit dem WhatsApp-Server ist nun nicht ganz trivial und whatsmeow deckt eine Vielzahl von Anwendungen ab. So ist ein Client normalerweise in mehreren Chats aktiv und kann mehrere Geräte einhängen, aber die Programmierung eines Bots ist verhältnismäßig simpel, und deshalb definiert Listing 1 eine vereinfachte Abstraktion. Das Hauptprogramm wird später mit NewWaChat() den Konstruktor (ab Zeile 19) aufrufen, gefolgt von Run() ab Zeile 24, das bis zum Programmabbruch im WhatsApp-Kanal lauscht und werkelt.

Um festzulegen, was passieren soll, falls die Nachricht einer Kontaktperson ankommt, dient der Callback OnMessage. Er ist Teil der Paket-Struktur in Zeile 16, wird vom Anwender gesetzt, und kommt in Zeile 45 zum Einsatz, falls ein Event über das WhatsApp-Netzwerk eintrudelt. Außer der Nachricht bekommt er noch den JID des Senders mit, einer im WhatsApp-Netzwerk eindeutigen User-ID. Der Handler wird sie später nutzen, um dem Absender einer Frage mit Send() ab Zeile 80 eine Antwort zu schicken.

Listing 1: chat.go

    01 package main
    02 import (
    03   "context"
    04   "fmt"
    05   _ "github.com/mattn/go-sqlite3"
    06   "go.mau.fi/whatsmeow"
    07   waE2E "go.mau.fi/whatsmeow/binary/proto"
    08   "go.mau.fi/whatsmeow/store/sqlstore"
    09   "go.mau.fi/whatsmeow/types"
    10   "go.mau.fi/whatsmeow/types/events"
    11   waLog "go.mau.fi/whatsmeow/util/log"
    12 )
    13 type WaChat struct {
    14   dbPath    string
    15   client    *whatsmeow.Client
    16   OnMessage func(types.JID, string)
    17   OnQR      func(string)
    18 }
    19 func NewWaChat() *WaChat {
    20   return &WaChat{
    21     dbPath: "state.db",
    22   }
    23 }
    24 func (wa *WaChat) Run() error {
    25   dbLog := waLog.Stdout("Database", "DEBUG", true)
    26   ctx := context.Background()
    27   container, err := sqlstore.New(ctx, "sqlite3",
    28     "file:"+wa.dbPath+"?_foreign_keys=on", dbLog)
    29   if err != nil {
    30     return err
    31   }
    32   deviceStore, err := container.GetFirstDevice(ctx)
    33   if err != nil {
    34     return err
    35   }
    36   clientLog := waLog.Stdout("Client", "DEBUG", true)
    37   wa.client = whatsmeow.NewClient(deviceStore, clientLog)
    38   if wa.OnMessage == nil {
    39     return fmt.Errorf("No OnMessage")
    40   }
    41   wa.client.AddEventHandler(
    42     func(evt interface{}) {
    43       switch v := evt.(type) {
    44       case *events.Message:
    45         wa.OnMessage(v.Info.Chat,
    46           v.Message.GetConversation())
    47       }
    48     })
    49   if wa.client.Store.ID == nil {
    50     // needs to be registered
    51     qrChan, _ := wa.client.GetQRChannel(context.Background())
    52     err := wa.client.Connect()
    53     if err != nil {
    54       return err
    55     }
    56     for evt := range qrChan {
    57       if evt.Event == "code" {
    58         if wa.OnQR == nil {
    59           return fmt.Errorf("No QR handler")
    60         }
    61         wa.OnQR(evt.Code)
    62         break
    63       }
    64     }
    65   } else {
    66     err = wa.client.Connect()
    67     if err != nil {
    68       return err
    69     }
    70   }
    71   ch := make(chan bool)
    72   <-ch // wait forever
    73   return nil
    74 }
    75 func (wa *WaChat) Contact(jid types.JID) string {
    76   ctx := context.Background()
    77   contact, _ := wa.client.Store.Contacts.GetContact(ctx, jid)
    78   return contact.FullName
    79 }
    80 func (wa *WaChat) Send(jid types.JID, msg string) error {
    81   ctx := context.Background()
    82   wmsg := &waE2E.Message{
    83     Conversation: &msg,
    84   }
    85   _, err := wa.client.SendMessage(ctx, jid, wmsg)
    86   return err
    87 }
    88 func (wa *WaChat) Disconnect() {
    89   wa.client.Disconnect()
    90 }

Falls jedoch zur Erstanmeldung ein QR-Code-Request ankommt, springt der OnQR-Handler an, den Zeile 17 in der Paketstruktur deklariert. Er wird ebenfalls vom Anwender gesetzt und vom Code in Zeile 61 mit einem QR-String vom WhatsApp-Server angesprungen. Die Applikation produziert daraus das QR-Code-Bild und zeigt es an (Abbildung 2).

Plauderer hilft Debuggen

Gerade beim anfänglichen Debuggen der Applikation ist der in Zeile 36 definierte Logger hilfreich, der den Whatsmeow-Client dazu veranlasst, auf dem Terminal detaillierte Meldungen über den aktuellen Stand der Dinge auszugeben.

Der Whatsmeow-Client merkt sich eine einmal erfolgte Autorisierung, sowie weitere Meta-Daten, wie die Kontakte des Users. Er sichert seine Daten persistent in einer SQLite-Datei, die eine ganze Latte von Tabellen enthält (Abbildung 7). Zeile 27 legt den Namen dieser Containerdatei fest, und muss gleichzeitig noch das Foreign-Key-Feature von SQLite3 anschalten, damit der Whatsmeow-Client später nicht mault.

Abbildung 7: Der Whatsmeow-Client legt Registrierungsdaten in einer SQLite-Datenbank ab.

GUI spendiert

Zur Darstellung der Chat-Nachrichten bräuchte der Bot eigentlich keine GUI, aber schöner ist's, da bei der Erstanmeldung ja ein QR-Code hochschnellt. Listing 2 nutzt das bewährte Fyne-Framework für eine plattformunabhängige Desktop-App. Außer den von Fyne benötigten Paketen zieht es go-qrcode von Github heran, um aus Text-Strings QR-Codes zu generieren, sowie das Typenpaket types aus dem Whatsmeow-Projekt, denn der OnMessage()-Callback nimmt als ersten Parameter die WhatsApp-JID des sendenden Users.

Listing 2: ui.go

    01 package main
    02 import (
    03   "bytes"
    04   "image"
    05   _ "image/png"
    06   "strings"
    07   "fyne.io/fyne/v2"
    08   "fyne.io/fyne/v2/app"
    09   "fyne.io/fyne/v2/canvas"
    10   "fyne.io/fyne/v2/container"
    11   "fyne.io/fyne/v2/widget"
    12   "github.com/skip2/go-qrcode"
    13   "go.mau.fi/whatsmeow/types"
    14 )
    15 func main() {
    16   a := app.New()
    17   w := a.NewWindow("Whatsapp Client")
    18   img := canvas.NewImageFromImage(nil)
    19   img.FillMode = canvas.ImageFillContain
    20   msgs := container.NewVBox()
    21   scroll := container.NewScroll(msgs)
    22   scroll.SetMinSize(fyne.NewSize(400, 300))
    23   quit := widget.NewButton("Quit", func() { a.Quit() })
    24   wa := NewWaChat()
    25   defer wa.Disconnect()
    26   wa.OnQR = func(qr string) {
    27     png, err := qrcode.Encode(qr, qrcode.Medium, 256)
    28     if err != nil {
    29       panic(err)
    30     }
    31     i, _, err := image.Decode(bytes.NewReader(png))
    32     if err != nil {
    33       panic(err)
    34     }
    35     fyne.Do(func() {
    36       img.Image = i
    37       img.SetMinSize(fyne.NewSize(300, 300))
    38       img.Refresh()
    39     })
    40   }
    41   const prompt = "ai:"
    42   wa.OnMessage = func(jid types.JID, msg string) {
    43     fyne.Do(func() {
    44       if wa.Contact(jid) == "" {
    45         return
    46       }
    47       msgs.Add(fmtMsg(wa.Contact(jid)+":", msg))
    48       if strings.HasPrefix(strings.ToLower(msg), prompt) {
    49         resp, err := askOpenAI(msg[len(prompt):])
    50         if err != nil {
    51           panic(err)
    52         }
    53         wa.Send(jid, resp)
    54         msgs.Add(fmtMsg(prompt, resp))
    55       }
    56       msgs.Refresh()
    57     })
    58   }
    59   w.SetContent(
    60     container.NewVBox(img, scroll, quit))
    61   go wa.Run()
    62   w.ShowAndRun()
    63 }
    64 func fmtMsg(prompt, msg string) *widget.RichText {
    65   rt := widget.NewRichText()
    66   rt.AppendMarkdown("**" + prompt + "** " + msg)
    67   rt.Wrapping = fyne.TextWrapWord
    68   return rt
    69 }

Um daraus einen String mit dem bürgerlichen Namen des Users zu erhalten, ruft Zeile 44 Contact() aus Listing 1 auf, das in die lokale SQLite-Datenbank mit den Kontaktdaten hineinlangt und den Namen zur JID extrahiert.

Fängt der Nachrichtentext mit "ai:" an, leitet Zeile 46 mit askOpenAI() aus Listing 3 eine Anfrage an die OpenAI-API weiter. Die Antwort schickt Zeile 50 wieder zurück an den anfragenden User oder den Chat-Raum. Die Liste aller eingegangenen und gesendeten Nachrichten zeigt das scrollbare Widget scroll an.

Falls ein QR-Request hochkommt, springt der Callback ab Zeile 26 an, macht mit Encode() in Zeile 27 ein QR-Bild aus dem QR-String und Zeile 36 frischt das zugehörige Image-Widget auf. Alle drei verwendeten Widgets, das QR-Bild, die Scrollbox mit den Nachrichten und ein Quit-Button reiht NewVBox() in Zeile 57 übereinander auf und ShowAndRun() wirft die Fyne-Eventschleife an. Diese muss im Haupt-Thread des Go-Programms laufen, die WhatsApp-Kommunikation findet jedoch in einer Goroutine im Hintergrund statt, damit die GUI zackig auf Usereingaben reagiert. Muss nun diese Goroutine im Hintergrund Fyne-Elemente auffrischen wie zum Beispiel im QR-Callback ab Zeile 35, sorgt das Konstrukt fyne.Do() dafür, dass dies Fyne im Hauptthread ordnungsgemäß untergejubelt wird.

Kostenpflichtiges Orakel

Listing 3 funkt schließlich das Orakel auf OpenAI per API an. Aus Bequemlichkeit nutzt es das Paket go-openai von Github statt die HTTP-Requests manuell aufzubauen und das zurückkommende Json aufzudröseln.

Listing 3: ai.go

    01 package main
    02 import (
    03   "context"
    04   "github.com/mschilli/go-murmur"
    05   openai "github.com/sashabaranov/go-openai"
    06 )
    07 func askOpenAI(prompt string) (string, error) {
    08   m := murmur.NewMurmur()
    09   apiKey, err := m.Lookup("openai-api")
    10   if err != nil {
    11     return "", err
    12   }
    13   client := openai.NewClient(apiKey)
    14   resp, err := client.CreateChatCompletion(
    15     context.Background(),
    16     openai.ChatCompletionRequest{
    17       Model: openai.GPT4oMini,
    18       Messages: []openai.ChatCompletionMessage{
    19         {Role: openai.ChatMessageRoleUser, Content: prompt},
    20       },
    21     },
    22   )
    23   if err != nil {
    24     return "", err
    25   }
    26   return resp.Choices[0].Message.Content, nil
    27 }

OpenAI lässt sich API-Abfragen übrigens vergüten, und nicht einmal das kostenpflichtige monatliche Abo deckt sie ab. Vielmehr bezahlen User ein paar Centbruchteile pro Antwort-Token. Jeder API-Anfrage liegt der API-Key bei, den OpenAI bei der Anmeldung mit Kreditkarte bereitstellt. Den Schlüssel als Text-String erwartet Listing 2 in einer .murmur-Datei im Home-Verzeichnis im Format openai-api: sk-proj-XXX". Das in Zeile 17 eingestellte AI-Modell GPT4oMini ist nicht ganz neu (aktuell ist 5.2), aber reagiert zuppig und kostet pro 1 Million Tokens etwa 60 Cent, was wohl im Privatbereich niemand jemals aufbrauchen wird. Wer mit dem Kreuzer rechnen muss, kann gerne auch ein anderes oder sogar ein lokal installiertes AI-System nutzen, mittlerweile gibt es die wie Sand am Meer.

Und, wie gesagt, die Anbindung des Bots an ein AI-System ist nur ein Beispiel von vielen möglichen. Der Bot könnte genauso mittels Home-Automation einen Temperaturfühler abfragen oder das Licht einschalten oder die Haustür öffnen.

Unter Linux klinkt sich Fyne mittels eines C-Wrappers aus Go in die Bibliotheken libx11-dev, libgl1-mesa-dev, libxcursor-dev und xorg-dev ein, die der User zum Beispiel auf Ubuntu mit sudo apt-get install nachinstallieren muss, damit ein darauffolgendes go build einer Fyne-App auch das notwendige Fundament findet.

Der übliche Dreisprung go mod init wa; go mod tidy; go build ui.go chat.go ai.go löst die Abhängigkeiten in allen drei Listings auf, holt den benötigten Code von Github ab und compiliert schließlich alles zu einem ausführbaren Binary. Beim ersten Hochfahren existiert die SQLite-Datei mit den Anmeldungsdaten noch nicht und die hochschnellende Fyne-GUI zeigt den QR-Code für die Registrierung. Den gilt es, mit der WhatsApp-App auf dem Smartphone unter dem Dialog "Linked Devices -> Link New Device" abzufotografieren, dann zeigen die Log-Texte auf dem Terminal, wie sich die neue handgestrickte App erfolgreich im Netzwerk anmeldet. Ab diesem Zeitpunkt lauscht der Bot unter dem gleichen Usernamen auf eingehende Nachrichten und schickt bei Bedarf OpenAI-Antworten auf mit "ai:" anfangende gestellte Fragen im Chat.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2026/04/snapshot/

[2]

Whatsmeow, Go-Library für WhatsApp-Web, https://github.com/tulir/whatsmeow

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.