]> git.giacomolli.uk - onechat/commitdiff
Show the WhatsApp link with /whatsapp status
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 14:36:14 +0000 (11:36 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 14:36:14 +0000 (11:36 -0300)
The daemon opens the WhatsApp session in whatsapp/session.db and keeps a
linked device connected, so the next steps can pair, read, and send. The
line /whatsapp status in the terminal client or in Telegram tells
whether WhatsApp is not linked, linked and connected, or linked and
disconnected, with the times of the connection.

The clients handle a /whatsapp line before it reaches the agent, and the
daemon answers only the client that asked, with a notice event that the
terminal client now shows. So the lines of the later pairing, which hold
the phone number and the code, never reach the log, the model, or the
other client.

The session gives full access to the account, so the daemon refuses a
session file whose mode is not 0600, and the library logs nothing,
because its log lines hold the own phone number. The daemon opens the
session only after it holds the lock of the data directory. The library
and its SQLite driver, modernc.org/sqlite, are pure Go.

Issue: 6f69ca1

12 files changed:
README.rst
daemon.go
daemon_start_helper_test.go
go.mod
go.sum
nix/package.nix
socket.go
telegram.go
terminal.go
whatsapp.go [new file with mode: 0644]
whatsapp_fake_helper_test.go [new file with mode: 0644]
whatsapp_test.go [new file with mode: 0644]

index 7712c0ec5a8ce05dfee6836587403dbf34e02fdd..3a2d25bdff8f45a31b755ed4d573c6822abb5acc 100644 (file)
@@ -172,6 +172,25 @@ write of a message can make the message repeat once. A network failure
 or a rate limit makes the bot wait and try again, and does not stop the
 daemon.
 
+**********
+ WhatsApp
+**********
+
+The daemon connects to the WhatsApp account of the user as a linked
+device. In the terminal client or in Telegram, the line ``/whatsapp
+status`` shows the link: not linked, linked and connected, or linked and
+disconnected, with the times of the connection. The answer goes only to
+the client that asked, and the line does not go to the agent.
+
+The session is ``whatsapp/session.db`` in the data directory. It gives
+full access to the WhatsApp account, so the daemon refuses the file when
+its mode is not 0600. The shell of the agent runs as the user, so it can
+read the session. If the session leaks, remove the linked device in
+WhatsApp on the phone, and the session stops working. Without the file,
+WhatsApp is not linked. When the session holds a linked device, the
+daemon connects at its start, and after a network failure it connects
+again by itself.
+
 *******
  Probe
 *******
index a4041f1b10ff7feb555972aca563662c128ee2da..d67c5be60c663aa9f712de3ac5819776a2ff3785 100644 (file)
--- a/daemon.go
+++ b/daemon.go
@@ -27,8 +27,9 @@ const (
 
 // daemon runs "onechat daemon": it owns the root agent and its files and serves
 // the clients on the Unix socket in the data directory until ctx ends. When
-// telegram.json exists, it also runs the Telegram bot. It holds the lock of the
-// token file, so it is the only process that refreshes the tokens.
+// telegram.json exists, it also runs the Telegram bot. It also keeps a linked
+// WhatsApp device connected. It holds the lock of the token file, so it is the
+// only process that refreshes the tokens and opens the WhatsApp session.
 func daemon(ctx context.Context, out io.Writer) (err error) {
        dir, err := dataDir()
        if err != nil {
@@ -62,6 +63,15 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
                err = errors.Join(err, tokens.Close())
        }()
 
+       whatsapp, err := openWhatsApp(ctx, dir, time.Now, log.New(out, "whatsapp: ", log.LstdFlags), whatsappNewClient)
+       if err != nil {
+               return err
+       }
+
+       defer func() {
+               err = errors.Join(err, whatsapp.Close())
+       }()
+
        address, auth, err := modelEndpoint(config, dir)
        if err != nil {
                return err
@@ -106,13 +116,16 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
                }
        }
 
-       return daemonServe(ctx, listener, telegram, func(notify func(Event)) (*TurnLoop, error) {
+       return daemonServe(ctx, listener, telegram, whatsapp, func(notify func(Event)) (*TurnLoop, error) {
                return newTurnLoop(agent, memory, subagents, client, time.Now, notify)
        })
 }
 
-// DaemonHub holds the connected clients and sends each event to all of them.
+// DaemonHub holds the connected clients and sends each event to all of them. It
+// runs the WhatsApp commands of the clients on whatsapp.
 type DaemonHub struct {
+       whatsapp *WhatsApp
+
        mutex   sync.Mutex
        clients map[*DaemonClient]bool
        workers sync.WaitGroup
@@ -178,7 +191,12 @@ func (h *DaemonHub) read(ctx context.Context, client *DaemonClient, l *TurnLoop)
                var request SocketRequest
                err := json.Unmarshal(scanner.Bytes(), &request)
                if err == nil {
-                       err = daemonRunRequest(l, request)
+                       err = daemonRunRequest(l, h.whatsapp, request, func(text string) {
+                               h.Send(client, Event{
+                                       Kind: "notice",
+                                       Text: text,
+                               })
+                       })
                }
 
                if err != nil {
@@ -287,14 +305,16 @@ func daemonListenSocket(path string) (net.Listener, error) {
 }
 
 // daemonServe runs the loop that start makes and serves the clients of listener
-// until ctx ends or the loop fails. It closes listener. When bot is not nil, it
-// runs the bot too, and a failure of the bot stops the agent.
-func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, start func(notify func(Event)) (*TurnLoop, error)) error {
+// until ctx ends or the loop fails. It closes listener. It keeps whatsapp
+// connected. When bot is not nil, it runs the bot too, and a failure of the bot
+// stops the agent.
+func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, whatsapp *WhatsApp, start func(notify func(Event)) (*TurnLoop, error)) error {
        ctx, cancel := context.WithCancel(ctx)
        defer cancel()
 
        hub := &DaemonHub{
-               clients: make(map[*DaemonClient]bool),
+               whatsapp: whatsapp,
+               clients:  make(map[*DaemonClient]bool),
        }
 
        notify := hub.Broadcast
@@ -311,20 +331,26 @@ func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, s
        }
 
        var workers sync.WaitGroup
-       workers.Add(1)
+       workers.Add(2)
        go func() {
                defer workers.Done()
 
                hub.Accept(ctx, listener, l)
        }()
 
+       go func() {
+               defer workers.Done()
+
+               whatsapp.Run(ctx)
+       }()
+
        var botErr error
        if bot != nil {
                workers.Add(1)
                go func() {
                        defer workers.Done()
 
-                       botErr = bot.Run(ctx, l)
+                       botErr = bot.Run(ctx, l, whatsapp)
                        cancel()
                }()
        }
@@ -342,8 +368,9 @@ func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, s
        return errors.Join(err, botErr)
 }
 
-// daemonRunRequest runs one request of a client.
-func daemonRunRequest(l *TurnLoop, request SocketRequest) error {
+// daemonRunRequest runs one request of a client. The answers of a WhatsApp
+// command go to answer.
+func daemonRunRequest(l *TurnLoop, whatsapp *WhatsApp, request SocketRequest, answer func(text string)) error {
        switch request.Type {
        case "send":
                if request.Text == "" {
@@ -358,6 +385,10 @@ func daemonRunRequest(l *TurnLoop, request SocketRequest) error {
                        return errors.New("no turn runs")
                }
 
+               return nil
+       case "whatsapp":
+               whatsapp.Command(request.Text, answer)
+
                return nil
        case "stop":
                found, err := l.StopSubagent(request.Text)
index 1952e77440ed41408d43f9aa2d23c34a16b32b67..cf1fbf6a507e8bde0923646e679150f699daa209 100644 (file)
@@ -8,7 +8,8 @@ import (
        "github.com/stretchr/testify/require"
 )
 
-// daemonStartTestWithBot is daemonStartTest with bot, which can be nil.
+// daemonStartTestWithBot is daemonStartTest with bot, which can be nil. WhatsApp
+// is not linked.
 func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string {
        t.Helper()
 
@@ -21,6 +22,7 @@ func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) s
        memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer())
        require.NoError(t, err)
 
+       whatsapp, _ := whatsappStartTest(t, dir)
        socket := filepath.Join(dir, socketFileName)
        listener, err := daemonListenSocket(socket)
        require.NoError(t, err)
@@ -29,7 +31,7 @@ func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) s
        ctx, cancel := context.WithCancel(t.Context())
        done := make(chan error)
        go func() {
-               done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) {
+               done <- daemonServe(ctx, listener, bot, whatsapp, func(notify func(Event)) (*TurnLoop, error) {
                        return newTurnLoop(agent, memory, newTestSubagents(t), client, clock.Now, notify)
                })
        }()
diff --git a/go.mod b/go.mod
index 673fbdce0f029158463e41177a1b45ba5948dd10..2fdae0630030d3e54cdc7595733a31d37e3a9b03 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,35 @@ go 1.26.5
 
 require (
        github.com/stretchr/testify v1.12.0
+       go.mau.fi/whatsmeow v0.0.0-20261107160454-79dc2a8c33df
        golang.org/x/sys v0.49.0
+       modernc.org/sqlite v1.62.0
 )
 
-require gopkg.in/yaml.v3 v3.0.1 // indirect
+require (
+       filippo.io/edwards25519 v1.2.0 // indirect
+       github.com/beeper/argo-go v1.1.2 // indirect
+       github.com/coder/websocket v1.8.15 // indirect
+       github.com/dustin/go-humanize v1.1.0 // indirect
+       github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
+       github.com/google/uuid v1.6.0 // indirect
+       github.com/mattn/go-colorable v0.1.14 // indirect
+       github.com/mattn/go-isatty v0.0.24 // indirect
+       github.com/ncruces/go-strftime v1.1.0 // indirect
+       github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect
+       github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+       github.com/rs/zerolog v1.35.1 // indirect
+       github.com/vektah/gqlparser/v2 v2.5.27 // indirect
+       go.mau.fi/libsignal v0.2.2 // indirect
+       go.mau.fi/util v0.11.0 // indirect
+       golang.org/x/crypto v0.58.0 // indirect
+       golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
+       golang.org/x/net v0.61.0 // indirect
+       golang.org/x/sync v0.24.0 // indirect
+       golang.org/x/text v0.43.0 // indirect
+       google.golang.org/protobuf v1.36.12 // indirect
+       gopkg.in/yaml.v3 v3.0.1 // indirect
+       modernc.org/libc v1.80.1 // indirect
+       modernc.org/mathutil v1.7.1 // indirect
+       modernc.org/memory v1.12.1 // indirect
+)
diff --git a/go.sum b/go.sum
index 8de869740c4c7b674c07ac5b4f169ea78451bcf5..13d902bd688811d5e7803bddefac3cf6c8ebdb69 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,100 @@
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
+github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
+github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
+github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
+github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
+github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
+github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
+github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
+github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
+github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E=
+github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
 github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
 github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
+github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
+github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
+go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU=
+go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0=
+go.mau.fi/util v0.11.0 h1:JNEpkVRxfTdJUC0DIGKiRlLKzl4G/qLX/eVzoP7QuwY=
+go.mau.fi/util v0.11.0/go.mod h1:04AEFVsc4zNdIkDC1gkdqbg/nZFolHY50pyXh2DSy7s=
+go.mau.fi/whatsmeow v0.0.0-20261107160454-79dc2a8c33df h1:gvTdAioELjy181i4g6O0EtLpXtMSDI5DfjyyE8Txm60=
+go.mau.fi/whatsmeow v0.0.0-20261107160454-79dc2a8c33df/go.mod h1:iY4lm61nTLWsNiIE1EmHUZwhKGBeoV4dhnVE2uMtNoQ=
+golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
+golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
+golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
+golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
+golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
+golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
 golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
+golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
+golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
+golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
+google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b h1:WE5SLOURcNd8s6uPR/n4WV4weQxxYKi5OpEZkfNVvdE=
+modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.38.1 h1:wdxJVBuFT8JufZnnQyS6Bbz3NOWhViD0xJOGR96Bpd4=
+modernc.org/ccgo/v4 v4.38.1/go.mod h1:kOpEkJRbFwBvjUWwn4j1j9B7iFHbl1+orhgTMCDkDBk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
+modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.80.1 h1:ejn7HtTV3GknNKbklqWQ1nvUrfX8mTxs1D+xp/JQwy4=
+modernc.org/libc v1.80.1/go.mod h1:pvo25Rs3OJEbDqhUV9jdEGevxeLJrJeDmNid/AcQV1g=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.62.0 h1:gMaCjSM1qpl+iOjiWhR1LYP3zqrYDI+5GYLGt6veP2g=
+modernc.org/sqlite v1.62.0/go.mod h1:Jzjh1mt4s0HuQ0KvtB1LIuHiGRmU2D5pgj8dUhW7/SE=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
index ceb629f34e9dc325a63dbda065bda053ade3341a..1a7e8d09c655763eeacad1d1796ca17ff9037007 100644 (file)
@@ -9,7 +9,7 @@ buildGoModule {
   inherit src;
 
   subPackages = [ "." ];
-  vendorHash = "sha256-Mpw1XigL2ARm8oTKwutBLHxdLChSfK1ZbGRaNww8jkk=";
+  vendorHash = "sha256-RxkfJaQ+YDa7C7EecVo0XBh26Kg0qQ+1QvSnnjjEKwk=";
 
   # The flake check runs the full replay.
   checkFlags = [ "-short" ];
index 36d664157ed8175085b02512c9435343ee16eea5..a306c5c81e00a6ec5f03391a4e9ae93fe7d4af6e 100644 (file)
--- a/socket.go
+++ b/socket.go
@@ -30,7 +30,8 @@ func checkSocketPath(path string) error {
 }
 
 // SocketRequest is one line from a client: "send" with the text of a user
-// message, "interrupt", or "stop" with the name of a subagent in Text.
+// message, "interrupt", "stop" with the name of a subagent in Text, or
+// "whatsapp" with the text after /whatsapp of a WhatsApp command.
 type SocketRequest struct {
        Type string `json:"type"`
        Text string `json:"text,omitempty"`
index c560104c52b83cb4d590d60b939c2de04af277f2..16eff80c473eaba73d6cd5291b6aee7cdfc63a57 100644 (file)
@@ -185,7 +185,7 @@ func (b *TelegramBot) Notify(event Event) {
 // Run polls for the messages of the owner and sends the queued events to the
 // chat until ctx ends. It returns an error only when a message or the offset
 // cannot be written, because then the bot would lose or repeat messages.
-func (b *TelegramBot) Run(ctx context.Context, l *TurnLoop) error {
+func (b *TelegramBot) Run(ctx context.Context, l *TurnLoop, whatsapp *WhatsApp) error {
        ctx, cancel := context.WithCancel(ctx)
        defer cancel()
 
@@ -197,7 +197,7 @@ func (b *TelegramBot) Run(ctx context.Context, l *TurnLoop) error {
                b.send(ctx)
        }()
 
-       err := b.poll(ctx, l)
+       err := b.poll(ctx, l, whatsapp)
        cancel()
        sender.Wait()
 
@@ -247,7 +247,7 @@ func (b *TelegramBot) send(ctx context.Context) {
 // poll gets the updates from the stored offset on and handles each one, until
 // ctx ends. Each update is handled to its end also when ctx ends during it, so
 // a stop does not repeat its message.
-func (b *TelegramBot) poll(ctx context.Context, l *TurnLoop) error {
+func (b *TelegramBot) poll(ctx context.Context, l *TurnLoop, whatsapp *WhatsApp) error {
        offset, err := telegramLoadOffset(b.offsetPath)
        if err != nil {
                return err
@@ -273,7 +273,7 @@ func (b *TelegramBot) poll(ctx context.Context, l *TurnLoop) error {
                                return nil
                        }
 
-                       err = b.handle(context.WithoutCancel(ctx), l, update)
+                       err = b.handle(context.WithoutCancel(ctx), l, whatsapp, update)
                        if err != nil {
                                return err
                        }
@@ -349,10 +349,12 @@ func (b *TelegramBot) pause(ctx context.Context, err error, wait, asked time.Dur
 
 // handle runs one update. A text message of the owner in the private chat goes
 // to the agent, and handle returns when it is durable in the log. The line
-// /stop interrupts the turn, and the line /stop NAME stops the subagent NAME.
-// Any other message of the owner gets the answer that only text is supported.
-// Every other update is ignored, and its content is not logged.
-func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, update TelegramUpdate) error {
+// /stop interrupts the turn, the line /stop NAME stops the subagent NAME, and a
+// line that starts with /whatsapp runs a WhatsApp command on whatsapp, whose
+// answers go to the chat only. Any other message of the owner gets the answer
+// that only text is supported. Every other update is ignored, and its content
+// is not logged.
+func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, whatsapp *WhatsApp, update TelegramUpdate) error {
        message := update.Message
        if message == nil || message.Chat.Type != "private" || message.Chat.ID != b.owner || message.From == nil || message.From.ID != b.owner {
                b.logger.Print("update ignored: not a message of the owner in the private chat")
@@ -360,6 +362,7 @@ func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, update TelegramUp
        }
 
        name, stop := stopSubagentName(message.Text)
+       args, isWhatsApp := whatsappCommandArgs(message.Text)
        switch {
        case message.Text == "":
                b.notice(telegramTextOnly)
@@ -376,6 +379,8 @@ func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, update TelegramUp
                if !found {
                        b.notice("No subagent " + name + " runs.")
                }
+       case isWhatsApp:
+               whatsapp.Command(args, b.notice)
        default:
                _, err := l.Submit(telegramSource, message.Text)
                if err != nil {
index 96a8cc5cf5605671700802710ed757561a2b16e3..1982f87bd621658c57a83f7c348c8c7c1ac6403a 100644 (file)
@@ -19,8 +19,9 @@ const terminalResultLines = 10
 var errTerminalDaemonClosed = errors.New("the daemon closed the connection")
 
 // terminal runs "onechat chat": the terminal client of the daemon. Each line of
-// input is a user message, the line /stop interrupts the running turn, and the
-// line /stop NAME stops the subagent NAME. The client shows the replies, the
+// input is a user message, the line /stop interrupts the running turn, the line
+// /stop NAME stops the subagent NAME, and a line that starts with /whatsapp is
+// a WhatsApp command. The client shows the replies, the
 // tool calls, and their results as they happen. It ends at the end of the input
 // or when the daemon closes the connection.
 func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
@@ -86,19 +87,23 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
                }
 
                name, stop := stopSubagentName(line)
-               switch strings.TrimSpace(line) {
-               case "":
+               args, whatsapp := whatsappCommandArgs(line)
+               switch {
+               case strings.TrimSpace(line) == "":
                        continue
-               case stopCommand:
+               case strings.TrimSpace(line) == stopCommand:
                        request = SocketRequest{
                                Type: "interrupt",
                        }
-               default:
-                       if stop {
-                               request = SocketRequest{
-                                       Type: "stop",
-                                       Text: name,
-                               }
+               case stop:
+                       request = SocketRequest{
+                               Type: "stop",
+                               Text: name,
+                       }
+               case whatsapp:
+                       request = SocketRequest{
+                               Type: "whatsapp",
+                               Text: args,
                        }
                }
 
@@ -177,6 +182,8 @@ func terminalEventText(event Event) string {
                return "[done]"
        case "error":
                return "[error] " + event.Text
+       case "notice":
+               return event.Text
        default:
                return ""
        }
diff --git a/whatsapp.go b/whatsapp.go
new file mode 100644 (file)
index 0000000..05db5e4
--- /dev/null
@@ -0,0 +1,256 @@
+package main
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "log"
+       "net/url"
+       "os"
+       "path/filepath"
+       "strings"
+       "sync"
+       "time"
+
+       "go.mau.fi/whatsmeow"
+       "go.mau.fi/whatsmeow/store"
+       "go.mau.fi/whatsmeow/store/sqlstore"
+       "go.mau.fi/whatsmeow/types/events"
+       waLog "go.mau.fi/whatsmeow/util/log"
+
+       // The session store of the library opens SQLite with this pure Go
+       // driver.
+       _ "modernc.org/sqlite"
+)
+
+const (
+       // whatsappDirName is the directory of the WhatsApp files in the data
+       // directory.
+       whatsappDirName = "whatsapp"
+
+       // whatsappSessionFileName is the session database of the library in
+       // whatsappDirName.
+       whatsappSessionFileName = "session.db"
+
+       // whatsappCommand starts the input lines of the WhatsApp commands, such
+       // as "/whatsapp status".
+       whatsappCommand = "/whatsapp"
+
+       // whatsappUsage is the answer to a WhatsApp command that does not exist.
+       whatsappUsage = "Use /whatsapp status."
+
+       // whatsappTimeLayout is the layout of the times in the answers.
+       whatsappTimeLayout = "2006-01-02 15:04"
+)
+
+// WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
+// real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a
+// fake.
+type WhatsAppClient interface {
+       ConnectContext(ctx context.Context) error
+       Disconnect()
+}
+
+// openWhatsApp opens the WhatsApp session in the data directory dir. Without
+// the session file, WhatsApp is not linked. The session gives full access to
+// the account, so a file that another user can read is an error. The clock
+// gives the times of the status, logger gets the failures, and newClient makes
+// the client of a linked device with its event handler.
+func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
+       w := &WhatsApp{
+               dir:       filepath.Join(dir, whatsappDirName),
+               clock:     clock,
+               logger:    logger,
+               newClient: newClient,
+       }
+
+       path := filepath.Join(w.dir, whatsappSessionFileName)
+       info, err := os.Stat(path)
+       if errors.Is(err, os.ErrNotExist) {
+               return w, nil
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("read WhatsApp session: %w", err)
+       }
+
+       if info.Mode().Perm()&0o077 != 0 {
+               return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
+       }
+
+       address, err := whatsappSessionAddress(path)
+       if err != nil {
+               return nil, err
+       }
+
+       w.container, err = sqlstore.New(ctx, "sqlite", address, waLog.Noop)
+       if err != nil {
+               return nil, fmt.Errorf("open WhatsApp session: %w", err)
+       }
+
+       w.device, err = w.container.GetFirstDevice(ctx)
+       if err != nil {
+               return nil, errors.Join(fmt.Errorf("read WhatsApp device: %w", err), w.container.Close())
+       }
+
+       return w, nil
+}
+
+// WhatsApp is the link of the daemon to the WhatsApp account of the user, as
+// a linked device. The session database of the library in dir holds the keys
+// of the device. A device with an ID is linked, and Run keeps it connected.
+// The library connects again by itself after a network failure. The mutex
+// protects the state of the connection, which the event handler of the library
+// changes from its own goroutines.
+//
+// The library logs nothing, because its log lines hold the ID of the own
+// account, which is the phone number of the user.
+type WhatsApp struct {
+       dir       string
+       clock     func() time.Time
+       logger    *log.Logger
+       newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient
+       container *sqlstore.Container
+       device    *store.Device
+       client    WhatsAppClient
+
+       mutex          sync.Mutex
+       connected      bool
+       connectedAt    time.Time
+       disconnectedAt time.Time
+}
+
+// Run connects a linked device and keeps the connection until ctx ends. A
+// failed first connection is logged, and the library tries again when the
+// failure is one of the network.
+func (w *WhatsApp) Run(ctx context.Context) {
+       if !w.linked() {
+               <-ctx.Done()
+               return
+       }
+
+       w.client = w.newClient(w.device, w.handle)
+       err := w.client.ConnectContext(ctx)
+       if err != nil {
+               w.logger.Printf("connect: %v", err)
+       }
+
+       <-ctx.Done()
+       w.client.Disconnect()
+}
+
+// Command runs the WhatsApp command args, the text after /whatsapp, and gives
+// each answer to answer. The answers go only to the client that sent the
+// command.
+func (w *WhatsApp) Command(args string, answer func(text string)) {
+       switch strings.TrimSpace(args) {
+       case "status":
+               answer(w.Status())
+       default:
+               answer(whatsappUsage)
+       }
+}
+
+// Status returns the state of the link: not linked, linked and connected, or
+// linked and disconnected, with the times of the connection. It never holds
+// the phone number.
+func (w *WhatsApp) Status() string {
+       w.mutex.Lock()
+       defer w.mutex.Unlock()
+
+       switch {
+       case !w.linked():
+               return "WhatsApp is not linked."
+       case w.connected:
+               return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
+       case w.disconnectedAt.IsZero():
+               return "WhatsApp is linked and not connected yet."
+       case w.connectedAt.IsZero():
+               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
+       default:
+               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
+       }
+}
+
+// Close closes the session database.
+func (w *WhatsApp) Close() error {
+       if w.container == nil {
+               return nil
+       }
+
+       return w.container.Close()
+}
+
+// linked tells if the session holds a linked device.
+func (w *WhatsApp) linked() bool {
+       return w.device != nil && w.device.ID != nil
+}
+
+// handle takes one event of the library and tells if it was handled. A
+// disconnection keeps the time of its start while the library tries again.
+func (w *WhatsApp) handle(event any) bool {
+       w.mutex.Lock()
+       defer w.mutex.Unlock()
+
+       switch event.(type) {
+       case *events.Connected:
+               w.connected = true
+               w.connectedAt = w.clock()
+       case *events.Disconnected:
+               if w.connected || w.disconnectedAt.IsZero() {
+                       w.disconnectedAt = w.clock()
+               }
+
+               w.connected = false
+       }
+
+       return true
+}
+
+// whatsappNewClient returns the client of the library for device, with handle
+// as its event handler. The client tries again after a failed first
+// connection, acknowledges a message only after handle returns true, and
+// downloads no old history.
+func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
+       client := whatsmeow.NewClient(device, waLog.Noop)
+       client.InitialAutoReconnect = true
+       client.SynchronousAck = true
+       client.ManualHistorySyncDownload = true
+       client.AddEventHandlerWithSuccessStatus(handle)
+
+       return client
+}
+
+// whatsappSessionAddress returns the SQLite address of the session database at
+// path, with foreign keys on, as the library needs, and a wait for a busy
+// database.
+func whatsappSessionAddress(path string) (string, error) {
+       path, err := filepath.Abs(path)
+       if err != nil {
+               return "", fmt.Errorf("find WhatsApp session: %w", err)
+       }
+
+       address := url.URL{
+               Scheme:   "file",
+               Path:     path,
+               RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
+       }
+
+       return address.String(), nil
+}
+
+// whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
+// command, or false for any other line.
+func whatsappCommandArgs(line string) (string, bool) {
+       line = strings.TrimSpace(line)
+       if line == whatsappCommand {
+               return "", true
+       }
+
+       args, found := strings.CutPrefix(line, whatsappCommand+" ")
+       if !found {
+               return "", false
+       }
+
+       return strings.TrimSpace(args), true
+}
diff --git a/whatsapp_fake_helper_test.go b/whatsapp_fake_helper_test.go
new file mode 100644 (file)
index 0000000..f7e6cad
--- /dev/null
@@ -0,0 +1,111 @@
+package main
+
+import (
+       "context"
+       "io"
+       "log"
+       "os"
+       "path/filepath"
+       "sync"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+       waAdv "go.mau.fi/whatsmeow/proto/waAdv"
+       "go.mau.fi/whatsmeow/store"
+       "go.mau.fi/whatsmeow/store/sqlstore"
+       "go.mau.fi/whatsmeow/types"
+       waLog "go.mau.fi/whatsmeow/util/log"
+)
+
+// whatsappTestNumber is the phone number of the own account in the tests. It
+// is a number for fiction.
+const whatsappTestNumber = "15550001111"
+
+// WhatsAppFake is a fake WhatsApp client. It keeps the event handler that the
+// daemon gives it, and Emit gives events to the handler, as the library does.
+type WhatsAppFake struct {
+       mutex     sync.Mutex
+       handle    func(event any) bool
+       connected chan struct{}
+}
+
+// ConnectContext tells the test that the daemon connected.
+func (f *WhatsAppFake) ConnectContext(ctx context.Context) error {
+       close(f.connected)
+
+       return nil
+}
+
+// Disconnect does nothing.
+func (f *WhatsAppFake) Disconnect() {
+}
+
+// Emit gives event to the handler of the daemon and returns its answer.
+func (f *WhatsAppFake) Emit(event any) bool {
+       f.mutex.Lock()
+       handle := f.handle
+       f.mutex.Unlock()
+
+       return handle(event)
+}
+
+// newClient keeps handle and returns the fake as the client of device.
+func (f *WhatsAppFake) newClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       f.handle = handle
+
+       return f
+}
+
+// whatsappStartTest opens the WhatsApp of the data directory dir with a fake
+// client and the test clock, and closes it at the end of the test.
+func whatsappStartTest(t *testing.T, dir string) (*WhatsApp, *WhatsAppFake) {
+       t.Helper()
+
+       fake := &WhatsAppFake{
+               connected: make(chan struct{}),
+       }
+
+       whatsapp, err := openWhatsApp(t.Context(), dir, newTestClock().Now, log.New(io.Discard, "", 0), fake.newClient)
+       require.NoError(t, err)
+
+       t.Cleanup(func() {
+               require.NoError(t, whatsapp.Close())
+       })
+
+       return whatsapp, fake
+}
+
+// whatsappTestSession writes a session database with mode 0600 to the data
+// directory dir. When linked is true, it holds a device linked to
+// whatsappTestNumber, and else a device with no link.
+func whatsappTestSession(t *testing.T, dir string, linked bool) {
+       t.Helper()
+
+       path := filepath.Join(dir, whatsappDirName, whatsappSessionFileName)
+       require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
+       require.NoError(t, os.WriteFile(path, nil, 0o600))
+
+       address, err := whatsappSessionAddress(path)
+       require.NoError(t, err)
+
+       container, err := sqlstore.New(t.Context(), "sqlite", address, waLog.Noop)
+       require.NoError(t, err)
+
+       if linked {
+               device := container.NewDevice()
+               jid := types.NewADJID(whatsappTestNumber, 0, 1)
+               device.ID = &jid
+               device.Account = &waAdv.ADVSignedDeviceIdentity{
+                       Details:             []byte{},
+                       AccountSignature:    make([]byte, 64),
+                       AccountSignatureKey: make([]byte, 32),
+                       DeviceSignature:     make([]byte, 64),
+               }
+               require.NoError(t, device.Save(t.Context()))
+       }
+
+       require.NoError(t, container.Close())
+}
diff --git a/whatsapp_test.go b/whatsapp_test.go
new file mode 100644 (file)
index 0000000..2504bd9
--- /dev/null
@@ -0,0 +1,183 @@
+package main
+
+import (
+       "context"
+       "io"
+       "log"
+       "os"
+       "path/filepath"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+       "go.mau.fi/whatsmeow/types/events"
+)
+
+func TestWhatsAppStatusFollowsTheConnection(t *testing.T) {
+       dir := t.TempDir()
+       whatsappTestSession(t, dir, true)
+       clock := newTestClock()
+       fake := &WhatsAppFake{
+               connected: make(chan struct{}),
+       }
+
+       whatsapp, err := openWhatsApp(t.Context(), dir, clock.Now, log.New(io.Discard, "", 0), fake.newClient)
+       require.NoError(t, err)
+
+       t.Cleanup(func() {
+               require.NoError(t, whatsapp.Close())
+       })
+
+       ctx, cancel := context.WithCancel(t.Context())
+       done := make(chan struct{})
+       go func() {
+               defer close(done)
+
+               whatsapp.Run(ctx)
+       }()
+
+       t.Cleanup(func() {
+               cancel()
+               <-done
+       })
+
+       <-fake.connected
+       require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
+
+       require.True(t, fake.Emit(&events.Connected{}))
+       require.Equal(t, "WhatsApp is linked and connected since 2026-01-01 12:00.", whatsapp.Status())
+
+       clock.now = clock.now.Add(20 * time.Minute)
+       require.True(t, fake.Emit(&events.Disconnected{}))
+       clock.now = clock.now.Add(time.Minute)
+       require.True(t, fake.Emit(&events.Disconnected{}))
+       require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:20. The last connection started at 2026-01-01 12:00.", whatsapp.Status())
+}
+
+func TestWhatsAppStatusOfAFirstConnectionThatFailed(t *testing.T) {
+       dir := t.TempDir()
+       whatsappTestSession(t, dir, true)
+       whatsapp, fake := whatsappStartTest(t, dir)
+
+       ctx, cancel := context.WithCancel(t.Context())
+       done := make(chan struct{})
+       go func() {
+               defer close(done)
+
+               whatsapp.Run(ctx)
+       }()
+
+       t.Cleanup(func() {
+               cancel()
+               <-done
+       })
+
+       <-fake.connected
+       require.True(t, fake.Emit(&events.Disconnected{}))
+       require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:00.", whatsapp.Status())
+}
+
+func TestWhatsAppIsNotLinkedWithoutALinkedDevice(t *testing.T) {
+       testCases := []struct {
+               name    string
+               session func(t *testing.T, dir string)
+       }{
+               {
+                       name: "no session file",
+                       session: func(t *testing.T, dir string) {
+                       },
+               },
+               {
+                       name: "a session of a pairing that did not end",
+                       session: func(t *testing.T, dir string) {
+                               whatsappTestSession(t, dir, false)
+                       },
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       dir := t.TempDir()
+                       tc.session(t, dir)
+                       whatsapp, _ := whatsappStartTest(t, dir)
+                       require.Equal(t, "WhatsApp is not linked.", whatsapp.Status())
+               })
+       }
+}
+
+func TestWhatsAppRefusesASessionThatOthersCanRead(t *testing.T) {
+       dir := t.TempDir()
+       whatsappTestSession(t, dir, true)
+       require.NoError(t, os.Chmod(filepath.Join(dir, whatsappDirName, whatsappSessionFileName), 0o644))
+
+       _, err := openWhatsApp(t.Context(), dir, time.Now, log.New(io.Discard, "", 0), whatsappNewClient)
+       require.EqualError(t, err, "whatsapp/session.db holds the WhatsApp session, so its mode must be 0600")
+}
+
+func TestWhatsAppCommandsAnswerOnlyTheClientThatAsked(t *testing.T) {
+       server := newModelFake(t)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
+       socket := daemonStartTestWithBot(t, server, bot)
+       asking := terminalStartTestClient(t, socket)
+       other := terminalStartTestClient(t, socket)
+       asking.WaitFor(t, "[connected to OneChat]")
+       other.WaitFor(t, "[connected to OneChat]")
+
+       asking.Type("/whatsapp status")
+       asking.WaitFor(t, "WhatsApp is not linked.")
+       asking.Type(" /whatsapp  unknown ")
+       asking.WaitFor(t, whatsappUsage)
+
+       telegram.Add(TelegramUpdate{
+               UpdateID: 1,
+               Message:  telegramOwnerMessage("/whatsapp status"),
+       })
+
+       telegramWaitForSent(t, telegram, "WhatsApp is not linked.")
+       require.Equal(t, []string{"WhatsApp is not linked."}, telegram.Sent())
+       require.Equal(t, "[connected to OneChat]\n", other.Output())
+       require.Zero(t, server.Calls())
+}
+
+func TestWhatsAppCommandArgs(t *testing.T) {
+       testCases := []struct {
+               name  string
+               line  string
+               args  string
+               found bool
+       }{
+               {
+                       name:  "a command with its arguments",
+                       line:  " /whatsapp  pair +1 555 ",
+                       args:  "pair +1 555",
+                       found: true,
+               },
+               {
+                       name:  "the command alone",
+                       line:  "/whatsapp",
+                       args:  "",
+                       found: true,
+               },
+               {
+                       name:  "a word that starts with the command",
+                       line:  "/whatsappstatus",
+                       args:  "",
+                       found: false,
+               },
+               {
+                       name:  "a message that names the command",
+                       line:  "what does /whatsapp status do?",
+                       args:  "",
+                       found: false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       args, found := whatsappCommandArgs(tc.line)
+                       require.Equal(t, tc.args, args)
+                       require.Equal(t, tc.found, found)
+               })
+       }
+}