From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 14:36:14 +0000 (-0300) Subject: Show the WhatsApp link with /whatsapp status X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/ec230eee6b06580031637bc97c99ec6db7b41926?ds=sidebyside Show the WhatsApp link with /whatsapp status 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 --- diff --git a/README.rst b/README.rst index 7712c0e..3a2d25b 100644 --- a/README.rst +++ b/README.rst @@ -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 ******* diff --git a/daemon.go b/daemon.go index a4041f1..d67c5be 100644 --- 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) diff --git a/daemon_start_helper_test.go b/daemon_start_helper_test.go index 1952e77..cf1fbf6 100644 --- a/daemon_start_helper_test.go +++ b/daemon_start_helper_test.go @@ -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 673fbdc..2fdae06 100644 --- 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 8de8697..13d902b 100644 --- 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= diff --git a/nix/package.nix b/nix/package.nix index ceb629f..1a7e8d0 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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" ]; diff --git a/socket.go b/socket.go index 36d6641..a306c5c 100644 --- 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"` diff --git a/telegram.go b/telegram.go index c560104..16eff80 100644 --- a/telegram.go +++ b/telegram.go @@ -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 { diff --git a/terminal.go b/terminal.go index 96a8cc5..1982f87 100644 --- a/terminal.go +++ b/terminal.go @@ -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 index 0000000..05db5e4 --- /dev/null +++ b/whatsapp.go @@ -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 index 0000000..f7e6cad --- /dev/null +++ b/whatsapp_fake_helper_test.go @@ -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 index 0000000..2504bd9 --- /dev/null +++ b/whatsapp_test.go @@ -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) + }) + } +}