feat(whatsapp): add native WhatsApp channel with whatsmeow (#720)

Replace Node.js Baileys bridge with native go.mau.fi/whatsmeow — zero
external dependencies. QR auth, media support, markdown formatting,
typing indicators, dual JID/LID identity, group policies, pairing.

Resolves #703
This commit is contained in:
Duc Nguyen
2026-04-07 12:12:44 +07:00
committed by GitHub
parent 20c4478fe1
commit 0db1e93abf
33 changed files with 2052 additions and 440 deletions
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+7 -3
View File
@@ -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
+16 -10
View File
@@ -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) |
+15
View File
@@ -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
+17 -8
View File
@@ -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
)
+47 -13
View File
@@ -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=
+102
View File
@@ -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
}
+50 -44
View File
@@ -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,
}
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"
}
// DB instances default to "pairing" for groups (secure by default).
if waCfg.GroupPolicy == "" {
waCfg.GroupPolicy = "pairing"
ch, err := New(waCfg, msgBus, pairingSvc, db, pendingStore, dialect)
if err != nil {
return nil, err
}
ch.SetName(name)
return ch, nil
}
ch, err := New(waCfg, msgBus, pairingSvc)
if err != nil {
return nil, err
}
ch.SetName(name)
return ch, nil
}
+137
View File
@@ -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)<br\s*/?>`), "\n"},
{regexp.MustCompile(`(?i)</?p\s*>`), "\n"},
{regexp.MustCompile(`(?i)<b>([\s\S]*?)</b>`), "**${1}**"},
{regexp.MustCompile(`(?i)<strong>([\s\S]*?)</strong>`), "**${1}**"},
{regexp.MustCompile(`(?i)<i>([\s\S]*?)</i>`), "_${1}_"},
{regexp.MustCompile(`(?i)<em>([\s\S]*?)</em>`), "_${1}_"},
{regexp.MustCompile(`(?i)<s>([\s\S]*?)</s>`), "~~${1}~~"},
{regexp.MustCompile(`(?i)<strike>([\s\S]*?)</strike>`), "~~${1}~~"},
{regexp.MustCompile(`(?i)<del>([\s\S]*?)</del>`), "~~${1}~~"},
{regexp.MustCompile(`(?i)<code>([\s\S]*?)</code>`), "`${1}`"},
{regexp.MustCompile(`(?i)<a\s+href="([^"]+)"[^>]*>([\s\S]*?)</a>`), "[${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
}
+49
View File
@@ -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", "<b>bold</b>", "*bold*"},
{"html italic", "<em>italic</em>", "_italic_"},
{"html strikethrough", "<del>removed</del>", "~removed~"},
{"html br", "line1<br>line2", "line1\nline2"},
{"html link", `<a href="https://x.com">link</a>`, "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)
}
})
}
}
+266
View File
@@ -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
}
@@ -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)
}
}
})
}
+131
View File
@@ -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)
}
})
}
}
+182
View File
@@ -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)
}
}
+141
View File
@@ -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)
}
}
+243
View File
@@ -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, &params)
}
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
}
}
}
}
+153 -342
View File
@@ -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
)
// 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.
func init() {
// Set device name shown in WhatsApp's "Linked Devices" screen (once at package init).
wastore.DeviceProps.Os = proto.String("GoClaw")
}
// 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")
if c.client != nil {
c.client.Disconnect()
}
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 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:
// Cancel all active typing goroutines.
c.typingCancel.Range(func(key, value any) bool {
if fn, ok := value.(context.CancelFunc); ok {
fn()
}
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
}
msgType, _ := msg["type"].(string)
if msgType == "message" {
c.handleIncomingMessage(msg)
}
}
}
// 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)
}
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"
}
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"
c.typingCancel.Delete(key)
return true
}
}
// 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"
}
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 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,
)
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.SetRunning(false)
c.MarkStopped("Stopped")
return nil
}
// 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())
}
}
// 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()
c.MarkHealthy("WhatsApp authenticated and connected")
}
// handleDisconnected processes the Disconnected event.
func (c *Channel) handleDisconnected() {
c.lastQRMu.Lock()
c.waAuthenticated = false
c.lastQRMu.Unlock()
c.MarkDegraded("WhatsApp disconnected", "Waiting for reconnect",
channels.ChannelFailureKindNetwork, true)
// whatsmeow auto-reconnects — no manual reconnect loop needed.
}
// 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.MarkDegraded("WhatsApp logged out", "Re-scan QR to reconnect",
channels.ChannelFailureKindAuth, false)
}
+8 -6
View File
@@ -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 {
+2 -4
View File
@@ -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
}
+5
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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"
)
+3
View File
@@ -183,4 +183,7 @@ const (
// Zalo Personal
MethodZaloPersonalQRStart = "zalo.personal.qr.start"
MethodZaloPersonalContacts = "zalo.personal.contacts"
// WhatsApp
MethodWhatsAppQRStart = "whatsapp.qr.start"
)
+20 -1
View File
@@ -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"
}
}
+20 -1
View File
@@ -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"
}
}
+20 -1
View File
@@ -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": "二维码"
}
}
+7 -3
View File
@@ -68,9 +68,7 @@ export const credentialsSchema: Record<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
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<Record<string, WizardConfig>> = {
formBanner: "wizard.zaloPersonal.formBanner",
excludeConfigFields: ["allow_from"],
},
whatsapp: {
steps: ["auth"],
createLabel: "wizard.whatsapp.createLabel",
formBanner: "wizard.whatsapp.formBanner",
},
};
@@ -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<string, ComponentType<WizardAuthStepProps>> = {
zalo_personal: ZaloAuthStep,
whatsapp: WhatsAppAuthStep,
};
export const wizardConfigSteps: Record<string, ComponentType<WizardConfigStepProps>> = {
@@ -66,6 +69,7 @@ export const wizardEditConfigs: Record<string, ComponentType<WizardEditConfigPro
/** Re-auth dialogs for re-authentication from the channels table */
export const reauthDialogs: Record<string, ComponentType<ReauthDialogProps>> = {
zalo_personal: ZaloPersonalQRDialog,
whatsapp: WhatsAppReauthDialog,
};
/** Set of channel types that support re-authentication from the table */
@@ -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<string | null>(null);
const [status, setStatus] = useState<QrStatus>("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,
};
}
@@ -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 (
<Dialog open={open} onOpenChange={(v) => { if (!loading) onOpenChange(v); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t("whatsapp.reauthTitle", { name: instanceName })}</DialogTitle>
<DialogDescription>
{t("whatsapp.scanHint")}
</DialogDescription>
</DialogHeader>
{/* Already connected state */}
{status === "connected" && (
<div className="flex flex-col items-center gap-4 py-2">
<div className="text-center space-y-2">
<p className="text-sm text-green-600 font-medium">{t("whatsapp.alreadyLinked")}</p>
<p className="text-xs text-muted-foreground">
{t("whatsapp.alreadyLinkedDetail")}
</p>
</div>
<div className="flex justify-end gap-2 w-full">
<Button variant="outline" onClick={() => onOpenChange(false)}>{t("whatsapp.close")}</Button>
<Button variant="destructive" onClick={triggerReauth} disabled={loading}>{t("whatsapp.relinkDevice")}</Button>
</div>
</div>
)}
{/* Done state */}
{status === "done" && (
<div className="flex flex-col items-center gap-4 py-4">
<p className="text-sm text-green-600 font-medium">{t("whatsapp.connectedSuccess")}</p>
</div>
)}
{/* QR scan flow */}
{status !== "connected" && status !== "done" && (
<>
<div className="flex flex-col items-center gap-4 py-4 min-h-[200px]">
{status === "error" && (
<p className="text-sm text-destructive">{errorMsg}</p>
)}
{status === "waiting" && !qrPng && (
<p className="text-sm text-muted-foreground">{t("whatsapp.waitingForQr")}</p>
)}
{status === "waiting" && qrPng && (
<>
<img
src={`data:image/png;base64,${qrPng}`}
alt="WhatsApp QR Code"
className="w-52 h-52 border rounded"
/>
<p className="text-xs text-muted-foreground text-center">
{t("whatsapp.scanHint")}
</p>
</>
)}
{status === "idle" && (
<p className="text-sm text-muted-foreground">{t("whatsapp.initializing")}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>{t("whatsapp.close")}</Button>
{status === "error" && (
<Button onClick={() => retry()} disabled={loading}>{t("whatsapp.retry")}</Button>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -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 (
<>
<div className="flex flex-col items-center gap-4 py-4 min-h-0">
{status === "done" && (
<p className="text-sm text-green-600 font-medium">
{t("whatsapp.loginSuccessLoading")}
</p>
)}
{status === "error" && (
<p className="text-sm text-destructive">{errorMsg}</p>
)}
{status === "waiting" && !qrPng && (
<p className="text-sm text-muted-foreground">
{t("whatsapp.waitingForQr")}
</p>
)}
{status === "waiting" && qrPng && (
<>
<img
src={`data:image/png;base64,${qrPng}`}
alt="WhatsApp QR Code"
className="w-52 h-52 border rounded"
/>
<p className="text-xs text-muted-foreground text-center">
{t("whatsapp.scanHint")}
</p>
</>
)}
{status === "idle" && (
<p className="text-sm text-muted-foreground">{t("whatsapp.initializing")}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onSkip} disabled={loading}>
{t("whatsapp.skip")}
</Button>
{status === "error" && (
<Button onClick={() => retry()} disabled={loading}>
{t("whatsapp.retry")}
</Button>
)}
</DialogFooter>
</>
);
}