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 Jahren, anno 2009. Ich arbeitet damals schon geraume Zeit beim Unternehmen Yahoo! im kalifornischen Sunnyvale als Softwareingenieur. 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 auf Zack waren.
Um es kurz zu machen, die neue Firma hieß WhatsApp, schlug ein wie eine Bombe, und nur fünf Jahre später zahlte Facebook (heute Meta) rund 19 Milliarden Dollar dafür. Das katapultierte meine ehemaligen Kollegen in den Olymp der reichsten Menschen Kaliforniens. Hätte ich damals zugesagt, würde ich heute nur noch als Hauptsponsor von Benefizveranstaltungen fungieren – das wäre auch kein Leben. Außerdem, soviel zu meiner Fähigkeit, die IT-Zukunft vorherzusagen.
Ende zu Ende
Heute hat WhatsApp die gute alte E-Mail im Privatleben fast gänzlich abgelöst. Allerdings stellt Meta keine API-Lösung für den Nachrichtenservice bereit. In diesem Fall springt wie gewohnt die Open-Surce-Welt in die Bresche: Die Community hat mit »whatsmeow« eine Go-Bibliothek [1] entwickelt. Mit ihr lassen sich basierend auf der WhatsApp-Web-Technologie (WhatsApp im Webbrowser) beliebige Applikationen bauen. Sie hängen sich in offizielle Chats ein und senden und empfangen Nachrichten. Das ist nicht trivial, denn WhatsApp-Nachrichten sind immer noch Ende-zu-Ende-verschlüsselt. Einzige Ausnahme: Sobald ein Gesprächsteilnehmer “@Meta AI” sagt, darf sich der Facebook-Bot zuschalten, mitlesen und per künstlicher Intelligenz Antworten geben.
Der in dieser Ausgabe gezeigte handgestrickte Bot liest ebenfalls mit, funkt aber dabei nicht an die Meta-Zentrale, sondern behält erst einmal alles für sich. So lassen sich lustige Apps schreiben, die zum Beispiel auf bestimmte Texte reagieren, und passende Antworten verschicken. Als Idee schwebt mir vor, den Bot später 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 mein Eintrag als erster in Sekundenschnelle auftaucht.
Frag das Orakel
Als Beispielapplikation soll nun jedoch ein Bot dienen, der Fragen aus dem Chat entgegennimmt, sie an OpenAI weiterleitet und die Antwort des KI-Orakels zurück in den Chat schickt. So darf dann beispielsweise ein Teilnehmer fragen “ai: was ist 2 * 2?”. Nachdem er die Antwort bei OpenAI per API abgeholt hat, würde der Bot “2 * 2 ist 4” antworten.
WhatsApp-User-Accounts hängen an dezidierten Telefonnummern. Bei der Erstinstallation der App auf dem Mobiltelefon prüft sie, ob der User tatsächlich Zugang zur angegebenen Nummer hat, indem sie einen Code dorthin versendet. Läuft WhatsApp schon auf einem Gerät, üblicherweise auf 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. Sie soll den QR-Code fotografieren, den ein neues Gerät, zum Beispiel ein auf http://web.whatsapp.com eingenordeter Webbrowser (Abbildung 1), als Erstes anzeigt.
Bot als Doppelgänger
Nimmt anschließend die bereits registrierte Telefon-App den QR-Code des neuen Geräts auf, erfolgt die Anmeldung beim WhatsApp-Server. Und schon kann die neue App unter dem Account des Users nach Gutdünken schalten und walten. Genauso funktioniert später unser WhatsApp-Bot. Er repräsentiert lediglich ein weiteres Gerät (Abbildung 2) des angemeldeten Nutzers, das sowohl gleichzeitig online ist als auch im selben Chat mitmischen darf. Die von ihm gesendeten Nachrichten sehen aus, als kämen sie vom Nutzer – der eventuell auch im Chat weilt.
Abbildung 3 zeigt, wie die vorgestellte Linux-App ebenfalls einen QR-Code hochbringt, mit dem sie sich Zugang zum User-Account verschafft. Ist das einmal erledigt, speichert sie die Zugangsdaten auf der Festplatte, und beim nächsten Hochfahren geht sie nahtlos online.
Eingehende, an den User gerichtete Nachrichten, gibt die App in einem Scroll-Fenster (Abbildung 4) aus. 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 posten – ganz so als käme sie vom Nutzer selbst. Abbildung 5 zeigt den Chat-Dialog in der Orignal-App auf dem Smartphone. Den Inhalt der grün unterlegten Sprechblasen habe ich selbst ins Telefon getippt, die Antworten in Weiß stammen vom Bot.

Abbildung 5: 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 erweist sich als knifflig, und »whatsmeow« deckt eine Vielzahl von Anwendungen ab. So tummelt sich ein Client normalerweise in mehreren Chats und kann mehrere Geräte einhängen. Die Programmierung eines Bots fällt allerdings verhältnismäßig simpel aus. Deshalb definiert Listing 1 eine vereinfachte Abstraktion. Das Hauptprogramm ruft später mit »NewWaChat()« den Konstruktor (ab Zeile 19) auf, 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 Paketstruktur in Zeile 16, der Anwender setzt ihn, und er kommt in Zeile 45 zum Einsatz, sobald 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 nutzt sie später, um dem Absender einer Frage mit »Send()« ab Zeile 80 eine Antwort zu schicken.
Listing 1
chat.go
package main
import (
"context"
"fmt"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/whatsmeow"
waE2E "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
)
type WaChat struct {
dbPath string
client *whatsmeow.Client
OnMessage func(types.JID, string)
OnQR func(string)
}
func NewWaChat() *WaChat {
return &WaChat{
dbPath: "state.db",
}
}
func (wa *WaChat) Run() error {
dbLog := waLog.Stdout("Database", "DEBUG", true)
ctx := context.Background()
container, err := sqlstore.New(ctx, "sqlite3",
"file:"+wa.dbPath+"?_foreign_keys=on", dbLog)
if err != nil {
return err
}
deviceStore, err := container.GetFirstDevice(ctx)
if err != nil {
return err
}
clientLog := waLog.Stdout("Client", "DEBUG", true)
wa.client = whatsmeow.NewClient(deviceStore, clientLog)
if wa.OnMessage == nil {
return fmt.Errorf("No OnMessage")
}
wa.client.AddEventHandler(
func(evt interface{}) {
switch v := evt.(type) {
case *events.Message:
wa.OnMessage(v.Info.Chat,
v.Message.GetConversation())
}
})
if wa.client.Store.ID == nil {
// needs to be registered
qrChan, _ := wa.client.GetQRChannel(context.Background())
err := wa.client.Connect()
if err != nil {
return err
}
for evt := range qrChan {
if evt.Event == "code" {
if wa.OnQR == nil {
return fmt.Errorf("No QR handler")
}
wa.OnQR(evt.Code)
break
}
}
} else {
err = wa.client.Connect()
if err != nil {
return err
}
}
ch := make(chan bool)
<-ch // wait forever
return nil
}
func (wa *WaChat) Contact(jid types.JID) string {
ctx := context.Background()
contact, _ := wa.client.Store.Contacts.GetContact(ctx, jid)
return contact.FullName
}
func (wa *WaChat) Send(jid types.JID, msg string) error {
ctx := context.Background()
wmsg := &waE2E.Message{
Conversation: &msg,
}
_, err := wa.client.SendMessage(ctx, jid, wmsg)
return err
}
func (wa *WaChat) Disconnect() {
wa.client.Disconnect()
}
Kommt zur Erstanmeldung ein QR-Code-Request an, läuft der »OnQR«-Handler an, den Zeile 17 in der Paketstruktur deklariert. Auch ihn setzt der Anwender, und der Code in Zeile 61 springt ihn mit einem QR-String vom WhatsApp-Server an. Die Applikation produziert daraus das QR-Code-Bild und zeigt es an.
Gerade beim anfänglichen Debuggen der Applikation hilft der in Zeile 36 definierte Logger. Er veranlasst den Whatsmeow-Client dazu, auf dem Terminal detaillierte Meldungen über den aktuellen Stand der Dinge auszugeben. Der Whatsmeow-Client merkt sich eine einmal erfolgte Autorisierung und weitere Metadaten wie die Kontakte des Users. Er sichert seine Daten persistent in einer SQLite-Datei (Abbildung 6), die zahlreiche Tabellen enthält. Zeile 27 legt den Namen dieser Containerdatei fest, und muss gleichzeitig noch das Foreign-Key-Feature von SQLite3 anschalten, damit der Whatsmeow-Client nicht mault.
GUI spendieren
Zum Ausgeben der Chatnachrichten bräuchte der Bot grundsätzlich keine GUI, aber schöner ist es, 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 (Zeile 12) heran, um aus Textstrings QR-Codes zu generieren. Zudem kommt das Typenpaket »types« aus dem Whatsmeow-Projekt zum Einsatz, denn der »OnMessage()«-Callback (Zeile 42) verwendet als ersten Parameter die WhatsApp-JID des sendenden Users.
Listing 2
ui.go
package main
import (
"bytes"
"image"
_ "image/png"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/skip2/go-qrcode"
"go.mau.fi/whatsmeow/types"
)
func main() {
a := app.New()
w := a.NewWindow("Whatsapp Client")
img := canvas.NewImageFromImage(nil)
img.FillMode = canvas.ImageFillContain
msgs := container.NewVBox()
scroll := container.NewScroll(msgs)
scroll.SetMinSize(fyne.NewSize(400, 300))
quit := widget.NewButton("Quit", func() { a.Quit() })
wa := NewWaChat()
defer wa.Disconnect()
wa.OnQR = func(qr string) {
png, err := qrcode.Encode(qr, qrcode.Medium, 256)
if err != nil {
panic(err)
}
i, _, err := image.Decode(bytes.NewReader(png))
if err != nil {
panic(err)
}
fyne.Do(func() {
img.Image = i
img.SetMinSize(fyne.NewSize(300, 300))
img.Refresh()
})
}
const prompt = "ai:"
wa.OnMessage = func(jid types.JID, msg string) {
fyne.Do(func() {
msgs.Add(fmtMsg(wa.Contact(jid)+":", msg))
if strings.HasPrefix(strings.ToLower(msg), prompt) {
resp, err := askOpenAI(msg[len(prompt):])
if err != nil {
panic(err)
}
wa.Send(jid, resp)
msgs.Add(fmtMsg(prompt, resp))
}
msgs.Refresh()
})
}
w.SetContent(
container.NewVBox(img, scroll, quit))
go wa.Run()
w.ShowAndRun()
}
func fmtMsg(prompt, msg string) *widget.RichText {
rt := widget.NewRichText()
rt.AppendMarkdown("**" + prompt + "** " + msg)
rt.Wrapping = fyne.TextWrapWord
return rt
}
Um daraus einen String mit dem bürgerlichen Namen des Users zu erhalten, ruft Zeile 44 »Contact()« aus Listing 1 auf. Das Kommando stöbert in der lokalen SQLite-Datenbank mit den Kontaktdaten und extrahiert den Namen zur JID.
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 Chatraum. Die Liste aller eingegangenen und gesendeten Nachrichten liefert das scrollbare Widget »scroll«.
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 einen Quit-Button reiht »NewVBox()« in Zeile 57 übereinander auf. Der Befehl »ShowAndRun()« wirft die Fyne-Eventschleife an. Sie muss im Haupt-Thread des Go-Programms laufen. Die WhatsApp-Kommunikation hingegen findet in einer Go-Routine im Hintergrund statt, damit die GUI zackig auf Usereingaben reagiert. Muss nun diese Routine im Hintergrund Fyne-Elemente auffrischen wie 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
package main
import (
"context"
"github.com/mschilli/go-murmur"
openai "github.com/sashabaranov/go-openai"
)
func askOpenAI(prompt string) (string, error) {
m := murmur.NewMurmur()
apiKey, err := m.Lookup("openai-api")
if err != nil {
return "", err
}
client := openai.NewClient(apiKey)
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4oMini,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: prompt},
},
},
)
if err != nil {
return "", err
}
return resp.Choices[0].Message.Content, nil
}
OpenAI lässt sich API-Abfragen 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 nach der Anmeldung mit Kreditkarte bereitstellt. Den Schlüssel als Text-String erwartet Listing 2 in einer Datei ».murmur« im Home-Verzeichnis im Format »openai-api: sk-proj-XXX”«. Das in Zeile 17 eingestellte AI-Modell GPT4oMini ist nicht zwar ganz neu (aktuell ist Version 5.2), reagiert jedoch flott und kostet pro 1 Million Token etwa 60 US-Cent – im Privatbereich dürfte es schwerfallen, den Tokenvorrat jemals aufzubrauchen.
Wer mit dem Kreuzer rechnen muss, nutzt einfach ein anderes oder sogar ein lokal installiertes Modell. Mittlerweile gibt es sie wie Sand am Meer. Und wie gesagt, ist die Anbindung des Bots an ein AI-System nur ein Beispiel von vielen möglichen. Der Bot könnte genauso mittels Home-Automation einen Temperaturfühler abfragen, 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. Allerdings müssen Sie sie beispielsweise auf Ubuntu mit »sudo apt-get install« nachinstallieren, damit ein darauffolgendes »go build« einer Fyne-App das notwendige Fundament findet.
Der übliche Dreisprung aus Listing 4 löst die Abhängigkeiten in allen drei Listings auf, holt den benötigten Code von GitHub ab und kompiliert schließlich alles zu einem ausführbaren Binary.
Listing 4
Binary bauen
$ go mod init wa $ go mod tidy $ go build ui.go $ go chat.go ai.go
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 demselben Usernamen auf eingehende Nachrichten und schickt bei Bedarf OpenAI-Antworten auf mit “ai:” beginnende gestellte Fragen im Chat. (uba)
Der Autor
Michael Schilli arbeitet als Softwareengineer 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 gern Ihre Fragen.
Infos
- Whatsmeow, Go-Library für WhatsApp-Web: https://github.com/tulir/whatsmeow











