diff --git a/cmd/channels_cmd.go b/cmd/channels_cmd.go index 471550908..4353bbfc4 100644 --- a/cmd/channels_cmd.go +++ b/cmd/channels_cmd.go @@ -44,7 +44,7 @@ func channelsListCmd() *cobra.Command { {"discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != ""}, {"zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != ""}, {"feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != ""}, - {"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != ""}, + {"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled}, } if jsonOutput { diff --git a/cmd/doctor.go b/cmd/doctor.go index 1752216aa..055d554e4 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -120,7 +120,7 @@ func runDoctor() { checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "") checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "") checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "") - checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "") + checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled) } // External tools diff --git a/cmd/gateway.go b/cmd/gateway.go index f05b2a033..3e3007164 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -563,7 +563,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory) instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) - instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory) + instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages, "pgx")) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 64cc6cd6e..77976099d 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -68,9 +68,12 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } if cfg.Channels.WhatsApp.Enabled { - if cfg.Channels.WhatsApp.BridgeURL == "" { - recordMissingConfig(channels.TypeWhatsApp, "Set channels.whatsapp.bridge_url in config.") - } else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil); err != nil { + waDialect := "pgx" + if strings.Contains(fmt.Sprintf("%T", pgStores.DB.Driver()), "sqlite") { + waDialect = "sqlite3" + } + wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages, waDialect) + if err != nil { channelMgr.RecordFailure(channels.TypeWhatsApp, "", err) slog.Error("failed to initialize whatsapp channel", "error", err) } else { @@ -143,6 +146,7 @@ func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, chann methods.NewChannelInstancesMethods(pgStores.ChannelInstances, msgBus, msgBus).Register(server.Router()) zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router()) zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router()) + whatsapp.NewQRMethods(pgStores.ChannelInstances, channelMgr).Register(server.Router()) } // Register agent links WS RPC methods diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index ff8cffc0c..59aaf28bb 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -156,13 +156,13 @@ flowchart TD | Feature | Telegram | Feishu/Lark | Discord | Slack | WhatsApp | Zalo OA | Zalo Personal | |---------|----------|-------------|---------|-------|----------|---------|---------------| -| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | External WS bridge | Long polling | Internal protocol | +| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Direct protocol (in-process) | Long polling | Internal protocol | | DM support | Yes | Yes | Yes | Yes | Yes | Yes (DM only) | Yes | | Group support | Yes (mention gating) | Yes | Yes | Yes (mention gating + thread cache) | Yes | No | Yes | | Forum/Topics | Yes (per-topic config) | Yes (topic session mode) | -- | -- | -- | -- | -- | -| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | N/A (bridge) | 2,000 chars | 2,000 chars | +| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | WhatsApp native limit | 2,000 chars | 2,000 chars | | Streaming | Typing indicator | Streaming message cards | Edit "Thinking..." | Edit "Thinking..." (throttled 1s) | No | No | No | -| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | JSON messages | Images (5 MB) | -- | +| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | Images, audio, video, documents | Images (5 MB) | -- | | Speech-to-text | Yes (STT proxy) | -- | -- | -- | -- | -- | -- | | Voice routing | Yes (VoiceAgentID) | -- | -- | -- | -- | -- | -- | | Rich formatting | Markdown → HTML | Card messages | Markdown | Markdown → mrkdwn | Plain text | Plain text | Plain text | @@ -430,15 +430,18 @@ Auto-enables when both bot_token and app_token are set. ## 9. WhatsApp -The WhatsApp channel communicates through an external WebSocket bridge (e.g., whatsapp-web.js based). GoClaw does not implement the WhatsApp protocol directly. +The WhatsApp channel connects directly to the WhatsApp network via the multi-device protocol. Authentication state is stored in the database (PostgreSQL standard, SQLite for desktop edition). ### Key Behaviors -- **Bridge connection**: Connects to configurable `bridge_url` via WebSocket -- **JSON format**: Messages sent/received as JSON objects -- **Auto-reconnect**: Exponential backoff (1s → 30s max) -- **DM and group support**: Group detection via `@g.us` suffix in chat ID -- **Media handling**: Array of file paths from bridge protocol +- **Direct connection**: In-process WhatsApp client (direct to WhatsApp servers, no external bridge) +- **Database auth store**: Persists auth state, keys, and device info in the database +- **QR code authentication**: Interactive QR code for initial pairing, served via WebSocket API +- **Auto-reconnect**: Built-in reconnection with exponential backoff +- **DM and group support**: Full group messaging with mention detection via JID format +- **Media handling**: Direct media download/upload to WhatsApp servers with type detection +- **Typing indicators**: Typing state managed per chat with auto-refresh +- **Group mention gating**: Detects when bot is mentioned via LID (Local ID) and JID (standard format) --- @@ -590,7 +593,10 @@ flowchart TD | `internal/channels/slack/format.go` | Markdown → Slack mrkdwn pipeline | | `internal/channels/slack/reactions.go` | Status emoji reactions on messages | | `internal/channels/slack/stream.go` | Streaming message updates via placeholder editing | -| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: external WS bridge | +| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: direct protocol client, QR auth, database persistence | +| `internal/channels/whatsapp/factory.go` | Channel factory, database dialect detection | +| `internal/channels/whatsapp/qr_methods.go` | QR code generation and authentication flow | +| `internal/channels/whatsapp/format.go` | Message formatting (HTML-to-WhatsApp) | | `internal/channels/zalo/zalo.go` | Zalo OA: Bot API, long polling | | `internal/channels/zalo/personal/channel.go` | Zalo Personal: reverse-engineered protocol | | `internal/store/pg/pairing.go` | Pairing: code generation, approval, persistence (database-backed) | diff --git a/docs/17-changelog.md b/docs/17-changelog.md index 6ae4e2da4..5e37c9c4b 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -34,6 +34,21 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Added +#### WhatsApp Native Protocol Integration (2026-04-06) +- **Direct protocol migration**: Replaced Node.js Baileys bridge with direct in-process WhatsApp connectivity +- **Database auth persistence**: Auth state, device keys, and client metadata stored in PostgreSQL (standard) or SQLite (desktop) +- **QR authentication**: Interactive QR code authentication for device linking without external bridge relay +- **No more bridge_url**: Removed `bridge_url` configuration, eliminated `docker-compose.whatsapp.yml`, removed `bridge/whatsapp/` sidecar service +- **Enhanced media handling**: Direct media download/upload to WhatsApp servers with automatic type detection and streaming +- **Improved mention detection**: Group mention detection now uses LID (Local ID) + JID (standard format) for robust message routing +- **Files added**: + - `internal/channels/whatsapp/factory.go` — Dialect detection and channel factory + - `internal/channels/whatsapp/qr_methods.go` — QR code generation and authentication flow + - `internal/channels/whatsapp/format.go` — HTML-to-WhatsApp message formatting + - Database-backed auth persistence for cross-platform support + +### Refactored + #### Parallel Sub-Agent Enhancement (#600) (2026-03-31) - **Smart leader delegation**: Conditional leader delegation prompt instead of forced delegation for all subagent spawns - **Compaction prompt persistence**: Preserves pending subagent and team task state across context summarization to maintain work continuity diff --git a/go.mod b/go.mod index d622076f9..0d749fa5d 100644 --- a/go.mod +++ b/go.mod @@ -17,11 +17,13 @@ require ( github.com/mattn/go-shellwords v1.0.12 github.com/mymmrac/telego v1.6.0 github.com/redis/go-redis/v9 v9.18.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/slack-go/slack v0.19.0 github.com/spf13/cobra v1.10.2 github.com/titanous/json5 v1.0.0 github.com/wailsapp/wails/v2 v2.11.0 github.com/zalando/go-keyring v0.2.8 + go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 @@ -53,6 +55,7 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -64,11 +67,12 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/coder/websocket v1.8.12 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/creachadair/msync v0.7.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect @@ -91,7 +95,7 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.0 // indirect @@ -101,11 +105,13 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/samber/lo v1.49.1 // indirect github.com/spf13/cast v1.7.1 // indirect @@ -117,17 +123,20 @@ require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/util v0.9.6 // indirect go.uber.org/atomic v1.11.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/term v0.39.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -175,14 +184,14 @@ require ( go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.49.0 + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.33.0 + golang.org/x/text v0.34.0 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 00c05f1d6..0ecd917dc 100644 --- a/go.sum +++ b/go.sum @@ -8,16 +8,22 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= +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/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -60,6 +66,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +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/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -114,14 +122,15 @@ github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= @@ -159,6 +168,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -188,6 +199,7 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -288,9 +300,11 @@ github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9a github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -299,6 +313,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= @@ -337,6 +353,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= @@ -367,11 +385,18 @@ github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGG github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= 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/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44= github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= @@ -433,6 +458,8 @@ github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpB github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +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/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= @@ -469,6 +496,12 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPc github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= +go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 h1:E4A6eca9vMJQctC9DIfzUIg27TrJ8IrDHgkJwJ8WPUQ= +go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= 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.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -505,10 +538,10 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -518,8 +551,8 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -532,16 +565,17 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/channels/whatsapp/auth.go b/internal/channels/whatsapp/auth.go new file mode 100644 index 000000000..76deb6f3e --- /dev/null +++ b/internal/channels/whatsapp/auth.go @@ -0,0 +1,102 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + + "go.mau.fi/whatsmeow" +) + +// StartQRFlow initiates the QR authentication flow. +// Returns a channel that emits QR code strings and auth events. +// Lazily initializes the whatsmeow client if Start() hasn't been called yet +// (handles timing race between async instance reload and wizard auto-start). +// Serialized with Reauth via reauthMu to prevent races on rapid double-clicks. +func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() + if c.client == nil { + // Lazy init: wizard may request QR before Start() is called. + c.mu.Lock() + if c.client == nil { + if c.ctx == nil { + c.ctx, c.cancel = context.WithCancel(context.Background()) + } + deviceStore, err := c.container.GetFirstDevice(ctx) + if err != nil { + c.mu.Unlock() + return nil, fmt.Errorf("whatsapp get device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + } + c.mu.Unlock() + } + + if c.IsAuthenticated() { + return nil, nil // caller checks this + } + + qrChan, err := c.client.GetQRChannel(ctx) + if err != nil { + return nil, fmt.Errorf("whatsapp get QR channel: %w", err) + } + + if !c.client.IsConnected() { + if err := c.client.Connect(); err != nil { + return nil, fmt.Errorf("whatsapp connect for QR: %w", err) + } + } + + return qrChan, nil +} + +// Reauth clears the current session and prepares for a fresh QR scan. +// Serialized with StartQRFlow via reauthMu to prevent races on rapid double-clicks. +func (c *Channel) Reauth() error { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() + + slog.Info("whatsapp: reauth requested", "channel", c.Name()) + + c.lastQRMu.Lock() + c.waAuthenticated = false + c.lastQRB64 = "" + c.lastQRMu.Unlock() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + c.client.Disconnect() + } + + // Delete device from store to force fresh QR on next connect. + if c.client != nil && c.client.Store.ID != nil { + if err := c.client.Store.Delete(context.Background()); err != nil { + slog.Warn("whatsapp: failed to delete device store", "error", err) + } + } + + // Reset context so the new client gets a fresh lifecycle. + if c.cancel != nil { + c.cancel() + } + // Use parentCtx if available so the new lifecycle is still bound to the gateway. + parent := c.parentCtx + if parent == nil { + parent = context.Background() + } + c.ctx, c.cancel = context.WithCancel(parent) + + // Re-create client with fresh device store. + deviceStore, err := c.container.GetFirstDevice(context.Background()) + if err != nil { + return fmt.Errorf("whatsapp: get fresh device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + + return nil +} diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index 3a039f33e..e49801eb6 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -1,6 +1,7 @@ package whatsapp import ( + "database/sql" "encoding/json" "fmt" @@ -10,59 +11,64 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -// whatsappCreds maps the credentials JSON from the channel_instances table. -type whatsappCreds struct { - BridgeURL string `json:"bridge_url"` -} - // whatsappInstanceConfig maps the non-secret config JSONB from the channel_instances table. type whatsappInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` } -// Factory creates a WhatsApp channel from DB instance data. -func Factory(name string, creds json.RawMessage, cfg json.RawMessage, - msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { +// FactoryWithDB returns a ChannelFactory with DB access for whatsmeow auth state. +// dialect must be "pgx" (PostgreSQL) or "sqlite3" (SQLite/desktop). +func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore, dialect string) channels.ChannelFactory { + return func(name string, creds json.RawMessage, cfg json.RawMessage, + msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { - var c whatsappCreds - if len(creds) > 0 { - if err := json.Unmarshal(creds, &c); err != nil { - return nil, fmt.Errorf("decode whatsapp credentials: %w", err) + var ic whatsappInstanceConfig + if len(cfg) > 0 { + if err := json.Unmarshal(cfg, &ic); err != nil { + return nil, fmt.Errorf("decode whatsapp config: %w", err) + } } - } - if c.BridgeURL == "" { - return nil, fmt.Errorf("whatsapp bridge_url is required") - } - var ic whatsappInstanceConfig - if len(cfg) > 0 { - if err := json.Unmarshal(cfg, &ic); err != nil { - return nil, fmt.Errorf("decode whatsapp config: %w", err) + // Detect old bridge_url config and give clear migration error. + if len(cfg) > 0 { + var legacy struct{ BridgeURL string `json:"bridge_url"` } + if json.Unmarshal(cfg, &legacy) == nil && legacy.BridgeURL != "" { + return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from config") + } + } + if len(creds) > 0 { + var legacy struct{ BridgeURL string `json:"bridge_url"` } + if json.Unmarshal(creds, &legacy) == nil && legacy.BridgeURL != "" { + return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from credentials") + } } - } - - waCfg := config.WhatsAppConfig{ - Enabled: true, - BridgeURL: c.BridgeURL, - AllowFrom: ic.AllowFrom, - DMPolicy: ic.DMPolicy, - GroupPolicy: ic.GroupPolicy, - BlockReply: ic.BlockReply, - } - // DB instances default to "pairing" for groups (secure by default). - if waCfg.GroupPolicy == "" { - waCfg.GroupPolicy = "pairing" - } + waCfg := config.WhatsAppConfig{ + Enabled: true, + AllowFrom: ic.AllowFrom, + DMPolicy: ic.DMPolicy, + GroupPolicy: ic.GroupPolicy, + RequireMention: ic.RequireMention, + HistoryLimit: ic.HistoryLimit, + BlockReply: ic.BlockReply, + } + // DB instances default to "pairing" for groups (secure by default). + if waCfg.GroupPolicy == "" { + waCfg.GroupPolicy = "pairing" + } - ch, err := New(waCfg, msgBus, pairingSvc) - if err != nil { - return nil, err + ch, err := New(waCfg, msgBus, pairingSvc, db, pendingStore, dialect) + if err != nil { + return nil, err + } + ch.SetName(name) + return ch, nil } - - ch.SetName(name) - return ch, nil } diff --git a/internal/channels/whatsapp/format.go b/internal/channels/whatsapp/format.go new file mode 100644 index 000000000..0ac14f9d7 --- /dev/null +++ b/internal/channels/whatsapp/format.go @@ -0,0 +1,137 @@ +package whatsapp + +import ( + "fmt" + "regexp" + "strings" +) + +// markdownToWhatsApp converts Markdown-formatted LLM output to WhatsApp's native +// formatting syntax. WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```. +// Unsupported features are simplified: headers → bold, links → "text url", tables → plain. +func markdownToWhatsApp(text string) string { + if text == "" { + return "" + } + + // Pre-process: convert HTML tags from LLM output to Markdown equivalents. + text = htmlTagToWaMd(text) + + // Extract and protect fenced code blocks — WhatsApp renders ``` the same way. + codeBlocks := waExtractCodeBlocks(text) + text = codeBlocks.text + + // Headers (##, ###, etc.) → *bold text* (WhatsApp has no header concept). + text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "*$1*") + + // Blockquotes → plain text. + text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1") + + // Links [text](url) → "text url" (WhatsApp doesn't support markdown links). + text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, "$1 $2") + + // Bold: **text** or __text__ → *text* + text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "*$1*") + text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "*$1*") + + // Strikethrough: ~~text~~ → ~text~ + text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "~$1~") + + // Inline code: `code` → ```code``` (WhatsApp has no inline code, only blocks). + text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "```$1```") + + // List items: leading - or * → bullet • + text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ") + + // Restore code blocks as ``` … ``` preserving original content. + for i, code := range codeBlocks.codes { + // Trim trailing newline from extracted content — we add our own. + code = strings.TrimRight(code, "\n") + text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), "```\n"+code+"\n```") + } + + // Collapse 3+ blank lines to 2. + text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") + + return strings.TrimSpace(text) +} + +// htmlTagToWaMd converts common HTML tags in LLM output to Markdown equivalents +// so they are then processed by the markdown → WhatsApp pipeline above. +var htmlToWaMdReplacers = []struct { + re *regexp.Regexp + repl string +}{ + {regexp.MustCompile(`(?i)`), "\n"}, + {regexp.MustCompile(`(?i)`), "\n"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**${1}**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**${1}**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "`${1}`"}, + {regexp.MustCompile(`(?i)]*>([\s\S]*?)`), "[${2}](${1})"}, +} + +func htmlTagToWaMd(text string) string { + for _, r := range htmlToWaMdReplacers { + text = r.re.ReplaceAllString(text, r.repl) + } + return text +} + +type waCodeBlockMatch struct { + text string + codes []string +} + +// waExtractCodeBlocks pulls fenced code blocks out of text and replaces them with +// \x00CB{n}\x00 placeholders so other regex passes don't mangle their contents. +func waExtractCodeBlocks(text string) waCodeBlockMatch { + re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + matches := re.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, m := range matches { + codes = append(codes, m[1]) + } + + i := 0 + text = re.ReplaceAllStringFunc(text, func(_ string) string { + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder + }) + + return waCodeBlockMatch{text: text, codes: codes} +} + +// chunkText splits text into pieces that fit within maxLen, +// preferring to split at paragraph (\n\n) or line (\n) boundaries. +func chunkText(text string, maxLen int) []string { + if len(text) <= maxLen { + return []string{text} + } + + var chunks []string + for len(text) > 0 { + if len(text) <= maxLen { + chunks = append(chunks, text) + break + } + // Find the best split point: paragraph > line > space > hard cut. + cutAt := maxLen + if idx := strings.LastIndex(text[:maxLen], "\n\n"); idx > 0 { + cutAt = idx + } else if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 { + cutAt = idx + } else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 { + cutAt = idx + } + chunks = append(chunks, strings.TrimRight(text[:cutAt], " \n")) + text = strings.TrimLeft(text[cutAt:], " \n") + } + return chunks +} diff --git a/internal/channels/whatsapp/format_test.go b/internal/channels/whatsapp/format_test.go new file mode 100644 index 000000000..9b93ba86e --- /dev/null +++ b/internal/channels/whatsapp/format_test.go @@ -0,0 +1,49 @@ +package whatsapp + +import "testing" + +func TestMarkdownToWhatsApp(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"plain text", "hello world", "hello world"}, + {"header h1", "# Title", "*Title*"}, + {"header h3", "### Sub", "*Sub*"}, + {"bold stars", "this is **bold** text", "this is *bold* text"}, + {"bold underscores", "this is __bold__ text", "this is *bold* text"}, + {"strikethrough", "~~deleted~~", "~deleted~"}, + {"inline code", "use `fmt.Println`", "use ```fmt.Println```"}, + {"link", "[Go](https://go.dev)", "Go https://go.dev"}, + {"unordered list dash", "- item one\n- item two", "• item one\n• item two"}, + {"unordered list star", "* item one\n* item two", "• item one\n• item two"}, + {"blockquote", "> quoted text", "quoted text"}, + { + "fenced code block preserved", + "```go\nfmt.Println(\"hi\")\n```", + "```\nfmt.Println(\"hi\")\n```", + }, + { + "code block not mangled by bold regex", + "```\n**not bold**\n```", + "```\n**not bold**\n```", + }, + {"collapse blank lines", "a\n\n\n\nb", "a\n\nb"}, + {"html bold", "bold", "*bold*"}, + {"html italic", "italic", "_italic_"}, + {"html strikethrough", "removed", "~removed~"}, + {"html br", "line1
line2", "line1\nline2"}, + {"html link", `link`, "link https://x.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := markdownToWhatsApp(tt.in) + if got != tt.want { + t.Errorf("markdownToWhatsApp(%q)\n got: %q\nwant: %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/channels/whatsapp/inbound.go b/internal/channels/whatsapp/inbound.go new file mode 100644 index 000000000..ecc78b4fa --- /dev/null +++ b/internal/channels/whatsapp/inbound.go @@ -0,0 +1,266 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/channels/media" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const emptyMessageSentinel = "[empty message]" + +// handleIncomingMessage processes an incoming WhatsApp message. +func (c *Channel) handleIncomingMessage(evt *events.Message) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) + + if evt.Info.IsFromMe { + return + } + + senderJID := evt.Info.Sender + chatJID := evt.Info.Chat + + // WhatsApp uses dual identity: phone JID (@s.whatsapp.net) and LID (@lid). + // Groups may use LID addressing. Normalize to phone JID for consistent + // policy checks, pairing lookups, allowlists, and contact collection. + if evt.Info.AddressingMode == types.AddressingModeLID && !evt.Info.SenderAlt.IsEmpty() { + senderJID = evt.Info.SenderAlt + } + + senderID := senderJID.String() + chatID := chatJID.String() + + peerKind := "direct" + if chatJID.Server == types.GroupServer { + peerKind = "group" + } + + slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, + "addressing", evt.Info.AddressingMode, "policy", c.config.GroupPolicy) + + // DM/Group policy check. + if peerKind == "direct" { + if !c.checkDMPolicy(ctx, senderID, chatID) { + return + } + } else { + if !c.checkGroupPolicy(ctx, senderID, chatID) { + slog.Info("whatsapp group message rejected by policy", "sender_id", senderID, "chat_id", chatID, "policy", c.config.GroupPolicy) + return + } + } + + if !c.IsAllowed(senderID) { + slog.Info("whatsapp message rejected by allowlist", "sender_id", senderID) + return + } + + content := extractTextContent(evt.Message) + + var mediaList []media.MediaInfo + mediaList = c.downloadMedia(evt) + + if content == "" && len(mediaList) == 0 { + return + } + if content == "" { + content = emptyMessageSentinel + } + + // Group history + mention detection. + historyLimit := c.config.HistoryLimit + if historyLimit == 0 { + historyLimit = channels.DefaultGroupHistoryLimit + } + if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { + if !c.isMentioned(evt) { + // Not mentioned — record for context and skip. + senderLabel := evt.Info.PushName + if senderLabel == "" { + senderLabel = senderID + } + c.groupHistory.Record(chatID, channels.HistoryEntry{ + Sender: senderLabel, + SenderID: senderID, + Body: content, + Timestamp: evt.Info.Timestamp, + MessageID: string(evt.Info.ID), + }, historyLimit) + return + } + // Mentioned — prepend accumulated group context. + content = c.groupHistory.BuildContext(chatID, content, historyLimit) + c.groupHistory.Clear(chatID) + } + + metadata := map[string]string{ + "message_id": string(evt.Info.ID), + } + if evt.Info.PushName != "" { + metadata["user_name"] = evt.Info.PushName + } + + // Build media tags and bus.MediaFile list. + var mediaFiles []bus.MediaFile + if len(mediaList) > 0 { + mediaTags := media.BuildMediaTags(mediaList) + if mediaTags != "" { + if content != emptyMessageSentinel { + content = mediaTags + "\n\n" + content + } else { + content = mediaTags + } + } + for _, m := range mediaList { + if m.FilePath != "" { + mediaFiles = append(mediaFiles, bus.MediaFile{ + Path: m.FilePath, MimeType: m.ContentType, + }) + } + } + } + + // Annotate with sender identity. + if senderName := metadata["user_name"]; senderName != "" { + content = fmt.Sprintf("[From: %s]\n%s", senderName, content) + } + + // Collect contact. + if cc := c.ContactCollector(); cc != nil { + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, + metadata["user_name"], "", peerKind, "user", "", "") + } + + // Typing indicator. + if prevCancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { + if fn, ok := prevCancel.(context.CancelFunc); ok { + fn() + } + } + typingCtx, typingCancel := context.WithCancel(context.Background()) + c.typingCancel.Store(chatID, typingCancel) + go c.keepTyping(typingCtx, chatJID) + + // Derive userID from senderID. + userID := senderID + if idx := strings.IndexByte(senderID, '|'); idx > 0 { + userID = senderID[:idx] + } + + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: mediaFiles, + PeerKind: peerKind, + UserID: userID, + AgentID: c.AgentID(), + TenantID: c.TenantID(), + Metadata: metadata, + }) + + // Schedule temp media file cleanup after agent pipeline has had time to process. + var tmpPaths []string + for _, mf := range mediaFiles { + tmpPaths = append(tmpPaths, mf.Path) + } + scheduleMediaCleanup(c.ctx, tmpPaths, 5*time.Minute) +} + +// extractTextContent extracts text from any WhatsApp message variant. +// Includes quoted message context when present (reply-to messages). +func extractTextContent(msg *waE2E.Message) string { + if msg == nil { + return "" + } + + var text string + var quotedText string + + if msg.GetConversation() != "" { + text = msg.GetConversation() + } else if ext := msg.GetExtendedTextMessage(); ext != nil { + text = ext.GetText() + // Extract quoted (replied-to) message text. + if ci := ext.GetContextInfo(); ci != nil { + if qm := ci.GetQuotedMessage(); qm != nil { + quotedText = extractQuotedText(qm) + } + } + } else if img := msg.GetImageMessage(); img != nil { + text = img.GetCaption() + } else if vid := msg.GetVideoMessage(); vid != nil { + text = vid.GetCaption() + } else if doc := msg.GetDocumentMessage(); doc != nil { + text = doc.GetCaption() + } + + if quotedText != "" && text != "" { + return fmt.Sprintf("[Replying to: %s]\n%s", quotedText, text) + } + if quotedText != "" { + return fmt.Sprintf("[Replying to: %s]", quotedText) + } + return text +} + +// extractQuotedText extracts plain text from a quoted message (no recursion). +func extractQuotedText(msg *waE2E.Message) string { + if msg == nil { + return "" + } + if msg.GetConversation() != "" { + return msg.GetConversation() + } + if ext := msg.GetExtendedTextMessage(); ext != nil { + return ext.GetText() + } + if img := msg.GetImageMessage(); img != nil && img.GetCaption() != "" { + return img.GetCaption() + } + if vid := msg.GetVideoMessage(); vid != nil && vid.GetCaption() != "" { + return vid.GetCaption() + } + return "" +} + +// isMentioned checks if the linked account is @mentioned in a group message. +// WhatsApp uses dual identity: phone JID and LID. Mentions may use either format. +func (c *Channel) isMentioned(evt *events.Message) bool { + c.lastQRMu.RLock() + myJID := c.myJID + myLID := c.myLID + c.lastQRMu.RUnlock() + + if myJID.IsEmpty() && myLID.IsEmpty() { + return false // fail closed: unknown identity = not mentioned + } + + // Check mentioned JIDs from extended text. + if ext := evt.Message.GetExtendedTextMessage(); ext != nil { + if ci := ext.GetContextInfo(); ci != nil { + for _, jidStr := range ci.GetMentionedJID() { + mentioned, _ := types.ParseJID(jidStr) + if !myJID.IsEmpty() && mentioned.User == myJID.User { + return true + } + if !myLID.IsEmpty() && mentioned.User == myLID.User { + return true + } + } + } + } + return false +} diff --git a/internal/channels/whatsapp/media_download.go b/internal/channels/whatsapp/media_download.go new file mode 100644 index 000000000..cd69cc776 --- /dev/null +++ b/internal/channels/whatsapp/media_download.go @@ -0,0 +1,140 @@ +package whatsapp + +import ( + "context" + "log/slog" + "os" + "strings" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types/events" + + "github.com/nextlevelbuilder/goclaw/internal/channels/media" +) + +// downloadMedia downloads media attachments from a WhatsApp message. +func (c *Channel) downloadMedia(evt *events.Message) []media.MediaInfo { + msg := evt.Message + if msg == nil { + return nil + } + + type mediaItem struct { + mediaType string + mimetype string + filename string + download whatsmeow.DownloadableMessage + } + + var items []mediaItem + if img := msg.GetImageMessage(); img != nil { + items = append(items, mediaItem{"image", img.GetMimetype(), "", img}) + } + if vid := msg.GetVideoMessage(); vid != nil { + items = append(items, mediaItem{"video", vid.GetMimetype(), "", vid}) + } + if aud := msg.GetAudioMessage(); aud != nil { + items = append(items, mediaItem{"audio", aud.GetMimetype(), "", aud}) + } + if doc := msg.GetDocumentMessage(); doc != nil { + items = append(items, mediaItem{"document", doc.GetMimetype(), doc.GetFileName(), doc}) + } + if stk := msg.GetStickerMessage(); stk != nil { + items = append(items, mediaItem{"sticker", stk.GetMimetype(), "", stk}) + } + + if len(items) == 0 { + return nil + } + + var result []media.MediaInfo + for _, item := range items { + data, err := c.client.Download(c.ctx, item.download) + if err != nil { + reason := classifyDownloadError(err) + slog.Warn("whatsapp: media download failed", "type", item.mediaType, "reason", reason, "error", err) + continue + } + if len(data) > 20*1024*1024 { // 20MB limit + slog.Warn("whatsapp: media too large, skipping", "type", item.mediaType, + "size_mb", len(data)/(1024*1024)) + continue + } + + ext := mimeToExt(item.mimetype) + tmpFile, err := os.CreateTemp("", "goclaw_wa_*"+ext) + if err != nil { + slog.Warn("whatsapp: temp file creation failed", "error", err) + continue + } + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + continue + } + tmpFile.Close() + + result = append(result, media.MediaInfo{ + Type: item.mediaType, + FilePath: tmpFile.Name(), + ContentType: item.mimetype, + FileName: item.filename, + }) + } + return result +} + +// mimeToExt maps MIME types to file extensions. +func mimeToExt(mime string) string { + switch { + case strings.HasPrefix(mime, "image/jpeg"): + return ".jpg" + case strings.HasPrefix(mime, "image/png"): + return ".png" + case strings.HasPrefix(mime, "image/webp"): + return ".webp" + case strings.HasPrefix(mime, "video/mp4"): + return ".mp4" + case strings.HasPrefix(mime, "audio/ogg"): + return ".ogg" + case strings.HasPrefix(mime, "audio/mpeg"): + return ".mp3" + case strings.HasPrefix(mime, "application/pdf"): + return ".pdf" + default: + return ".bin" + } +} + +// classifyDownloadError returns a human-readable reason for a media download failure. +func classifyDownloadError(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"): + return "timeout" + case strings.Contains(msg, "decrypt") || strings.Contains(msg, "cipher"): + return "decrypt_error" + case strings.Contains(msg, "404") || strings.Contains(msg, "not found"): + return "expired" + case strings.Contains(msg, "unsupported"): + return "unsupported" + default: + return "unknown" + } +} + +// scheduleMediaCleanup removes temp media files after a delay. +// Uses time.AfterFunc so it does not block and respects the provided context for logging only. +func scheduleMediaCleanup(ctx context.Context, paths []string, delay time.Duration) { + if len(paths) == 0 { + return + } + time.AfterFunc(delay, func() { + for _, p := range paths { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + slog.Debug("whatsapp: temp media cleanup failed", "path", p, "error", err) + } + } + }) +} diff --git a/internal/channels/whatsapp/mention_test.go b/internal/channels/whatsapp/mention_test.go new file mode 100644 index 000000000..7d4caa139 --- /dev/null +++ b/internal/channels/whatsapp/mention_test.go @@ -0,0 +1,131 @@ +package whatsapp + +import ( + "testing" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +func TestIsMentioned(t *testing.T) { + // Helper to build an events.Message with mentioned JIDs in extended text. + makeEvt := func(mentionedJIDs []string) *events.Message { + return &events.Message{ + Message: &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String("hello @bot"), + ContextInfo: &waE2E.ContextInfo{ + MentionedJID: mentionedJIDs, + }, + }, + }, + } + } + + tests := []struct { + name string + myJID string // bot's phone JID + myLID string // bot's LID + mentions []string + want bool + }{ + { + name: "mentioned by phone JID", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: true, + }, + { + name: "mentioned by LID", + myLID: "9876543210@lid", + mentions: []string{"9876543210@lid"}, + want: true, + }, + { + name: "mentioned by JID with device suffix", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"1234567890:42@s.whatsapp.net"}, + want: true, + }, + { + name: "mentioned by LID with device suffix", + myLID: "9876543210@lid", + mentions: []string{"9876543210:5@lid"}, + want: true, + }, + { + name: "dual identity — mentioned via LID when JID also set", + myJID: "1234567890@s.whatsapp.net", + myLID: "9876543210@lid", + mentions: []string{"9876543210@lid"}, + want: true, + }, + { + name: "dual identity — mentioned via JID when LID also set", + myJID: "1234567890@s.whatsapp.net", + myLID: "9876543210@lid", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: true, + }, + { + name: "not mentioned — different user", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"9999999999@s.whatsapp.net"}, + want: false, + }, + { + name: "not mentioned — empty mentions", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{}, + want: false, + }, + { + name: "unknown identity — fail closed", + myJID: "", + myLID: "", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: false, + }, + { + name: "no extended text message", + myJID: "1234567890@s.whatsapp.net", + mentions: nil, // will use plain conversation message + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := &Channel{} + + // Set bot identity. + if tt.myJID != "" { + jid, _ := types.ParseJID(tt.myJID) + ch.myJID = jid + } + if tt.myLID != "" { + lid, _ := types.ParseJID(tt.myLID) + ch.myLID = lid + } + + var evt *events.Message + if tt.mentions == nil { + // Plain conversation message — no extended text. + evt = &events.Message{ + Message: &waE2E.Message{ + Conversation: proto.String("hello"), + }, + } + } else { + evt = makeEvt(tt.mentions) + } + + got := ch.isMentioned(evt) + if got != tt.want { + t.Errorf("isMentioned() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/channels/whatsapp/outbound.go b/internal/channels/whatsapp/outbound.go new file mode 100644 index 000000000..fa2e692d4 --- /dev/null +++ b/internal/channels/whatsapp/outbound.go @@ -0,0 +1,182 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// Send delivers an outbound message to WhatsApp via whatsmeow. +func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { + if c.client == nil || !c.client.IsConnected() { + return fmt.Errorf("whatsapp not connected") + } + + chatJID, err := types.ParseJID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid whatsapp JID %q: %w", msg.ChatID, err) + } + + // Send media attachments first. + if len(msg.Media) > 0 { + for i, m := range msg.Media { + caption := m.Caption + if caption == "" && i == 0 && msg.Content != "" { + caption = markdownToWhatsApp(msg.Content) + } + + data, readErr := os.ReadFile(m.URL) + if readErr != nil { + return fmt.Errorf("read media file: %w", readErr) + } + + waMsg, buildErr := c.buildMediaMessage(data, m.ContentType, caption) + if buildErr != nil { + return fmt.Errorf("build media message: %w", buildErr) + } + + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + return fmt.Errorf("send whatsapp media: %w", sendErr) + } + } + // Skip text if caption was used on first media. + if msg.Media[0].Caption == "" && msg.Content != "" { + msg.Content = "" + } + } + + // Send text (chunked if exceeding limit). + if msg.Content != "" { + formatted := markdownToWhatsApp(msg.Content) + chunks := chunkText(formatted, maxMessageLen) + for _, chunk := range chunks { + waMsg := &waE2E.Message{ + Conversation: proto.String(chunk), + } + if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { + return fmt.Errorf("send whatsapp message: %w", err) + } + } + } + + // Stop typing indicator. + if cancel, ok := c.typingCancel.LoadAndDelete(msg.ChatID); ok { + if fn, ok := cancel.(context.CancelFunc); ok { + fn() + } + } + go c.sendPresence(chatJID, types.ChatPresencePaused) + + return nil +} + +// buildMediaMessage uploads media to WhatsApp and returns the message proto. +func (c *Channel) buildMediaMessage(data []byte, mime, caption string) (*waE2E.Message, error) { + switch { + case strings.HasPrefix(mime, "image/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaImage) + if err != nil { + return nil, err + } + return &waE2E.Message{ + ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "video/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaVideo) + if err != nil { + return nil, err + } + return &waE2E.Message{ + VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "audio/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaAudio) + if err != nil { + return nil, err + } + return &waE2E.Message{ + AudioMessage: &waE2E.AudioMessage{ + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + default: // document + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaDocument) + if err != nil { + return nil, err + } + return &waE2E.Message{ + DocumentMessage: &waE2E.DocumentMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + } +} + +// keepTyping sends "composing" presence repeatedly until ctx is cancelled. +func (c *Channel) keepTyping(ctx context.Context, chatJID types.JID) { + c.sendPresence(chatJID, types.ChatPresenceComposing) + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.sendPresence(chatJID, types.ChatPresenceComposing) + } + } +} + +// sendPresence sends a WhatsApp chat presence update. +func (c *Channel) sendPresence(to types.JID, state types.ChatPresence) { + if c.client == nil || !c.client.IsConnected() { + return + } + if err := c.client.SendChatPresence(c.ctx, to, state, ""); err != nil { + slog.Debug("whatsapp: presence update failed", "state", state, "error", err) + } +} diff --git a/internal/channels/whatsapp/policy.go b/internal/channels/whatsapp/policy.go new file mode 100644 index 000000000..d305bc348 --- /dev/null +++ b/internal/channels/whatsapp/policy.go @@ -0,0 +1,141 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" +) + +// checkGroupPolicy evaluates the group policy for a sender. +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { + groupPolicy := c.config.GroupPolicy + if groupPolicy == "" { + groupPolicy = "open" + } + + switch groupPolicy { + case "disabled": + return false + case "allowlist": + return c.IsAllowed(senderID) + case "pairing": + if c.HasAllowList() && c.IsAllowed(senderID) { + return true + } + if _, cached := c.approvedGroups.Load(chatID); cached { + return true + } + groupSenderID := fmt.Sprintf("group:%s", chatID) + if c.pairingService != nil { + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) + if err != nil { + slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + "group_sender", groupSenderID, "channel", c.Name(), "error", err) + paired = true + } + if paired { + c.approvedGroups.Store(chatID, true) + return true + } + } + c.sendPairingReply(ctx, groupSenderID, chatID) + return false + default: // "open" + return true + } +} + +// checkDMPolicy evaluates the DM policy for a sender. +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { + dmPolicy := c.config.DMPolicy + if dmPolicy == "" { + dmPolicy = "pairing" + } + + switch dmPolicy { + case "disabled": + slog.Debug("whatsapp DM rejected: disabled", "sender_id", senderID) + return false + case "open": + return true + case "allowlist": + if !c.IsAllowed(senderID) { + slog.Debug("whatsapp DM rejected by allowlist", "sender_id", senderID) + return false + } + return true + default: // "pairing" + paired := false + if c.pairingService != nil { + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) + if err != nil { + slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + "sender_id", senderID, "channel", c.Name(), "error", err) + paired = true + } else { + paired = p + } + } + inAllowList := c.HasAllowList() && c.IsAllowed(senderID) + + if paired || inAllowList { + return true + } + + c.sendPairingReply(ctx, senderID, chatID) + return false + } +} + +// sendPairingReply sends a pairing code to the user via WhatsApp. +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { + if c.pairingService == nil { + slog.Warn("whatsapp pairing: no pairing service configured") + return + } + + // Debounce. + if lastSent, ok := c.pairingDebounce.Load(senderID); ok { + if time.Since(lastSent.(time.Time)) < pairingDebounceTime { + slog.Info("whatsapp pairing: debounced", "sender_id", senderID) + return + } + } + + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) + if err != nil { + slog.Warn("whatsapp pairing request failed", "sender_id", senderID, "channel", c.Name(), "error", err) + return + } + + replyText := fmt.Sprintf( + "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the account owner to approve with:\n goclaw pairing approve %s", + senderID, code, code, + ) + + if c.client == nil || !c.client.IsConnected() { + slog.Warn("whatsapp not connected, cannot send pairing reply") + return + } + + chatJID, parseErr := types.ParseJID(chatID) + if parseErr != nil { + slog.Warn("whatsapp pairing: invalid chatID JID", "chatID", chatID, "error", parseErr) + return + } + + waMsg := &waE2E.Message{ + Conversation: proto.String(replyText), + } + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + slog.Warn("failed to send whatsapp pairing reply", "error", sendErr) + } else { + c.pairingDebounce.Store(senderID, time.Now()) + slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code) + } +} diff --git a/internal/channels/whatsapp/qr_methods.go b/internal/channels/whatsapp/qr_methods.go new file mode 100644 index 000000000..60a0b10e8 --- /dev/null +++ b/internal/channels/whatsapp/qr_methods.go @@ -0,0 +1,243 @@ +package whatsapp + +import ( + "context" + "encoding/base64" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + qrcode "github.com/skip2/go-qrcode" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + goclawprotocol "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +const qrSessionTimeout = 3 * time.Minute + +// cancelEntry wraps a CancelFunc so it can be stored in sync.Map.CompareAndDelete. +type cancelEntry struct { + cancel context.CancelFunc +} + +// QRMethods handles whatsapp.qr.start — delivers QR codes to the UI wizard. +type QRMethods struct { + instanceStore store.ChannelInstanceStore + manager *channels.Manager + activeSessions sync.Map // instanceID (string) -> *cancelEntry +} + +func NewQRMethods(instanceStore store.ChannelInstanceStore, manager *channels.Manager) *QRMethods { + return &QRMethods{instanceStore: instanceStore, manager: manager} +} + +func (m *QRMethods) Register(router *gateway.MethodRouter) { + router.Register(goclawprotocol.MethodWhatsAppQRStart, m.handleQRStart) +} + +func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) { + var params struct { + InstanceID string `json:"instance_id"` + ForceReauth bool `json:"force_reauth"` + } + if req.Params != nil { + _ = json.Unmarshal(req.Params, ¶ms) + } + + instID, err := uuid.Parse(params.InstanceID) + if err != nil { + client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "invalid instance_id")) + return + } + + inst, err := m.instanceStore.Get(ctx, instID) + if err != nil || inst.ChannelType != channels.TypeWhatsApp { + client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrNotFound, "whatsapp instance not found")) + return + } + + qrCtx, cancel := context.WithTimeout(ctx, qrSessionTimeout) + entry := &cancelEntry{cancel: cancel} + + // Cancel any previous QR session for this instance. + if prev, loaded := m.activeSessions.Swap(params.InstanceID, entry); loaded { + if prevEntry, ok := prev.(*cancelEntry); ok { + prevEntry.cancel() + } + } + + // ACK immediately — QR/done events arrive asynchronously. + client.SendResponse(goclawprotocol.NewOKResponse(req.ID, map[string]any{"status": "started"})) + + go m.runQRSession(qrCtx, entry, client, params.InstanceID, inst.Name, params.ForceReauth) +} + +func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry, + client *gateway.Client, instanceIDStr, channelName string, forceReauth bool) { + + defer entry.cancel() + defer m.activeSessions.CompareAndDelete(instanceIDStr, entry) + + // Wait for channel to appear in manager — instance creation triggers an async + // reload, so the channel may not be registered yet when the wizard fires QR start. + var wa *Channel + for attempt := 0; attempt < 10; attempt++ { + if ch, ok := m.manager.GetChannel(channelName); ok { + if w, ok := ch.(*Channel); ok { + wa = w + break + } + } + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } + } + if wa == nil { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "channel not found", + }, + }) + return + } + + // Already authenticated and no force-reauth → signal connected. + if wa.IsAuthenticated() && !forceReauth { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + "already_connected": true, + }, + }) + return + } + + // Force reauth: clear session and prepare for fresh QR. + if forceReauth { + if err := wa.Reauth(); err != nil { + slog.Warn("whatsapp QR: reauth failed", "error", err) + } + } + + // Deliver cached QR if available. + if cached := wa.GetLastQRB64(); cached != "" { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": cached, + }, + }) + } + + // Start QR flow — get QR channel from whatsmeow. + qrChan, err := wa.StartQRFlow(ctx) + if err != nil { + slog.Warn("whatsapp QR: start flow failed", "error", err) + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": err.Error(), + }, + }) + return + } + + if qrChan == nil { + // Already authenticated (StartQRFlow returned nil). + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + "already_connected": true, + }, + }) + return + } + + // Process QR events from whatsmeow. + for { + select { + case <-ctx.Done(): + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "QR session timed out — restart to try again", + }, + }) + return + + case evt, ok := <-qrChan: + if !ok { + return // channel closed + } + + switch evt.Event { + case "code": + png, qrErr := qrcode.Encode(evt.Code, qrcode.Medium, 256) + if qrErr != nil { + slog.Warn("whatsapp: QR PNG encode failed", "error", qrErr) + continue + } + pngB64 := base64.StdEncoding.EncodeToString(png) + + wa.cacheQR(pngB64) + + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": pngB64, + }, + }) + + case "success": + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + }, + }) + slog.Info("whatsapp QR session completed", "instance", instanceIDStr) + return + + case "timeout": + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "QR code expired — restart to try again", + }, + }) + return + } + } + } +} diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 3fb305efd..ac1e1d01b 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -2,14 +2,18 @@ package whatsapp import ( "context" - "encoding/json" + "database/sql" "fmt" "log/slog" - "strings" "sync" "time" - "github.com/gorilla/websocket" + "go.mau.fi/whatsmeow" + wastore "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" @@ -17,52 +21,118 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -const pairingDebounceTime = 60 * time.Second +const ( + pairingDebounceTime = 60 * time.Second + maxMessageLen = 4096 // WhatsApp practical message length limit +) + +func init() { + // Set device name shown in WhatsApp's "Linked Devices" screen (once at package init). + wastore.DeviceProps.Os = proto.String("GoClaw") +} -// Channel connects to a WhatsApp bridge via WebSocket. -// The bridge (e.g. whatsapp-web.js based) handles the actual WhatsApp -// protocol; this channel just sends/receives JSON messages over WS. +// Channel connects directly to WhatsApp via go.mau.fi/whatsmeow. +// Auth state is stored in PostgreSQL (standard) or SQLite (desktop). type Channel struct { *channels.BaseChannel - conn *websocket.Conn + client *whatsmeow.Client + container *sqlstore.Container config config.WhatsAppConfig mu sync.Mutex - connected bool ctx context.Context cancel context.CancelFunc + parentCtx context.Context // stored from Start() for Reauth() context chain pairingService store.PairingStore pairingDebounce sync.Map // senderID → time.Time approvedGroups sync.Map // chatID → true (in-memory cache for paired groups) + groupHistory *channels.PendingHistory // tracks group messages for context + + // QR state + lastQRMu sync.RWMutex + lastQRB64 string // base64-encoded PNG, empty when authenticated + waAuthenticated bool // true once WhatsApp account is connected + myJID types.JID // linked account's phone JID for mention detection + myLID types.JID // linked account's LID — WhatsApp's newer identifier + + // typingCancel tracks active typing-refresh loops per chatID. + typingCancel sync.Map // chatID string → context.CancelFunc + + // reauthMu serializes Reauth() and StartQRFlow() to prevent race when user clicks reauth rapidly. + reauthMu sync.Mutex } -// New creates a new WhatsApp channel from config. -func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) { - if cfg.BridgeURL == "" { - return nil, fmt.Errorf("whatsapp bridge_url is required") - } +// GetLastQRB64 returns the most recent QR PNG (base64). +func (c *Channel) GetLastQRB64() string { + c.lastQRMu.RLock() + defer c.lastQRMu.RUnlock() + return c.lastQRB64 +} + +// IsAuthenticated reports whether the WhatsApp account is currently authenticated. +func (c *Channel) IsAuthenticated() bool { + c.lastQRMu.RLock() + defer c.lastQRMu.RUnlock() + return c.waAuthenticated +} + +// cacheQR stores the latest QR PNG (base64) for late-joining wizard clients. +func (c *Channel) cacheQR(pngB64 string) { + c.lastQRMu.Lock() + c.lastQRB64 = pngB64 + c.lastQRMu.Unlock() +} + +// New creates a new WhatsApp channel backed by whatsmeow. +// dialect must be "pgx" (PostgreSQL) or "sqlite3" (SQLite/desktop). +func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, + pairingSvc store.PairingStore, db *sql.DB, + pendingStore store.PendingMessageStore, dialect string) (*Channel, error) { base := channels.NewBaseChannel(channels.TypeWhatsApp, msgBus, cfg.AllowFrom) base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy) + container := sqlstore.NewWithDB(db, dialect, nil) + if err := container.Upgrade(context.Background()); err != nil { + return nil, fmt.Errorf("whatsapp sqlstore upgrade: %w", err) + } + return &Channel{ BaseChannel: base, config: cfg, pairingService: pairingSvc, + container: container, + groupHistory: channels.MakeHistory("whatsapp", pendingStore, base.TenantID()), }, nil } -// Start connects to the WhatsApp bridge WebSocket and begins listening. +// Start initializes the whatsmeow client and connects to WhatsApp. func (c *Channel) Start(ctx context.Context) error { - slog.Info("starting whatsapp channel", "bridge_url", c.config.BridgeURL) + slog.Info("starting whatsapp channel (whatsmeow)") + c.MarkStarting("Initializing WhatsApp connection") + c.parentCtx = ctx c.ctx, c.cancel = context.WithCancel(ctx) - if err := c.connect(); err != nil { - // Don't fail hard — reconnect loop will keep trying - slog.Warn("initial whatsapp bridge connection failed, will retry", "error", err) + deviceStore, err := c.container.GetFirstDevice(ctx) + if err != nil { + return fmt.Errorf("whatsapp get device: %w", err) } - go c.listenLoop() + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + + if c.client.Store.ID == nil { + // Not paired yet — QR flow will be triggered by qr_methods.go. + slog.Info("whatsapp: not paired yet, waiting for QR scan", "channel", c.Name()) + c.MarkDegraded("Awaiting QR scan", "Scan QR code to authenticate", + channels.ChannelFailureKindAuth, false) + } else { + if err := c.client.Connect(); err != nil { + slog.Warn("whatsapp: initial connect failed", "error", err) + c.MarkDegraded("Connection failed", err.Error(), + channels.ChannelFailureKindNetwork, true) + } + } c.SetRunning(true) return nil @@ -78,333 +148,74 @@ func (c *Channel) Stop(_ context.Context) error { if c.cancel != nil { c.cancel() } - - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - _ = c.conn.Close() - c.conn = nil - } - c.connected = false - c.SetRunning(false) - - return nil -} - -// Send delivers an outbound message to the WhatsApp bridge. -func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return fmt.Errorf("whatsapp bridge not connected") - } - - payload := map[string]any{ - "type": "message", - "to": msg.ChatID, - "content": msg.Content, - } - - data, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal whatsapp message: %w", err) + if c.client != nil { + c.client.Disconnect() } - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("send whatsapp message: %w", err) - } - - return nil -} - -// connect establishes the WebSocket connection to the bridge. -func (c *Channel) connect() error { - dialer := websocket.DefaultDialer - dialer.HandshakeTimeout = 10 * time.Second - - conn, _, err := dialer.Dial(c.config.BridgeURL, nil) - if err != nil { - return fmt.Errorf("dial whatsapp bridge %s: %w", c.config.BridgeURL, err) - } - - c.mu.Lock() - c.conn = conn - c.connected = true - c.mu.Unlock() - - slog.Info("whatsapp bridge connected", "url", c.config.BridgeURL) - return nil -} - -// listenLoop reads messages from the bridge with automatic reconnection. -func (c *Channel) listenLoop() { - backoff := time.Second - - for { - select { - case <-c.ctx.Done(): - return - default: - } - - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - // Not connected — attempt reconnect with backoff - slog.Info("attempting whatsapp bridge reconnect", "backoff", backoff) - - select { - case <-c.ctx.Done(): - return - case <-time.After(backoff): - } - - if err := c.connect(); err != nil { - slog.Warn("whatsapp bridge reconnect failed", "error", err) - backoff = min(backoff*2, 30*time.Second) - continue - } - - backoff = time.Second // reset on success - continue - } - - _, message, err := conn.ReadMessage() - if err != nil { - slog.Warn("whatsapp read error, will reconnect", "error", err) - - c.mu.Lock() - if c.conn != nil { - _ = c.conn.Close() - c.conn = nil - } - c.connected = false - c.mu.Unlock() - - continue - } - - var msg map[string]any - if err := json.Unmarshal(message, &msg); err != nil { - slog.Warn("invalid whatsapp message JSON", "error", err) - continue + // Cancel all active typing goroutines. + c.typingCancel.Range(func(key, value any) bool { + if fn, ok := value.(context.CancelFunc); ok { + fn() } + c.typingCancel.Delete(key) + return true + }) - msgType, _ := msg["type"].(string) - if msgType == "message" { - c.handleIncomingMessage(msg) - } - } + c.SetRunning(false) + c.MarkStopped("Stopped") + return nil } -// handleIncomingMessage processes a message received from the bridge. -// Expected format: {"type":"message","from":"...","chat":"...","content":"...","id":"...","from_name":"...","media":[...]} -func (c *Channel) handleIncomingMessage(msg map[string]any) { - ctx := context.Background() - ctx = store.WithTenantID(ctx, c.TenantID()) - senderID, ok := msg["from"].(string) - if !ok || senderID == "" { - return - } - - chatID, _ := msg["chat"].(string) - if chatID == "" { - chatID = senderID - } - - // WhatsApp groups have chatID ending in "@g.us" - peerKind := "direct" - if strings.HasSuffix(chatID, "@g.us") { - peerKind = "group" - } - - // DM/Group policy check - if peerKind == "direct" { - if !c.checkDMPolicy(ctx, senderID, chatID) { - return - } - } else { - if !c.checkGroupPolicy(ctx, senderID, chatID) { - slog.Debug("whatsapp group message rejected by policy", "sender_id", senderID) - return - } - } - - // Allowlist check - if !c.IsAllowed(senderID) { - slog.Debug("whatsapp message rejected by allowlist", "sender_id", senderID) - return - } - - content, _ := msg["content"].(string) - if content == "" { - content = "[empty message]" - } - - var media []string - if mediaData, ok := msg["media"].([]any); ok { - media = make([]string, 0, len(mediaData)) - for _, m := range mediaData { - if path, ok := m.(string); ok { - media = append(media, path) - } - } - } - - metadata := make(map[string]string) - if messageID, ok := msg["id"].(string); ok { - metadata["message_id"] = messageID - } - if userName, ok := msg["from_name"].(string); ok { - metadata["user_name"] = userName - } - - slog.Debug("whatsapp message received", - "sender_id", senderID, - "chat_id", chatID, - "preview", channels.Truncate(content, 50), - ) - - // Collect contact for processed messages. - if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind, "user", "", "") - } - - // Annotate with sender identity so the agent knows who is messaging. - if senderName := metadata["user_name"]; senderName != "" { - content = fmt.Sprintf("[From: %s]\n%s", senderName, content) +// handleEvent dispatches whatsmeow events. +func (c *Channel) handleEvent(evt any) { + switch v := evt.(type) { + case *events.Message: + c.handleIncomingMessage(v) + case *events.Connected: + c.handleConnected() + case *events.Disconnected: + c.handleDisconnected() + case *events.LoggedOut: + c.handleLoggedOut(v) + case *events.PairSuccess: + slog.Info("whatsapp: pair success", "channel", c.Name()) } - - c.HandleMessage(senderID, chatID, content, media, metadata, peerKind) } -// checkGroupPolicy evaluates the group policy for a sender, with pairing support. -func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { - groupPolicy := c.config.GroupPolicy - if groupPolicy == "" { - groupPolicy = "open" +// handleConnected processes the Connected event. +func (c *Channel) handleConnected() { + c.lastQRMu.Lock() + c.waAuthenticated = true + c.lastQRB64 = "" + if c.client.Store.ID != nil { + c.myJID = *c.client.Store.ID + c.myLID = c.client.Store.GetLID() + slog.Info("whatsapp: connected", "jid", c.myJID.String(), + "lid", c.myLID.String(), "channel", c.Name()) } + c.lastQRMu.Unlock() - switch groupPolicy { - case "disabled": - return false - case "allowlist": - return c.IsAllowed(senderID) - case "pairing": - if c.IsAllowed(senderID) { - return true - } - if _, cached := c.approvedGroups.Load(chatID); cached { - return true - } - groupSenderID := fmt.Sprintf("group:%s", chatID) - if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) - if err != nil { - slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", - "group_sender", groupSenderID, "channel", c.Name(), "error", err) - paired = true - } - if paired { - c.approvedGroups.Store(chatID, true) - return true - } - } - c.sendPairingReply(ctx, groupSenderID, chatID) - return false - default: // "open" - return true - } + c.MarkHealthy("WhatsApp authenticated and connected") } -// checkDMPolicy evaluates the DM policy for a sender, handling pairing flow. -func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { - dmPolicy := c.config.DMPolicy - if dmPolicy == "" { - dmPolicy = "pairing" - } +// handleDisconnected processes the Disconnected event. +func (c *Channel) handleDisconnected() { + c.lastQRMu.Lock() + c.waAuthenticated = false + c.lastQRMu.Unlock() - switch dmPolicy { - case "disabled": - slog.Debug("whatsapp DM rejected: disabled", "sender_id", senderID) - return false - case "open": - return true - case "allowlist": - if !c.IsAllowed(senderID) { - slog.Debug("whatsapp DM rejected by allowlist", "sender_id", senderID) - return false - } - return true - default: // "pairing" - paired := false - if c.pairingService != nil { - p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) - if err != nil { - slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", - "sender_id", senderID, "channel", c.Name(), "error", err) - paired = true - } else { - paired = p - } - } - inAllowList := c.HasAllowList() && c.IsAllowed(senderID) - - if paired || inAllowList { - return true - } - - c.sendPairingReply(ctx, senderID, chatID) - return false - } + c.MarkDegraded("WhatsApp disconnected", "Waiting for reconnect", + channels.ChannelFailureKindNetwork, true) + // whatsmeow auto-reconnects — no manual reconnect loop needed. } -// sendPairingReply sends a pairing code to the user via the WS bridge. -func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { - if c.pairingService == nil { - return - } - - // Debounce - if lastSent, ok := c.pairingDebounce.Load(senderID); ok { - if time.Since(lastSent.(time.Time)) < pairingDebounceTime { - return - } - } - - code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) - if err != nil { - slog.Debug("whatsapp pairing request failed", "sender_id", senderID, "error", err) - return - } - - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) +// handleLoggedOut processes the LoggedOut event. +func (c *Channel) handleLoggedOut(evt *events.LoggedOut) { + slog.Warn("whatsapp: logged out", "reason", evt.Reason, "channel", c.Name()) + c.lastQRMu.Lock() + c.waAuthenticated = false + c.lastQRMu.Unlock() - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - slog.Warn("whatsapp bridge not connected, cannot send pairing reply") - return - } - - payload, _ := json.Marshal(map[string]any{ - "type": "message", - "to": chatID, - "content": replyText, - }) - - if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { - slog.Warn("failed to send whatsapp pairing reply", "error", err) - } else { - c.pairingDebounce.Store(senderID, time.Now()) - slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code) - } + c.MarkDegraded("WhatsApp logged out", "Re-scan QR to reconnect", + channels.ChannelFailureKindAuth, false) } diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index a45de45a1..0f31e5f1a 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -132,12 +132,14 @@ type SlackConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled"` - BridgeURL string `json:"bridge_url"` - AllowFrom FlexibleStringSlice `json:"allow_from"` - DMPolicy string `json:"dm_policy,omitempty"` // "open" (default), "allowlist", "disabled" - GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + Enabled bool `json:"enabled"` + AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop) + AllowFrom FlexibleStringSlice `json:"allow_from"` + DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default for DB instances), "open", "allowlist", "disabled" + GroupPolicy string `json:"group_policy,omitempty"` // "pairing" (default for DB instances), "open" (default for config), "allowlist", "disabled" + RequireMention *bool `json:"require_mention,omitempty"` // only respond in groups when bot is @mentioned (default false) + HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 200, 0=disabled) + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) } type ZaloConfig struct { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 38b3319e4..0c5c7f640 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -120,7 +120,7 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_LARK_APP_SECRET", &c.Channels.Feishu.AppSecret) envStr("GOCLAW_LARK_ENCRYPT_KEY", &c.Channels.Feishu.EncryptKey) envStr("GOCLAW_LARK_VERIFICATION_TOKEN", &c.Channels.Feishu.VerificationToken) - envStr("GOCLAW_WHATSAPP_BRIDGE_URL", &c.Channels.WhatsApp.BridgeURL) + // WhatsApp no longer needs bridge_url — runs natively via whatsmeow. envStr("GOCLAW_SLACK_BOT_TOKEN", &c.Channels.Slack.BotToken) envStr("GOCLAW_SLACK_APP_TOKEN", &c.Channels.Slack.AppToken) envStr("GOCLAW_SLACK_USER_TOKEN", &c.Channels.Slack.UserToken) @@ -144,9 +144,7 @@ func (c *Config) applyEnvOverrides() { if c.Channels.Feishu.AppID != "" && c.Channels.Feishu.AppSecret != "" { c.Channels.Feishu.Enabled = true } - if c.Channels.WhatsApp.BridgeURL != "" { - c.Channels.WhatsApp.Enabled = true - } + // WhatsApp is enabled via config or DB instances (no bridge_url needed). if c.Channels.Slack.BotToken != "" && c.Channels.Slack.AppToken != "" { c.Channels.Slack.Enabled = true } diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go index 1615ac866..e7ac9d01c 100644 --- a/internal/gateway/event_filter.go +++ b/internal/gateway/event_filter.go @@ -116,6 +116,11 @@ func clientCanReceiveEvent(c *Client, event bus.Event) bool { return false } + // WhatsApp QR events: delivered directly to the requesting client, not broadcast. + if strings.HasPrefix(event.Name, "whatsapp.") { + return false + } + // Skill dep events → broadcast (non-sensitive, skill names only). if strings.HasPrefix(event.Name, "skill.") { return true diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index 63d3307bf..744f37c8e 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -28,7 +28,7 @@ func IsDefaultChannelInstance(name string) bool { if strings.HasSuffix(name, "/default") { return true } - // Legacy Telegram default uses bare name "telegram" + // Legacy config-based defaults that were seeded with bare channel-type names. switch name { case "telegram", "discord", "feishu", "zalo_oa", "whatsapp": return true diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index c9ed2e68c..52ddea7fc 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -99,6 +99,10 @@ const ( EventZaloPersonalQRCode = "zalo.personal.qr.code" EventZaloPersonalQRDone = "zalo.personal.qr.done" + // WhatsApp QR login events (client-scoped, not broadcast). + EventWhatsAppQRCode = "whatsapp.qr.code" + EventWhatsAppQRDone = "whatsapp.qr.done" + // Tenant access revocation — forces affected user's UI to logout. EventTenantAccessRevoked = "tenant.access.revoked" ) diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 2d26490a3..a76a74cda 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -183,4 +183,7 @@ const ( // Zalo Personal MethodZaloPersonalQRStart = "zalo.personal.qr.start" MethodZaloPersonalContacts = "zalo.personal.contacts" + + // WhatsApp + MethodWhatsAppQRStart = "whatsapp.qr.start" ) diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index fe33498dc..f9cca4b77 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -223,7 +223,6 @@ "help": "For webhook mode" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "Bridge URL" }, "api_server": { "label": "API Server URL", "help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default." @@ -416,6 +415,10 @@ "zaloPersonal": { "createLabel": "Create & Authenticate", "formBanner": "After creating, you'll authenticate via QR code and configure allowed users." + }, + "whatsapp": { + "createLabel": "Create & Scan QR", + "formBanner": "After creating, scan the QR code with WhatsApp to authenticate." } }, "fallback": { @@ -464,5 +467,21 @@ "retry": "Retry", "close": "Close", "skip": "Skip" + }, + "whatsapp": { + "loginSuccessLoading": "✅ WhatsApp connected! Loading channel...", + "waitingForQr": "Waiting for QR code...", + "scanHint": "Open WhatsApp → tap You → Linked Devices → Link a Device", + "initializing": "Initializing...", + "skip": "Skip", + "retry": "Retry", + "close": "Close", + "reauthTitle": "Link WhatsApp — {{name}}", + "reauthDescription": "Scan the QR code with WhatsApp to link your device.", + "alreadyLinked": "✅ Device already linked", + "alreadyLinkedDetail": "WhatsApp is connected. To link a different device, click Re-link — this will log out the current session.", + "relinkDevice": "Re-link Device", + "connectedSuccess": "✅ WhatsApp connected successfully!", + "tabQrCode": "QR Code" } } diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 32517a959..b545d8118 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -213,7 +213,6 @@ "encrypt_key": { "label": "Khóa mã hóa", "help": "Cho chế độ webhook" }, "verification_token": { "label": "Token xác minh", "help": "Cho chế độ webhook" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "URL Bridge" }, "api_server": { "label": "URL máy chủ API", "help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định." }, "proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" }, "dm_policy": { "label": "Chính sách DM" }, @@ -331,6 +330,10 @@ "zaloPersonal": { "createLabel": "Tạo & Xác thực", "formBanner": "Sau khi tạo, bạn sẽ xác thực bằng mã QR và cấu hình người dùng được phép." + }, + "whatsapp": { + "createLabel": "Tạo & Quét QR", + "formBanner": "Sau khi tạo, quét mã QR bằng WhatsApp để xác thực." } }, "fallback": { @@ -379,5 +382,21 @@ "retry": "Thử lại", "close": "Đóng", "skip": "Bỏ qua" + }, + "whatsapp": { + "loginSuccessLoading": "✅ Đã kết nối WhatsApp! Đang tải channel...", + "waitingForQr": "Đang chờ mã QR...", + "scanHint": "Mở WhatsApp → nhấn Bạn → Thiết bị đã liên kết → Liên kết thiết bị", + "initializing": "Đang khởi tạo...", + "skip": "Bỏ qua", + "retry": "Thử lại", + "close": "Đóng", + "reauthTitle": "Liên kết WhatsApp — {{name}}", + "reauthDescription": "Quét mã QR bằng WhatsApp để liên kết thiết bị.", + "alreadyLinked": "✅ Thiết bị đã được liên kết", + "alreadyLinkedDetail": "WhatsApp đã kết nối. Để liên kết thiết bị khác, nhấn Liên kết lại — thao tác này sẽ đăng xuất phiên hiện tại.", + "relinkDevice": "Liên kết lại", + "connectedSuccess": "✅ Kết nối WhatsApp thành công!", + "tabQrCode": "Mã QR" } } diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 7dd7a31c0..7742cb312 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -213,7 +213,6 @@ "encrypt_key": { "label": "加密密钥", "help": "用于Webhook模式" }, "verification_token": { "label": "验证Token", "help": "用于Webhook模式" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "Bridge URL" }, "api_server": { "label": "API 服务器 URL", "help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。" }, "proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" }, "dm_policy": { "label": "私聊策略" }, @@ -331,6 +330,10 @@ "zaloPersonal": { "createLabel": "创建并认证", "formBanner": "创建后,您将通过二维码进行认证并配置允许的用户。" + }, + "whatsapp": { + "createLabel": "创建并扫码", + "formBanner": "创建后,请用 WhatsApp 扫描二维码完成认证。" } }, "fallback": { @@ -379,5 +382,21 @@ "retry": "重试", "close": "关闭", "skip": "跳过" + }, + "whatsapp": { + "loginSuccessLoading": "✅ WhatsApp 已连接!正在加载 channel...", + "waitingForQr": "正在等待二维码...", + "scanHint": "打开 WhatsApp → 点击【您】→【已连接的设备】→【连接设备】", + "initializing": "初始化中...", + "skip": "跳过", + "retry": "重试", + "close": "关闭", + "reauthTitle": "连接 WhatsApp — {{name}}", + "reauthDescription": "使用 WhatsApp 扫描二维码以连接设备。", + "alreadyLinked": "✅ 设备已连接", + "alreadyLinkedDetail": "WhatsApp 已连接。如需连接其他设备,请点击【重新连接】——这将退出当前会话。", + "relinkDevice": "重新连接", + "connectedSuccess": "✅ WhatsApp 连接成功!", + "tabQrCode": "二维码" } } diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index ed27d3685..ba92f4527 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -68,9 +68,7 @@ export const credentialsSchema: Record = { { key: "webhook_secret", label: "Webhook Secret", type: "password" }, ], zalo_personal: [], - whatsapp: [ - { key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "http://bridge:3000" }, - ], + whatsapp: [], }; // --- Config schemas --- @@ -151,6 +149,7 @@ export const configSchema: Record = { whatsapp: [ { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, { key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" }, + { key: "require_mention", label: "Require @Mention in Groups", type: "boolean", help: "Only respond in group chats when the bot is explicitly @mentioned" }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, ], @@ -223,4 +222,9 @@ export const wizardConfig: Partial> = { formBanner: "wizard.zaloPersonal.formBanner", excludeConfigFields: ["allow_from"], }, + whatsapp: { + steps: ["auth"], + createLabel: "wizard.whatsapp.createLabel", + formBanner: "wizard.whatsapp.formBanner", + }, }; diff --git a/ui/web/src/pages/channels/channel-wizard-registry.tsx b/ui/web/src/pages/channels/channel-wizard-registry.tsx index 6435bb345..e8fb316a4 100644 --- a/ui/web/src/pages/channels/channel-wizard-registry.tsx +++ b/ui/web/src/pages/channels/channel-wizard-registry.tsx @@ -48,11 +48,14 @@ export interface ReauthDialogProps { import { ZaloAuthStep, ZaloConfigStep, ZaloEditConfig } from "./zalo/zalo-wizard-steps"; import { ZaloPersonalQRDialog } from "./zalo/zalo-personal-qr-dialog"; +import { WhatsAppAuthStep } from "./whatsapp/whatsapp-wizard-steps"; +import { WhatsAppReauthDialog } from "./whatsapp/whatsapp-reauth-dialog"; // --- Component registries --- export const wizardAuthSteps: Record> = { zalo_personal: ZaloAuthStep, + whatsapp: WhatsAppAuthStep, }; export const wizardConfigSteps: Record> = { @@ -66,6 +69,7 @@ export const wizardEditConfigs: Record> = { zalo_personal: ZaloPersonalQRDialog, + whatsapp: WhatsAppReauthDialog, }; /** Set of channel types that support re-authentication from the table */ diff --git a/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts b/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts new file mode 100644 index 000000000..b2c73d262 --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts @@ -0,0 +1,69 @@ +import { useState, useCallback } from "react"; +import { useWsCall } from "@/hooks/use-ws-call"; +import { useWsEvent } from "@/hooks/use-ws-event"; + +export type QrStatus = "idle" | "waiting" | "done" | "connected" | "error"; + +export function useWhatsAppQrLogin(instanceId: string | null) { + const [qrPng, setQrPng] = useState(null); + const [status, setStatus] = useState("idle"); + const [errorMsg, setErrorMsg] = useState(""); + const { call: startQR, loading } = useWsCall("whatsapp.qr.start"); + + const start = useCallback(async (forceReauth = false) => { + if (!instanceId) return; + setStatus("waiting"); + setQrPng(null); + setErrorMsg(""); + try { + await startQR({ instance_id: instanceId, force_reauth: forceReauth }); + } catch (err) { + setStatus("error"); + setErrorMsg(err instanceof Error ? err.message : "Failed to start QR session"); + } + }, [startQR, instanceId]); + + /** Logout current WhatsApp session and start a fresh QR scan flow. */ + const triggerReauth = useCallback(() => start(true), [start]); + + const reset = useCallback(() => { + setStatus("idle"); + setQrPng(null); + setErrorMsg(""); + }, []); + + useWsEvent( + "whatsapp.qr.code", + useCallback( + (payload: unknown) => { + const p = payload as { instance_id: string; png_b64: string }; + if (p.instance_id !== instanceId) return; + setQrPng(p.png_b64); + setStatus("waiting"); + }, + [instanceId], + ), + ); + + useWsEvent( + "whatsapp.qr.done", + useCallback( + (payload: unknown) => { + const p = payload as { instance_id: string; success: boolean; already_connected?: boolean; error?: string }; + if (p.instance_id !== instanceId) return; + if (p.success) { + // Distinguish: already connected before any QR vs. freshly authenticated via QR scan. + setStatus(p.already_connected ? "connected" : "done"); + } else { + setStatus("error"); + setErrorMsg(p.error ?? "QR authentication failed"); + } + }, + [instanceId], + ), + ); + + return { + qrPng, status, errorMsg, loading, start, reset, retry: start, triggerReauth, + }; +} diff --git a/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx b/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx new file mode 100644 index 000000000..eb6547483 --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx @@ -0,0 +1,118 @@ +// Re-authentication dialog for WhatsApp — triggered from the channels table. +// QR code scan only. + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useWhatsAppQrLogin } from "./use-whatsapp-qr-login"; +import type { ReauthDialogProps } from "../channel-wizard-registry"; + +export function WhatsAppReauthDialog({ + open, + onOpenChange, + instanceId, + instanceName, + onSuccess, +}: ReauthDialogProps) { + const { t } = useTranslation("channels"); + const { + qrPng, status, errorMsg, loading, start, reset, retry, triggerReauth, + } = useWhatsAppQrLogin(instanceId); + + // Auto-start QR when dialog opens; intentionally omits `start` from deps + // because we only want to trigger on open/close transitions, not on identity changes. + useEffect(() => { + if (open && status === "idle") start(); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + // Reset state when dialog closes + useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + // Auto-close after a fresh QR scan completes (not "already connected") + useEffect(() => { + if (status !== "done") return; + onSuccess(); + const id = setTimeout(() => onOpenChange(false), 1500); + return () => clearTimeout(id); + }, [status, onSuccess, onOpenChange]); + + return ( + { if (!loading) onOpenChange(v); }}> + + + {t("whatsapp.reauthTitle", { name: instanceName })} + + {t("whatsapp.scanHint")} + + + + {/* Already connected state */} + {status === "connected" && ( +
+
+

{t("whatsapp.alreadyLinked")}

+

+ {t("whatsapp.alreadyLinkedDetail")} +

+
+
+ + +
+
+ )} + + {/* Done state */} + {status === "done" && ( +
+

{t("whatsapp.connectedSuccess")}

+
+ )} + + {/* QR scan flow */} + {status !== "connected" && status !== "done" && ( + <> +
+ {status === "error" && ( +

{errorMsg}

+ )} + {status === "waiting" && !qrPng && ( +

{t("whatsapp.waitingForQr")}

+ )} + {status === "waiting" && qrPng && ( + <> + WhatsApp QR Code +

+ {t("whatsapp.scanHint")} +

+ + )} + {status === "idle" && ( +

{t("whatsapp.initializing")}

+ )} +
+
+ + {status === "error" && ( + + )} +
+ + )} +
+
+ ); +} diff --git a/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx b/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx new file mode 100644 index 000000000..39a112811 --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx @@ -0,0 +1,72 @@ +// WhatsApp wizard step components for the channel create wizard. +// QR auth is driven directly by whatsmeow's GetQRChannel(), delivered via WS events. +// Registered in channel-wizard-registry.tsx. + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { useWhatsAppQrLogin } from "./use-whatsapp-qr-login"; +import type { WizardAuthStepProps } from "../channel-wizard-registry"; + +/** QR code authentication step for WhatsApp — displayed in create wizard after instance creation. */ +export function WhatsAppAuthStep({ instanceId, onComplete, onSkip }: WizardAuthStepProps) { + const { t } = useTranslation("channels"); + const { qrPng, status, errorMsg, loading, start, retry, reset } = useWhatsAppQrLogin(instanceId); + + // Auto-start QR on mount + useEffect(() => { + start(); + return () => reset(); + }, [start, reset]); + + // Signal completion to parent when bridge confirms connection + useEffect(() => { + if (status === "done") onComplete(); + }, [status, onComplete]); + + return ( + <> +
+ {status === "done" && ( +

+ {t("whatsapp.loginSuccessLoading")} +

+ )} + {status === "error" && ( +

{errorMsg}

+ )} + {status === "waiting" && !qrPng && ( +

+ {t("whatsapp.waitingForQr")} +

+ )} + {status === "waiting" && qrPng && ( + <> + WhatsApp QR Code +

+ {t("whatsapp.scanHint")} +

+ + )} + {status === "idle" && ( +

{t("whatsapp.initializing")}

+ )} +
+ + + {status === "error" && ( + + )} + + + ); +}