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
*******
// 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 {
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
}
}
- 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
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 {
}
// 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
}
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()
}()
}
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 == "" {
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)
"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()
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)
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)
})
}()
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
+)
+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=
inherit src;
subPackages = [ "." ];
- vendorHash = "sha256-Mpw1XigL2ARm8oTKwutBLHxdLChSfK1ZbGRaNww8jkk=";
+ vendorHash = "sha256-RxkfJaQ+YDa7C7EecVo0XBh26Kg0qQ+1QvSnnjjEKwk=";
# The flake check runs the full replay.
checkFlags = [ "-short" ];
}
// 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"`
// 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()
b.send(ctx)
}()
- err := b.poll(ctx, l)
+ err := b.poll(ctx, l, whatsapp)
cancel()
sender.Wait()
// 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
return nil
}
- err = b.handle(context.WithoutCancel(ctx), l, update)
+ err = b.handle(context.WithoutCancel(ctx), l, whatsapp, update)
if err != nil {
return err
}
// 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")
}
name, stop := stopSubagentName(message.Text)
+ args, isWhatsApp := whatsappCommandArgs(message.Text)
switch {
case message.Text == "":
b.notice(telegramTextOnly)
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 {
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 {
}
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,
}
}
return "[done]"
case "error":
return "[error] " + event.Text
+ case "notice":
+ return event.Text
default:
return ""
}
--- /dev/null
+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
+}
--- /dev/null
+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())
+}
--- /dev/null
+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)
+ })
+ }
+}