mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-18 05:27:45 +00:00
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:
+1
-1
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
goclawprotocol "github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
const qrSessionTimeout = 3 * time.Minute
|
||||
|
||||
// cancelEntry wraps a CancelFunc so it can be stored in sync.Map.CompareAndDelete.
|
||||
type cancelEntry struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// QRMethods handles whatsapp.qr.start — delivers QR codes to the UI wizard.
|
||||
type QRMethods struct {
|
||||
instanceStore store.ChannelInstanceStore
|
||||
manager *channels.Manager
|
||||
activeSessions sync.Map // instanceID (string) -> *cancelEntry
|
||||
}
|
||||
|
||||
func NewQRMethods(instanceStore store.ChannelInstanceStore, manager *channels.Manager) *QRMethods {
|
||||
return &QRMethods{instanceStore: instanceStore, manager: manager}
|
||||
}
|
||||
|
||||
func (m *QRMethods) Register(router *gateway.MethodRouter) {
|
||||
router.Register(goclawprotocol.MethodWhatsAppQRStart, m.handleQRStart)
|
||||
}
|
||||
|
||||
func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) {
|
||||
var params struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ForceReauth bool `json:"force_reauth"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
_ = json.Unmarshal(req.Params, ¶ms)
|
||||
}
|
||||
|
||||
instID, err := uuid.Parse(params.InstanceID)
|
||||
if err != nil {
|
||||
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "invalid instance_id"))
|
||||
return
|
||||
}
|
||||
|
||||
inst, err := m.instanceStore.Get(ctx, instID)
|
||||
if err != nil || inst.ChannelType != channels.TypeWhatsApp {
|
||||
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrNotFound, "whatsapp instance not found"))
|
||||
return
|
||||
}
|
||||
|
||||
qrCtx, cancel := context.WithTimeout(ctx, qrSessionTimeout)
|
||||
entry := &cancelEntry{cancel: cancel}
|
||||
|
||||
// Cancel any previous QR session for this instance.
|
||||
if prev, loaded := m.activeSessions.Swap(params.InstanceID, entry); loaded {
|
||||
if prevEntry, ok := prev.(*cancelEntry); ok {
|
||||
prevEntry.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// ACK immediately — QR/done events arrive asynchronously.
|
||||
client.SendResponse(goclawprotocol.NewOKResponse(req.ID, map[string]any{"status": "started"}))
|
||||
|
||||
go m.runQRSession(qrCtx, entry, client, params.InstanceID, inst.Name, params.ForceReauth)
|
||||
}
|
||||
|
||||
func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry,
|
||||
client *gateway.Client, instanceIDStr, channelName string, forceReauth bool) {
|
||||
|
||||
defer entry.cancel()
|
||||
defer m.activeSessions.CompareAndDelete(instanceIDStr, entry)
|
||||
|
||||
// Wait for channel to appear in manager — instance creation triggers an async
|
||||
// reload, so the channel may not be registered yet when the wizard fires QR start.
|
||||
var wa *Channel
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
if ch, ok := m.manager.GetChannel(channelName); ok {
|
||||
if w, ok := ch.(*Channel); ok {
|
||||
wa = w
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if wa == nil {
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRDone,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"success": false,
|
||||
"error": "channel not found",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Already authenticated and no force-reauth → signal connected.
|
||||
if wa.IsAuthenticated() && !forceReauth {
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRDone,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"success": true,
|
||||
"already_connected": true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Force reauth: clear session and prepare for fresh QR.
|
||||
if forceReauth {
|
||||
if err := wa.Reauth(); err != nil {
|
||||
slog.Warn("whatsapp QR: reauth failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver cached QR if available.
|
||||
if cached := wa.GetLastQRB64(); cached != "" {
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRCode,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"png_b64": cached,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Start QR flow — get QR channel from whatsmeow.
|
||||
qrChan, err := wa.StartQRFlow(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("whatsapp QR: start flow failed", "error", err)
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRDone,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if qrChan == nil {
|
||||
// Already authenticated (StartQRFlow returned nil).
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRDone,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"success": true,
|
||||
"already_connected": true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Process QR events from whatsmeow.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRDone,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"success": false,
|
||||
"error": "QR session timed out — restart to try again",
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
case evt, ok := <-qrChan:
|
||||
if !ok {
|
||||
return // channel closed
|
||||
}
|
||||
|
||||
switch evt.Event {
|
||||
case "code":
|
||||
png, qrErr := qrcode.Encode(evt.Code, qrcode.Medium, 256)
|
||||
if qrErr != nil {
|
||||
slog.Warn("whatsapp: QR PNG encode failed", "error", qrErr)
|
||||
continue
|
||||
}
|
||||
pngB64 := base64.StdEncoding.EncodeToString(png)
|
||||
|
||||
wa.cacheQR(pngB64)
|
||||
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRCode,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"png_b64": pngB64,
|
||||
},
|
||||
})
|
||||
|
||||
case "success":
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRDone,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"success": true,
|
||||
},
|
||||
})
|
||||
slog.Info("whatsapp QR session completed", "instance", instanceIDStr)
|
||||
return
|
||||
|
||||
case "timeout":
|
||||
client.SendEvent(goclawprotocol.EventFrame{
|
||||
Type: goclawprotocol.FrameTypeEvent,
|
||||
Event: goclawprotocol.EventWhatsAppQRDone,
|
||||
Payload: map[string]any{
|
||||
"instance_id": instanceIDStr,
|
||||
"success": false,
|
||||
"error": "QR code expired — restart to try again",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -183,4 +183,7 @@ const (
|
||||
// Zalo Personal
|
||||
MethodZaloPersonalQRStart = "zalo.personal.qr.start"
|
||||
MethodZaloPersonalContacts = "zalo.personal.contacts"
|
||||
|
||||
// WhatsApp
|
||||
MethodWhatsAppQRStart = "whatsapp.qr.start"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "二维码"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user