From e3d5c7ac08ad80d7047f8be415f25bac39e8d630 Mon Sep 17 00:00:00 2001 From: Ratnesh Maurya <85143283+ratnesh-maurya@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:24:50 +0530 Subject: [PATCH 1/4] chore(forge-ui): record missing indirect dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go.mod did not list the indirect dependencies reached through forge-core (otel, grpc, backoff, logr and friends), so `go mod tidy` reported a diff and gopls surfaced "not in your go.mod file" across the package. Builds worked because the workspace resolved them, which is why this went unnoticed. Pre-existing and unrelated to any feature work — confirmed by reproducing `go mod tidy -diff` on a clean checkout. --- forge-ui/go.mod | 27 ++++++++++++++++- forge-ui/go.sum | 78 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/forge-ui/go.mod b/forge-ui/go.mod index 8a5177d2..e99f25a0 100644 --- a/forge-ui/go.mod +++ b/forge-ui/go.mod @@ -5,13 +5,38 @@ go 1.25.0 require ( github.com/initializ/forge/forge-core v0.0.0 github.com/initializ/forge/forge-skills v0.0.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gowebpki/jcs v1.0.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) replace ( diff --git a/forge-ui/go.sum b/forge-ui/go.sum index f7cd70a1..b766e506 100644 --- a/forge-ui/go.sum +++ b/forge-ui/go.sum @@ -1,17 +1,89 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 22ae06220d374a643844627cd964c5024fe88fa1 Mon Sep 17 00:00:00 2001 From: Ratnesh Maurya <85143283+ratnesh-maurya@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:25:23 +0530 Subject: [PATCH 2/4] feat(channels): add WhatsApp channel via WhatsApp Web (whatsmeow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth channel adapter alongside Slack, Telegram and MS Teams, implementing channels.ChannelPlugin over the WhatsApp Web multidevice protocol. Library choice: whatsmeow, not Baileys. OpenClaw's WhatsApp extension — the reference for this work — is TypeScript on Baileys, but forge is pure Go and the release image builds CGO_ENABLED=0. A Node sidecar would mean a second runtime in the image, an invented IPC protocol, and WhatsApp traffic outside the egress enforcer, which wraps Go's http.Transport. whatsmeow speaks the identical protocol in-process. whatsmeow is pinned to fb386f152837 (2026-08-16), the last commit before upstream raised its minimum to Go 1.26. Taking HEAD would force the repo's Go floor from 1.25 to 1.26 across go.work, two go.mod files, the Dockerfile and seven CI pins. Revisit when the repo moves to 1.26 for other reasons: WhatsApp version-gates the Web protocol and rejects stale clients (see the err-client-outdated path in wapair). Pairing is a QR scan, not a token — the one adapter here whose credential is a file rather than an env var. Two entry points share the state machine in internal/wapair: `forge channel whatsapp-login`, and an inline phase in the init wizard which pairs into a temp store that scaffold then relocates into the project (the wizard runs before the project directory exists). The session is written 0600 under a 0700 directory; it is the credential. Pairing completion is NOT the PairSuccess event. WhatsApp then drops the socket, the client reconnects and completes a login handshake, and only after that is the device registered server-side. Disconnecting on PairSuccess produced a device the server rejected with 401 "logged out from another device" seconds into the next run. wapair now waits for the post-pair reconnect, holds the socket through a settle window so the async prekey upload lands, and verifies IsLoggedIn before reporting success. Admission defaults are closed. An empty allowed_senders means owner-only rather than "anyone with the number", matching OpenClaw's dmAllowFrom default; opening up is the explicit `allowed_senders: anyone`. Group traffic always requires an @-mention. self_chat lets the paired phone talk to its own agent in the "Message Yourself" chat — the agent's own replies are self-sent there too, so the dedup ring, which records every outbound id before SendResponse returns, is the loop guard. Messages predating startup by more than five minutes are dropped so a restart does not answer a replayed conversation, or its own replayed replies. Also adds a YAML-scalar helper to the init template. The egress bundle introduced the first wildcard domain ("*.whatsapp.net"), whose leading "*" YAML reads as an alias reference, breaking `forge init` with "did not find expected alphabetic or numeric character". Quoting is conditional, so existing generated configs are unchanged. Known gaps, documented in docs/core-concepts/channels.md: - No DEFER approvals or MCP consent. OpenClaw implements approvals via emoji reactions, which sidesteps the unreliable interactive message types; worth adopting, not done here. - No UserEmail on inbound events — WhatsApp has no email identity, so delegated (auth.type: user) MCP cannot resolve a subject. - No media. Captions on inbound media are read as prompt text. - Group context is built from observed traffic and lost on restart. WhatsApp has no server-side history fetch for a linked device, unlike Graph's /chats/{id}/messages. - No reconnect supervision. whatsmeow's internal retry is all there is; OpenClaw runs a heartbeat, watchdog and backoff. Automating WhatsApp Web is against WhatsApp's Terms of Service and can get the linked number banned. The warning is surfaced by `channel add`, the login command's help, and the docs. --- docs/core-concepts/channels.md | 130 ++++ forge-cli/cmd/channel.go | 74 +- forge-cli/cmd/channel_whatsapp_login.go | 182 +++++ forge-cli/cmd/init.go | 32 +- forge-cli/cmd/init_whatsapp.go | 58 ++ forge-cli/cmd/init_whatsapp_test.go | 147 ++++ forge-cli/cmd/init_yaml_test.go | 77 +++ forge-cli/go.mod | 37 +- forge-cli/go.sum | 122 +++- forge-cli/internal/tui/steps/channel_step.go | 46 ++ .../tui/steps/channel_step_whatsapp.go | 236 +++++++ .../tui/steps/channel_step_whatsapp_test.go | 309 +++++++++ forge-cli/internal/tui/steps/egress_step.go | 2 + forge-cli/internal/wapair/wapair.go | 342 ++++++++++ forge-cli/internal/wapair/wapair_test.go | 218 ++++++ forge-cli/templates/init/env-whatsapp.tmpl | 11 + forge-cli/templates/init/forge.yaml.tmpl | 6 +- .../templates/init/whatsapp-config.yaml.tmpl | 67 ++ forge-core/catalog/channels.go | 10 + forge-core/security/capabilities.go | 5 + forge-plugins/channels/markdown/whatsapp.go | 227 +++++++ .../channels/markdown/whatsapp_test.go | 273 ++++++++ forge-plugins/channels/whatsapp/admission.go | 222 ++++++ .../channels/whatsapp/admission_test.go | 361 ++++++++++ forge-plugins/channels/whatsapp/dedup.go | 94 +++ forge-plugins/channels/whatsapp/dedup_test.go | 122 ++++ forge-plugins/channels/whatsapp/history.go | 151 +++++ .../channels/whatsapp/history_test.go | 196 ++++++ forge-plugins/channels/whatsapp/jid.go | 189 ++++++ forge-plugins/channels/whatsapp/jid_test.go | 174 +++++ forge-plugins/channels/whatsapp/session.go | 125 ++++ forge-plugins/channels/whatsapp/whatsapp.go | 630 ++++++++++++++++++ .../channels/whatsapp/whatsapp_test.go | 450 +++++++++++++ forge-plugins/go.mod | 28 + forge-plugins/go.sum | 57 ++ forge-ui/handlers_create.go | 2 +- forge.yaml.example | 2 + go.work.sum | 17 +- 38 files changed, 5386 insertions(+), 45 deletions(-) create mode 100644 forge-cli/cmd/channel_whatsapp_login.go create mode 100644 forge-cli/cmd/init_whatsapp.go create mode 100644 forge-cli/cmd/init_whatsapp_test.go create mode 100644 forge-cli/cmd/init_yaml_test.go create mode 100644 forge-cli/internal/tui/steps/channel_step_whatsapp.go create mode 100644 forge-cli/internal/tui/steps/channel_step_whatsapp_test.go create mode 100644 forge-cli/internal/wapair/wapair.go create mode 100644 forge-cli/internal/wapair/wapair_test.go create mode 100644 forge-cli/templates/init/env-whatsapp.tmpl create mode 100644 forge-cli/templates/init/whatsapp-config.yaml.tmpl create mode 100644 forge-plugins/channels/markdown/whatsapp.go create mode 100644 forge-plugins/channels/markdown/whatsapp_test.go create mode 100644 forge-plugins/channels/whatsapp/admission.go create mode 100644 forge-plugins/channels/whatsapp/admission_test.go create mode 100644 forge-plugins/channels/whatsapp/dedup.go create mode 100644 forge-plugins/channels/whatsapp/dedup_test.go create mode 100644 forge-plugins/channels/whatsapp/history.go create mode 100644 forge-plugins/channels/whatsapp/history_test.go create mode 100644 forge-plugins/channels/whatsapp/jid.go create mode 100644 forge-plugins/channels/whatsapp/jid_test.go create mode 100644 forge-plugins/channels/whatsapp/session.go create mode 100644 forge-plugins/channels/whatsapp/whatsapp.go create mode 100644 forge-plugins/channels/whatsapp/whatsapp_test.go diff --git a/docs/core-concepts/channels.md b/docs/core-concepts/channels.md index 6ef444ac..a3050e38 100644 --- a/docs/core-concepts/channels.md +++ b/docs/core-concepts/channels.md @@ -20,6 +20,8 @@ Both channels use **outbound-only connections** — no public URLs, no ngrok, no |---------|---------|------|-------------| | Slack | `slack.Plugin` | Socket Mode | 3000 | | Telegram | `telegram.Plugin` | Polling or Webhook | 3001 | +| MS Teams | `msteams.Plugin` | Graph API polling | — (outbound only) | +| WhatsApp | `whatsapp.Plugin` | WhatsApp Web (paired session) | — (outbound only) | > **Note:** Slack uses Socket Mode — an outbound WebSocket connection from the agent to Slack's servers. No public URL or ngrok is needed for local development. @@ -31,6 +33,9 @@ forge channel add slack # Add Telegram adapter forge channel add telegram + +# Add WhatsApp adapter (then pair: forge channel whatsapp-login) +forge channel add whatsapp ``` This command: @@ -175,6 +180,131 @@ Mode options: - `polling` (default) — Long-polling via `getUpdates` - `webhook` — Receives updates via HTTP webhook (loopback-only binding with secret token verification) +### WhatsApp (`whatsapp-config.yaml`) + +```yaml +adapter: whatsapp +settings: + session_path: .forge/channels/whatsapp-session.db + admit: dm_or_group_mention # dm | group_mention | dm_or_group_mention + allowed_groups: "" # comma/newline-separated group JIDs; empty = all + allowed_senders: "" # empty = OWNER ONLY; "anyone" opens it up + self_chat: true # answer your own "Message Yourself" chat + include_recent_history: true + recent_history_count: 20 +``` + +| Setting | Default | Notes | +|---|---|---| +| `session_path` | `.forge/channels/whatsapp-session.db` | The paired session. **This file is the credential.** | +| `admit` | `dm_or_group_mention` | Group traffic always requires an explicit @-mention. | +| `allowed_groups` | *(all)* | Group JIDs; the `@g.us` suffix may be omitted. | +| `allowed_senders` | *(owner only)* | Empty admits only the paired account. List numbers to add people, or set `anyone` to open it up. The owner always passes. | +| `self_chat` | `true` | Answer messages you send yourself. | +| `include_recent_history` | `true` | Injects observed chat context into the prompt. | +| `recent_history_count` | `20` | Per-chat window; block soft-capped at ~5000 chars. | + +Unlike every other adapter's list settings, **a space is not a separator** in +`allowed_senders` — a space is part of a written phone number +(`+1 (415) 555-0100`). Use commas or newlines. + +### Talking to your own agent + +The paired number *is* the agent. The simplest way to use it is WhatsApp's +**Message Yourself** chat: open it on the paired phone and type. No second +account needed. + +That works because `self_chat` is on by default. Your own messages arrive +flagged as self-sent, and so do the agent's replies — the loop guard is the +dedup ring, which records every message the agent sends before it can come +back around. Self-messages are accepted **only** in that chat, never in groups. + +To have the agent serve other people instead, list them: + +```yaml +allowed_senders: "+1 (415) 555-0100, +44 7700 900123" +``` + +`allowed_senders` is **owner-only when empty** — a stranger who happens to have +the number gets nothing. That is deliberate: an open agent spends your LLM +budget and reaches whatever tools it has. Opening it up is an explicit choice: + +```yaml +allowed_senders: anyone +``` + +### Messages from before the agent started + +Reconnecting delivers a backlog, and a restart begins with an empty dedup ring. +Messages dated more than five minutes before the process started are dropped +rather than answered, so a restart doesn't reply to a replayed conversation — +and, in the self-chat, doesn't answer the agent's own replayed replies. + +A brief restart still picks up anything sent while the agent was down. + +## WhatsApp Setup + +WhatsApp has no bot token. The adapter authenticates by linking itself as a +WhatsApp Web device, exactly like the desktop client: + +```bash +forge channel add whatsapp +forge channel whatsapp-login # renders a QR code in the terminal +# phone: WhatsApp → Settings → Linked Devices → Link a Device → scan +forge run --with whatsapp +``` + +The pairing is written to `session_path`. Keep it out of version control and +off shared volumes — anyone holding that file can send messages as the linked +account. Re-pairing requires `--force`, so re-running the login command by +accident cannot revoke a working session. + +### Terms of Service and ban risk + +This adapter speaks the **WhatsApp Web multidevice protocol** (via +[whatsmeow](https://pkg.go.dev/go.mau.fi/whatsmeow)), not the official WhatsApp +Cloud API and not Twilio. Automating that protocol is against WhatsApp's Terms +of Service and can get the linked number banned. The ban attaches to the phone +number, not the machine, and is not reliably reversible. + +**Pair a dedicated number, never a personal one.** If you need a +ToS-sanctioned path, the WhatsApp Cloud API is a different integration and is +not what this adapter implements. + +### Identities: phone numbers and LIDs + +WhatsApp is migrating group participants to hidden-number identifiers (LIDs, +`@lid`) instead of phone numbers. The adapter tracks both identities for +the paired account, so an @-mention resolves under either. For +`allowed_senders`, a LID sender is matched via the phone-number alternate the +server supplies; when no alternate is available the message is dropped and the +log line names the LID so you can add it to the list directly. + +### Group history is observed, not fetched + +`include_recent_history` works differently here than in the Teams adapter. +Microsoft Graph exposes `/chats/{id}/messages`, so Teams can fetch prior +messages on demand. WhatsApp has no equivalent for a linked device — history +reaches one only through a sync push at pairing time. The adapter therefore +builds its context window from traffic it observes while running. + +The consequence: **context covers messages seen since the adapter started, and +is lost on restart.** An agent restarted mid-conversation will not see what +came before. + +### Not supported + +- **DEFER approvals and MCP delegated consent.** WhatsApp's interactive + message types are unreliable over the Web protocol, so the adapter + implements neither `ApprovalDeliverer` nor `ConsentDeliverer`. A + `security.defer` route naming `whatsapp` will warn at startup; resolve those + approvals via `POST /tasks/{id}/decisions` instead. +- **`UserEmail` on inbound events.** WhatsApp has no email identity, so + delegated (`auth.type: user`) MCP tools cannot resolve an on-behalf-of + subject on this channel. +- **Media.** Attachments are not downloaded or sent; captions on inbound media + are read as prompt text. + ### Telegram Webhook Security When running in webhook mode, the Telegram adapter applies multiple security controls: diff --git a/forge-cli/cmd/channel.go b/forge-cli/cmd/channel.go index 0718b823..2003dc64 100644 --- a/forge-cli/cmd/channel.go +++ b/forge-cli/cmd/channel.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "path/filepath" + "slices" "strings" "syscall" @@ -18,29 +19,38 @@ import ( "github.com/initializ/forge/forge-plugins/channels/msteams" "github.com/initializ/forge/forge-plugins/channels/slack" "github.com/initializ/forge/forge-plugins/channels/telegram" + "github.com/initializ/forge/forge-plugins/channels/whatsapp" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) +// supportedAdapters is the canonical list of channel adapters `forge channel` +// accepts. Keep in sync with createPlugin and defaultRegistry below. +var supportedAdapters = []string{"slack", "telegram", "msteams", "whatsapp"} + +func isSupportedAdapter(name string) bool { + return slices.Contains(supportedAdapters, name) +} + var channelCmd = &cobra.Command{ Use: "channel", Short: "Manage agent communication channels", - Long: "Add and serve channel adapters (Slack, Telegram, MS Teams) for your agent.", + Long: "Add and serve channel adapters (Slack, Telegram, MS Teams, WhatsApp) for your agent.", } var channelAddCmd = &cobra.Command{ - Use: "add ", + Use: "add ", Short: "Add a channel adapter to the project", Args: cobra.ExactArgs(1), - ValidArgs: []string{"slack", "telegram", "msteams"}, + ValidArgs: []string{"slack", "telegram", "msteams", "whatsapp"}, RunE: runChannelAdd, } var channelServeCmd = &cobra.Command{ - Use: "serve ", + Use: "serve ", Short: "Run a standalone channel adapter (for container use)", Args: cobra.ExactArgs(1), - ValidArgs: []string{"slack", "telegram", "msteams"}, + ValidArgs: []string{"slack", "telegram", "msteams", "whatsapp"}, RunE: runChannelServe, } @@ -107,8 +117,8 @@ func init() { func runChannelAdd(cmd *cobra.Command, args []string) error { adapter := args[0] - if adapter != "slack" && adapter != "telegram" && adapter != "msteams" { - return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram, msteams)", adapter) + if !isSupportedAdapter(adapter) { + return fmt.Errorf("unsupported adapter: %s (supported: %s)", adapter, strings.Join(supportedAdapters, ", ")) } wd, err := os.Getwd() @@ -160,8 +170,8 @@ func runChannelAdd(cmd *cobra.Command, args []string) error { func runChannelServe(cmd *cobra.Command, args []string) error { adapter := args[0] - if adapter != "slack" && adapter != "telegram" && adapter != "msteams" { - return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram, msteams)", adapter) + if !isSupportedAdapter(adapter) { + return fmt.Errorf("unsupported adapter: %s (supported: %s)", adapter, strings.Join(supportedAdapters, ", ")) } // Honor every layer's denied_channels list (issue #90 / FWS-6 @@ -241,6 +251,8 @@ func createPlugin(name string) corechannels.ChannelPlugin { return telegram.New() case "msteams": return msteams.New() + case "whatsapp": + return whatsapp.New() default: return nil } @@ -252,6 +264,7 @@ func defaultRegistry() *corechannels.Registry { r.Register(slack.New()) r.Register(telegram.New()) r.Register(msteams.New()) + r.Register(whatsapp.New()) return r } @@ -391,6 +404,32 @@ func addChannelEgressToForgeYAML(path, adapter string) error { } egressMap["allowed_domains"] = domainsAny + case "whatsapp": + // Add "whatsapp" to egress.capabilities (same pattern as slack). + // The capability resolves to web.whatsapp.com + *.whatsapp.net via + // DefaultCapabilityBundles in forge-core/security/capabilities.go. + var caps []string + if existing, ok := egressMap["capabilities"]; ok { + if arr, ok := existing.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + caps = append(caps, s) + } + } + } + } + for _, c := range caps { + if c == "whatsapp" { + return nil // already present + } + } + caps = append(caps, "whatsapp") + capsAny := make([]any, len(caps)) + for i, s := range caps { + capsAny[i] = s + } + egressMap["capabilities"] = capsAny + case "msteams": // Add "msteams" to egress.capabilities (same pattern as slack). // The capability resolves to graph.microsoft.com + login.microsoftonline.com @@ -466,6 +505,23 @@ func printSetupInstructions(adapter string) { fmt.Println() fmt.Println(" This adapter is outbound-only — no public endpoint required.") fmt.Println(" Default poll cadence is 5s (configurable in msteams-config.yaml).") + case "whatsapp": + fmt.Println("WhatsApp setup instructions:") + fmt.Println(" 1. Use a DEDICATED phone number, not a personal one (see the warning below)") + fmt.Println(" 2. Run: forge channel whatsapp-login") + fmt.Println(" 3. On the phone, open WhatsApp → Settings → Linked Devices →") + fmt.Println(" Link a Device, and scan the QR code shown in your terminal") + fmt.Println(" 4. Run: forge run --with whatsapp") + fmt.Println() + fmt.Println(" There is no bot token. The pairing is stored at") + fmt.Println(" .forge/channels/whatsapp-session.db — that file IS the credential;") + fmt.Println(" keep it out of version control.") + fmt.Println() + fmt.Println(" WARNING: this uses WhatsApp Web (the same protocol as a linked") + fmt.Println(" desktop client), not the official WhatsApp Cloud API. Automating it") + fmt.Println(" is against WhatsApp's Terms of Service and can get the linked number") + fmt.Println(" banned. The ban applies to the number, not this machine, and is not") + fmt.Println(" reliably reversible.") } fmt.Println() fmt.Println(strings.Repeat("─", 40)) diff --git a/forge-cli/cmd/channel_whatsapp_login.go b/forge-cli/cmd/channel_whatsapp_login.go new file mode 100644 index 00000000..dafcddf8 --- /dev/null +++ b/forge-cli/cmd/channel_whatsapp_login.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/initializ/forge/forge-cli/internal/wapair" + corechannels "github.com/initializ/forge/forge-core/channels" + "github.com/initializ/forge/forge-plugins/channels/whatsapp" +) + +// channelWhatsappLoginCmd pairs a WhatsApp account with this agent by linking +// it as a WhatsApp Web device. +// +// The flow has two halves, like the MS Teams device-code login: a user half +// (scan the QR code on the phone) and a client half (hold the socket open +// until WhatsApp confirms the pairing). This command runs both so the operator +// only has to do the visible part. +var channelWhatsappLoginCmd = &cobra.Command{ + Use: "whatsapp-login", + Short: "Pair a WhatsApp account by scanning a QR code", + Long: `Link a WhatsApp account to this agent by scanning a QR code, the same way +you link WhatsApp Web or the WhatsApp desktop app. + +This command: + 1. Opens (creating if absent) the session store + 2. Renders a QR code in the terminal, refreshing it as each one expires + 3. Waits for you to scan it from the phone + 4. Persists the paired session so ` + "`forge run --with whatsapp`" + ` can connect + +On the phone: WhatsApp → Settings → Linked Devices → Link a Device. + +The session store is written to the session_path in whatsapp-config.yaml +(default .forge/channels/whatsapp-session.db). That file IS the credential — +anyone holding it can send messages as the linked account. Keep it out of +version control. + +WARNING: this uses the WhatsApp Web protocol, not the official WhatsApp Cloud +API. Automating it is against WhatsApp's Terms of Service and can get the +linked number banned. Pair a dedicated number, never a personal one.`, + RunE: runChannelWhatsappLogin, +} + +var ( + whatsappLoginSessionPath string + whatsappLoginTimeoutSecs int + whatsappLoginForce bool +) + +func init() { + channelWhatsappLoginCmd.Flags().StringVar(&whatsappLoginSessionPath, "session-path", "", + "Path to the session store (defaults to session_path in whatsapp-config.yaml, else .forge/channels/whatsapp-session.db)") + channelWhatsappLoginCmd.Flags().IntVar(&whatsappLoginTimeoutSecs, "timeout-seconds", 300, + "Maximum time to wait for the QR code to be scanned (default 300 / 5 minutes)") + channelWhatsappLoginCmd.Flags().BoolVar(&whatsappLoginForce, "force", false, + "Re-pair even if the session store already holds a paired account") + channelCmd.AddCommand(channelWhatsappLoginCmd) +} + +func runChannelWhatsappLogin(cmd *cobra.Command, args []string) error { + sessionPath := resolveWhatsappSessionPath(whatsappLoginSessionPath) + + stderr := cmd.ErrOrStderr() + writeln := func(s string) { _, _ = io.WriteString(stderr, s+"\n") } + writef := func(format string, a ...any) { _, _ = fmt.Fprintf(stderr, format, a...) } + + // A paired session already present is almost always the operator running + // this twice, not a request to re-pair. Re-pairing silently would revoke + // the working link, so require an explicit --force. + if !whatsappLoginForce && whatsapp.SessionExists(cmd.Context(), sessionPath) { + writef("A paired WhatsApp session already exists at %s\n", sessionPath) + writeln("Pass --force to discard it and pair again.") + return nil + } + + ctx, cancel := context.WithTimeout(cmd.Context(), time.Duration(whatsappLoginTimeoutSecs)*time.Second) + defer cancel() + + // A --force re-pair must start from a clean store: whatsmeow will not + // re-issue QR codes for a device row left half-registered by the previous + // pairing. + if whatsappLoginForce { + if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("whatsapp: clearing existing session: %w", err) + } + } + + session, err := wapair.Start(ctx, sessionPath) + if err != nil { + return err + } + defer session.Close() //nolint:errcheck + + writeln("") + writeln("───────────────────────────────────────────────────────────") + writeln(" On your phone: WhatsApp → Settings → Linked Devices →") + writeln(" Link a Device, then scan this code:") + writeln("───────────────────────────────────────────────────────────") + + for evt := range session.Events() { + switch evt.Kind { + case wapair.EventQR: + wapair.RenderQR(evt.Code, stderr) + writef(" Code expires in %s — a new one will appear automatically.\n", evt.Timeout.Round(time.Second)) + + case wapair.EventScanned: + writeln("") + writeln(" Scanned — finalizing the link with WhatsApp...") + writeln(" (Do not interrupt; the pairing is not complete yet.)") + + case wapair.EventPaired: + writeln("") + writeln("✓ Paired.") + if evt.JID != "" { + writef(" Account: %s\n", evt.JID) + } + writef(" Session: %s\n", sessionPath) + writeln("") + writeln(" This file is the credential — keep it out of version control.") + writeln(" Start the agent with: forge run --with whatsapp") + return nil + + case wapair.EventError: + // A deadline here is the operator not scanning in time, not a + // protocol failure — say so in those terms. + if errors.Is(evt.Err, context.DeadlineExceeded) { + return fmt.Errorf("whatsapp: timed out after %ds waiting for the QR code to be scanned — re-run with --timeout-seconds to allow longer", + whatsappLoginTimeoutSecs) + } + return fmt.Errorf("whatsapp: %w", evt.Err) + } + } + + return errors.New("whatsapp: pairing ended without completing — re-run `forge channel whatsapp-login` to retry") +} + +// resolveWhatsappSessionPath picks the session store location: the --session-path +// flag, else session_path from whatsapp-config.yaml in the working directory, +// else the adapter default. Relative paths resolve against the working +// directory so the login command and the adapter agree on the location. +func resolveWhatsappSessionPath(flagValue string) string { + path := strings.TrimSpace(flagValue) + if path == "" { + path = whatsappSessionPathFromConfig("whatsapp-config.yaml") + } + if path == "" { + path = ".forge/channels/whatsapp-session.db" + } + if filepath.IsAbs(path) { + return path + } + wd, err := os.Getwd() + if err != nil { + return path + } + return filepath.Join(wd, path) +} + +// whatsappSessionPathFromConfig reads session_path out of a channel config, +// resolving the _env indirection the way the adapter does. Returns "" when the +// file is absent or holds no session_path — the caller falls back to the +// default. +func whatsappSessionPathFromConfig(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var cfg corechannels.ChannelConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return "" + } + return strings.TrimSpace(corechannels.ResolveEnvVars(&cfg)["session_path"]) +} diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 69a6a39f..61112c32 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -711,6 +711,14 @@ func parseSkillsFile(path string) ([]toolEntry, error) { return tools, nil } +// initTemplateFuncs are the helpers available to the init templates. +// yamlScalar quotes values that would otherwise be misparsed — notably a +// wildcard egress domain ("*.whatsapp.net"), whose leading "*" YAML reads as +// an alias reference. +var initTemplateFuncs = template.FuncMap{ + "yamlScalar": yamlScalar, +} + func scaffold(opts *initOptions) error { normalizeCustomProvider(opts) @@ -733,6 +741,11 @@ func scaffold(opts *initOptions) error { } } + // Move a session paired inline by the wizard into the project. Consumes + // the synthetic key BEFORE buildTemplateData so the temp path can never + // reach the generated .env. + whatsappPaired, whatsappRelocErr := relocateWhatsappSession(opts, dir) + data := buildTemplateData(opts) manifest := getFileManifest(opts) @@ -742,7 +755,7 @@ func scaffold(opts *initOptions) error { return fmt.Errorf("reading template %s: %w", f.TemplatePath, err) } - tmpl, err := template.New(f.TemplatePath).Parse(tmplContent) + tmpl, err := template.New(f.TemplatePath).Funcs(initTemplateFuncs).Parse(tmplContent) if err != nil { return fmt.Errorf("parsing template %s: %w", f.TemplatePath, err) } @@ -860,6 +873,13 @@ func scaffold(opts *initOptions) error { return nil } + // Surface a failed session relocation here rather than aborting: the + // project is scaffolded and usable, only the pairing is missing. + if whatsappRelocErr != nil { + fmt.Printf("\n Warning: could not install the paired WhatsApp session: %v\n", whatsappRelocErr) + fmt.Printf(" Re-pair with: forge channel whatsapp-login\n") + } + fmt.Printf("\nCreated agent project in ./%s\n", opts.AgentID) // Show channel-specific reminders @@ -868,6 +888,16 @@ func scaffold(opts *initOptions) error { fmt.Println() fmt.Println(" Slack reminder: /invite @YourBot in each channel you want it active in.") } + if ch == "whatsapp" { + fmt.Println() + if whatsappPaired { + fmt.Println(" WhatsApp: paired. The session is at .forge/channels/whatsapp-session.db") + fmt.Println(" — that file is the credential; keep it out of version control.") + } else { + fmt.Println(" WhatsApp: not paired yet. Run `forge channel whatsapp-login`") + fmt.Println(" from the project directory before `forge run --with whatsapp`.") + } + } } // In non-interactive mode, just print the command diff --git a/forge-cli/cmd/init_whatsapp.go b/forge-cli/cmd/init_whatsapp.go new file mode 100644 index 00000000..e3831bba --- /dev/null +++ b/forge-cli/cmd/init_whatsapp.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-cli/internal/tui/steps" +) + +// whatsappSessionRelativePath is where the adapter expects the paired session, +// matching session_path in whatsapp-config.yaml.tmpl. +const whatsappSessionRelativePath = ".forge/channels/whatsapp-session.db" + +// relocateWhatsappSession moves a session paired during the init wizard into +// the freshly scaffolded project. +// +// The wizard pairs before the project directory exists, so it writes to a temp +// store and passes the path through a synthetic env key. This consumes that +// key — the path must never reach the generated .env, where it would both leak +// a temp path and be wrong the moment the temp dir is cleaned. +// +// Failure is reported but not fatal: the project is already scaffolded and +// usable, and the operator can re-pair with `forge channel whatsapp-login`. +func relocateWhatsappSession(opts *initOptions, dir string) (paired bool, err error) { + src := opts.EnvVars[steps.WhatsappSessionTokenKey] + delete(opts.EnvVars, steps.WhatsappSessionTokenKey) + if src == "" { + return false, nil + } + // The temp dir is ours to remove either way — a failed copy leaves nothing + // worth keeping behind. + defer os.RemoveAll(filepath.Dir(src)) //nolint:errcheck + + if _, err := os.Stat(src); err != nil { + return false, fmt.Errorf("paired session missing at %s: %w", src, err) + } + + dst := filepath.Join(dir, whatsappSessionRelativePath) + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return false, fmt.Errorf("creating session directory: %w", err) + } + // copyFileMode (cmd/skill_import.go) honours the umask, so a restrictive + // mode can come out looser than asked. This file is the WhatsApp + // credential, so force it afterwards. + // + // A rename would be cheaper than a copy but cannot be relied on: the temp + // dir and the project often sit on different filesystems (/var/folders vs + // $HOME on macOS, tmpfs vs the workspace in a container), where rename + // fails with EXDEV. + if err := copyFileMode(src, dst, 0o600); err != nil { + return false, fmt.Errorf("writing session to %s: %w", dst, err) + } + if err := os.Chmod(dst, 0o600); err != nil { + return false, fmt.Errorf("securing session file %s: %w", dst, err) + } + return true, nil +} diff --git a/forge-cli/cmd/init_whatsapp_test.go b/forge-cli/cmd/init_whatsapp_test.go new file mode 100644 index 00000000..94501052 --- /dev/null +++ b/forge-cli/cmd/init_whatsapp_test.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-cli/internal/tui/steps" +) + +// stagePairedSession writes a stand-in for the session the wizard would have +// paired into a temp store, and returns its path. +func stagePairedSession(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "whatsapp-session.db") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("staging session: %v", err) + } + return path +} + +func TestRelocateWhatsappSession_NoTokenIsNoop(t *testing.T) { + opts := &initOptions{EnvVars: map[string]string{}} + paired, err := relocateWhatsappSession(opts, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if paired { + t.Error("expected paired=false with no session token") + } +} + +func TestRelocateWhatsappSession_MovesSessionIntoProject(t *testing.T) { + src := stagePairedSession(t, "pairing-bytes") + project := t.TempDir() + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + paired, err := relocateWhatsappSession(opts, project) + if err != nil { + t.Fatalf("relocate: %v", err) + } + if !paired { + t.Error("expected paired=true") + } + + dst := filepath.Join(project, whatsappSessionRelativePath) + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("reading relocated session: %v", err) + } + if string(got) != "pairing-bytes" { + t.Errorf("session content = %q, want the staged bytes", got) + } +} + +// The temp path must never survive into the generated .env — it would leak a +// temp location and be wrong the moment the temp dir is cleaned. +func TestRelocateWhatsappSession_ConsumesSyntheticKey(t *testing.T) { + src := stagePairedSession(t, "x") + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, t.TempDir()); err != nil { + t.Fatalf("relocate: %v", err) + } + if _, ok := opts.EnvVars[steps.WhatsappSessionTokenKey]; ok { + t.Error("synthetic session key must be stripped from EnvVars") + } +} + +// The key must be consumed even when relocation fails, or a stale temp path +// reaches .env. +func TestRelocateWhatsappSession_ConsumesKeyOnFailure(t *testing.T) { + opts := &initOptions{EnvVars: map[string]string{ + steps.WhatsappSessionTokenKey: filepath.Join(t.TempDir(), "absent", "gone.db"), + }} + + paired, err := relocateWhatsappSession(opts, t.TempDir()) + if err == nil { + t.Fatal("expected an error for a missing session file") + } + if paired { + t.Error("expected paired=false on failure") + } + if _, ok := opts.EnvVars[steps.WhatsappSessionTokenKey]; ok { + t.Error("synthetic key must be stripped even when relocation fails") + } +} + +// The session is the WhatsApp credential; a world-readable copy is a leak. +func TestRelocateWhatsappSession_RelocatedFileIsOwnerOnly(t *testing.T) { + src := stagePairedSession(t, "secret") + project := t.TempDir() + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, project); err != nil { + t.Fatalf("relocate: %v", err) + } + + info, err := os.Stat(filepath.Join(project, whatsappSessionRelativePath)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("relocated session mode = %04o, want 0600", perm) + } +} + +func TestRelocateWhatsappSession_RemovesTempDir(t *testing.T) { + src := stagePairedSession(t, "x") + tempDir := filepath.Dir(src) + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, t.TempDir()); err != nil { + t.Fatalf("relocate: %v", err) + } + if _, err := os.Stat(tempDir); !os.IsNotExist(err) { + t.Errorf("temp store should be removed after relocation, stat err = %v", err) + } +} + +// .forge/channels/ does not exist in a fresh project. +func TestRelocateWhatsappSession_CreatesNestedDirs(t *testing.T) { + src := stagePairedSession(t, "x") + project := filepath.Join(t.TempDir(), "brand-new") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, project); err != nil { + t.Fatalf("relocate: %v", err) + } + if _, err := os.Stat(filepath.Join(project, whatsappSessionRelativePath)); err != nil { + t.Errorf("expected nested dirs created: %v", err) + } +} + +// The destination must match session_path in whatsapp-config.yaml.tmpl, or the +// adapter looks somewhere the wizard never wrote. +func TestRelocateWhatsappSession_PathMatchesAdapterDefault(t *testing.T) { + const want = ".forge/channels/whatsapp-session.db" + if whatsappSessionRelativePath != want { + t.Errorf("whatsappSessionRelativePath = %q, want %q (must match whatsapp-config.yaml.tmpl)", + whatsappSessionRelativePath, want) + } +} diff --git a/forge-cli/cmd/init_yaml_test.go b/forge-cli/cmd/init_yaml_test.go new file mode 100644 index 00000000..6626408a --- /dev/null +++ b/forge-cli/cmd/init_yaml_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "strings" + "testing" + "text/template" + + "gopkg.in/yaml.v3" +) + +// Regression: the WhatsApp egress bundle introduced the first wildcard domain +// in DefaultCapabilityBundles. Rendered unquoted, its leading "*" is a YAML +// alias indicator, and `forge init` failed with "did not find expected +// alphabetic or numeric character" on the generated forge.yaml. +func TestInitTemplateFuncs_QuotesWildcardDomain(t *testing.T) { + if got := yamlScalar("*.whatsapp.net"); got != `"*.whatsapp.net"` { + t.Errorf("yamlScalar(%q) = %q, want it quoted", "*.whatsapp.net", got) + } +} + +// Ordinary domains must stay unquoted so existing generated configs keep +// their current formatting. +func TestInitTemplateFuncs_LeavesPlainDomainsAlone(t *testing.T) { + for _, s := range []string{"api.openai.com", "web.whatsapp.com", "api.telegram.org", "slack", "web_search"} { + if got := yamlScalar(s); got != s { + t.Errorf("yamlScalar(%q) = %q, want it unchanged", s, got) + } + } +} + +// The template must actually have the helper registered — a missing FuncMap +// entry fails at Parse, which is exactly the wiring this guards. +func TestInitTemplateFuncs_RegisteredOnTemplate(t *testing.T) { + tmpl, err := template.New("t").Funcs(initTemplateFuncs).Parse(`{{yamlScalar .}}`) + if err != nil { + t.Fatalf("parsing with initTemplateFuncs: %v", err) + } + var b strings.Builder + if err := tmpl.Execute(&b, "*.whatsapp.net"); err != nil { + t.Fatalf("execute: %v", err) + } + if b.String() != `"*.whatsapp.net"` { + t.Errorf("rendered %q, want %q", b.String(), `"*.whatsapp.net"`) + } +} + +// End-to-end shape check: the rendered egress block parses and round-trips +// the wildcard intact. +func TestInitTemplate_EgressBlockWithWildcardParses(t *testing.T) { + domains := []string{"*.whatsapp.net", "api.openai.com", "web.whatsapp.com"} + + tmpl := template.Must(template.New("egress").Funcs(initTemplateFuncs).Parse( + "egress:\n mode: allowlist\n allowed_domains:\n{{- range .}}\n - {{yamlScalar .}}\n{{- end}}\n")) + + var b strings.Builder + if err := tmpl.Execute(&b, domains); err != nil { + t.Fatalf("execute: %v", err) + } + + var doc struct { + Egress struct { + Mode string `yaml:"mode"` + AllowedDomains []string `yaml:"allowed_domains"` + } `yaml:"egress"` + } + if err := yaml.Unmarshal([]byte(b.String()), &doc); err != nil { + t.Fatalf("rendered egress block does not parse: %v\n%s", err, b.String()) + } + if len(doc.Egress.AllowedDomains) != len(domains) { + t.Fatalf("got %d domains, want %d: %v", len(doc.Egress.AllowedDomains), len(domains), doc.Egress.AllowedDomains) + } + for i, want := range domains { + if doc.Egress.AllowedDomains[i] != want { + t.Errorf("domain %d = %q, want %q", i, doc.Egress.AllowedDomains[i], want) + } + } +} diff --git a/forge-cli/go.mod b/forge-cli/go.mod index 0d9b8dd1..06e2e54c 100644 --- a/forge-cli/go.mod +++ b/forge-cli/go.mod @@ -13,11 +13,13 @@ require ( github.com/initializ/forge/forge-skills v0.0.0 github.com/initializ/forge/forge-ui v0.0.0 github.com/initializ/guardrails v0.12.0 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/spf13/cobra v1.10.2 + go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/term v0.43.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.1 @@ -26,8 +28,10 @@ require ( ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beeper/argo-go v1.1.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect @@ -38,7 +42,10 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -67,7 +74,8 @@ require ( github.com/klauspost/compress v1.16.7 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -77,9 +85,14 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/zerolog v1.35.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect @@ -90,6 +103,8 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.etcd.io/bbolt v1.5.0 // indirect + go.mau.fi/libsignal v0.2.2 // indirect + go.mau.fi/util v0.10.0 // indirect go.mongodb.org/mongo-driver v1.17.7 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect @@ -100,21 +115,27 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.82.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.58.0 // indirect + rsc.io/qr v0.2.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/forge-cli/go.sum b/forge-cli/go.sum index 71c11303..740fbf47 100644 --- a/forge-cli/go.sum +++ b/forge-cli/go.sum @@ -1,7 +1,17 @@ +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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -32,11 +42,17 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -81,8 +97,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -91,6 +107,8 @@ github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/initializ/ctxzip v0.3.0 h1:qG+Li/qRCL/Bsx3eD3E6q2y2QDWUlIGXhQmt8ViPSsQ= @@ -118,12 +136,18 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= +github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -140,21 +164,31 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b h1:sS7HLzwS+dO+gxATgQfeZDEdUZe2pKAB3nGoUwP5zU0= +github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -172,6 +206,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= @@ -195,6 +231,16 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +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.10.0 h1:vH9IXZmfBKa96p47HxrVqEPkrj02zDJg3o4EF172+Lk= +go.mau.fi/util v0.10.0/go.mod h1:uZwpm9sK4wO2Qqy+t6QoVq29szMsRxWXp9/BkQLG4xk= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde h1:eMHY9dMDkNuDMWhfTbMZHbbsxj7G6mfujjKei1HaFQM= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde/go.mod h1:z0ZZNt4hq3FZbUKnunexE/QscCx7VkLvQSvtggc/aE8= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 h1:xJ13dqFcK/oPNImltlk7sumI/uiQ9EON/6g/iwpT1Nc= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837/go.mod h1:UFP0D7aj+biuFejsllcoNiHTQorh0ODFUyG4Txpqfks= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1 h1:4EyoinbJqFq4qozxem5nklT8+pI4OaokUuG+ayE3nKQ= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1/go.mod h1:aMd13H2xFFGH9cskcvxo4Aae+TmyFN38yw+HvsrpwVg= go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -229,29 +275,31 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -261,18 +309,18 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -280,8 +328,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -294,8 +342,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -318,6 +366,36 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +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.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/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.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +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.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +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= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/forge-cli/internal/tui/steps/channel_step.go b/forge-cli/internal/tui/steps/channel_step.go index 3f35a2bb..9147ed5f 100644 --- a/forge-cli/internal/tui/steps/channel_step.go +++ b/forge-cli/internal/tui/steps/channel_step.go @@ -11,6 +11,7 @@ import ( "github.com/initializ/forge/forge-cli/internal/devicecode" "github.com/initializ/forge/forge-cli/internal/tui" "github.com/initializ/forge/forge-cli/internal/tui/components" + "github.com/initializ/forge/forge-cli/internal/wapair" "github.com/initializ/forge/forge-core/catalog" ) @@ -24,6 +25,7 @@ const ( channelMsteamsClientIDPhase channelMsteamsClientSecretPhase channelMsteamsDeviceLoginPhase + channelWhatsappPairPhase channelDonePhase ) @@ -47,6 +49,26 @@ type msteamsRefreshTokenReadyMsg struct { err error } +// whatsapp QR-pairing sub-states inside channelWhatsappPairPhase. +type whatsappPairStatus int + +const ( + whatsappPairConnecting whatsappPairStatus = iota // opening the socket + whatsappPairScanning // showing a QR, waiting for the scan + whatsappPairFinalizing // scanned; post-pair handshake running + whatsappPairErr // failed; show retry/skip +) + +// Tea messages produced by the pairing goroutine. +type whatsappSessionStartedMsg struct { + session *wapair.Session + err error +} +type whatsappPairEventMsg struct { + event wapair.Event + ok bool // false once the event stream closes +} + // ChannelStep handles channel connector selection. type ChannelStep struct { styles *tui.StyleSet @@ -62,6 +84,17 @@ type ChannelStep struct { loginErr string channel string tokens map[string]string + + // whatsapp QR-pairing state. The session is paired into a temp store + // because the project directory does not exist yet at wizard time; + // scaffold moves the file into place afterwards. + pairStatus whatsappPairStatus + pairSession *wapair.Session + pairSessionPath string + pairQR string + pairErr string + pairJID string + pairTempDir string } // channelSelectItems projects the catalog channels into TUI select items. @@ -140,6 +173,8 @@ func (s *ChannelStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s.updateMsteamsClientSecretPhase(msg) case channelMsteamsDeviceLoginPhase: return s.updateMsteamsDeviceLoginPhase(msg) + case channelWhatsappPairPhase: + return s.updateWhatsappPairPhase(msg) } return s, nil @@ -157,6 +192,13 @@ func (s *ChannelStep) updateSelectPhase(msg tea.Msg) (tui.Step, tea.Cmd) { case "none": s.complete = true return s, func() tea.Msg { return tui.StepCompleteMsg{} } + case "whatsapp": + // No token to collect — WhatsApp authenticates by QR pairing, run + // inline here against a live socket. + s.phase = channelWhatsappPairPhase + s.pairStatus = whatsappPairConnecting + s.pairErr = "" + return s, s.startWhatsappPairCmd() case "telegram": s.phase = channelTokenPhase s.keyInput = components.NewSecretInput( @@ -496,6 +538,8 @@ func (s *ChannelStep) View(width int) string { return ins + s.keyInput.View(width) case channelMsteamsDeviceLoginPhase: return s.viewMsteamsDeviceLogin() + case channelWhatsappPairPhase: + return s.viewWhatsappPair() } return "" } @@ -552,6 +596,8 @@ func (s *ChannelStep) Summary() string { return "Slack" case "msteams": return "MS Teams" + case "whatsapp": + return "WhatsApp" } return s.channel } diff --git a/forge-cli/internal/tui/steps/channel_step_whatsapp.go b/forge-cli/internal/tui/steps/channel_step_whatsapp.go new file mode 100644 index 00000000..e62a8ca3 --- /dev/null +++ b/forge-cli/internal/tui/steps/channel_step_whatsapp.go @@ -0,0 +1,236 @@ +package steps + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/wapair" +) + +// WhatsappSessionTokenKey is the synthetic key the channel step uses to hand +// the paired session's temp path to scaffold, which moves the file into the +// project and strips the key before .env is written. Mirrors the existing +// __egress_domains / __custom_shape convention in cmd/init.go. +const WhatsappSessionTokenKey = "__whatsapp_session" + +// qrMaxRows is the tallest QR we will draw inline. WhatsApp pairing payloads +// render to roughly 29 rows at QuietZone 1; anything beyond this plus the +// wizard's own chrome will not fit a normal terminal, and a wrapped QR is +// unscannable noise rather than a degraded picture. +const qrMaxRows = 34 + +// updateWhatsappPairPhase is the state machine for inline QR pairing. It +// handles the two async events (session started, pairing event) plus the +// retry/skip keys available in the error state. +func (s *ChannelStep) updateWhatsappPairPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + switch m := msg.(type) { + case whatsappSessionStartedMsg: + if m.err != nil { + s.failWhatsappPair(m.err.Error()) + return s, nil + } + s.pairSession = m.session + return s, s.nextWhatsappEventCmd() + + case whatsappPairEventMsg: + if !m.ok { + // Stream closed without a terminal event. + s.failWhatsappPair("pairing ended unexpectedly") + return s, nil + } + switch m.event.Kind { + case wapair.EventScanned: + // Stop showing a code the phone has already taken, and stop + // offering skip: tearing the socket down now would abort the + // handshake and leave a half-registered device. + s.pairStatus = whatsappPairFinalizing + s.pairQR = "" + return s, s.nextWhatsappEventCmd() + + case wapair.EventQR: + s.pairStatus = whatsappPairScanning + s.pairQR = m.event.Code + return s, s.nextWhatsappEventCmd() + + case wapair.EventPaired: + s.pairJID = m.event.JID + // Hand the temp session path to scaffold, which relocates it into + // the project once the directory exists. Read from the step rather + // than the session so the transition stays independent of whether + // the socket is still open. + s.tokens[WhatsappSessionTokenKey] = s.pairSessionPath + s.closeWhatsappSession() + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + + case wapair.EventError: + s.failWhatsappPair(m.event.Err.Error()) + return s, nil + } + return s, nil + + case tea.KeyMsg: + switch m.String() { + case "s", "S": + // Skip — available while scanning as well as after a failure, since + // a QR too tall for the terminal is only escapable this way. + // Finishing unpaired is fine: the agent scaffolds, and the operator + // pairs later with `forge channel whatsapp-login`. + // Ignore while connecting (nothing to tear down yet) and while + // finalizing (aborting mid-handshake is what produces a + // half-registered device the server later rejects with 401). + if s.pairStatus == whatsappPairConnecting || s.pairStatus == whatsappPairFinalizing { + return s, nil + } + s.cleanupWhatsappTemp() + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + case "r", "R": + if s.pairStatus != whatsappPairErr { + return s, nil + } + s.pairStatus = whatsappPairConnecting + s.pairErr = "" + s.pairQR = "" + return s, s.startWhatsappPairCmd() + } + } + + return s, nil +} + +// failWhatsappPair moves into the error state, releasing the socket first so a +// retry doesn't stack a second live connection on the same store. +func (s *ChannelStep) failWhatsappPair(msg string) { + s.closeWhatsappSession() + s.pairStatus = whatsappPairErr + s.pairErr = msg + s.pairQR = "" +} + +// closeWhatsappSession releases the pairing socket and SQLite handle. The temp +// directory is deliberately left in place: on success scaffold still needs to +// read the session file out of it. +func (s *ChannelStep) closeWhatsappSession() { + if s.pairSession != nil { + _ = s.pairSession.Close() + s.pairSession = nil + } +} + +// cleanupWhatsappTemp removes the temp store. Only safe on the abandon paths — +// never after a successful pairing, whose file scaffold has yet to copy. +func (s *ChannelStep) cleanupWhatsappTemp() { + s.closeWhatsappSession() + if s.pairTempDir != "" { + _ = os.RemoveAll(s.pairTempDir) + s.pairTempDir = "" + } + delete(s.tokens, WhatsappSessionTokenKey) +} + +// startWhatsappPairCmd opens a pairing session against a temp store. +// +// The project directory does not exist yet — the wizard runs before scaffold — +// so the pairing cannot be written to its final location. It lands in a temp +// dir and scaffold relocates it. +func (s *ChannelStep) startWhatsappPairCmd() tea.Cmd { + // Reuse the temp dir across retries so a failed attempt doesn't leak one. + if s.pairTempDir == "" { + dir, err := os.MkdirTemp("", "forge-whatsapp-pair-") + if err != nil { + return func() tea.Msg { + return whatsappSessionStartedMsg{err: fmt.Errorf("creating temp session dir: %w", err)} + } + } + s.pairTempDir = dir + } + // A retry must start from a clean store: whatsmeow will not re-issue QR + // codes for a device row that is half-registered from a failed attempt. + sessionPath := filepath.Join(s.pairTempDir, "whatsapp-session.db") + _ = os.Remove(sessionPath) + s.pairSessionPath = sessionPath + + return func() tea.Msg { + sess, err := wapair.Start(context.Background(), sessionPath) + return whatsappSessionStartedMsg{session: sess, err: err} + } +} + +// nextWhatsappEventCmd waits for one pairing event. Each event re-issues this +// command, so the QR refreshes as WhatsApp rotates codes. +func (s *ChannelStep) nextWhatsappEventCmd() tea.Cmd { + sess := s.pairSession + if sess == nil { + return nil + } + return func() tea.Msg { + evt, ok := <-sess.Events() + return whatsappPairEventMsg{event: evt, ok: ok} + } +} + +func (s *ChannelStep) viewWhatsappPair() string { + header := s.styles.SecondaryTxt.Render("WhatsApp Setup — link a device:") + + switch s.pairStatus { + case whatsappPairConnecting: + return fmt.Sprintf(" %s\n %s\n\n", + header, + s.styles.AccentTxt.Render("⣾ Connecting to WhatsApp..."), + ) + + case whatsappPairScanning: + var b strings.Builder + fmt.Fprintf(&b, " %s\n\n", header) + fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("On your phone: WhatsApp → Settings → Linked Devices →")) + fmt.Fprintf(&b, " %s\n\n", s.styles.DimTxt.Render("Link a Device, then scan this code:")) + + rows, cols := wapair.QRDimensions(s.pairQR) + if rows > qrMaxRows { + // Drawing it anyway would wrap into unscannable noise and push the + // rest of the wizard off-screen. Point at the standalone command, + // which owns the full terminal. + fmt.Fprintf(&b, " %s\n", s.styles.ErrorTxt.Render( + fmt.Sprintf("The QR code needs %d×%d and does not fit here.", rows, cols))) + fmt.Fprintf(&b, " %s\n\n", s.styles.DimTxt.Render( + "Press S to skip, then run `forge channel whatsapp-login`.")) + } else { + var qr strings.Builder + wapair.RenderQR(s.pairQR, &qr) + for _, line := range strings.Split(strings.TrimRight(qr.String(), "\n"), "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString("\n") + } + + fmt.Fprintf(&b, " %s\n", s.styles.ErrorTxt.Render("Use a DEDICATED number, not your personal one —")) + fmt.Fprintf(&b, " %s\n\n", s.styles.ErrorTxt.Render("WhatsApp may ban numbers used for automation.")) + fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("⣾ Waiting for the scan. The code refreshes automatically.")) + fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("(Press S to skip and pair later.)")) + return b.String() + + case whatsappPairFinalizing: + return fmt.Sprintf(" %s\n\n %s\n %s\n\n", + header, + s.styles.AccentTxt.Render("⣾ Scanned — finalizing the link with WhatsApp..."), + s.styles.DimTxt.Render("Keep this running; interrupting now would leave the pairing incomplete."), + ) + + case whatsappPairErr: + return fmt.Sprintf(" %s\n\n %s\n %s\n\n %s\n %s\n", + header, + s.styles.ErrorTxt.Render("✗ Pairing failed:"), + s.styles.DimTxt.Render(" "+s.pairErr), + s.styles.DimTxt.Render("Press R to retry, or S to skip and pair later with"), + s.styles.DimTxt.Render("`forge channel whatsapp-login`."), + ) + } + return "" +} diff --git a/forge-cli/internal/tui/steps/channel_step_whatsapp_test.go b/forge-cli/internal/tui/steps/channel_step_whatsapp_test.go new file mode 100644 index 00000000..5a80d83d --- /dev/null +++ b/forge-cli/internal/tui/steps/channel_step_whatsapp_test.go @@ -0,0 +1,309 @@ +package steps + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/wapair" +) + +// newPairStep builds a step already sitting in the pairing phase, with the +// temp-store bookkeeping the network path would normally have set up. +func newPairStep(t *testing.T) *ChannelStep { + t.Helper() + s := NewChannelStep(tui.NewStyleSet(tui.DarkTheme)) + s.channel = "whatsapp" + s.phase = channelWhatsappPairPhase + s.pairStatus = whatsappPairConnecting + s.pairTempDir = t.TempDir() + s.pairSessionPath = filepath.Join(s.pairTempDir, "whatsapp-session.db") + return s +} + +func TestChannelStep_SelectingWhatsappEntersPairPhase(t *testing.T) { + s := NewChannelStep(tui.NewStyleSet(tui.DarkTheme)) + s.channel = "whatsapp" + s.phase = channelWhatsappPairPhase + s.pairStatus = whatsappPairConnecting + + if got := s.View(80); !strings.Contains(got, "Connecting to WhatsApp") { + t.Errorf("connecting view should say so, got %q", got) + } + if s.Complete() { + t.Error("step must not be complete while connecting") + } +} + +func TestWhatsappPair_QREventRendersCode(t *testing.T) { + s := newPairStep(t) + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, + event: wapair.Event{Kind: wapair.EventQR, Code: "2@abc123,def456,ghi789"}, + }) + + if s.pairStatus != whatsappPairScanning { + t.Fatalf("status = %v, want scanning", s.pairStatus) + } + view := s.View(80) + if !strings.Contains(view, "Linked Devices") { + t.Errorf("view should carry scan instructions, got %q", view) + } + if !strings.Contains(view, "█") && !strings.Contains(view, "▀") { + t.Errorf("view should contain a rendered QR, got %q", view) + } +} + +// A refreshed code must replace the previous one, not stack. +func TestWhatsappPair_QRRefreshReplacesCode(t *testing.T) { + s := newPairStep(t) + for _, code := range []string{"2@first", "2@second"} { + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, event: wapair.Event{Kind: wapair.EventQR, Code: code}, + }) + } + if s.pairQR != "2@second" { + t.Errorf("pairQR = %q, want the latest code", s.pairQR) + } +} + +func TestWhatsappPair_PairedSetsSessionTokenAndCompletes(t *testing.T) { + s := newPairStep(t) + + _, cmd := s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, + event: wapair.Event{Kind: wapair.EventPaired, JID: "14155550100@s.whatsapp.net"}, + }) + + if !s.Complete() { + t.Error("step should be complete after pairing") + } + if got := s.tokens[WhatsappSessionTokenKey]; got != s.pairSessionPath { + t.Errorf("session token = %q, want %q", got, s.pairSessionPath) + } + if cmd == nil { + t.Fatal("expected a StepCompleteMsg command") + } + if _, ok := cmd().(tui.StepCompleteMsg); !ok { + t.Error("expected StepCompleteMsg") + } +} + +// The temp store must survive a successful pairing — scaffold still has to +// copy the file out of it. +func TestWhatsappPair_PairedKeepsTempStore(t *testing.T) { + s := newPairStep(t) + tempDir := s.pairTempDir + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, event: wapair.Event{Kind: wapair.EventPaired}, + }) + + if _, err := os.Stat(tempDir); err != nil { + t.Errorf("temp store removed after pairing, scaffold cannot read it: %v", err) + } +} + +func TestWhatsappPair_ErrorEntersErrorState(t *testing.T) { + s := newPairStep(t) + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, + event: wapair.Event{Kind: wapair.EventError, Err: errors.New("boom")}, + }) + + if s.pairStatus != whatsappPairErr { + t.Fatalf("status = %v, want error", s.pairStatus) + } + if s.Complete() { + t.Error("an error must not complete the step") + } + view := s.View(80) + for _, want := range []string{"Pairing failed", "boom", "whatsapp-login"} { + if !strings.Contains(view, want) { + t.Errorf("error view missing %q, got %q", want, view) + } + } +} + +func TestWhatsappPair_SessionStartFailureEntersErrorState(t *testing.T) { + s := newPairStep(t) + _, _ = s.updateWhatsappPairPhase(whatsappSessionStartedMsg{err: errors.New("no socket")}) + + if s.pairStatus != whatsappPairErr { + t.Fatalf("status = %v, want error", s.pairStatus) + } + if !strings.Contains(s.pairErr, "no socket") { + t.Errorf("pairErr = %q, want the underlying cause", s.pairErr) + } +} + +// A closed event stream with no terminal event must not hang the wizard. +func TestWhatsappPair_ClosedStreamFailsRatherThanHangs(t *testing.T) { + s := newPairStep(t) + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ok: false}) + + if s.pairStatus != whatsappPairErr { + t.Fatalf("status = %v, want error", s.pairStatus) + } +} + +func TestWhatsappPair_SkipCompletesWithoutToken(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairScanning + tempDir := s.pairTempDir + + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + + if !s.Complete() { + t.Error("skip should complete the step") + } + if _, ok := s.tokens[WhatsappSessionTokenKey]; ok { + t.Error("skip must not leave a session token behind") + } + if _, err := os.Stat(tempDir); !os.IsNotExist(err) { + t.Errorf("skip should remove the temp store, stat err = %v", err) + } +} + +// Skip is offered in the scanning view, so it has to work there — not only +// after a failure. A QR too tall for the terminal is escapable no other way. +func TestWhatsappPair_SkipWorksWhileScanning(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairScanning + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'S'}}) + if !s.Complete() { + t.Error("skip should work while scanning") + } +} + +// While connecting there is no session to tear down yet; a keypress then must +// be ignored rather than completing the step in a half-built state. +func TestWhatsappPair_SkipIgnoredWhileConnecting(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairConnecting + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if s.Complete() { + t.Error("skip must be ignored while still connecting") + } +} + +func TestWhatsappPair_RetryOnlyFromErrorState(t *testing.T) { + s := newPairStep(t) + + s.pairStatus = whatsappPairScanning + if _, cmd := s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}); cmd != nil { + t.Error("retry must be ignored while scanning") + } + + s.pairStatus = whatsappPairErr + s.pairErr = "boom" + _, cmd := s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + if cmd == nil { + t.Fatal("retry from the error state should start a new attempt") + } + if s.pairStatus != whatsappPairConnecting { + t.Errorf("status = %v, want connecting after retry", s.pairStatus) + } + if s.pairErr != "" { + t.Errorf("retry should clear the previous error, got %q", s.pairErr) + } +} + +// Retries must reuse one temp dir rather than leaking a new one each time. +func TestWhatsappPair_RetryReusesTempDir(t *testing.T) { + s := newPairStep(t) + first := s.pairTempDir + + s.pairStatus = whatsappPairErr + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + + if s.pairTempDir != first { + t.Errorf("temp dir changed on retry: %q -> %q", first, s.pairTempDir) + } +} + +func TestChannelStep_WhatsappSummary(t *testing.T) { + s := NewChannelStep(tui.NewStyleSet(tui.DarkTheme)) + s.channel = "whatsapp" + if got := s.Summary(); got != "WhatsApp" { + t.Errorf("Summary() = %q, want %q", got, "WhatsApp") + } +} + +// The wizard has limited vertical room. A realistic WhatsApp pairing payload +// must render small enough to draw inline, or the QR guard silently degrades +// every pairing to the standalone command. +func TestWhatsappPair_RealisticQRFitsInline(t *testing.T) { + // Shape and length match a real pairing code: four comma-separated + // base64 segments, ~200 chars total. + code := "2@" + strings.Repeat("A1b2C3d4", 8) + "," + strings.Repeat("E5f6G7h8", 6) + + "," + strings.Repeat("I9j0K1l2", 5) + "," + strings.Repeat("M3n4O5p6", 2) + + rows, cols := wapair.QRDimensions(code) + if rows > qrMaxRows { + t.Errorf("QR renders %d rows, over the %d inline budget — pairing would always fall back", rows, qrMaxRows) + } + if cols > 80 { + t.Errorf("QR renders %d cols, wider than an 80-col terminal", cols) + } + t.Logf("realistic pairing QR: %d rows x %d cols (payload %d chars)", rows, cols, len(code)) +} + +// After the phone accepts the code the QR must disappear — leaving it on +// screen invites a second scan of a dead code. +func TestWhatsappPair_ScannedEntersFinalizing(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairScanning + s.pairQR = "2@stale" + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, event: wapair.Event{Kind: wapair.EventScanned}, + }) + + if s.pairStatus != whatsappPairFinalizing { + t.Fatalf("status = %v, want finalizing", s.pairStatus) + } + if s.pairQR != "" { + t.Error("the scanned QR must be cleared") + } + view := s.View(80) + if !strings.Contains(view, "finalizing") { + t.Errorf("view should say it is finalizing, got %q", view) + } + if strings.Contains(view, "█") { + t.Error("view must not still draw the scanned QR") + } +} + +// Skipping mid-handshake closes the socket before the post-pair login lands, +// which is exactly what produces a device the server rejects with 401. The +// key must be ignored in this window. +func TestWhatsappPair_SkipIgnoredWhileFinalizing(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairFinalizing + + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + + if s.Complete() { + t.Error("skip must be ignored while the pairing handshake is running") + } + if _, ok := s.tokens[WhatsappSessionTokenKey]; ok { + t.Error("no session token should be set mid-handshake") + } +} + +// The finalizing view must not promise a skip key that is deliberately inert. +func TestWhatsappPair_FinalizingViewDoesNotOfferSkip(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairFinalizing + if view := s.View(80); strings.Contains(view, "Press S") { + t.Errorf("finalizing view must not offer skip, got %q", view) + } +} diff --git a/forge-cli/internal/tui/steps/egress_step.go b/forge-cli/internal/tui/steps/egress_step.go index 7a5849c7..2aac89c1 100644 --- a/forge-cli/internal/tui/steps/egress_step.go +++ b/forge-cli/internal/tui/steps/egress_step.go @@ -166,6 +166,8 @@ func inferSource(domain string, ctx *tui.WizardContext) string { "wss-primary.slack.com": "channel", "api.slack.com": "channel", "files.slack.com": "channel", + "web.whatsapp.com": "channel", + "*.whatsapp.net": "channel", } if src, ok := channelDomains[domain]; ok { return src diff --git a/forge-cli/internal/wapair/wapair.go b/forge-cli/internal/wapair/wapair.go new file mode 100644 index 00000000..0a8c6b05 --- /dev/null +++ b/forge-cli/internal/wapair/wapair.go @@ -0,0 +1,342 @@ +// Package wapair drives the WhatsApp Web QR pairing flow. +// +// It is the WhatsApp counterpart to internal/devicecode: a small, UI-agnostic +// driver that both `forge channel whatsapp-login` and the init wizard's +// channel step use, so the pairing state machine lives in exactly one place. +package wapair + +import ( + "context" + "fmt" + "io" + "sync" + "time" + + "github.com/mdp/qrterminal/v3" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types/events" + + "github.com/initializ/forge/forge-plugins/channels/whatsapp" +) + +// EventKind discriminates the events a pairing session emits. +type EventKind int + +const ( + // EventQR carries a new code to display. WhatsApp rotates codes every + // ~20s, so several of these arrive before the user finishes scanning. + EventQR EventKind = iota + // EventScanned means the phone accepted the code and the post-pair + // handshake is running. NOT yet usable — EventPaired is the completion. + EventScanned + // EventPaired means the scan succeeded and the session is persisted. + EventPaired + // EventError is terminal: the session cannot complete. + EventError +) + +// Event is one step of the pairing flow. +type Event struct { + Kind EventKind + Code string // EventQR: the raw payload to encode + Timeout time.Duration // EventQR: how long until the next code + JID string // EventPaired: the linked account + Err error // EventError +} + +// Session is an in-flight pairing attempt. Close it when done — it holds an +// open socket and a SQLite handle. +type Session struct { + sessionPath string + container io.Closer + client *whatsmeow.Client + events chan Event + cancel context.CancelFunc + closeOnce sync.Once + + // Post-pair completion signals. PairSuccess is NOT the end of pairing: + // the server drops the socket, the client reconnects and completes a + // login handshake, and only then is the device registered server-side. + connected chan struct{} + connOnce sync.Once + fatal chan error +} + +// Start opens (creating if absent) the session store at sessionPath and begins +// a pairing attempt. Events are delivered on Events() until the channel closes. +// +// The caller owns the returned Session and must Close it. +func Start(ctx context.Context, sessionPath string) (*Session, error) { + ctx, cancel := context.WithCancel(ctx) + + container, device, err := whatsapp.NewSessionDevice(ctx, sessionPath) + if err != nil { + cancel() + return nil, err + } + + client := whatsmeow.NewClient(device, nil) + + s := &Session{ + sessionPath: sessionPath, + container: container, + client: client, + events: make(chan Event, 4), + cancel: cancel, + connected: make(chan struct{}), + fatal: make(chan error, 1), + } + + // Watch for the post-pair reconnect. The QR channel cannot report it: it + // closes on PairSuccess and treats a later Connected as an unexpected + // event, so completion has to be observed on the client itself. + client.AddEventHandler(func(evt any) { + switch e := evt.(type) { + case *events.Connected: + s.connOnce.Do(func() { close(s.connected) }) + case *events.LoggedOut: + s.failFast(fmt.Errorf("server rejected the new pairing (%s)", e.Reason)) + case *events.ConnectFailure: + s.failFast(fmt.Errorf("connection failed after pairing (%s)", e.Reason)) + } + }) + + // GetQRChannel must be called BEFORE Connect: pairing codes arrive on the + // socket immediately after it opens, and a channel registered afterwards + // misses them. + qrChan, err := client.GetQRChannel(ctx) + if err != nil { + cancel() + _ = container.Close() + return nil, fmt.Errorf("whatsapp: opening QR channel: %w", err) + } + if err := client.Connect(); err != nil { + cancel() + _ = container.Close() + return nil, fmt.Errorf("whatsapp: connecting: %w", err) + } + + go s.pump(ctx, qrChan) + return s, nil +} + +// failFast records a terminal post-pair failure without blocking the emitter. +func (s *Session) failFast(err error) { + select { + case s.fatal <- err: + default: + } +} + +// pump translates whatsmeow's QR items into Events and closes the channel when +// the flow reaches a terminal state. +func (s *Session) pump(ctx context.Context, qrChan <-chan whatsmeow.QRChannelItem) { + defer close(s.events) + + for evt := range qrChan { + switch evt.Event { + case "code": + s.emit(ctx, Event{Kind: EventQR, Code: evt.Code, Timeout: evt.Timeout}) + + case "success": + // The phone accepted the code, but pairing is NOT done — see + // awaitPairCompletion. Tell the caller so it can stop showing a + // QR that has already been scanned. + s.emit(ctx, Event{Kind: EventScanned}) + s.emit(ctx, s.awaitPairCompletion(ctx)) + return + + case "timeout": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("QR code expired without being scanned")}) + return + + case "err-client-outdated": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("WhatsApp rejected this client as outdated — the pinned whatsmeow version needs updating")}) + return + + case "err-scanned-without-multidevice": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("the account scanned the code without multi-device enabled — enable it in WhatsApp → Settings → Linked Devices")}) + return + + case "error": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("pairing failed: %w", evt.Error)}) + return + + default: + // Every other "err-" event is terminal (e.g. err-unexpected-state). + // Reporting it as informational would leave the caller waiting on a + // pairing that can never complete. + if len(evt.Event) > 4 && evt.Event[:4] == "err-" { + if evt.Error != nil { + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("pairing failed (%s): %w", evt.Event, evt.Error)}) + } else { + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("pairing failed (%s)", evt.Event)}) + } + return + } + } + } + + // The channel closed with no terminal event: the context was cancelled or + // the socket dropped mid-pairing. + if ctx.Err() != nil { + s.emit(context.Background(), Event{Kind: EventError, Err: ctx.Err()}) + return + } + s.emit(context.Background(), Event{Kind: EventError, Err: fmt.Errorf("pairing ended without completing")}) +} + +// emit delivers an event unless the caller has gone away. +func (s *Session) emit(ctx context.Context, e Event) { + select { + case s.events <- e: + case <-ctx.Done(): + } +} + +// Events returns the pairing event stream. It closes when the flow reaches a +// terminal state. +func (s *Session) Events() <-chan Event { return s.events } + +// SessionPath is where the pairing is persisted. +func (s *Session) SessionPath() string { return s.sessionPath } + +// Close tears down the socket and the session store. Safe to call repeatedly. +func (s *Session) Close() error { + var err error + s.closeOnce.Do(func() { + s.cancel() + if s.client != nil { + s.client.Disconnect() + } + if s.container != nil { + err = s.container.Close() + } + }) + return err +} + +// RenderQR writes code as a scannable QR block. +// +// Half-block rendering keeps the code square in a terminal whose cells are +// taller than they are wide; a full-block render is stretched and many phones +// fail to lock onto it. QuietZone is 1 rather than qrterminal's default 4 — +// the standard 4-module margin costs 6 rows and 6 columns, which is the +// difference between fitting and not fitting in a typical 80x30 terminal. +// Phones scan reliably at 1 against a contrasting terminal background. +func RenderQR(code string, w io.Writer) { + qrterminal.GenerateWithConfig(code, qrterminal.Config{ + Level: qrterminal.L, + Writer: w, + HalfBlocks: true, + BlackChar: qrterminal.BLACK_BLACK, + WhiteChar: qrterminal.WHITE_WHITE, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + QuietZone: 1, + }) +} + +// QRDimensions reports the rows and columns RenderQR needs for code, so a +// caller can warn before drawing something the terminal will wrap into +// unscannable noise. +func QRDimensions(code string) (rows, cols int) { + var c countingWriter + RenderQR(code, &c) + return c.rows, c.cols +} + +// countingWriter measures rendered output without retaining it. +type countingWriter struct { + rows int + cols int + cur int +} + +func (c *countingWriter) Write(p []byte) (int, error) { + for _, b := range p { + if b == '\n' { + if c.cur > c.cols { + c.cols = c.cur + } + c.cur = 0 + c.rows++ + continue + } + // Count runes, not bytes — the block glyphs are multi-byte. + if b&0xC0 != 0x80 { + c.cur++ + } + } + return len(p), nil +} + +// pairCompletionTimeout bounds the post-scan handshake. The reconnect is +// usually sub-second; this only guards against a server that never completes. +const pairCompletionTimeout = 90 * time.Second + +// pairSettleDelay is how long the socket is held open after the post-pair +// login lands. +// +// whatsmeow uploads prekeys and kicks off app-state sync asynchronously once +// logged in. Tearing the connection down the instant Connected fires can cut +// that short, leaving a device the server considers half-registered. +const pairSettleDelay = 4 * time.Second + +// awaitPairCompletion blocks until the pairing is actually usable, and returns +// the Event to report. +// +// PairSuccess (the QR channel's "success") is NOT the end of pairing. At that +// point whatsmeow has written the device locally, but the server then drops +// the socket; the client reconnects and completes a login handshake, and only +// after that is the device registered server-side. +// +// Disconnecting on PairSuccess is what produced the field failure this guards +// against: pairing appeared to succeed, and the very next `forge run` was +// dropped with 401 "logged out from another device", because the registration +// was never finished. +func (s *Session) awaitPairCompletion(ctx context.Context) Event { + return awaitCompletion(ctx, s.connected, s.fatal, s.client.IsLoggedIn, func() string { + if s.client.Store.ID != nil { + return s.client.Store.ID.ToNonAD().String() + } + return "" + }, pairSettleDelay, pairCompletionTimeout) +} + +// awaitCompletion is the socket-free core of awaitPairCompletion, so the +// completion rules can be tested without a live WhatsApp connection. +func awaitCompletion( + ctx context.Context, + connected <-chan struct{}, + fatal <-chan error, + isLoggedIn func() bool, + jid func() string, + settle, timeout time.Duration, +) Event { + select { + case <-connected: + // Logged in post-pair. Hold the socket briefly so the asynchronous + // prekey upload and app-state sync can finish. + select { + case err := <-fatal: + return Event{Kind: EventError, Err: err} + case <-time.After(settle): + case <-ctx.Done(): + return Event{Kind: EventError, Err: ctx.Err()} + } + if !isLoggedIn() { + return Event{Kind: EventError, Err: fmt.Errorf("pairing did not complete: the server did not confirm the new device")} + } + return Event{Kind: EventPaired, JID: jid()} + + case err := <-fatal: + return Event{Kind: EventError, Err: err} + + case <-time.After(timeout): + return Event{Kind: EventError, Err: fmt.Errorf("timed out waiting for WhatsApp to confirm the new device")} + + case <-ctx.Done(): + return Event{Kind: EventError, Err: ctx.Err()} + } +} diff --git a/forge-cli/internal/wapair/wapair_test.go b/forge-cli/internal/wapair/wapair_test.go new file mode 100644 index 00000000..573818c8 --- /dev/null +++ b/forge-cli/internal/wapair/wapair_test.go @@ -0,0 +1,218 @@ +package wapair + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +const ( + testSettle = 20 * time.Millisecond + testTimeout = 500 * time.Millisecond +) + +func loggedIn() bool { return true } +func loggedOut() bool { return false } +func testJID() string { return "14155550100@s.whatsapp.net" } + +// The regression this guards: PairSuccess is not the end of pairing. Reporting +// success before the post-pair reconnect leaves the device half-registered, +// and the next connection is dropped with 401 "logged out from another +// device". Completion must wait for the reconnect. +func TestAwaitCompletion_WaitsForPostPairConnect(t *testing.T) { + connected := make(chan struct{}) + fatal := make(chan error, 1) + + var got Event + done := make(chan struct{}) + go func() { + got = awaitCompletion(context.Background(), connected, fatal, loggedIn, testJID, testSettle, testTimeout) + close(done) + }() + + // Nothing may be reported while the reconnect is still outstanding. + select { + case <-done: + t.Fatal("reported completion before the post-pair reconnect") + case <-time.After(50 * time.Millisecond): + } + + close(connected) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("did not complete after the reconnect") + } + + if got.Kind != EventPaired { + t.Fatalf("kind = %v, want EventPaired (err=%v)", got.Kind, got.Err) + } + if got.JID != testJID() { + t.Errorf("JID = %q, want %q", got.JID, testJID()) + } +} + +// The settle window exists so the asynchronous prekey upload can finish; +// completion must not be reported before it elapses. +func TestAwaitCompletion_HoldsSocketForSettleWindow(t *testing.T) { + connected := make(chan struct{}) + close(connected) + + start := time.Now() + got := awaitCompletion(context.Background(), connected, make(chan error, 1), + loggedIn, testJID, 150*time.Millisecond, testTimeout) + + if elapsed := time.Since(start); elapsed < 150*time.Millisecond { + t.Errorf("returned after %v, expected to hold for the settle window", elapsed) + } + if got.Kind != EventPaired { + t.Errorf("kind = %v, want EventPaired", got.Kind) + } +} + +// A logout arriving during the wait is the server rejecting the pairing. +func TestAwaitCompletion_LogoutBeforeConnectIsFatal(t *testing.T) { + fatal := make(chan error, 1) + fatal <- errors.New("server rejected the new pairing (401)") + + got := awaitCompletion(context.Background(), make(chan struct{}), fatal, + loggedIn, testJID, testSettle, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "rejected") { + t.Errorf("err = %v, want the rejection reason", got.Err) + } +} + +// A logout can also land inside the settle window, after a good connect. +func TestAwaitCompletion_LogoutDuringSettleIsFatal(t *testing.T) { + connected := make(chan struct{}) + close(connected) + fatal := make(chan error, 1) + + go func() { + time.Sleep(20 * time.Millisecond) + fatal <- errors.New("logged out from another device") + }() + + got := awaitCompletion(context.Background(), connected, fatal, + loggedIn, testJID, 2*time.Second, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "logged out") { + t.Errorf("err = %v, want the logout reason", got.Err) + } +} + +// Connected then not-logged-in means the handshake did not actually land. +// Reporting success there would hand back an unusable session. +func TestAwaitCompletion_NotLoggedInAfterSettleFails(t *testing.T) { + connected := make(chan struct{}) + close(connected) + + got := awaitCompletion(context.Background(), connected, make(chan error, 1), + loggedOut, testJID, testSettle, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "did not complete") { + t.Errorf("err = %v, want a completion failure", got.Err) + } +} + +func TestAwaitCompletion_TimesOutWaitingForConnect(t *testing.T) { + got := awaitCompletion(context.Background(), make(chan struct{}), make(chan error, 1), + loggedIn, testJID, testSettle, 60*time.Millisecond) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "timed out") { + t.Errorf("err = %v, want a timeout", got.Err) + } +} + +func TestAwaitCompletion_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got := awaitCompletion(ctx, make(chan struct{}), make(chan error, 1), + loggedIn, testJID, testSettle, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !errors.Is(got.Err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", got.Err) + } +} + +// The real settle window has to be long enough to be meaningful and short +// enough not to look like a hang. +func TestPairTimings_AreSane(t *testing.T) { + if pairSettleDelay < time.Second { + t.Errorf("pairSettleDelay = %v, too short for the prekey upload to land", pairSettleDelay) + } + if pairSettleDelay > 15*time.Second { + t.Errorf("pairSettleDelay = %v, long enough to read as a hang", pairSettleDelay) + } + if pairCompletionTimeout <= pairSettleDelay { + t.Errorf("pairCompletionTimeout (%v) must exceed pairSettleDelay (%v)", pairCompletionTimeout, pairSettleDelay) + } +} + +func TestRenderQR_ProducesBlockGlyphs(t *testing.T) { + var b strings.Builder + RenderQR("2@test,payload,here", &b) + out := b.String() + if !strings.Contains(out, "█") && !strings.Contains(out, "▀") { + t.Errorf("expected block glyphs, got %q", out[:min(80, len(out))]) + } +} + +func TestQRDimensions_MatchesRenderedOutput(t *testing.T) { + const code = "2@abcdefgh,ijklmnop,qrstuvwx" + + rows, cols := QRDimensions(code) + + var b strings.Builder + RenderQR(code, &b) + lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") + + if rows != len(lines) { + t.Errorf("QRDimensions rows = %d, rendered %d lines", rows, len(lines)) + } + widest := 0 + for _, l := range lines { + if n := len([]rune(l)); n > widest { + widest = n + } + } + if cols != widest { + t.Errorf("QRDimensions cols = %d, widest rendered line = %d", cols, widest) + } +} + +// A tighter quiet zone is what makes the QR fit inside the init wizard. +func TestRenderQR_UsesTightQuietZone(t *testing.T) { + code := "2@" + strings.Repeat("A1b2C3d4", 20) + rows, cols := QRDimensions(code) + if rows > 34 || cols > 80 { + t.Errorf("QR is %dx%d — too large to draw inline in the wizard", rows, cols) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/forge-cli/templates/init/env-whatsapp.tmpl b/forge-cli/templates/init/env-whatsapp.tmpl new file mode 100644 index 00000000..53de345e --- /dev/null +++ b/forge-cli/templates/init/env-whatsapp.tmpl @@ -0,0 +1,11 @@ +# WhatsApp channel adapter. +# +# There is no bot token: WhatsApp Web authenticates by QR pairing, and the +# resulting session lives in the file named by session_path in +# whatsapp-config.yaml (default .forge/channels/whatsapp-session.db). +# Pair with: forge channel whatsapp-login +# +# Optional — override the session store location. Uncomment and set to use a +# path other than the one in whatsapp-config.yaml (for example a mounted +# volume in a container deployment). +# WHATSAPP_SESSION_PATH= diff --git a/forge-cli/templates/init/forge.yaml.tmpl b/forge-cli/templates/init/forge.yaml.tmpl index cca69161..ef2341ad 100644 --- a/forge-cli/templates/init/forge.yaml.tmpl +++ b/forge-cli/templates/init/forge.yaml.tmpl @@ -31,14 +31,14 @@ tools: channels: {{- range .Channels}} - - {{.}} + - {{yamlScalar .}} {{- end}} {{- end}} {{- if .BuiltinTools}} builtin_tools: {{- range .BuiltinTools}} - - {{.}} + - {{yamlScalar .}} {{- end}} {{- end}} {{- if .Compression}} @@ -55,7 +55,7 @@ egress: mode: allowlist allowed_domains: {{- range .EgressDomains}} - - {{.}} + - {{yamlScalar .}} {{- end}} {{- end}} diff --git a/forge-cli/templates/init/whatsapp-config.yaml.tmpl b/forge-cli/templates/init/whatsapp-config.yaml.tmpl new file mode 100644 index 00000000..7e065352 --- /dev/null +++ b/forge-cli/templates/init/whatsapp-config.yaml.tmpl @@ -0,0 +1,67 @@ +adapter: whatsapp + +settings: + # Paired session store, written by `forge channel whatsapp-login`. + # Relative paths resolve against the project root. + # + # This file IS the credential — anyone holding it can send messages as the + # linked account. Keep it out of version control and off shared volumes. + session_path: .forge/channels/whatsapp-session.db + + # Admission policy: which messages reach the agent. + # - "dm": only 1:1 chats + # - "group_mention": only group messages that @-mention the agent + # - "dm_or_group_mention" (default, recommended): either + # + # An agent in a group must not answer every line of an unrelated human + # conversation, so group traffic always requires an explicit mention. + admit: dm_or_group_mention + + # Restrict which groups the agent acts in. Empty = every group it is a + # member of, still subject to `admit`. Comma- or newline-separated group + # JIDs ("120363000000000000@g.us"); the "@g.us" suffix may be omitted. + allowed_groups: "" + + # Who may invoke the agent. + # + # EMPTY MEANS OWNER ONLY — the paired account is the sole permitted sender. + # Anyone who has the number can otherwise reach the agent, spend its LLM + # budget and use whatever it can access, so the closed default is the safe + # one. The owner always passes regardless of this list. + # + # To admit other people, list them: comma- or newline-separated phone + # numbers in any written form ("+1 (415) 555-0100") or full JIDs. + # To open the agent to anyone with the number, set: allowed_senders: anyone + # + # NOTE: space is NOT a separator here, unlike other adapters' list settings — + # a space is part of a written phone number. + # + # WhatsApp is migrating group participants to hidden-number LIDs. A LID + # sender is matched via its phone-number alternate when the server supplies + # one; when it doesn't, the message is dropped and the reason names the LID + # so you can add it to this list directly. + allowed_senders: "" + + # Answer messages you send to yourself, in WhatsApp's "Message Yourself" + # chat. This is the personal-agent flow: pair once, then talk to the agent + # from the same phone, with no second account. + # + # Your own messages are what the agent reads there, and its own replies look + # identical on the wire — the loop guard is the dedup ring, which records + # every message the agent sends before it can come back. Self-messages are + # only ever accepted in this chat, never in groups. + self_chat: true + + # Prepend recently observed messages from the chat to each dispatched + # prompt, so the agent can answer "summarise the above" in a group. + # + # LIMITATION: unlike the MS Teams adapter, this window is built from traffic + # the adapter itself observed — WhatsApp has no server-side "fetch recent + # messages" API for a linked device. Context therefore covers messages seen + # since the adapter started and is lost on restart. + include_recent_history: true + + # How many recent messages to keep per chat and inject as context. The + # rendered block is soft-capped at ~5000 chars to protect the LLM context + # budget, so very long messages may be trimmed before this count is reached. + recent_history_count: 20 diff --git a/forge-core/catalog/channels.go b/forge-core/catalog/channels.go index 8f729143..5b7de9ff 100644 --- a/forge-core/catalog/channels.go +++ b/forge-core/catalog/channels.go @@ -40,6 +40,16 @@ var channels = []Channel{ {EnvVar: "MSTEAMS_CLIENT_SECRET", Prompt: "MS Teams Client Secret (from Entra app)", Secret: true}, }, }, + { + ID: "whatsapp", + Label: "WhatsApp", + Description: "QR pairing via WhatsApp Web, no public URL needed", + Icon: "\U0001F4AC", + // WhatsApp has no bot token. The session is a QR pairing captured by + // an interactive device-login flow, so there is nothing to prompt for + // here — same reason MSTEAMS_REFRESH_TOKEN is absent above. + Credentials: nil, + }, } // AllChannels returns the catalog of messaging channels in display order. diff --git a/forge-core/security/capabilities.go b/forge-core/security/capabilities.go index b210d729..9a7d06ab 100644 --- a/forge-core/security/capabilities.go +++ b/forge-core/security/capabilities.go @@ -9,6 +9,11 @@ var DefaultCapabilityBundles = map[string][]string{ // (graph.microsoft.us / microsoftgraph.chinacloudapi.cn / their respective // login hosts) via egress.allowed_domains. "msteams": {"graph.microsoft.com", "login.microsoftonline.com"}, + // WhatsApp Web multidevice. The websocket is a fixed host; media hosts are + // handed to the client at runtime by the server (mmg, mmg-fallback and + // regional media-*.cdn names), so the media side has to be a wildcard — + // pinning today's hostnames would break on the next CDN reshuffle. + "whatsapp": {"web.whatsapp.com", "*.whatsapp.net"}, } // ResolveCapabilities returns a deduplicated list of domains for the given capability names. diff --git a/forge-plugins/channels/markdown/whatsapp.go b/forge-plugins/channels/markdown/whatsapp.go new file mode 100644 index 00000000..90ebcbd9 --- /dev/null +++ b/forge-plugins/channels/markdown/whatsapp.go @@ -0,0 +1,227 @@ +package markdown + +import ( + "regexp" + "strings" +) + +// WhatsApp accepts up to 65536 characters in a text message body, but a wall +// that long is unreadable on a phone and the client truncates it behind a +// "Read more" fold. We split at 4000 — the same threshold Slack uses — so +// each chunk stays a scannable message. +const whatsappBodyLimit = 4000 + +// ToWhatsAppText converts standard markdown to WhatsApp's formatting subset. +// +// WhatsApp supports only *bold*, _italic_, ~strikethrough~, `inline code` and +// ```fenced code```. There are no headings, no links-with-text, no tables and +// no native lists. Everything else degrades to plain text rather than leaking +// raw markdown syntax at the reader: +// +// # Heading → *Heading* +// - item → • item +// [text](url) → text: url +// | a | b | → a — b +// --- → ────────── +// +// Note the asterisk inversion: markdown **bold** is WhatsApp *bold*, and +// markdown *italic* is WhatsApp _italic_. Bold is therefore converted through +// a placeholder so the italic pass can't reclaim the single asterisks it +// just produced. +func ToWhatsAppText(md string) string { + lines := strings.Split(md, "\n") + out := make([]string, 0, len(lines)) + inFence := false + + for _, line := range lines { + // Fenced code delimiters and bodies pass through verbatim — WhatsApp + // renders ``` fences natively and the contents must not be rewritten. + if strings.HasPrefix(line, "```") { + inFence = !inFence + out = append(out, line) + continue + } + if inFence { + out = append(out, line) + continue + } + + out = append(out, convertWhatsAppBlockLine(line)) + } + + return strings.Join(out, "\n") +} + +// convertWhatsAppBlockLine handles block-level elements and inline transforms +// for a single non-fenced line. +func convertWhatsAppBlockLine(line string) string { + // Table separator rows ("|---|:--:|") carry no content — drop them + // entirely rather than rendering a row of dashes. + if tableSepRe.MatchString(line) { + return "" + } + + // Table rows: "| a | b |" → "a — b". Done before the inline pass so cell + // contents are transformed but the pipes themselves are not. + if tableRowRe.MatchString(line) { + trimmed := strings.Trim(strings.TrimSpace(line), "|") + cells := strings.Split(trimmed, "|") + for i, c := range cells { + cells[i] = applyWhatsAppInline(strings.TrimSpace(c)) + } + return strings.Join(cells, " — ") + } + + // Horizontal rule. WhatsApp has no
, so draw one. + if hrRe.MatchString(line) { + return "──────────" + } + + // Headings: "# Title" → "*Title*". Inline transforms are deliberately NOT + // applied to the heading text — nesting a *bold* span inside the wrapping + // asterisks produces unbalanced markers that WhatsApp renders literally. + // Mirrors convertSlackBlockLine's handling for the same reason. + if m := headerRe.FindStringSubmatch(line); m != nil { + return "*" + m[2] + "*" + } + + // Blockquote. WhatsApp added native "> " quoting; keep the marker. + if m := blockquoteRe.FindStringSubmatch(line); m != nil { + return "> " + applyWhatsAppInline(m[1]) + } + + // Ordered list: keep the numbering, which WhatsApp renders natively. + if m := orderedListRe.FindStringSubmatch(line); m != nil { + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + num := orderedNumRe.FindString(strings.TrimSpace(line)) + return indent + num + " " + applyWhatsAppInline(m[1]) + } + + // Unordered list: "- item" → "• item". A leading "*" would otherwise be + // read as an unbalanced bold marker. Indentation is preserved so nested + // lists — common in agent output — keep their shape. + if m := whatsappBulletRe.FindStringSubmatch(line); m != nil { + return m[1] + "• " + applyWhatsAppInline(m[2]) + } + + return applyWhatsAppInline(line) +} + +// applyWhatsAppInline applies inline markdown transforms for WhatsApp text. +// +// Inline code spans are lifted out first and restored last, so their contents +// are never rewritten — a literal "**" inside `code` must survive intact. +func applyWhatsAppInline(line string) string { + line, codes := liftInlineCode(line) + + // Images before links: "![alt](url)" shares a suffix with "[text](url)", + // so running linkRe first would leave a stray "!" behind. + line = imageRe.ReplaceAllStringFunc(line, func(m string) string { + g := imageRe.FindStringSubmatch(m) + return flattenLink(g[1], g[2]) + }) + line = linkRe.ReplaceAllStringFunc(line, func(m string) string { + g := linkRe.FindStringSubmatch(m) + return flattenLink(g[1], g[2]) + }) + + // Bold: **text** → placeholder, so the italic pass below cannot match the + // single asterisks this produces. Restored after italic. + line = boldRe.ReplaceAllStringFunc(line, func(m string) string { + return "\x01" + boldRe.FindStringSubmatch(m)[1] + "\x02" + }) + + // Strikethrough: ~~text~~ → ~text~ + line = strikethroughRe.ReplaceAllString(line, "~${1}~") + + // Italic: *text* → _text_ (placeholders shield the converted bold spans) + line = italicRe.ReplaceAllString(line, "_${1}_") + + line = strings.ReplaceAll(line, "\x01", "*") + line = strings.ReplaceAll(line, "\x02", "*") + + return restoreInlineCode(line, codes) +} + +// flattenLink renders a markdown link as WhatsApp-safe text. WhatsApp +// auto-links bare URLs but has no anchor syntax, so the label has to be +// spelled out beside the target. A label identical to (or contained in) the +// URL would just be noise, so the URL alone is emitted. +func flattenLink(text, url string) string { + text = strings.TrimSpace(text) + url = strings.TrimSpace(url) + if text == "" || text == url || strings.Contains(url, text) { + return url + } + return text + ": " + url +} + +// liftInlineCode replaces each `...` span with an index sentinel and returns +// the extracted spans. The sentinel uses \x00 delimiters, which cannot occur +// in agent output that has already been through the LLM. +func liftInlineCode(line string) (string, []string) { + if !strings.Contains(line, "`") { + return line, nil + } + var codes []string + out := inlineCodeRe.ReplaceAllStringFunc(line, func(m string) string { + codes = append(codes, m) + return "\x00" + itoa(len(codes)-1) + "\x00" + }) + return out, codes +} + +// restoreInlineCode substitutes the sentinels planted by liftInlineCode back +// to their original `...` spans. +func restoreInlineCode(line string, codes []string) string { + for i, c := range codes { + line = strings.ReplaceAll(line, "\x00"+itoa(i)+"\x00", c) + } + return line +} + +// SplitMessageWhatsApp splits text into chunks that each fit within the +// WhatsApp readability limit. Prefers paragraph boundaries, then newlines, +// then hard splits — same strategy as the Teams and Slack splitters. +func SplitMessageWhatsApp(text string) []string { + return SplitMessage(text, whatsappBodyLimit) +} + +// StripWhatsAppMention removes a leading "@" or "@" the sender +// typed to invoke the agent in a group, so the prompt handed to the LLM +// doesn't start with the agent's own handle. +// +// WhatsApp mentions are wire-encoded as the mentioned party's E.164 number +// prefixed with "@" (the client substitutes the display name at render time), +// so both forms are matched. Case-insensitive, leading position only. +func StripWhatsAppMention(text string, handles ...string) string { + trimmed := strings.TrimSpace(text) + for _, h := range handles { + h = strings.TrimSpace(strings.TrimPrefix(h, "@")) + if h == "" { + continue + } + prefix := "@" + h + if !strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(prefix)) { + continue + } + stripped := strings.TrimSpace(trimmed[len(prefix):]) + return strings.TrimLeft(stripped, ":, ") + } + return text +} + +// Regexes specific to the WhatsApp text subset. The markdown package already +// defines headerRe / blockquoteRe / orderedListRe / boldRe / italicRe / +// inlineCodeRe / linkRe / strikethroughRe — reuse those. +var ( + imageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) + // whatsappBulletRe allows leading indentation, unlike the package-level + // bulletRe, so nested bullets survive the conversion. Group 1 is the + // indent, group 2 the item text. + whatsappBulletRe = regexp.MustCompile(`^([ \t]*)[*\-]\s+(.+)$`) + tableRowRe = regexp.MustCompile(`^\s*\|.*\|\s*$`) + tableSepRe = regexp.MustCompile(`^\s*\|[\s:|-]+\|\s*$`) + hrRe = regexp.MustCompile(`^\s*(?:-{3,}|\*{3,}|_{3,})\s*$`) + orderedNumRe = regexp.MustCompile(`^\d+\.`) +) diff --git a/forge-plugins/channels/markdown/whatsapp_test.go b/forge-plugins/channels/markdown/whatsapp_test.go new file mode 100644 index 00000000..11475a8f --- /dev/null +++ b/forge-plugins/channels/markdown/whatsapp_test.go @@ -0,0 +1,273 @@ +package markdown + +import ( + "strings" + "testing" +) + +func TestToWhatsAppText_Bold(t *testing.T) { + got := ToWhatsAppText("**hello** world") + want := "*hello* world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Italic(t *testing.T) { + got := ToWhatsAppText("be *brave*") + want := "be _brave_" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// The asterisk inversion is the whole risk in this converter: markdown bold +// becomes a single asterisk, which is exactly what the italic rule matches. +func TestToWhatsAppText_BoldNotReclaimedByItalic(t *testing.T) { + got := ToWhatsAppText("**bold** and *italic*") + want := "*bold* and _italic_" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Strikethrough(t *testing.T) { + got := ToWhatsAppText("~~gone~~") + want := "~gone~" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_InlineCodeUnchanged(t *testing.T) { + got := ToWhatsAppText("call `foo()` here") + want := "call `foo()` here" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// Markdown syntax inside a code span is literal and must survive untouched. +func TestToWhatsAppText_InlineCodeProtectsMarkers(t *testing.T) { + got := ToWhatsAppText("use `a **b** c` verbatim") + want := "use `a **b** c` verbatim" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_MultipleInlineCodeSpans(t *testing.T) { + got := ToWhatsAppText("`one` then **bold** then `two`") + want := "`one` then *bold* then `two`" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_FencedCodeVerbatim(t *testing.T) { + in := "```go\nx := **notbold**\n```" + got := ToWhatsAppText(in) + if got != in { + t.Errorf("fenced code must pass through verbatim: got %q, want %q", got, in) + } +} + +func TestToWhatsAppText_Heading(t *testing.T) { + got := ToWhatsAppText("# Title") + want := "*Title*" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_HeadingAllLevels(t *testing.T) { + got := ToWhatsAppText("### Deep") + want := "*Deep*" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Bullets(t *testing.T) { + got := ToWhatsAppText("- one\n* two") + want := "• one\n• two" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_NestedBulletsKeepIndent(t *testing.T) { + got := ToWhatsAppText("- top\n - nested") + want := "• top\n • nested" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_OrderedListKeepsNumbering(t *testing.T) { + got := ToWhatsAppText("1. first\n2. second") + want := "1. first\n2. second" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Blockquote(t *testing.T) { + got := ToWhatsAppText("> quoted **text**") + want := "> quoted *text*" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Link(t *testing.T) { + got := ToWhatsAppText("see [the docs](https://example.com/x)") + want := "see the docs: https://example.com/x" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// A label that merely repeats the URL adds nothing — emit the URL alone. +func TestToWhatsAppText_LinkLabelSameAsURL(t *testing.T) { + got := ToWhatsAppText("[https://example.com](https://example.com)") + want := "https://example.com" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Image(t *testing.T) { + got := ToWhatsAppText("![a chart](https://example.com/c.png)") + want := "a chart: https://example.com/c.png" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// Images must be flattened before links, or the "!" is orphaned. +func TestToWhatsAppText_ImageLeavesNoStrayBang(t *testing.T) { + got := ToWhatsAppText("![alt](u)") + if strings.Contains(got, "!") { + t.Errorf("stray %q in image conversion: %q", "!", got) + } +} + +func TestToWhatsAppText_Table(t *testing.T) { + in := "| Name | Count |\n|------|-------|\n| foo | 3 |" + got := ToWhatsAppText(in) + want := "Name — Count\n\nfoo — 3" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_TableCellsGetInlineTransforms(t *testing.T) { + got := ToWhatsAppText("| **a** | b |") + want := "*a* — b" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_HorizontalRule(t *testing.T) { + got := ToWhatsAppText("---") + want := "──────────" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_PlainTextUnchanged(t *testing.T) { + got := ToWhatsAppText("nothing special here") + want := "nothing special here" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Empty(t *testing.T) { + if got := ToWhatsAppText(""); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +// No output path may leak raw markdown bold/link syntax at the reader. +func TestToWhatsAppText_NoRawMarkdownLeaks(t *testing.T) { + in := "# H\n\n**b** _i_ [t](u)\n\n- x\n\n| a | b |\n|---|---|\n| 1 | 2 |" + got := ToWhatsAppText(in) + for _, bad := range []string{"**", "](", "|---|"} { + if strings.Contains(got, bad) { + t.Errorf("output leaks %q: %q", bad, got) + } + } +} + +func TestSplitMessageWhatsApp_ShortStaysWhole(t *testing.T) { + chunks := SplitMessageWhatsApp("short") + if len(chunks) != 1 || chunks[0] != "short" { + t.Errorf("got %v, want single chunk", chunks) + } +} + +func TestSplitMessageWhatsApp_SplitsAtLimit(t *testing.T) { + long := strings.Repeat("a", whatsappBodyLimit+100) + chunks := SplitMessageWhatsApp(long) + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks, got %d", len(chunks)) + } + for i, c := range chunks { + if len(c) > whatsappBodyLimit { + t.Errorf("chunk %d over limit: %d > %d", i, len(c), whatsappBodyLimit) + } + } +} + +func TestStripWhatsAppMention_ByNumber(t *testing.T) { + got := StripWhatsAppMention("@14155550100 what is the status?", "14155550100", "forge") + want := "what is the status?" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStripWhatsAppMention_ByDisplayName(t *testing.T) { + got := StripWhatsAppMention("@Forge Bot: deploy please", "14155550100", "Forge Bot") + want := "deploy please" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStripWhatsAppMention_CaseInsensitive(t *testing.T) { + got := StripWhatsAppMention("@forge bot ping", "Forge Bot") + want := "ping" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// A mention in the middle of a sentence is part of the prompt, not an +// invocation prefix — leave it alone. +func TestStripWhatsAppMention_OnlyLeading(t *testing.T) { + in := "tell @forge to stop" + if got := StripWhatsAppMention(in, "forge"); got != in { + t.Errorf("got %q, want unchanged %q", got, in) + } +} + +func TestStripWhatsAppMention_NoMatch(t *testing.T) { + in := "plain message" + if got := StripWhatsAppMention(in, "forge"); got != in { + t.Errorf("got %q, want unchanged %q", got, in) + } +} + +func TestStripWhatsAppMention_EmptyHandlesIgnored(t *testing.T) { + in := "@forge hello" + got := StripWhatsAppMention(in, "", " ", "forge") + want := "hello" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/forge-plugins/channels/whatsapp/admission.go b/forge-plugins/channels/whatsapp/admission.go new file mode 100644 index 00000000..738c172b --- /dev/null +++ b/forge-plugins/channels/whatsapp/admission.go @@ -0,0 +1,222 @@ +package whatsapp + +// AdmitMode is the inbound message gating policy. +type AdmitMode string + +const ( + // AdmitDM admits only 1:1 chat messages; every group message is dropped. + AdmitDM AdmitMode = "dm" + // AdmitGroupMention admits only group messages that @-mention the agent. + AdmitGroupMention AdmitMode = "group_mention" + // AdmitDMOrGroupMention admits 1:1 messages and @-mentions in groups. This + // is the default: an agent sitting in a group must not answer every line + // of an unrelated human conversation. + AdmitDMOrGroupMention AdmitMode = "dm_or_group_mention" +) + +// admissionConfig is the resolved gating policy, built once at Init. +type admissionConfig struct { + Mode AdmitMode + // AllowedGroups restricts which groups the agent acts in. Empty = every + // group, still subject to Mode. + AllowedGroups map[string]bool + // AllowedSenders restricts who may invoke the agent. Empty means OWNER + // ONLY: the paired account is the sole permitted sender. Anyone with the + // number can otherwise reach the agent, spend its LLM budget and use + // whatever it can access, so the closed default is the safe one. + // AllowAnySender opens it up explicitly. + AllowedSenders map[string]bool + // AllowAnySender disables the sender allowlist entirely (`allowed_senders: + // anyone`). Deliberately verbose: it is the setting that exposes the agent + // to the whole world. + AllowAnySender bool + // SelfChat accepts messages the owner sends in their own "Message + // Yourself" chat, so the paired phone can talk to its own agent without a + // second account. + SelfChat bool + // OwnJIDs are the paired account's identities (phone JID, and LID when + // known). Used to recognise the self-chat and the owner as a sender. + OwnJIDs []string +} + +// isOwner reports whether either of a sender's identities is the paired +// account. +func (c admissionConfig) isOwner(senderJID, senderAlt string) bool { + for _, own := range c.OwnJIDs { + if own == "" { + continue + } + n := NormalizeJID(own) + if n == NormalizeJID(senderJID) || (senderAlt != "" && n == NormalizeJID(senderAlt)) { + return true + } + } + return false +} + +// isSelfChat reports whether chatJID is the owner's own chat — WhatsApp's +// "Message Yourself" conversation, whose JID is the account's own. +func (c admissionConfig) isSelfChat(chatJID string) bool { + if IsGroupJID(chatJID) { + return false + } + for _, own := range c.OwnJIDs { + if own != "" && NormalizeJID(own) == NormalizeJID(chatJID) { + return true + } + } + return false +} + +// admissionResult is the verdict the gate returns to the caller. When admit +// is false, reason is the structured log line the caller should emit at DEBUG +// so operators can diagnose silent drops. +type admissionResult struct { + admit bool + reason string +} + +// admit applies the admission gate to one inbound message. The order matters: +// +// 1. Conversation kind — newsletters, broadcast lists and the status feed are +// never answerable. +// 2. Self-loop — our own outbound echo. +// 3. Sender allowlist. +// 4. Group allowlist. +// 5. Mode filter. +// +// Dedup is the caller's responsibility: it must run for ALL inbound messages, +// not only admitted ones, or a re-delivered message that was dropped the first +// time can slip through on a later pass. +// +// Unlike the Teams adapter, the self-loop check here is authoritative. +// whatsmeow sets IsFromMe on anything sent by the paired account, including +// messages this adapter sent, so it distinguishes the agent's own output from +// a human's on the same account. +// +// senderAlt is whatsmeow's alternate address for the sender (MessageSource. +// SenderAlt): a phone-number sender carries its LID there and a LID sender +// carries its phone number. Both are checked against the allowlist so a group +// that has migrated to hidden numbers doesn't lock out an operator who wrote +// the list in phone numbers. Pass "" when there is no alternate. +func admit(chatJID, senderJID, senderAlt string, isFromMe, mentioned bool, cfg admissionConfig) admissionResult { + // 1. Conversation kind. + if !IsConversationJID(chatJID) { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping non-conversation message (chat=" + chatJID + "); newsletters, broadcast lists and status are not answerable", + } + } + + selfChat := cfg.isSelfChat(chatJID) + + // 2. Own messages. + // + // In the owner's self-chat these are the whole point: the paired phone + // talks to its own agent, so IsFromMe is how the prompt arrives. The + // agent's OWN replies are also IsFromMe there, and the loop guard for + // those is the dedup ring, which marks every outbound id before + // SendResponse returns and runs before this gate. Anywhere else, an own + // message is our echo and answering it would recurse until rate-limited. + if isFromMe { + if !cfg.SelfChat || !selfChat { + return admissionResult{admit: false, reason: "whatsapp: dropping own outbound message (is_from_me)"} + } + } + + // 3. Sender allowlist. The owner always passes — in the self-chat there is + // no one else, and locking the paired account out of its own agent would + // be nonsense. + if !cfg.AllowAnySender && !cfg.isOwner(senderJID, senderAlt) { + if len(cfg.AllowedSenders) == 0 { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping non-owner sender (" + senderJID + ") — allowed_senders is empty, which means owner-only; list the number, or set allowed_senders: anyone to open the agent up", + } + } + if !senderAllowed(senderJID, senderAlt, cfg.AllowedSenders) { + if IsLIDJID(senderJID) && senderAlt == "" { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping hidden-number sender (lid=" + senderJID + ") — allowed_senders is keyed on phone numbers and no phone-number alternate was supplied; add the LID itself to admit this sender", + } + } + return admissionResult{ + admit: false, + reason: "whatsapp: dropping sender not in allowed_senders (sender=" + senderJID + ")", + } + } + } + + isGroup := IsGroupJID(chatJID) + + // 4. Group allowlist. + if isGroup && len(cfg.AllowedGroups) > 0 && !cfg.AllowedGroups[NormalizeJID(chatJID)] { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping group not in allowed_groups (chat=" + chatJID + ")", + } + } + + // 5. Mode filter. + switch cfg.Mode { + case AdmitDM: + if isGroup { + return admissionResult{admit: false, reason: "whatsapp: dropping group message (admit=dm)"} + } + case AdmitGroupMention: + if !isGroup { + return admissionResult{admit: false, reason: "whatsapp: dropping dm (admit=group_mention)"} + } + if !mentioned { + return admissionResult{admit: false, reason: "whatsapp: dropping non-mention group message (admit=group_mention)"} + } + case AdmitDMOrGroupMention: + if isGroup && !mentioned { + return admissionResult{admit: false, reason: "whatsapp: dropping non-mention group message (admit=dm_or_group_mention)"} + } + default: + // Unknown mode — fall back to dm_or_group_mention semantics rather + // than admitting everything. + if isGroup && !mentioned { + return admissionResult{admit: false, reason: "whatsapp: dropping non-mention group message under default dm_or_group_mention gate"} + } + } + + return admissionResult{admit: true} +} + +// senderAllowed reports whether either of a sender's identities is listed. +func senderAllowed(senderJID, senderAlt string, allowed map[string]bool) bool { + if allowed[NormalizeJID(senderJID)] { + return true + } + return senderAlt != "" && allowed[NormalizeJID(senderAlt)] +} + +// isMentioned reports whether ownJID appears in a message's mention list. +// +// WhatsApp carries mentions out-of-band in contextInfo.mentionedJid rather +// than as markup in the body, so this is an exact JID-set membership test, not +// a text scan. Comparison is on the normalized JID because the mention list +// holds bare user JIDs while the paired account may be device-qualified. +func isMentioned(mentionedJIDs []string, ownJIDs ...string) bool { + if len(mentionedJIDs) == 0 { + return false + } + own := make(map[string]bool, len(ownJIDs)) + for _, j := range ownJIDs { + if j = NormalizeJID(j); j != "" { + own[j] = true + } + } + if len(own) == 0 { + return false + } + for _, m := range mentionedJIDs { + if own[NormalizeJID(m)] { + return true + } + } + return false +} diff --git a/forge-plugins/channels/whatsapp/admission_test.go b/forge-plugins/channels/whatsapp/admission_test.go new file mode 100644 index 00000000..40e1334d --- /dev/null +++ b/forge-plugins/channels/whatsapp/admission_test.go @@ -0,0 +1,361 @@ +package whatsapp + +import ( + "strings" + "testing" +) + +const ( + testDM = "14155550100@s.whatsapp.net" + testGroup = "120363000000000000@g.us" + testSender = "14155550100@s.whatsapp.net" + testOwn = "14155550999@s.whatsapp.net" +) + +const testOwnJID = "14155550999@s.whatsapp.net" + +// defaultCfg is the open-sender baseline most of these cases assume. The +// shipped default is owner-only; ownerOnlyCfg covers that. +func defaultCfg() admissionConfig { + return admissionConfig{ + Mode: AdmitDMOrGroupMention, + AllowAnySender: true, + SelfChat: true, + OwnJIDs: []string{testOwnJID}, + } +} + +// ownerOnlyCfg is the shipped default: no allowlist entries, no opt-out. +func ownerOnlyCfg() admissionConfig { + return admissionConfig{ + Mode: AdmitDMOrGroupMention, + SelfChat: true, + OwnJIDs: []string{testOwnJID}, + } +} + +func TestAdmit_DMAdmittedByDefault(t *testing.T) { + got := admit(testDM, testSender, "", false, false, defaultCfg()) + if !got.admit { + t.Errorf("expected DM admitted, got drop: %s", got.reason) + } +} + +func TestAdmit_GroupMentionAdmittedByDefault(t *testing.T) { + got := admit(testGroup, testSender, "", false, true, defaultCfg()) + if !got.admit { + t.Errorf("expected mentioned group message admitted, got drop: %s", got.reason) + } +} + +func TestAdmit_GroupWithoutMentionDropped(t *testing.T) { + got := admit(testGroup, testSender, "", false, false, defaultCfg()) + if got.admit { + t.Error("expected non-mention group message dropped") + } + if !strings.Contains(got.reason, "non-mention") { + t.Errorf("reason should name the gate, got %q", got.reason) + } +} + +// The agent answering its own output recurses until the server rate-limits it. +func TestAdmit_SelfLoopDropped(t *testing.T) { + got := admit(testDM, testOwn, "", true, false, defaultCfg()) + if got.admit { + t.Error("expected own outbound message dropped") + } + if !strings.Contains(got.reason, "is_from_me") { + t.Errorf("reason should name the self-loop guard, got %q", got.reason) + } +} + +// Replying into a newsletter or the status feed either fails or fans out to +// every contact — neither is ever admissible. +func TestAdmit_NonConversationJIDsDropped(t *testing.T) { + for _, chat := range []string{"123@newsletter", "status@broadcast", "list@broadcast"} { + got := admit(chat, testSender, "", false, true, defaultCfg()) + if got.admit { + t.Errorf("expected %q dropped", chat) + } + if !strings.Contains(got.reason, "non-conversation") { + t.Errorf("reason for %q should name the kind gate, got %q", chat, got.reason) + } + } +} + +func TestAdmit_ModeDM_DropsGroups(t *testing.T) { + cfg := admissionConfig{Mode: AdmitDM, AllowAnySender: true, OwnJIDs: []string{testOwnJID}} + if got := admit(testGroup, testSender, "", false, true, cfg); got.admit { + t.Error("expected group dropped under admit=dm even when mentioned") + } + if got := admit(testDM, testSender, "", false, false, cfg); !got.admit { + t.Errorf("expected DM admitted under admit=dm, got drop: %s", got.reason) + } +} + +func TestAdmit_ModeGroupMention_DropsDMs(t *testing.T) { + cfg := admissionConfig{Mode: AdmitGroupMention, AllowAnySender: true, OwnJIDs: []string{testOwnJID}} + if got := admit(testDM, testSender, "", false, false, cfg); got.admit { + t.Error("expected DM dropped under admit=group_mention") + } + if got := admit(testGroup, testSender, "", false, true, cfg); !got.admit { + t.Errorf("expected mentioned group message admitted, got drop: %s", got.reason) + } + if got := admit(testGroup, testSender, "", false, false, cfg); got.admit { + t.Error("expected non-mention group message dropped under admit=group_mention") + } +} + +// An unrecognised mode must not open the gate wider than the default. +func TestAdmit_UnknownModeFallsBackToDefault(t *testing.T) { + cfg := admissionConfig{Mode: AdmitMode("nonsense"), AllowAnySender: true, OwnJIDs: []string{testOwnJID}} + if got := admit(testGroup, testSender, "", false, false, cfg); got.admit { + t.Error("expected unknown mode to keep the non-mention group gate closed") + } + if got := admit(testDM, testSender, "", false, false, cfg); !got.admit { + t.Errorf("expected unknown mode to still admit DMs, got drop: %s", got.reason) + } +} + +func TestAdmit_SenderAllowlist(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + + if got := admit(testDM, "14155550100@s.whatsapp.net", "", false, false, cfg); !got.admit { + t.Errorf("expected listed sender admitted, got drop: %s", got.reason) + } + if got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, cfg); got.admit { + t.Error("expected unlisted sender dropped") + } +} + +// A sender on a second linked device must still match a bare-number entry. +func TestAdmit_SenderAllowlistIgnoresDeviceSuffix(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + if got := admit(testDM, "14155550100:4@s.whatsapp.net", "", false, false, cfg); !got.admit { + t.Errorf("expected device-qualified sender admitted, got drop: %s", got.reason) + } +} + +// A phone-number allowlist can never match a hidden-number sender. Fail closed +// with a reason that says so, rather than one that reads like a plain miss. +func TestAdmit_LIDSenderFailsClosedWithExplanation(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + got := admit(testGroup, "98765@lid", "", false, true, cfg) + if got.admit { + t.Error("expected LID sender dropped against a number-keyed allowlist") + } + if !strings.Contains(got.reason, "lid") { + t.Errorf("reason should explain the LID mismatch, got %q", got.reason) + } +} + +// A group that has migrated to hidden numbers reports a LID sender with the +// phone number in SenderAlt. An operator who wrote the allowlist in phone +// numbers must not be locked out by that migration. +func TestAdmit_LIDSenderMatchesViaPhoneAlternate(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + got := admit(testGroup, "98765@lid", "14155550100@s.whatsapp.net", false, true, cfg) + if !got.admit { + t.Errorf("expected LID sender admitted via phone alternate, got drop: %s", got.reason) + } +} + +// The reverse migration: allowlist written in LIDs, sender arrives by number. +func TestAdmit_PhoneSenderMatchesViaLIDAlternate(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("98765@lid", serverUser) + got := admit(testGroup, "14155550100@s.whatsapp.net", "98765@lid", false, true, cfg) + if !got.admit { + t.Errorf("expected sender admitted via LID alternate, got drop: %s", got.reason) + } +} + +// An alternate that is itself unlisted must not widen the gate. +func TestAdmit_UnlistedAlternateStillDropped(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + got := admit(testGroup, "98765@lid", "14155550777@s.whatsapp.net", false, true, cfg) + if got.admit { + t.Error("expected drop when neither identity is listed") + } +} + +// With no sender allowlist configured, a LID sender is ordinary traffic. +func TestAdmit_LIDSenderAdmittedWithoutAllowlist(t *testing.T) { + if got := admit(testGroup, "98765@lid", "", false, true, defaultCfg()); !got.admit { + t.Errorf("expected LID sender admitted with no allowlist, got drop: %s", got.reason) + } +} + +func TestAdmit_GroupAllowlist(t *testing.T) { + cfg := defaultCfg() + cfg.AllowedGroups = parseJIDSet("120363000000000000", serverGroup) + + if got := admit(testGroup, testSender, "", false, true, cfg); !got.admit { + t.Errorf("expected listed group admitted, got drop: %s", got.reason) + } + if got := admit("120363000000000009@g.us", testSender, "", false, true, cfg); got.admit { + t.Error("expected unlisted group dropped") + } +} + +// The group allowlist gates groups only; it must not silently block DMs. +func TestAdmit_GroupAllowlistDoesNotAffectDMs(t *testing.T) { + cfg := defaultCfg() + cfg.AllowedGroups = parseJIDSet("120363000000000000", serverGroup) + if got := admit(testDM, testSender, "", false, false, cfg); !got.admit { + t.Errorf("expected DM admitted despite group allowlist, got drop: %s", got.reason) + } +} + +func TestIsMentioned(t *testing.T) { + own := "14155550999@s.whatsapp.net" + + if !isMentioned([]string{"14155550100@s.whatsapp.net", own}, own) { + t.Error("expected mention detected") + } + if isMentioned([]string{"14155550100@s.whatsapp.net"}, own) { + t.Error("expected no mention when own JID absent") + } + if isMentioned(nil, own) { + t.Error("expected no mention for empty list") + } + if isMentioned([]string{own}) { + t.Error("expected no mention when no own JID supplied") + } +} + +// The mention list holds bare JIDs while the paired account is device- +// qualified; comparison has to normalize both sides. +func TestIsMentioned_NormalizesBothSides(t *testing.T) { + if !isMentioned([]string{"14155550999@s.whatsapp.net"}, "14155550999:12@s.whatsapp.net") { + t.Error("expected device-qualified own JID to match a bare mention") + } +} + +// A LID-migrated group reports the agent's LID, not its number — both +// identities must be accepted. +func TestIsMentioned_MatchesEitherIdentity(t *testing.T) { + if !isMentioned([]string{"55555@lid"}, "14155550999@s.whatsapp.net", "55555@lid") { + t.Error("expected LID mention to match the agent's LID identity") + } +} + +// --- self-chat (the personal-agent flow) --- + +// The owner's "Message Yourself" chat: the chat JID is the owner's own, and +// the prompt arrives with IsFromMe set. This is the whole feature. +func TestAdmit_SelfChatAcceptsOwnMessage(t *testing.T) { + got := admit(testOwnJID, testOwnJID, "", true, false, ownerOnlyCfg()) + if !got.admit { + t.Errorf("expected own message admitted in the self-chat, got drop: %s", got.reason) + } +} + +func TestAdmit_SelfChatDisabledDropsOwnMessage(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.SelfChat = false + got := admit(testOwnJID, testOwnJID, "", true, false, cfg) + if got.admit { + t.Error("expected own message dropped when self_chat is off") + } +} + +// Outside the self-chat an own message is our echo. Answering it recurses. +func TestAdmit_OwnMessageElsewhereStillDropped(t *testing.T) { + cfg := ownerOnlyCfg() + for _, chat := range []string{testDM, testGroup} { + if got := admit(chat, testOwnJID, "", true, true, cfg); got.admit { + t.Errorf("expected own message dropped in %q", chat) + } + } +} + +// A group is never a self-chat, even one the owner created. +func TestAdmit_SelfChatNeverAppliesToGroups(t *testing.T) { + cfg := ownerOnlyCfg() + if cfg.isSelfChat(testGroup) { + t.Error("a group must never count as the self-chat") + } +} + +// The owner's LID is also their identity, so the self-chat must resolve +// under it too. +func TestAdmit_SelfChatMatchesLIDIdentity(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.OwnJIDs = []string{testOwnJID, "55555@lid"} + if got := admit("55555@lid", "55555@lid", "", true, false, cfg); !got.admit { + t.Errorf("expected self-chat under the LID identity admitted, got drop: %s", got.reason) + } +} + +// --- owner-only default --- + +// The shipped default must not let a stranger with the number reach the agent. +func TestAdmit_EmptyAllowlistMeansOwnerOnly(t *testing.T) { + cfg := ownerOnlyCfg() + + if got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, cfg); got.admit { + t.Error("expected a stranger dropped under the owner-only default") + } + if got := admit(testDM, testOwnJID, "", false, false, cfg); !got.admit { + t.Errorf("expected the owner admitted, got drop: %s", got.reason) + } +} + +// The drop reason must explain the default, not read like a bare miss. +func TestAdmit_OwnerOnlyReasonIsActionable(t *testing.T) { + got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, ownerOnlyCfg()) + for _, want := range []string{"owner-only", "allowed_senders"} { + if !strings.Contains(got.reason, want) { + t.Errorf("reason should mention %q, got %q", want, got.reason) + } + } +} + +func TestAdmit_AllowAnySenderOpensItUp(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.AllowAnySender = true + if got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, cfg); !got.admit { + t.Errorf("expected any sender admitted with the opt-out, got drop: %s", got.reason) + } +} + +// Listing others must not lock the owner out of their own agent. +func TestAdmit_OwnerPassesEvenWhenNotListed(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + if got := admit(testDM, testOwnJID, "", false, false, cfg); !got.admit { + t.Errorf("owner must always pass, got drop: %s", got.reason) + } +} + +func TestAdmit_OwnerMatchedViaAlternateIdentity(t *testing.T) { + cfg := ownerOnlyCfg() + if got := admit(testGroup, "99999@lid", testOwnJID, false, true, cfg); !got.admit { + t.Errorf("owner should be recognised via SenderAlt, got drop: %s", got.reason) + } +} + +func TestIsAnySender(t *testing.T) { + for _, s := range []string{"anyone", "any", "*", " ANYONE ", "Any"} { + if !isAnySender(s) { + t.Errorf("isAnySender(%q) = false, want true", s) + } + } + for _, s := range []string{"", " ", "+14155550100", "anyone@s.whatsapp.net"} { + if isAnySender(s) { + t.Errorf("isAnySender(%q) = true, want false", s) + } + } +} diff --git a/forge-plugins/channels/whatsapp/dedup.go b/forge-plugins/channels/whatsapp/dedup.go new file mode 100644 index 00000000..35030bd2 --- /dev/null +++ b/forge-plugins/channels/whatsapp/dedup.go @@ -0,0 +1,94 @@ +package whatsapp + +import "sync" + +// dedup is a sliding-window deduplicator for WhatsApp message IDs. +// +// Two inbound paths can deliver the same message twice: a history sync after +// reconnect replays recent messages, and an unacknowledged delivery is retried +// by the server. The ring filters both so the agent isn't invoked twice for +// one prompt. +// +// It also carries the outbound echo guard. Every message this adapter sends is +// marked before the send returns, so if it comes back through history sync it +// is dropped before admission. That is belt-and-braces alongside the IsFromMe +// check in admit() — IsFromMe covers the live path, the ring covers replay, +// where a message sent by the human operator on their own phone and one sent +// by the agent are otherwise indistinguishable. +// +// Capacity defaults to 1000 entries. Evicts the oldest entry when full. +// All operations are safe for concurrent use. +type dedup struct { + mu sync.Mutex + cap int + order []string // insertion order — order[head] is the oldest + head int // index of the next slot to overwrite + set map[string]bool // membership lookup +} + +func newDedup(capacity int) *dedup { + if capacity <= 0 { + capacity = 1000 + } + return &dedup{ + cap: capacity, + order: make([]string, 0, capacity), + set: make(map[string]bool, capacity), + } +} + +// seen reports whether id was previously marked. +func (d *dedup) seen(id string) bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.set[id] +} + +// mark records id and evicts the oldest entry if the ring is full. +func (d *dedup) mark(id string) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.set[id] { + return + } + + if len(d.order) < d.cap { + d.order = append(d.order, id) + } else { + // Evict the entry at head, then overwrite. + delete(d.set, d.order[d.head]) + d.order[d.head] = id + d.head = (d.head + 1) % d.cap + } + d.set[id] = true +} + +// markSeen records id and reports whether it had already been marked. It is +// the single-call form of seen-then-mark, so two goroutines racing on the same +// redelivered message cannot both observe it as new. +func (d *dedup) markSeen(id string) bool { + d.mu.Lock() + defer d.mu.Unlock() + + if d.set[id] { + return true + } + + if len(d.order) < d.cap { + d.order = append(d.order, id) + } else { + delete(d.set, d.order[d.head]) + d.order[d.head] = id + d.head = (d.head + 1) % d.cap + } + d.set[id] = true + return false +} + +// size returns the current number of tracked IDs (for tests). +func (d *dedup) size() int { + d.mu.Lock() + defer d.mu.Unlock() + return len(d.order) +} diff --git a/forge-plugins/channels/whatsapp/dedup_test.go b/forge-plugins/channels/whatsapp/dedup_test.go new file mode 100644 index 00000000..ab83e0d9 --- /dev/null +++ b/forge-plugins/channels/whatsapp/dedup_test.go @@ -0,0 +1,122 @@ +package whatsapp + +import ( + "strconv" + "sync" + "testing" +) + +func TestDedup_MarkAndSeen(t *testing.T) { + d := newDedup(10) + if d.seen("a") { + t.Error("expected unseen id") + } + d.mark("a") + if !d.seen("a") { + t.Error("expected id seen after mark") + } +} + +func TestDedup_MarkIsIdempotent(t *testing.T) { + d := newDedup(10) + d.mark("a") + d.mark("a") + if d.size() != 1 { + t.Errorf("expected size 1 after duplicate mark, got %d", d.size()) + } +} + +func TestDedup_EvictsOldestWhenFull(t *testing.T) { + d := newDedup(3) + d.mark("a") + d.mark("b") + d.mark("c") + d.mark("d") // evicts "a" + + if d.seen("a") { + t.Error("expected oldest entry evicted") + } + for _, id := range []string{"b", "c", "d"} { + if !d.seen(id) { + t.Errorf("expected %q retained", id) + } + } + if d.size() != 3 { + t.Errorf("expected size capped at 3, got %d", d.size()) + } +} + +func TestDedup_DefaultCapacity(t *testing.T) { + for _, c := range []int{0, -1} { + d := newDedup(c) + for i := range 1000 { + d.mark(strconv.Itoa(i)) + } + if d.size() != 1000 { + t.Errorf("newDedup(%d): expected default capacity 1000, got size %d", c, d.size()) + } + } +} + +func TestDedup_MarkSeenReportsPriorState(t *testing.T) { + d := newDedup(10) + if d.markSeen("a") { + t.Error("expected first markSeen to report unseen") + } + if !d.markSeen("a") { + t.Error("expected second markSeen to report seen") + } + if d.size() != 1 { + t.Errorf("expected size 1, got %d", d.size()) + } +} + +// Two goroutines racing on the same redelivered message must not both treat it +// as new — exactly one may win. +func TestDedup_MarkSeenIsAtomicUnderRace(t *testing.T) { + d := newDedup(100) + const goroutines = 50 + + var wg sync.WaitGroup + var mu sync.Mutex + newCount := 0 + + for range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + if !d.markSeen("same-id") { + mu.Lock() + newCount++ + mu.Unlock() + } + }() + } + wg.Wait() + + if newCount != 1 { + t.Errorf("expected exactly one goroutine to observe the id as new, got %d", newCount) + } +} + +func TestDedup_ConcurrentMarkAndSeen(t *testing.T) { + d := newDedup(500) + var wg sync.WaitGroup + + for i := range 200 { + wg.Add(2) + go func() { + defer wg.Done() + d.mark(strconv.Itoa(i)) + }() + go func() { + defer wg.Done() + _ = d.seen(strconv.Itoa(i)) + }() + } + wg.Wait() + + if d.size() != 200 { + t.Errorf("expected 200 distinct ids, got %d", d.size()) + } +} diff --git a/forge-plugins/channels/whatsapp/history.go b/forge-plugins/channels/whatsapp/history.go new file mode 100644 index 00000000..bf3689d4 --- /dev/null +++ b/forge-plugins/channels/whatsapp/history.go @@ -0,0 +1,151 @@ +package whatsapp + +import ( + "fmt" + "strings" + "sync" + "time" +) + +// maxTrackedChats bounds how many conversations the history ring holds before +// evicting the least recently active. Without it, an account in many groups +// grows the ring without limit. +const maxTrackedChats = 200 + +// historyEntry is one observed message, kept for conversational context. +type historyEntry struct { + ID string + Author string + Text string + At time.Time +} + +// chatHistory is a bounded, in-memory record of recently observed messages, +// keyed by chat JID. +// +// This exists because WhatsApp has no server-side "fetch recent messages" call +// the way Microsoft Graph does (/chats/{id}/messages). Prior history reaches a +// linked device only through a history-sync push at pairing time, and +// whatsmeow does not retain it. So the adapter accumulates its own window from +// the traffic it sees. +// +// The consequence is worth stating plainly: context covers messages observed +// SINCE THE ADAPTER STARTED, and is lost on restart. An agent restarted +// mid-conversation will not see what came before. That is a real limitation of +// the protocol, not a shortcut — persisting it would mean writing a message +// store, which is out of scope for the channel adapter. +type chatHistory struct { + mu sync.Mutex + perChat map[string][]historyEntry + touched map[string]time.Time + max int +} + +func newChatHistory(maxPerChat int) *chatHistory { + if maxPerChat <= 0 { + maxPerChat = 20 + } + return &chatHistory{ + perChat: make(map[string][]historyEntry), + touched: make(map[string]time.Time), + max: maxPerChat, + } +} + +// record appends an entry to a chat's window, trimming the oldest beyond the +// per-chat cap and evicting the least recently active chat when tracking too +// many. +func (h *chatHistory) record(chat string, e historyEntry) { + if chat == "" || strings.TrimSpace(e.Text) == "" { + return + } + h.mu.Lock() + defer h.mu.Unlock() + + entries := append(h.perChat[chat], e) + if len(entries) > h.max { + entries = entries[len(entries)-h.max:] + } + h.perChat[chat] = entries + h.touched[chat] = time.Now() + + if len(h.perChat) > maxTrackedChats { + h.evictOldestLocked() + } +} + +// evictOldestLocked drops the least recently active chat. Caller holds mu. +func (h *chatHistory) evictOldestLocked() { + var oldest string + var oldestAt time.Time + for chat, at := range h.touched { + if oldest == "" || at.Before(oldestAt) { + oldest, oldestAt = chat, at + } + } + if oldest != "" { + delete(h.perChat, oldest) + delete(h.touched, oldest) + } +} + +// recent returns up to n of a chat's most recent entries, oldest first. +func (h *chatHistory) recent(chat string, n int) []historyEntry { + h.mu.Lock() + defer h.mu.Unlock() + + entries := h.perChat[chat] + if n <= 0 || len(entries) == 0 { + return nil + } + if len(entries) > n { + entries = entries[len(entries)-n:] + } + out := make([]historyEntry, len(entries)) + copy(out, entries) + return out +} + +// prependHistory formats a chat's recent messages chronologically and prepends +// them as a context block before the user's current prompt. skipID drops the +// current message so it isn't duplicated inside its own context. +// +// The block is soft-capped at historySoftCap characters, counted from the most +// recent message backwards, so a chatty group can't crowd out the prompt. +func prependHistory(entries []historyEntry, skipID, prompt string) string { + if len(entries) == 0 { + return prompt + } + + // Walk newest→oldest accumulating under the cap, then emit oldest→newest. + var kept []string + total := 0 + for i := len(entries) - 1; i >= 0; i-- { + e := entries[i] + if e.ID == skipID { + continue + } + text := strings.TrimSpace(e.Text) + if text == "" { + continue + } + line := fmt.Sprintf("%s %s: %s\n", e.At.Format("01-02 15:04"), e.Author, text) + if total+len(line) > historySoftCap { + break + } + total += len(line) + kept = append(kept, line) + } + if len(kept) == 0 { + return prompt + } + + var b strings.Builder + b.WriteString("[Recent chat history for context — most recent message at the bottom:]\n") + for i := len(kept) - 1; i >= 0; i-- { + b.WriteString(kept[i]) + } + b.WriteString("[End of history. The user's current message follows:]\n\n") + b.WriteString(prompt) + return b.String() +} diff --git a/forge-plugins/channels/whatsapp/history_test.go b/forge-plugins/channels/whatsapp/history_test.go new file mode 100644 index 00000000..4bd4a364 --- /dev/null +++ b/forge-plugins/channels/whatsapp/history_test.go @@ -0,0 +1,196 @@ +package whatsapp + +import ( + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func entry(id, author, text string) historyEntry { + return historyEntry{ID: id, Author: author, Text: text, At: time.Date(2026, 9, 9, 14, 30, 0, 0, time.UTC)} +} + +func TestChatHistory_RecordAndRecent(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", "hello")) + h.record("chat1", entry("2", "bob", "hi")) + + got := h.recent("chat1", 5) + if len(got) != 2 { + t.Fatalf("expected 2 entries, got %d", len(got)) + } + if got[0].Text != "hello" || got[1].Text != "hi" { + t.Errorf("expected oldest-first order, got %v", got) + } +} + +func TestChatHistory_IsolatedPerChat(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", "in one")) + h.record("chat2", entry("2", "bob", "in two")) + + if got := h.recent("chat1", 5); len(got) != 1 || got[0].Text != "in one" { + t.Errorf("chat1 leaked: %v", got) + } + if got := h.recent("chat2", 5); len(got) != 1 || got[0].Text != "in two" { + t.Errorf("chat2 leaked: %v", got) + } +} + +func TestChatHistory_TrimsToPerChatCap(t *testing.T) { + h := newChatHistory(3) + for i := range 10 { + h.record("chat1", entry(strconv.Itoa(i), "alice", "msg"+strconv.Itoa(i))) + } + got := h.recent("chat1", 10) + if len(got) != 3 { + t.Fatalf("expected cap of 3, got %d", len(got)) + } + if got[0].Text != "msg7" || got[2].Text != "msg9" { + t.Errorf("expected the newest three, got %v", got) + } +} + +func TestChatHistory_RecentHonoursN(t *testing.T) { + h := newChatHistory(10) + for i := range 5 { + h.record("chat1", entry(strconv.Itoa(i), "alice", "msg"+strconv.Itoa(i))) + } + if got := h.recent("chat1", 2); len(got) != 2 || got[1].Text != "msg4" { + t.Errorf("expected the newest two, got %v", got) + } + if got := h.recent("chat1", 0); got != nil { + t.Errorf("expected nil for n=0, got %v", got) + } +} + +func TestChatHistory_SkipsEmptyText(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", " ")) + h.record("", entry("2", "alice", "no chat")) + if got := h.recent("chat1", 5); len(got) != 0 { + t.Errorf("expected empty text skipped, got %v", got) + } +} + +// An account in many groups must not grow the ring without bound. +func TestChatHistory_EvictsLeastRecentChat(t *testing.T) { + h := newChatHistory(2) + for i := range maxTrackedChats + 10 { + h.record("chat"+strconv.Itoa(i), entry("m", "alice", "text")) + // Distinct touch times so eviction order is deterministic. + h.mu.Lock() + h.touched["chat"+strconv.Itoa(i)] = time.Now().Add(time.Duration(i) * time.Millisecond) + h.mu.Unlock() + } + h.mu.Lock() + tracked := len(h.perChat) + h.mu.Unlock() + + if tracked > maxTrackedChats { + t.Errorf("expected at most %d chats tracked, got %d", maxTrackedChats, tracked) + } + if got := h.recent("chat0", 5); len(got) != 0 { + t.Errorf("expected the oldest chat evicted, got %v", got) + } +} + +// recent returns a copy; a caller mutating it must not corrupt the ring. +func TestChatHistory_RecentReturnsCopy(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", "original")) + + got := h.recent("chat1", 5) + got[0].Text = "mutated" + + if again := h.recent("chat1", 5); again[0].Text != "original" { + t.Errorf("caller mutation leaked into the ring: %q", again[0].Text) + } +} + +func TestChatHistory_ConcurrentRecord(t *testing.T) { + h := newChatHistory(50) + var wg sync.WaitGroup + for i := range 100 { + wg.Add(1) + go func() { + defer wg.Done() + h.record("chat1", entry(strconv.Itoa(i), "alice", "msg")) + }() + } + wg.Wait() + if got := h.recent("chat1", 100); len(got) != 50 { + t.Errorf("expected 50 entries after cap, got %d", len(got)) + } +} + +func TestPrependHistory_BuildsContextBlock(t *testing.T) { + entries := []historyEntry{ + entry("1", "alice", "deploy failed"), + entry("2", "bob", "looking now"), + entry("3", "alice", "@agent summarise"), + } + got := prependHistory(entries, "3", "summarise") + + if !strings.Contains(got, "deploy failed") || !strings.Contains(got, "looking now") { + t.Errorf("expected prior messages in block, got %q", got) + } + if strings.Contains(got, "@agent summarise") { + t.Errorf("current message must be skipped inside its own context, got %q", got) + } + if !strings.HasSuffix(got, "summarise") { + t.Errorf("prompt must come last, got %q", got) + } + if strings.Index(got, "deploy failed") > strings.Index(got, "looking now") { + t.Errorf("expected oldest-first ordering, got %q", got) + } +} + +func TestPrependHistory_EmptyReturnsPromptUnchanged(t *testing.T) { + if got := prependHistory(nil, "1", "hello"); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +// Skipping the only entry leaves nothing to prepend. +func TestPrependHistory_OnlyCurrentMessage(t *testing.T) { + entries := []historyEntry{entry("1", "alice", "hello")} + if got := prependHistory(entries, "1", "hello"); got != "hello" { + t.Errorf("got %q, want the bare prompt", got) + } +} + +// A chatty group must not crowd the prompt out of the context budget. +func TestPrependHistory_SoftCap(t *testing.T) { + var entries []historyEntry + for i := range 500 { + entries = append(entries, entry(strconv.Itoa(i), "alice", strings.Repeat("x", 100))) + } + got := prependHistory(entries, "none", "the prompt") + + if len(got) > historySoftCap+500 { + t.Errorf("block exceeded soft cap: %d chars", len(got)) + } + if !strings.HasSuffix(got, "the prompt") { + t.Error("prompt must survive the cap") + } +} + +// The cap counts backwards from the newest, so the most recent context is the +// part that survives. +func TestPrependHistory_CapKeepsNewest(t *testing.T) { + var entries []historyEntry + for i := range 500 { + entries = append(entries, entry(strconv.Itoa(i), "alice", strings.Repeat("x", 100)+strconv.Itoa(i))) + } + got := prependHistory(entries, "none", "prompt") + + if !strings.Contains(got, "x499") { + t.Error("expected the newest entry retained under the cap") + } + if strings.Contains(got, "x0\n") { + t.Error("expected the oldest entry dropped under the cap") + } +} diff --git a/forge-plugins/channels/whatsapp/jid.go b/forge-plugins/channels/whatsapp/jid.go new file mode 100644 index 00000000..29a5c6b5 --- /dev/null +++ b/forge-plugins/channels/whatsapp/jid.go @@ -0,0 +1,189 @@ +// Package whatsapp implements the WhatsApp channel plugin via the WhatsApp +// Web multidevice protocol (whatsmeow). It is a paired-session adapter — no +// inbound webhooks, no public endpoint, no bot token. Authentication is a QR +// pairing captured by `forge channel whatsapp-login` and persisted to a local +// session store. +package whatsapp + +import ( + "strings" +) + +// WhatsApp JID server suffixes. A JID is "@", where the server +// determines what kind of conversation it addresses. +const ( + // serverUser addresses an individual by phone number: "14155550100@s.whatsapp.net". + serverUser = "s.whatsapp.net" + // serverGroup addresses a group: "120363000000000000@g.us". + serverGroup = "g.us" + // serverNewsletter addresses a Channel (one-way broadcast feed). The agent + // never participates in these. + serverNewsletter = "newsletter" + // serverBroadcast addresses a broadcast list or the status feed + // ("status@broadcast"). Never a conversation the agent should answer. + serverBroadcast = "broadcast" + // serverLID addresses a user by their hidden-number identifier. WhatsApp + // is migrating group participants to LIDs, so an inbound sender may arrive + // as a LID with no phone number attached. + serverLID = "lid" +) + +// SplitJID separates a JID into its user and server parts. A JID with no "@" +// yields the whole string as the user and an empty server. +func SplitJID(jid string) (user, server string) { + jid = strings.TrimSpace(jid) + at := strings.LastIndex(jid, "@") + if at < 0 { + return jid, "" + } + return jid[:at], strings.ToLower(jid[at+1:]) +} + +// JIDServer returns the server portion of a JID, lowercased. +func JIDServer(jid string) string { + _, server := SplitJID(jid) + return server +} + +// JIDUser returns the user portion of a JID with the device and agent +// suffixes removed. +// +// whatsmeow renders a specific linked device as ":@server" and +// newer builds add an agent ordinal as ".". Neither belongs in an +// identity comparison — the same person messaging from phone and desktop must +// compare equal — so both are stripped. +func JIDUser(jid string) string { + user, _ := SplitJID(jid) + if i := strings.IndexByte(user, ':'); i >= 0 { + user = user[:i] + } + if i := strings.IndexByte(user, '.'); i >= 0 { + user = user[:i] + } + return user +} + +// NormalizeJID reduces a JID to its comparable form: lowercased server, and a +// user with device and agent suffixes stripped. Use it before any equality +// check or allowlist lookup. +func NormalizeJID(jid string) string { + server := JIDServer(jid) + if server == "" { + return JIDUser(jid) + } + return JIDUser(jid) + "@" + server +} + +// IsGroupJID reports whether the JID addresses a group chat. +func IsGroupJID(jid string) bool { return JIDServer(jid) == serverGroup } + +// IsNewsletterJID reports whether the JID addresses a Channel / newsletter. +func IsNewsletterJID(jid string) bool { return JIDServer(jid) == serverNewsletter } + +// IsBroadcastJID reports whether the JID addresses a broadcast list or the +// status feed. +func IsBroadcastJID(jid string) bool { return JIDServer(jid) == serverBroadcast } + +// IsUserJID reports whether the JID addresses an individual, by either phone +// number or hidden-number LID. +func IsUserJID(jid string) bool { + switch JIDServer(jid) { + case serverUser, serverLID: + return true + } + return false +} + +// IsLIDJID reports whether the JID is a hidden-number identifier rather than a +// phone number. A LID sender has no E.164 form, so an allowlist keyed on phone +// numbers cannot match it. +func IsLIDJID(jid string) bool { return JIDServer(jid) == serverLID } + +// IsConversationJID reports whether the JID addresses something the agent may +// hold a conversation in — a DM or a group. Newsletters, broadcast lists and +// the status feed are excluded: they are one-way feeds, and replying to one +// either fails or fans a message out to every contact. +func IsConversationJID(jid string) bool { + return IsGroupJID(jid) || IsUserJID(jid) +} + +// E164ToJID builds a user JID from a phone number in any common written form +// ("+1 (415) 555-0100", "1-415-555-0100", "14155550100"). Returns "" when the +// input holds no digits. +// +// An input that already looks like a JID is normalized and returned as-is, so +// operators may write either form in an allowlist. +func E164ToJID(number string) string { + number = strings.TrimSpace(number) + if strings.Contains(number, "@") { + return NormalizeJID(number) + } + digits := digitsOnly(number) + if digits == "" { + return "" + } + return digits + "@" + serverUser +} + +// JIDToE164 renders a user JID as a "+"-prefixed phone number. Returns "" for +// a group, newsletter, broadcast or LID JID — none of which carry a number. +func JIDToE164(jid string) string { + if !IsUserJID(jid) || IsLIDJID(jid) { + return "" + } + digits := digitsOnly(JIDUser(jid)) + if digits == "" { + return "" + } + return "+" + digits +} + +// digitsOnly strips every non-digit rune. Used to make written phone numbers +// comparable regardless of the punctuation an operator typed. +func digitsOnly(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +// parseJIDSet builds a lookup set from a comma- or newline-separated config +// list. Entries may be written as JIDs or as bare phone numbers; both +// normalize to the same key, so a caller looks up with NormalizeJID. +// +// Group IDs have no phone-number form, so a bare group id ("120363...") is +// accepted and qualified with the group server. +// +// Note the separator set is narrower than the Teams adapter's +// parseAllowBotIDs, which also splits on spaces. A space is legitimate INSIDE +// a written phone number ("+1 (415) 555-0100"), so treating it as a separator +// would shred one entry into four bogus ones. +func parseJIDSet(s string, defaultServer string) map[string]bool { + out := map[string]bool{} + if strings.TrimSpace(s) == "" { + return out + } + for _, raw := range strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + if !strings.Contains(entry, "@") { + if defaultServer == serverUser { + if jid := E164ToJID(entry); jid != "" { + out[jid] = true + } + continue + } + entry += "@" + defaultServer + } + out[NormalizeJID(entry)] = true + } + return out +} diff --git a/forge-plugins/channels/whatsapp/jid_test.go b/forge-plugins/channels/whatsapp/jid_test.go new file mode 100644 index 00000000..0063ce79 --- /dev/null +++ b/forge-plugins/channels/whatsapp/jid_test.go @@ -0,0 +1,174 @@ +package whatsapp + +import "testing" + +func TestSplitJID(t *testing.T) { + tests := []struct { + in string + wantUser string + wantServer string + }{ + {"14155550100@s.whatsapp.net", "14155550100", "s.whatsapp.net"}, + {"120363000000000000@g.us", "120363000000000000", "g.us"}, + {"14155550100@S.WhatsApp.Net", "14155550100", "s.whatsapp.net"}, + {"bare", "bare", ""}, + {"", "", ""}, + } + for _, tt := range tests { + user, server := SplitJID(tt.in) + if user != tt.wantUser || server != tt.wantServer { + t.Errorf("SplitJID(%q) = (%q, %q), want (%q, %q)", tt.in, user, server, tt.wantUser, tt.wantServer) + } + } +} + +// A device-qualified JID must compare equal to the bare one — the same person +// messaging from phone and desktop is one identity. +func TestJIDUser_StripsDeviceAndAgent(t *testing.T) { + tests := map[string]string{ + "14155550100@s.whatsapp.net": "14155550100", + "14155550100:5@s.whatsapp.net": "14155550100", + "14155550100.0@s.whatsapp.net": "14155550100", + "14155550100.0:5@s.whatsapp.net": "14155550100", + } + for in, want := range tests { + if got := JIDUser(in); got != want { + t.Errorf("JIDUser(%q) = %q, want %q", in, got, want) + } + } +} + +func TestNormalizeJID(t *testing.T) { + tests := map[string]string{ + "14155550100:5@s.whatsapp.net": "14155550100@s.whatsapp.net", + "14155550100@S.WHATSAPP.NET": "14155550100@s.whatsapp.net", + "120363000000000000@g.us": "120363000000000000@g.us", + "bare": "bare", + } + for in, want := range tests { + if got := NormalizeJID(in); got != want { + t.Errorf("NormalizeJID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestJIDClassification(t *testing.T) { + tests := []struct { + jid string + group, newsletter, broadcast, user, lid, convo bool + }{ + {jid: "14155550100@s.whatsapp.net", user: true, convo: true}, + {jid: "120363000000000000@g.us", group: true, convo: true}, + {jid: "123456@newsletter", newsletter: true}, + {jid: "status@broadcast", broadcast: true}, + {jid: "98765@lid", user: true, lid: true, convo: true}, + } + for _, tt := range tests { + if got := IsGroupJID(tt.jid); got != tt.group { + t.Errorf("IsGroupJID(%q) = %v, want %v", tt.jid, got, tt.group) + } + if got := IsNewsletterJID(tt.jid); got != tt.newsletter { + t.Errorf("IsNewsletterJID(%q) = %v, want %v", tt.jid, got, tt.newsletter) + } + if got := IsBroadcastJID(tt.jid); got != tt.broadcast { + t.Errorf("IsBroadcastJID(%q) = %v, want %v", tt.jid, got, tt.broadcast) + } + if got := IsUserJID(tt.jid); got != tt.user { + t.Errorf("IsUserJID(%q) = %v, want %v", tt.jid, got, tt.user) + } + if got := IsLIDJID(tt.jid); got != tt.lid { + t.Errorf("IsLIDJID(%q) = %v, want %v", tt.jid, got, tt.lid) + } + if got := IsConversationJID(tt.jid); got != tt.convo { + t.Errorf("IsConversationJID(%q) = %v, want %v", tt.jid, got, tt.convo) + } + } +} + +func TestE164ToJID(t *testing.T) { + tests := map[string]string{ + "+1 (415) 555-0100": "14155550100@s.whatsapp.net", + "1-415-555-0100": "14155550100@s.whatsapp.net", + "14155550100": "14155550100@s.whatsapp.net", + " +14155550100 ": "14155550100@s.whatsapp.net", + "14155550100@s.whatsapp.net": "14155550100@s.whatsapp.net", + "": "", + "not-a-number": "", + } + for in, want := range tests { + if got := E164ToJID(in); got != want { + t.Errorf("E164ToJID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestJIDToE164(t *testing.T) { + tests := map[string]string{ + "14155550100@s.whatsapp.net": "+14155550100", + "14155550100:3@s.whatsapp.net": "+14155550100", + "120363000000000000@g.us": "", // groups carry no number + "98765@lid": "", // hidden numbers carry no number + "123@newsletter": "", + } + for in, want := range tests { + if got := JIDToE164(in); got != want { + t.Errorf("JIDToE164(%q) = %q, want %q", in, got, want) + } + } +} + +// Operators write allowlists by hand, so both punctuated numbers and raw JIDs +// have to land on the same key. +func TestParseJIDSet_SendersAcceptNumbersAndJIDs(t *testing.T) { + set := parseJIDSet("+1 (415) 555-0100, 14155550199@s.whatsapp.net", serverUser) + for _, want := range []string{"14155550100@s.whatsapp.net", "14155550199@s.whatsapp.net"} { + if !set[want] { + t.Errorf("expected %q in set, got %v", want, set) + } + } + if len(set) != 2 { + t.Errorf("expected 2 entries, got %d: %v", len(set), set) + } +} + +func TestParseJIDSet_GroupsQualifyBareIDs(t *testing.T) { + set := parseJIDSet("120363000000000000, 120363000000000001@g.us", serverGroup) + for _, want := range []string{"120363000000000000@g.us", "120363000000000001@g.us"} { + if !set[want] { + t.Errorf("expected %q in set, got %v", want, set) + } + } +} + +func TestParseJIDSet_Separators(t *testing.T) { + set := parseJIDSet("14155550100,\n14155550101,14155550102", serverUser) + if len(set) != 3 { + t.Errorf("expected 3 entries across comma and newline, got %d: %v", len(set), set) + } +} + +// A space is part of a written phone number, not a separator — splitting on it +// would shred one entry into several bogus ones. +func TestParseJIDSet_SpaceIsNotASeparator(t *testing.T) { + set := parseJIDSet("+1 (415) 555-0100", serverUser) + if len(set) != 1 || !set["14155550100@s.whatsapp.net"] { + t.Errorf("expected one entry for a spaced phone number, got %v", set) + } +} + +func TestParseJIDSet_Empty(t *testing.T) { + for _, in := range []string{"", " ", "\n"} { + if got := parseJIDSet(in, serverUser); len(got) != 0 { + t.Errorf("parseJIDSet(%q) = %v, want empty", in, got) + } + } +} + +// A device suffix in a config entry must not create a key the runtime lookup +// (which normalizes) can never hit. +func TestParseJIDSet_NormalizesDeviceSuffix(t *testing.T) { + set := parseJIDSet("14155550100:7@s.whatsapp.net", serverUser) + if !set["14155550100@s.whatsapp.net"] { + t.Errorf("expected device suffix stripped, got %v", set) + } +} diff --git a/forge-plugins/channels/whatsapp/session.go b/forge-plugins/channels/whatsapp/session.go new file mode 100644 index 00000000..266292dd --- /dev/null +++ b/forge-plugins/channels/whatsapp/session.go @@ -0,0 +1,125 @@ +package whatsapp + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + + // Pure-Go SQLite driver, registered as "sqlite". Deliberately NOT + // mattn/go-sqlite3: the release image builds with CGO_ENABLED=0 (see the + // repo Dockerfile), under which a cgo driver compiles but panics at + // sql.Open with "unknown driver". modernc has no cgo dependency. + _ "modernc.org/sqlite" +) + +// sqlDialect is the driver name modernc.org/sqlite registers with database/sql. +// whatsmeow's dbutil matches any dialect with the "sqlite" prefix, so this +// selects SQLite SQL generation as well as the driver. +const sqlDialect = "sqlite" + +// sessionDSN builds the connection string for the pairing store. +// +// foreign_keys is enabled because whatsmeow's schema relies on cascading +// deletes to clean up per-device signal state; without it a logout leaves +// orphaned rows that make the next pairing fail. Note the pragma syntax is +// modernc's (`_pragma=foreign_keys(1)`), not mattn's (`_foreign_keys=on`) — +// mattn's form is silently ignored by this driver. +func sessionDSN(path string) string { + return "file:" + path + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)" +} + +// openContainer opens (creating if absent) the whatsmeow session store at +// path and runs any pending schema upgrades. +func openContainer(ctx context.Context, path string) (*sqlstore.Container, error) { + if path == "" { + return nil, fmt.Errorf("whatsapp: session_path is empty") + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("whatsapp: creating session directory %s: %w", dir, err) + } + } + container, err := sqlstore.New(ctx, sqlDialect, sessionDSN(path), nil) + if err != nil { + return nil, fmt.Errorf("whatsapp: opening session store %s: %w", path, err) + } + + // The store holds the pairing: anyone who can read it can send messages as + // the linked account. SQLite creates the file under the process umask, + // which on a default system is world-readable — tighten it to owner-only. + // Best-effort: a store on a filesystem without POSIX modes (a mounted + // volume, Windows) is still usable, so a failure here must not block + // startup. + if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) { + return container, nil //nolint:nilerr // hardening is best-effort; see comment above + } + return container, nil +} + +// loadPairedDevice returns the paired device from the session store at path. +// +// It fails when the store holds no completed pairing. Pairing requires a human +// to scan a QR code, so it cannot happen inside `forge run` — the adapter +// refuses to start rather than blocking a server boot on a terminal +// interaction that may never come. +func loadPairedDevice(ctx context.Context, path string) (*sqlstore.Container, *store.Device, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil, fmt.Errorf("whatsapp: no session at %s — run `forge channel whatsapp-login` to pair", path) + } + return nil, nil, fmt.Errorf("whatsapp: stat session %s: %w", path, err) + } + + container, err := openContainer(ctx, path) + if err != nil { + return nil, nil, err + } + + device, err := container.GetFirstDevice(ctx) + if err != nil { + _ = container.Close() + return nil, nil, fmt.Errorf("whatsapp: reading device from %s: %w", path, err) + } + if device == nil || device.ID == nil { + _ = container.Close() + return nil, nil, fmt.Errorf("whatsapp: session at %s is not paired — run `forge channel whatsapp-login`", path) + } + return container, device, nil +} + +// NewSessionDevice opens the store at path and returns a device to pair. +// +// An existing paired device is reused so a re-login refreshes the same session +// rather than accumulating rows for abandoned pairings. Exported for the +// `forge channel whatsapp-login` command, which owns the QR flow — the adapter +// itself never creates a device. +func NewSessionDevice(ctx context.Context, path string) (*sqlstore.Container, *store.Device, error) { + container, err := openContainer(ctx, path) + if err != nil { + return nil, nil, err + } + device, err := container.GetFirstDevice(ctx) + if err != nil { + _ = container.Close() + return nil, nil, fmt.Errorf("whatsapp: reading device from %s: %w", path, err) + } + if device == nil { + device = container.NewDevice() + } + return container, device, nil +} + +// SessionExists reports whether a paired session is present at path. Used by +// the CLI and the init wizard to decide whether to offer pairing. +func SessionExists(ctx context.Context, path string) bool { + container, device, err := loadPairedDevice(ctx, path) + if err != nil { + return false + } + _ = container.Close() + return device != nil && device.ID != nil +} diff --git a/forge-plugins/channels/whatsapp/whatsapp.go b/forge-plugins/channels/whatsapp/whatsapp.go new file mode 100644 index 00000000..77cddf3d --- /dev/null +++ b/forge-plugins/channels/whatsapp/whatsapp.go @@ -0,0 +1,630 @@ +package whatsapp + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + "sync" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/channels" + "github.com/initializ/forge/forge-plugins/channels/markdown" +) + +const ( + defaultSessionPath = ".forge/channels/whatsapp-session.db" + // handlerTimeout bounds one agent turn. Matches the Telegram adapter. + handlerTimeout = 10 * time.Minute + // typingRefresh is how often the composing indicator is re-sent. WhatsApp + // expires it after roughly 10s, so a slow turn needs a heartbeat or the + // chat looks idle while the agent is working. + typingRefresh = 8 * time.Second + // historySoftCap bounds the injected context block, mirroring the Teams + // adapter's budget guard. + historySoftCap = 5000 + // staleGrace is how far before startup an inbound message may be dated and + // still be acted on. + // + // Reconnecting delivers a backlog, and after a restart the dedup ring is + // empty — so without this the agent answers a replayed conversation, and + // in the self-chat it answers its OWN replayed replies, which loops. The + // window is wide enough that a brief restart still picks up messages sent + // while the agent was down, and narrow enough that a history sync of an + // old conversation is ignored. + staleGrace = 5 * time.Minute +) + +// Plugin implements channels.ChannelPlugin for WhatsApp over the WhatsApp Web +// multidevice protocol. +// +// Unlike every other adapter in this repo, authentication is not a token from +// config: it is a QR pairing captured out-of-band by +// `forge channel whatsapp-login` and persisted to a local session store. Start +// therefore fails closed on an unpaired session rather than attempting to pair +// — pairing needs a human at a terminal, which `forge run` cannot assume. +type Plugin struct { + cfg adapterConfig + + // Built at Start. + container *sqlstore.Container + client *whatsmeow.Client + dedup *dedup + history *chatHistory + + // The paired account's identities, captured at Start. WhatsApp addresses + // the same account by phone number (ownJID) and by hidden-number LID + // (ownLID); a mention may arrive under either, so both are kept. + ownJID string + ownLID string + // ownPushName is the display name other participants see, used to strip a + // typed "@Name" prefix off the prompt. + ownPushName string + + // logger is an optional structured ops logger (SetLogger). When set, + // operational signals route through it; nil → log.Printf. + logger channels.Logger + + // startedAt bounds how far back inbound messages are acted on. See + // staleGrace. + startedAt time.Time + + // Lifecycle. + stopCh chan struct{} + once sync.Once +} + +type adapterConfig struct { + SessionPath string + Admission admissionConfig + IncludeRecentHistory bool + RecentHistoryCount int +} + +// New returns an uninitialised plugin. Init must be called before Start. +func New() *Plugin { + return &Plugin{ + dedup: newDedup(1000), + stopCh: make(chan struct{}), + } +} + +func (p *Plugin) Name() string { return "whatsapp" } + +// SetLogger wires a structured ops logger (channels.LoggerAware). Optional. +func (p *Plugin) SetLogger(l channels.Logger) { p.logger = l } + +func (p *Plugin) Init(cfg channels.ChannelConfig) error { + settings := channels.ResolveEnvVars(&cfg) + + ac := adapterConfig{ + SessionPath: strOrDefault(settings["session_path"], defaultSessionPath), + Admission: admissionConfig{ + Mode: AdmitMode(strOrDefault(settings["admit"], string(AdmitDMOrGroupMention))), + AllowedGroups: parseJIDSet(settings["allowed_groups"], serverGroup), + AllowedSenders: parseJIDSet(settings["allowed_senders"], serverUser), + AllowAnySender: isAnySender(settings["allowed_senders"]), + SelfChat: parseBool(settings["self_chat"], true), + }, + IncludeRecentHistory: parseBool(settings["include_recent_history"], true), + RecentHistoryCount: parseInt(settings["recent_history_count"], 20), + } + + switch ac.Admission.Mode { + case AdmitDM, AdmitGroupMention, AdmitDMOrGroupMention: + default: + return fmt.Errorf("whatsapp: admit must be one of dm, group_mention, dm_or_group_mention, got %q", ac.Admission.Mode) + } + if ac.RecentHistoryCount < 0 { + return fmt.Errorf("whatsapp: recent_history_count must not be negative, got %d", ac.RecentHistoryCount) + } + + p.cfg = ac + if ac.IncludeRecentHistory { + p.history = newChatHistory(ac.RecentHistoryCount) + } + return nil +} + +// Start connects the paired session and dispatches inbound messages to +// handler. It blocks until ctx is cancelled or Stop is called. +func (p *Plugin) Start(ctx context.Context, handler channels.EventHandler) error { + container, device, err := loadPairedDevice(ctx, p.cfg.SessionPath) + if err != nil { + return err + } + p.container = container + + p.ownJID = device.ID.ToNonAD().String() + if lid := device.GetLID(); !lid.IsEmpty() { + p.ownLID = lid.ToNonAD().String() + } + p.ownPushName = device.PushName + + // The paired account's identities are only known once the session is + // loaded, so the owner-dependent parts of the gate are filled in here + // rather than at Init. + p.cfg.Admission.OwnJIDs = []string{p.ownJID, p.ownLID} + p.startedAt = time.Now() + + p.client = whatsmeow.NewClient(device, nil) + p.client.AddEventHandler(func(evt any) { + switch v := evt.(type) { + case *events.Message: + p.onMessage(ctx, v, handler) + case *events.LoggedOut: + // The pairing was revoked from the phone. Reconnecting cannot + // recover it — only a fresh QR scan can — so say so loudly rather + // than looping on a dead session. + p.logError("whatsapp: session logged out — the linked device was removed; re-pair with `forge channel whatsapp-login`", map[string]any{ + "reason": v.Reason.String(), + }) + case *events.Connected: + p.logInfo("whatsapp: connected", map[string]any{"jid": p.ownJID}) + case *events.Disconnected: + p.logWarn("whatsapp: disconnected, whatsmeow will retry", nil) + } + }) + + if err := p.client.Connect(); err != nil { + return fmt.Errorf("whatsapp: connecting: %w", err) + } + + select { + case <-ctx.Done(): + case <-p.stopCh: + } + return nil +} + +// Stop disconnects the client and closes the session store. Safe to call more +// than once. +func (p *Plugin) Stop() error { + var err error + p.once.Do(func() { + close(p.stopCh) + if p.client != nil { + p.client.Disconnect() + } + if p.container != nil { + err = p.container.Close() + } + }) + return err +} + +// NormalizeEvent converts a raw whatsmeow message event into a ChannelEvent. +// +// The live path does not use this — whatsmeow delivers typed events, not +// bytes, so onMessage normalizes directly. It exists to satisfy +// channels.ChannelPlugin and to keep the JSON shape testable. +func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) { + var msg events.Message + if err := json.Unmarshal(raw, &msg); err != nil { + return nil, fmt.Errorf("whatsapp: parse message event: %w", err) + } + event := p.normalizeMessage(&msg) + if event == nil { + return nil, fmt.Errorf("whatsapp: message carries no text content") + } + return event, nil +} + +// onMessage runs the dedup → admission → dispatch pipeline for one inbound +// message. +func (p *Plugin) onMessage(ctx context.Context, msg *events.Message, handler channels.EventHandler) { + // Dedup first, and for EVERY message rather than only admitted ones: a + // history sync replays messages that were already evaluated, and + // re-running admission on them would re-dispatch anything that passes. + if p.dedup.markSeen(msg.Info.ID) { + return + } + + // Drop replayed history before anything else acts on it. + if p.isStale(msg.Info.Timestamp) { + p.logDebug("whatsapp: dropping message predating startup (history replay)", map[string]any{ + "message_id": msg.Info.ID, + "sent_at": msg.Info.Timestamp.Format(time.RFC3339), + }) + return + } + + chatJID := msg.Info.Chat.String() + senderJID := msg.Info.Sender.String() + senderAlt := "" + if !msg.Info.SenderAlt.IsEmpty() { + senderAlt = msg.Info.SenderAlt.String() + } + + // Record BEFORE admission, and regardless of its verdict. The surrounding + // human conversation in a group is exactly the context that makes a later + // "@agent summarise this" answerable, and none of those lines are + // themselves admitted. + p.recordHistory(msg) + + mentioned := isMentioned(mentionedJIDs(msg.Message), p.ownJID, p.ownLID) + + result := admit(chatJID, senderJID, senderAlt, msg.Info.IsFromMe, mentioned, p.cfg.Admission) + if !result.admit { + p.logDebug(result.reason, map[string]any{ + "message_id": msg.Info.ID, + "chat": chatJID, + "sender": senderJID, + }) + return + } + + event := p.normalizeMessage(msg) + if event == nil { + // Media with no caption, a reaction, a poll vote — nothing to prompt + // the agent with. Not an error. + p.logDebug("whatsapp: dropping message with no text content", map[string]any{ + "message_id": msg.Info.ID, + "chat": chatJID, + }) + return + } + + // Inject the observed conversation so the agent can answer prompts that + // refer to the surrounding thread ("summarise the above"). Skips the + // current message so it isn't duplicated inside its own context. + if p.history != nil { + event.Message = prependHistory( + p.history.recent(chatJID, p.cfg.RecentHistoryCount), + msg.Info.ID, + event.Message, + ) + } + + go p.dispatch(event, msg.Info.Chat, handler) +} + +// isStale reports whether an inbound message predates this adapter run by more +// than staleGrace, i.e. it is replayed history rather than live traffic. +func (p *Plugin) isStale(ts time.Time) bool { + if p.startedAt.IsZero() || ts.IsZero() { + return false + } + return ts.Before(p.startedAt.Add(-staleGrace)) +} + +// recordHistory adds one observed message to the context window. +func (p *Plugin) recordHistory(msg *events.Message) { + if p.history == nil { + return + } + text := strings.TrimSpace(extractMessageText(msg.Message)) + if text == "" { + return + } + p.history.record(msg.Info.Chat.String(), historyEntry{ + ID: msg.Info.ID, + Author: historyAuthor(msg), + Text: text, + At: msg.Info.Timestamp, + }) +} + +// historyAuthor picks the most human-readable label available for a sender. +// PushName is the display name the sender chose; it is absent for some +// message sources, in which case the phone number is the best we have. +func historyAuthor(msg *events.Message) string { + if msg.Info.IsFromMe { + return "agent" + } + if msg.Info.PushName != "" { + return msg.Info.PushName + } + return JIDUser(msg.Info.Sender.String()) +} + +// dispatch forwards one normalized event to the agent and delivers the reply. +func (p *Plugin) dispatch(event *channels.ChannelEvent, chat types.JID, handler channels.EventHandler) { + // A fresh context: the agent turn must outlive the inbound event's + // context, which whatsmeow may cancel when the socket cycles. + taskCtx, cancel := context.WithTimeout(context.Background(), handlerTimeout) + defer cancel() + + stopTyping := p.startTypingIndicator(taskCtx, chat) + defer stopTyping() + + // Open channel.whatsapp.deliver around the dispatch so the internal A2A + // POST (carrying the traceparent injected by the router) nests under it. + spanCtx, _, finish := channels.StartDeliverSpan(taskCtx, "whatsapp", event) + var handlerErr error + defer finish(&handlerErr) + + resp, err := handler(spanCtx, event) + if err != nil { + handlerErr = err + p.logError("whatsapp: handler error", map[string]any{"error": err.Error(), "chat": event.WorkspaceID}) + return + } + + stopTyping() + if err := p.SendResponse(event, resp); err != nil { + handlerErr = err + p.logError("whatsapp: send response error", map[string]any{"error": err.Error(), "chat": event.WorkspaceID}) + } +} + +// normalizeMessage converts a whatsmeow message into a ChannelEvent, or nil +// when the message carries no text the agent could act on. +func (p *Plugin) normalizeMessage(msg *events.Message) *channels.ChannelEvent { + text := strings.TrimSpace(extractMessageText(msg.Message)) + if text == "" { + return nil + } + + // Strip the "@Name" the sender typed to invoke the agent in a group, so + // the prompt doesn't open with the agent's own handle. + text = markdown.StripWhatsAppMention(text, p.ownPushName, JIDUser(p.ownJID), JIDToE164(p.ownJID)) + + chatJID := msg.Info.Chat.String() + return &channels.ChannelEvent{ + Channel: "whatsapp", + WorkspaceID: chatJID, + // One durable session per chat: the router keys the A2A task on + // ThreadID, and a WhatsApp conversation is exactly the unit a user + // expects to have continuity. + ThreadID: chatJID, + UserID: JIDUser(msg.Info.Sender.String()), + MessageID: msg.Info.ID, + Message: text, + // UserEmail is deliberately empty: WhatsApp has no email identity, so + // delegated (auth.type: user) MCP tools cannot resolve an on-behalf-of + // subject on this channel. See docs/core-concepts/channels.md. + } +} + +// SendResponse delivers an agent reply back to the originating chat. +// +// Every outbound message ID is recorded in the dedup ring before returning. A +// history sync after reconnect replays our own sends, and while +// MessageSource.IsFromMe catches them on the live path, a replayed message +// sent by the human operator from their own phone is indistinguishable from +// one the agent sent — the ring is what tells them apart. +func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Message) error { + chat, err := types.ParseJID(event.WorkspaceID) + if err != nil { + return fmt.Errorf("whatsapp: parsing chat JID %q: %w", event.WorkspaceID, err) + } + + text := extractText(response) + body := markdown.ToWhatsAppText(text) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, chunk := range markdown.SplitMessageWhatsApp(body) { + if strings.TrimSpace(chunk) == "" { + continue + } + resp, err := p.client.SendMessage(ctx, chat, &waE2E.Message{ + Conversation: proto.String(chunk), + }) + if err != nil { + return fmt.Errorf("whatsapp: sending to %s: %w", chat, err) + } + p.markSent(resp.ID) + + // Record our own reply so a follow-up question ("expand on point 2") + // has the answer it refers to in context. + if p.history != nil { + p.history.record(event.WorkspaceID, historyEntry{ + ID: resp.ID, + Author: "agent", + Text: chunk, + At: time.Now(), + }) + } + } + return nil +} + +// markSent records an outbound message ID in the dedup ring. Safe to call with +// an empty id. +func (p *Plugin) markSent(id string) { + if id == "" || p.dedup == nil { + return + } + p.dedup.mark(id) +} + +// startTypingIndicator shows "typing…" in the chat until the returned stop +// func is called. WhatsApp expires the indicator after ~10s, so it is +// refreshed on a ticker. The returned func is safe to call more than once. +func (p *Plugin) startTypingIndicator(ctx context.Context, chat types.JID) func() { + send := func(state types.ChatPresence) { + if err := p.client.SendChatPresence(ctx, chat, state, types.ChatPresenceMediaText); err != nil { + p.logDebug("whatsapp: chat presence failed", map[string]any{"error": err.Error()}) + } + } + send(types.ChatPresenceComposing) + + done := make(chan struct{}) + var once sync.Once + + go func() { + ticker := time.NewTicker(typingRefresh) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + send(types.ChatPresenceComposing) + } + } + }() + + return func() { + once.Do(func() { + close(done) + send(types.ChatPresencePaused) + }) + } +} + +// --- message content helpers --- + +// extractMessageText pulls the prompt text out of a WhatsApp message. +// +// Text arrives as either a bare Conversation or an ExtendedTextMessage (used +// whenever the message carries context: a mention, a link preview, a reply). +// Media captions count too — "summarise this" under a document is a prompt. +func extractMessageText(m *waE2E.Message) string { + if m == nil { + return "" + } + if c := m.GetConversation(); c != "" { + return c + } + if t := m.GetExtendedTextMessage().GetText(); t != "" { + return t + } + if c := m.GetImageMessage().GetCaption(); c != "" { + return c + } + if c := m.GetVideoMessage().GetCaption(); c != "" { + return c + } + if c := m.GetDocumentMessage().GetCaption(); c != "" { + return c + } + // A message the user edited arrives wrapped; unwrap one level. + if e := m.GetEditedMessage().GetMessage(); e != nil { + return extractMessageText(e) + } + return "" +} + +// mentionedJIDs returns the JIDs @-mentioned in a message. +// +// WhatsApp carries mentions out-of-band in contextInfo.mentionedJid rather +// than as markup in the body, so this is the only reliable source — scanning +// the text would match a plain "@name" the sender typed by hand. +func mentionedJIDs(m *waE2E.Message) []string { + if m == nil { + return nil + } + if ci := m.GetExtendedTextMessage().GetContextInfo(); ci != nil { + return ci.GetMentionedJID() + } + return nil +} + +// extractText pulls the text content out of an A2A message, mirroring the +// pattern used by Slack, Telegram and Teams. +func extractText(msg *a2a.Message) string { + if msg == nil { + return "(no response)" + } + var parts []string + for _, p := range msg.Parts { + if p.Kind == a2a.PartKindText && p.Text != "" { + parts = append(parts, p.Text) + } + } + if len(parts) == 0 { + return "(no text response)" + } + return markdown.StripCompressionMarkers(strings.Join(parts, "\n")) +} + +// --- logging --- + +func (p *Plugin) logInfo(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Info(msg, fields) + return + } + log.Printf("[whatsapp] %s %v", msg, fields) +} + +func (p *Plugin) logWarn(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Warn(msg, fields) + return + } + log.Printf("[whatsapp] WARN %s %v", msg, fields) +} + +func (p *Plugin) logError(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Error(msg, fields) + return + } + log.Printf("[whatsapp] ERROR %s %v", msg, fields) +} + +func (p *Plugin) logDebug(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Debug(msg, fields) + return + } + log.Printf("[whatsapp] DEBUG %s %v", msg, fields) +} + +// --- settings helpers --- + +func strOrDefault(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return strings.TrimSpace(s) +} + +// isAnySender reports whether allowed_senders is the explicit opt-out that +// disables the sender allowlist. Spelled as a word rather than left empty +// because it is the setting that exposes the agent to anyone with the number, +// and an empty value should mean the safe thing, not the open one. +func isAnySender(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "any", "anyone", "*": + return true + } + return false +} + +func parseBool(s string, def bool) bool { + s = strings.TrimSpace(s) + if s == "" { + return def + } + v, err := strconv.ParseBool(s) + if err != nil { + return def + } + return v +} + +func parseInt(s string, def int) int { + s = strings.TrimSpace(s) + if s == "" { + return def + } + v, err := strconv.Atoi(s) + if err != nil { + return def + } + return v +} + +// interface guards +var ( + _ channels.ChannelPlugin = (*Plugin)(nil) + _ channels.LoggerAware = (*Plugin)(nil) +) diff --git a/forge-plugins/channels/whatsapp/whatsapp_test.go b/forge-plugins/channels/whatsapp/whatsapp_test.go new file mode 100644 index 00000000..843a42be --- /dev/null +++ b/forge-plugins/channels/whatsapp/whatsapp_test.go @@ -0,0 +1,450 @@ +package whatsapp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "google.golang.org/protobuf/proto" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/channels" +) + +func cfgWith(settings map[string]string) channels.ChannelConfig { + return channels.ChannelConfig{Adapter: "whatsapp", Settings: settings} +} + +func TestPlugin_Name(t *testing.T) { + if got := New().Name(); got != "whatsapp" { + t.Errorf("got %q, want %q", got, "whatsapp") + } +} + +func TestInit_Defaults(t *testing.T) { + p := New() + if err := p.Init(cfgWith(nil)); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SessionPath != defaultSessionPath { + t.Errorf("session path = %q, want %q", p.cfg.SessionPath, defaultSessionPath) + } + if p.cfg.Admission.Mode != AdmitDMOrGroupMention { + t.Errorf("admit = %q, want %q", p.cfg.Admission.Mode, AdmitDMOrGroupMention) + } + if !p.cfg.IncludeRecentHistory || p.history == nil { + t.Error("expected history enabled by default") + } + if p.cfg.RecentHistoryCount != 20 { + t.Errorf("history count = %d, want 20", p.cfg.RecentHistoryCount) + } +} + +func TestInit_ParsesSettings(t *testing.T) { + p := New() + err := p.Init(cfgWith(map[string]string{ + "session_path": "/tmp/x/session.db", + "admit": "dm", + "allowed_groups": "120363000000000000", + "allowed_senders": "+1 (415) 555-0100", + "include_recent_history": "false", + "recent_history_count": "5", + })) + if err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SessionPath != "/tmp/x/session.db" { + t.Errorf("session path = %q", p.cfg.SessionPath) + } + if p.cfg.Admission.Mode != AdmitDM { + t.Errorf("admit = %q", p.cfg.Admission.Mode) + } + if !p.cfg.Admission.AllowedGroups["120363000000000000@g.us"] { + t.Errorf("groups = %v", p.cfg.Admission.AllowedGroups) + } + if !p.cfg.Admission.AllowedSenders["14155550100@s.whatsapp.net"] { + t.Errorf("senders = %v", p.cfg.Admission.AllowedSenders) + } + if p.cfg.IncludeRecentHistory || p.history != nil { + t.Error("expected history disabled") + } +} + +// A typo in admit must fail loudly at Init rather than silently degrade to a +// different gate at runtime. +func TestInit_RejectsUnknownAdmitMode(t *testing.T) { + err := New().Init(cfgWith(map[string]string{"admit": "everything"})) + if err == nil { + t.Fatal("expected error for unknown admit mode") + } + if !strings.Contains(err.Error(), "admit must be one of") { + t.Errorf("error should list valid modes, got %v", err) + } +} + +func TestInit_RejectsNegativeHistoryCount(t *testing.T) { + err := New().Init(cfgWith(map[string]string{"recent_history_count": "-1"})) + if err == nil { + t.Fatal("expected error for negative history count") + } +} + +// Settings resolve through the shared _env indirection, so a session path can +// come from the environment like every other adapter's config. +func TestInit_ResolvesEnvSuffix(t *testing.T) { + t.Setenv("TEST_WA_SESSION", "/tmp/from-env.db") + p := New() + if err := p.Init(cfgWith(map[string]string{"session_path_env": "TEST_WA_SESSION"})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SessionPath != "/tmp/from-env.db" { + t.Errorf("session path = %q, want the env value", p.cfg.SessionPath) + } +} + +// Pairing needs a human scanning a QR code, so a server boot must fail with an +// actionable message rather than hang waiting for one. +func TestStart_FailsClosedWithoutSession(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{ + "session_path": filepath.Join(t.TempDir(), "absent.db"), + })); err != nil { + t.Fatalf("Init: %v", err) + } + + err := p.Start(context.Background(), nil) + if err == nil { + t.Fatal("expected Start to fail on an unpaired session") + } + if !strings.Contains(err.Error(), "whatsapp-login") { + t.Errorf("error should name the pairing command, got %v", err) + } +} + +func TestSessionExists_FalseWhenAbsent(t *testing.T) { + if SessionExists(context.Background(), filepath.Join(t.TempDir(), "absent.db")) { + t.Error("expected false for a missing session file") + } +} + +// A store that opens but holds no completed pairing is not a usable session. +func TestSessionExists_FalseForUnpairedStore(t *testing.T) { + path := filepath.Join(t.TempDir(), "fresh.db") + ctx := context.Background() + + container, device, err := NewSessionDevice(ctx, path) + if err != nil { + t.Fatalf("NewSessionDevice: %v", err) + } + defer container.Close() //nolint:errcheck + if device == nil { + t.Fatal("expected a device to pair") + } + if device.ID != nil { + t.Error("a fresh device must not be pre-paired") + } + if SessionExists(ctx, path) { + t.Error("expected false for a store with no completed pairing") + } +} + +// The store must be creatable under a directory that doesn't exist yet — +// .forge/channels/ is not present in a fresh project. +func TestNewSessionDevice_CreatesParentDirs(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "deeper", "session.db") + container, _, err := NewSessionDevice(context.Background(), path) + if err != nil { + t.Fatalf("NewSessionDevice: %v", err) + } + defer container.Close() //nolint:errcheck +} + +// The store holds the pairing — a world-readable credential file is a leak. +func TestNewSessionDevice_SessionFileIsOwnerOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.db") + container, _, err := NewSessionDevice(context.Background(), path) + if err != nil { + t.Fatalf("NewSessionDevice: %v", err) + } + defer container.Close() //nolint:errcheck + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat session: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("session file mode = %04o, want 0600", perm) + } +} + +func TestOpenContainer_CreatesDirOwnerOnly(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested") + container, err := openContainer(context.Background(), filepath.Join(dir, "s.db")) + if err != nil { + t.Fatalf("openContainer: %v", err) + } + defer container.Close() //nolint:errcheck + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("session dir mode = %04o, want 0700", perm) + } +} + +func TestOpenContainer_RejectsEmptyPath(t *testing.T) { + if _, err := openContainer(context.Background(), ""); err == nil { + t.Fatal("expected error for empty session path") + } +} + +func TestSessionDSN_UsesModerncPragmaSyntax(t *testing.T) { + dsn := sessionDSN("/tmp/x.db") + // mattn's "_foreign_keys=on" is silently ignored by modernc; the pragma + // form is what actually enables cascading deletes. + if !strings.Contains(dsn, "_pragma=foreign_keys(1)") { + t.Errorf("dsn missing the modernc foreign-keys pragma: %q", dsn) + } + if strings.Contains(dsn, "_foreign_keys=") { + t.Errorf("dsn uses the mattn pragma form, which modernc ignores: %q", dsn) + } +} + +func TestExtractMessageText(t *testing.T) { + tests := []struct { + name string + msg *waE2E.Message + want string + }{ + {"nil", nil, ""}, + {"conversation", &waE2E.Message{Conversation: proto.String("plain")}, "plain"}, + { + "extended text", + &waE2E.Message{ExtendedTextMessage: &waE2E.ExtendedTextMessage{Text: proto.String("with context")}}, + "with context", + }, + { + "image caption", + &waE2E.Message{ImageMessage: &waE2E.ImageMessage{Caption: proto.String("what is this?")}}, + "what is this?", + }, + { + "document caption", + &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{Caption: proto.String("summarise")}}, + "summarise", + }, + {"empty message", &waE2E.Message{}, ""}, + } + for _, tt := range tests { + if got := extractMessageText(tt.msg); got != tt.want { + t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) + } + } +} + +// An edited message arrives wrapped; the new text is what the agent should see. +func TestExtractMessageText_UnwrapsEdit(t *testing.T) { + msg := &waE2E.Message{ + EditedMessage: &waE2E.FutureProofMessage{ + Message: &waE2E.Message{Conversation: proto.String("corrected")}, + }, + } + if got := extractMessageText(msg); got != "corrected" { + t.Errorf("got %q, want %q", got, "corrected") + } +} + +func TestMentionedJIDs(t *testing.T) { + if got := mentionedJIDs(nil); got != nil { + t.Errorf("nil message: got %v", got) + } + if got := mentionedJIDs(&waE2E.Message{Conversation: proto.String("hi")}); got != nil { + t.Errorf("plain conversation carries no mentions: got %v", got) + } + + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String("@agent hi"), + ContextInfo: &waE2E.ContextInfo{ + MentionedJID: []string{"14155550999@s.whatsapp.net"}, + }, + }, + } + got := mentionedJIDs(msg) + if len(got) != 1 || got[0] != "14155550999@s.whatsapp.net" { + t.Errorf("got %v", got) + } +} + +func TestExtractText_A2A(t *testing.T) { + if got := extractText(nil); got != "(no response)" { + t.Errorf("nil: got %q", got) + } + if got := extractText(&a2a.Message{}); got != "(no text response)" { + t.Errorf("empty parts: got %q", got) + } + + msg := &a2a.Message{Parts: []a2a.Part{ + {Kind: a2a.PartKindText, Text: "line one"}, + {Kind: a2a.PartKindText, Text: "line two"}, + }} + if got := extractText(msg); got != "line one\nline two" { + t.Errorf("got %q", got) + } +} + +// ctxzip markers are internal artifacts; a user must never see one. +func TestExtractText_StripsCompressionMarkers(t *testing.T) { + msg := &a2a.Message{Parts: []a2a.Part{ + {Kind: a2a.PartKindText, Text: "before <> after"}, + }} + if got := extractText(msg); strings.Contains(got, "ctxzip") { + t.Errorf("marker leaked: %q", got) + } +} + +func TestParseBool(t *testing.T) { + tests := []struct { + in string + def bool + out bool + }{ + {"", true, true}, + {"", false, false}, + {"true", false, true}, + {"false", true, false}, + {"1", false, true}, + {"garbage", true, true}, + } + for _, tt := range tests { + if got := parseBool(tt.in, tt.def); got != tt.out { + t.Errorf("parseBool(%q, %v) = %v, want %v", tt.in, tt.def, got, tt.out) + } + } +} + +func TestParseInt(t *testing.T) { + tests := []struct { + in string + def int + out int + }{ + {"", 20, 20}, + {"5", 20, 5}, + {"garbage", 20, 20}, + {" 7 ", 20, 7}, + } + for _, tt := range tests { + if got := parseInt(tt.in, tt.def); got != tt.out { + t.Errorf("parseInt(%q, %d) = %d, want %d", tt.in, tt.def, got, tt.out) + } + } +} + +func TestStop_IsIdempotent(t *testing.T) { + p := New() + if err := p.Stop(); err != nil { + t.Errorf("first Stop: %v", err) + } + if err := p.Stop(); err != nil { + t.Errorf("second Stop: %v", err) + } +} + +func TestMarkSent_IgnoresEmptyID(t *testing.T) { + p := New() + p.markSent("") + if p.dedup.size() != 0 { + t.Errorf("expected empty id ignored, size = %d", p.dedup.size()) + } + p.markSent("real") + if !p.dedup.seen("real") { + t.Error("expected real id recorded") + } +} + +// --- history-replay guard --- + +// After a restart the dedup ring is empty, so a replayed conversation would be +// answered from scratch — and in the self-chat the agent would answer its own +// replayed replies, which loops. Anything predating the run is dropped. +func TestIsStale_DropsReplayedHistory(t *testing.T) { + p := New() + p.startedAt = time.Now() + + if !p.isStale(p.startedAt.Add(-time.Hour)) { + t.Error("an hour-old message should be treated as replayed history") + } + if !p.isStale(p.startedAt.Add(-staleGrace - time.Minute)) { + t.Error("a message past the grace window should be stale") + } +} + +// A brief restart must still pick up messages sent while the agent was down. +func TestIsStale_KeepsRecentMessagesWithinGrace(t *testing.T) { + p := New() + p.startedAt = time.Now() + + if p.isStale(p.startedAt.Add(-time.Minute)) { + t.Error("a message from just before startup should still be answered") + } + if p.isStale(p.startedAt.Add(time.Second)) { + t.Error("a message sent after startup is live traffic") + } +} + +// Before Start the clock is unset; nothing should be judged stale. +func TestIsStale_NoStartTimeAcceptsEverything(t *testing.T) { + p := New() + if p.isStale(time.Now().Add(-24 * time.Hour)) { + t.Error("with no start time recorded, nothing is stale") + } +} + +// A zero timestamp carries no information — don't drop on it. +func TestIsStale_ZeroTimestampNotStale(t *testing.T) { + p := New() + p.startedAt = time.Now() + if p.isStale(time.Time{}) { + t.Error("a zero timestamp must not be treated as stale") + } +} + +func TestInit_SelfChatDefaultsOn(t *testing.T) { + p := New() + if err := p.Init(cfgWith(nil)); err != nil { + t.Fatalf("Init: %v", err) + } + if !p.cfg.Admission.SelfChat { + t.Error("self_chat should default to true — it is the personal-agent flow") + } + if p.cfg.Admission.AllowAnySender { + t.Error("allowed_senders must default to owner-only, not open") + } +} + +func TestInit_AllowAnySenderOptIn(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"allowed_senders": "anyone"})); err != nil { + t.Fatalf("Init: %v", err) + } + if !p.cfg.Admission.AllowAnySender { + t.Error("allowed_senders: anyone should disable the allowlist") + } +} + +func TestInit_SelfChatCanBeDisabled(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"self_chat": "false"})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.Admission.SelfChat { + t.Error("self_chat: false should disable it") + } +} diff --git a/forge-plugins/go.mod b/forge-plugins/go.mod index f2854262..671ff781 100644 --- a/forge-plugins/go.mod +++ b/forge-plugins/go.mod @@ -6,4 +6,32 @@ require github.com/initializ/forge/forge-core v0.0.0 require github.com/gorilla/websocket v1.5.3 +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.0.1 // 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.0.0 // 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.10.0 // indirect + go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.58.0 // indirect +) + replace github.com/initializ/forge/forge-core => ../forge-core diff --git a/forge-plugins/go.sum b/forge-plugins/go.sum index 25a9fc4b..ec3941fd 100644 --- a/forge-plugins/go.sum +++ b/forge-plugins/go.sum @@ -1,2 +1,59 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +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.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/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.10.0 h1:vH9IXZmfBKa96p47HxrVqEPkrj02zDJg3o4EF172+Lk= +go.mau.fi/util v0.10.0/go.mod h1:uZwpm9sK4wO2Qqy+t6QoVq29szMsRxWXp9/BkQLG4xk= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde h1:eMHY9dMDkNuDMWhfTbMZHbbsxj7G6mfujjKei1HaFQM= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde/go.mod h1:z0ZZNt4hq3FZbUKnunexE/QscCx7VkLvQSvtggc/aE8= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 h1:xJ13dqFcK/oPNImltlk7sumI/uiQ9EON/6g/iwpT1Nc= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837/go.mod h1:UFP0D7aj+biuFejsllcoNiHTQorh0ODFUyG4Txpqfks= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1 h1:4EyoinbJqFq4qozxem5nklT8+pI4OaokUuG+ayE3nKQ= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1/go.mod h1:aMd13H2xFFGH9cskcvxo4Aae+TmyFN38yw+HvsrpwVg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +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/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= diff --git a/forge-ui/handlers_create.go b/forge-ui/handlers_create.go index 7b984671..26d76343 100644 --- a/forge-ui/handlers_create.go +++ b/forge-ui/handlers_create.go @@ -24,7 +24,7 @@ func (s *UIServer) handleGetWizardMeta(w http.ResponseWriter, _ *http.Request) { meta := WizardMetadata{ Providers: []string{"openai", "anthropic", "gemini", "ollama", "custom"}, Frameworks: []string{"forge", "crewai", "langchain"}, - Channels: []string{"slack", "telegram"}, + Channels: []string{"slack", "telegram", "msteams", "whatsapp"}, } // Per-provider model lists diff --git a/forge.yaml.example b/forge.yaml.example index 6c3ef66d..4645b215 100644 --- a/forge.yaml.example +++ b/forge.yaml.example @@ -35,6 +35,8 @@ skills: channels: - slack - telegram +# - msteams +# - whatsapp # pair first: forge channel whatsapp-login # Secret management (optional — defaults to env vars only) # secrets: diff --git a/go.work.sum b/go.work.sum index f4542c48..e35ed40b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,8 +1,10 @@ cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794/go.mod h1:7e+I0LQFUI9AXWxOfsQROs9xPhoJtbsyWcjJqDd4KPY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= @@ -10,9 +12,9 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= @@ -26,28 +28,41 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/perf v0.0.0-20250813145418-2f7363a06fe1/go.mod h1:rjfRjhHXb3XNVh/9i5Jr2tXoTd0vOlZN5rzsM8cQE6k= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= From 1d30f7ddd7054897898ebe699700e1d73f48325e Mon Sep 17 00:00:00 2001 From: Ratnesh Maurya <85143283+ratnesh-maurya@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:22:34 +0530 Subject: [PATCH 3/4] refactor(whatsapp): drop the ban warning from the wizard QR view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pairing screen is the most vertically constrained view in the wizard — the QR itself is 26 rows before any chrome — and the warning cost three lines directly under it, on the one screen where fitting matters most. It stays in the three places where it can be read properly: the `channel add` setup output, the `whatsapp-login` command's help, and docs/core-concepts/channels.md. For reference, OpenClaw carries no equivalent warning at all; its docs frame a separate number as an optimization ("setup and metadata are optimized for it"), not a risk. --- forge-cli/internal/tui/steps/channel_step_whatsapp.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/forge-cli/internal/tui/steps/channel_step_whatsapp.go b/forge-cli/internal/tui/steps/channel_step_whatsapp.go index e62a8ca3..d3da4296 100644 --- a/forge-cli/internal/tui/steps/channel_step_whatsapp.go +++ b/forge-cli/internal/tui/steps/channel_step_whatsapp.go @@ -210,8 +210,6 @@ func (s *ChannelStep) viewWhatsappPair() string { b.WriteString("\n") } - fmt.Fprintf(&b, " %s\n", s.styles.ErrorTxt.Render("Use a DEDICATED number, not your personal one —")) - fmt.Fprintf(&b, " %s\n\n", s.styles.ErrorTxt.Render("WhatsApp may ban numbers used for automation.")) fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("⣾ Waiting for the scan. The code refreshes automatically.")) fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("(Press S to skip and pair later.)")) return b.String() From 6dcb39fbe87783dac0b9d201f02597ec1d68d309 Mon Sep 17 00:00:00 2001 From: Ratnesh Maurya <85143283+ratnesh-maurya@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:27:47 +0530 Subject: [PATCH 4/4] feat(whatsapp): mark agent replies in the self-chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp decides which side of a thread a message renders on from its sender, and in the self-chat the agent sends AS the owner. Its replies are therefore visually identical to the owner's own prompts — same side, same colour — and nothing on the wire changes that. Replies in the self-chat are now prefixed, "⚒ Forge: " by default, matching the CLI banner's mark. Configurable via self_chat_prefix; an explicitly empty value disables it, which is why the setting is read with settingOrDefault rather than strOrDefault (the latter would swallow a deliberate ""). Applied only in the self-chat: a normal DM or group already distinguishes sender from recipient, so a marker there is noise. Every chunk of a split reply is marked, not just the first — an unmarked continuation is indistinguishable from something the owner typed when scrolling back. A chunk opening with a fenced code block gets the marker on its own line, since inlining it would put text before the opening ``` and WhatsApp would render the fence literally. --- docs/core-concepts/channels.md | 17 +++++ .../templates/init/whatsapp-config.yaml.tmpl | 8 ++ forge-plugins/channels/whatsapp/whatsapp.go | 45 ++++++++++++ .../channels/whatsapp/whatsapp_test.go | 73 +++++++++++++++++++ 4 files changed, 143 insertions(+) diff --git a/docs/core-concepts/channels.md b/docs/core-concepts/channels.md index a3050e38..fcee5f16 100644 --- a/docs/core-concepts/channels.md +++ b/docs/core-concepts/channels.md @@ -190,6 +190,7 @@ settings: allowed_groups: "" # comma/newline-separated group JIDs; empty = all allowed_senders: "" # empty = OWNER ONLY; "anyone" opens it up self_chat: true # answer your own "Message Yourself" chat + self_chat_prefix: "⚒ Forge: " # marks replies in the self-chat; "" disables include_recent_history: true recent_history_count: 20 ``` @@ -201,6 +202,7 @@ settings: | `allowed_groups` | *(all)* | Group JIDs; the `@g.us` suffix may be omitted. | | `allowed_senders` | *(owner only)* | Empty admits only the paired account. List numbers to add people, or set `anyone` to open it up. The owner always passes. | | `self_chat` | `true` | Answer messages you send yourself. | +| `self_chat_prefix` | `⚒ Forge: ` | Marks the agent's replies in the self-chat. `""` disables. | | `include_recent_history` | `true` | Injects observed chat context into the prompt. | | `recent_history_count` | `20` | Per-chat window; block soft-capped at ~5000 chars. | @@ -219,6 +221,21 @@ flagged as self-sent, and so do the agent's replies — the loop guard is the dedup ring, which records every message the agent sends before it can come back around. Self-messages are accepted **only** in that chat, never in groups. +In that chat the agent sends **as you**, so WhatsApp renders its replies on the +same side, in the same colour, as your own messages — the sender is identical, +and nothing on the wire can change that. Replies are therefore prefixed: + +``` +what is 2+2? +⚒ Forge: 4 +``` + +Change the marker with `self_chat_prefix`, or set it to `""` to turn it off. +It is only applied in the self-chat; a normal DM already distinguishes sender +from recipient. For real visual separation, message the agent from a second +WhatsApp account instead — a group works too, but note a group containing only +your own number will not: self-messages are accepted in the self-chat only. + To have the agent serve other people instead, list them: ```yaml diff --git a/forge-cli/templates/init/whatsapp-config.yaml.tmpl b/forge-cli/templates/init/whatsapp-config.yaml.tmpl index 7e065352..16825cb5 100644 --- a/forge-cli/templates/init/whatsapp-config.yaml.tmpl +++ b/forge-cli/templates/init/whatsapp-config.yaml.tmpl @@ -52,6 +52,14 @@ settings: # only ever accepted in this chat, never in groups. self_chat: true + # Marker prepended to the agent's replies in the self-chat. + # + # WhatsApp picks which side of the thread a message renders on from its + # sender, and in the self-chat the agent sends AS you — so without a marker + # its replies look exactly like your own messages. Set to "" to disable. + # Only ever applied in the self-chat; a normal DM already shows who sent what. + self_chat_prefix: "⚒ Forge: " + # Prepend recently observed messages from the chat to each dispatched # prompt, so the agent can answer "summarise the above" in a group. # diff --git a/forge-plugins/channels/whatsapp/whatsapp.go b/forge-plugins/channels/whatsapp/whatsapp.go index 77cddf3d..80bb6b95 100644 --- a/forge-plugins/channels/whatsapp/whatsapp.go +++ b/forge-plugins/channels/whatsapp/whatsapp.go @@ -33,6 +33,13 @@ const ( // historySoftCap bounds the injected context block, mirroring the Teams // adapter's budget guard. historySoftCap = 5000 + // defaultSelfChatPrefix marks the agent's replies in the owner's self-chat. + // + // WhatsApp decides which side of the thread a message renders on from its + // sender, and in the self-chat the agent sends AS the owner — so its + // replies are visually identical to the owner's own prompts. No wire-level + // setting changes that; a text marker is the only way to tell them apart. + defaultSelfChatPrefix = "⚒ Forge: " // staleGrace is how far before startup an inbound message may be dated and // still be acted on. // @@ -86,6 +93,7 @@ type Plugin struct { type adapterConfig struct { SessionPath string + SelfChatPrefix string Admission admissionConfig IncludeRecentHistory bool RecentHistoryCount int @@ -109,6 +117,9 @@ func (p *Plugin) Init(cfg channels.ChannelConfig) error { ac := adapterConfig{ SessionPath: strOrDefault(settings["session_path"], defaultSessionPath), + // strOrDefault would swallow a deliberate "" (meaning "no prefix"), so + // the presence of the key is what decides here. + SelfChatPrefix: settingOrDefault(settings, "self_chat_prefix", defaultSelfChatPrefix), Admission: admissionConfig{ Mode: AdmitMode(strOrDefault(settings["admit"], string(AdmitDMOrGroupMention))), AllowedGroups: parseJIDSet(settings["allowed_groups"], serverGroup), @@ -403,6 +414,15 @@ func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Messag text := extractText(response) body := markdown.ToWhatsAppText(text) + // Mark replies in the self-chat, where the agent sends as the owner and + // WhatsApp gives no visual distinction. Every chunk is marked, not just + // the first: an unmarked continuation is indistinguishable from something + // the owner typed when scrolling back. + prefix := "" + if p.cfg.SelfChatPrefix != "" && p.cfg.Admission.isSelfChat(event.WorkspaceID) { + prefix = p.cfg.SelfChatPrefix + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() @@ -410,6 +430,7 @@ func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Messag if strings.TrimSpace(chunk) == "" { continue } + chunk = applyPrefix(prefix, chunk) resp, err := p.client.SendMessage(ctx, chat, &waE2E.Message{ Conversation: proto.String(chunk), }) @@ -587,6 +608,30 @@ func strOrDefault(s, def string) string { return strings.TrimSpace(s) } +// applyPrefix prepends the reply marker. +// +// A chunk that opens with a fenced code block gets the marker on its own line: +// inlining it would put text before the opening ``` and WhatsApp would render +// the fence literally instead of as code. +func applyPrefix(prefix, chunk string) string { + if prefix == "" { + return chunk + } + if strings.HasPrefix(chunk, "```") { + return prefix + "\n" + chunk + } + return prefix + chunk +} + +// settingOrDefault returns the configured value when the key is present — +// including when it is deliberately empty — and def when it is absent. +func settingOrDefault(settings map[string]string, key, def string) string { + if v, ok := settings[key]; ok { + return v + } + return def +} + // isAnySender reports whether allowed_senders is the explicit opt-out that // disables the sender allowlist. Spelled as a word rather than left empty // because it is the setting that exposes the agent to anyone with the number, diff --git a/forge-plugins/channels/whatsapp/whatsapp_test.go b/forge-plugins/channels/whatsapp/whatsapp_test.go index 843a42be..f974962a 100644 --- a/forge-plugins/channels/whatsapp/whatsapp_test.go +++ b/forge-plugins/channels/whatsapp/whatsapp_test.go @@ -448,3 +448,76 @@ func TestInit_SelfChatCanBeDisabled(t *testing.T) { t.Error("self_chat: false should disable it") } } + +// --- self-chat reply prefix --- + +func TestApplyPrefix_PrependsMarker(t *testing.T) { + if got := applyPrefix("⚒ Forge: ", "hello"); got != "⚒ Forge: hello" { + t.Errorf("got %q", got) + } +} + +func TestApplyPrefix_EmptyPrefixIsNoop(t *testing.T) { + if got := applyPrefix("", "hello"); got != "hello" { + t.Errorf("got %q, want the chunk unchanged", got) + } +} + +// Inlining the marker before an opening fence makes WhatsApp render the fence +// literally instead of as a code block. +func TestApplyPrefix_FencedCodeGetsOwnLine(t *testing.T) { + got := applyPrefix("⚒ Forge: ", "```go\nfmt.Println()\n```") + if !strings.HasPrefix(got, "⚒ Forge: \n```go") { + t.Errorf("marker should sit on its own line before a fence, got %q", got) + } +} + +func TestInit_SelfChatPrefixDefault(t *testing.T) { + p := New() + if err := p.Init(cfgWith(nil)); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SelfChatPrefix != defaultSelfChatPrefix { + t.Errorf("prefix = %q, want %q", p.cfg.SelfChatPrefix, defaultSelfChatPrefix) + } + if !strings.Contains(p.cfg.SelfChatPrefix, "Forge") { + t.Errorf("default prefix should name Forge, got %q", p.cfg.SelfChatPrefix) + } +} + +func TestInit_SelfChatPrefixOverride(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"self_chat_prefix": "🤖 bot: "})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SelfChatPrefix != "🤖 bot: " { + t.Errorf("prefix = %q, want the override", p.cfg.SelfChatPrefix) + } +} + +// An explicitly empty value means "no marker" and must not fall back to the +// default the way strOrDefault would. +func TestInit_SelfChatPrefixCanBeDisabled(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"self_chat_prefix": ""})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SelfChatPrefix != "" { + t.Errorf("prefix = %q, want it disabled", p.cfg.SelfChatPrefix) + } +} + +// The marker exists because the self-chat has no sender distinction; a normal +// DM already shows who sent what, so marking there would just be noise. +func TestSelfChatPrefix_AppliesOnlyToSelfChat(t *testing.T) { + cfg := admissionConfig{OwnJIDs: []string{testOwnJID}} + + if !cfg.isSelfChat(testOwnJID) { + t.Error("the owner's own chat should be recognised as the self-chat") + } + for _, chat := range []string{"14155550100@s.whatsapp.net", "120363000000000000@g.us"} { + if cfg.isSelfChat(chat) { + t.Errorf("%q must not be treated as the self-chat", chat) + } + } +}