From c1ed163e77f23e2e81f99e4952907dbecc89de23 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 12:29:27 -0800 Subject: [PATCH 01/18] Added a native WhatsApp channel implementation. --- .goreleaser.yaml | 2 +- Makefile | 19 +++ README.md | 41 +++++- config/config.example.json | 2 + docs/troubleshooting.md | 43 ++++++ go.mod | 25 ++++ go.sum | 98 +++++++++++++ pkg/agent/loop.go | 32 ++++- pkg/channels/manager.go | 24 +++- pkg/channels/whatsapp_native.go | 235 ++++++++++++++++++++++++++++++++ pkg/config/config.go | 8 +- pkg/config/defaults.go | 8 +- pkg/migrate/config.go | 6 + 13 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 docs/troubleshooting.md create mode 100644 pkg/channels/whatsapp_native.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index af26509e6..cc221c2fd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,7 +31,7 @@ builds: - loong64 - arm goarm: - - "7" + - "7" main: ./cmd/picoclaw ignore: - goos: windows diff --git a/Makefile b/Makefile index a14723616..ba8168617 100644 --- a/Makefile +++ b/Makefile @@ -87,11 +87,30 @@ build: generate @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) +## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit) +build-linux-arm: generate + @echo "Building for linux/arm (GOARM=7)..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm" + +## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit) +build-linux-arm64: generate + @echo "Building for linux/arm64..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" + +## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit) +build-pi-zero: build-linux-arm build-linux-arm64 + @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" + ## build-all: Build picoclaw for all platforms build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) diff --git a/README.md b/README.md index 72a933b6f..35c62434f 100644 --- a/README.md +++ b/README.md @@ -154,10 +154,15 @@ make build # Build for multiple platforms make build-all +# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + # Build And Install make install ``` +**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both. + ## 🐳 Docker Compose You can also run PicoClaw using Docker Compose without installing anything locally. @@ -284,12 +289,13 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom +Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | | **Discord** | Easy (bot token + intents) | +| **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | @@ -380,6 +386,33 @@ picoclaw gateway +
+WhatsApp (native via whatsmeow) + +PicoClaw can connect to WhatsApp in two ways: + +- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). +- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. + +**Configure (native)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices. + +
+
QQ @@ -1066,7 +1099,11 @@ picoclaw agent -m "Hello" "allow_from": [""] }, "whatsapp": { - "enabled": false + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] }, "feishu": { "enabled": false, diff --git a/config/config.example.json b/config/config.example.json index 9575039f8..2a2fcc149 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -75,6 +75,8 @@ "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", "allow_from": [] }, "feishu": { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 000000000..219d2c6e3 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,43 @@ +# Troubleshooting + +## "model ... not found in model_list" or OpenRouter "free is not a valid model ID" + +**Symptom:** You see either: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter returns 400: `"free is not a valid model ID"` + +**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand. + +- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it. +- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing). + +**Fix:** In `~/.picoclaw/config.json` (or your config path): + +1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). +2. That entry’s **model** must be a valid OpenRouter model ID, for example: + - `"openrouter/free"` – auto free-tier + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Example snippet: + +```json +{ + "agents": { + "defaults": { + "model": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Get your key at [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/go.mod b/go.mod index 9bca4c127..d7f9b1901 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 @@ -18,16 +19,40 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 + go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.14.0 + google.golang.org/protobuf v1.36.11 + modernc.org/sqlite v1.46.1 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect + go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/util v0.9.6 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + rsc.io/qr v0.2.0 // indirect ) require ( diff --git a/go.sum b/go.sum index dfb477e51..941ab67ce 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,20 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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/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/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/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +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/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -25,12 +35,19 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 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.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= @@ -42,6 +59,7 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -63,6 +81,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -72,6 +92,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -91,8 +113,21 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +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-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/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -105,13 +140,23 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +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/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -153,11 +198,19 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +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-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= +go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -171,10 +224,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -217,8 +274,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -227,6 +287,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +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= @@ -234,6 +296,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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= @@ -243,6 +307,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -255,6 +321,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -269,3 +337,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7a4e9077f..572561d0e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -209,8 +209,15 @@ func (al *AgentLoop) Run(ctx context.Context) error { ChatID: msg.ChatID, Content: response, }) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": msg.Channel, + "chat_id": msg.ChatID, + "content_len": len(response), + }) + } else { + logger.DebugCF("agent", "Skipped outbound (message tool already sent)", map[string]any{"channel": msg.Channel}) } - } }() } } @@ -308,8 +315,14 @@ func (al *AgentLoop) ProcessDirectWithChannel( // ProcessHeartbeat processes a heartbeat request without session history. // Each heartbeat is independent and doesn't accumulate context. +// It uses the same mutex as processMessage so heartbeat and user messages never run concurrently. func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for heartbeat") + } + al.agentMu.Lock() + defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: "heartbeat", Channel: channel, @@ -362,6 +375,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) if !ok { agent = al.registry.GetDefaultAgent() } + if agent == nil { + return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + } + + // Reset message-tool state for this round so we don't skip publishing due to a previous round. + if tool, ok := agent.Tools.Get("message"); ok { + if mt, ok := tool.(tools.ContextualTool); ok { + mt.SetContext(msg.Channel, msg.ChatID) + } + } // Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron) sessionKey := route.SessionKey @@ -376,6 +399,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "matched_by": route.MatchedBy, }) + al.agentMu.Lock() + defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: msg.Channel, @@ -428,10 +453,15 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe // Use default agent for system messages agent := al.registry.GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for system message") + } // Use the origin session for context sessionKey := routing.BuildAgentMainSessionKey(agent.ID) + al.agentMu.Lock() + defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: originChannel, diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 38b408f5e..dfc4fd9f9 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -12,6 +12,7 @@ import ( "fmt" "math" "net/http" + "path/filepath" "sync" "time" @@ -210,8 +211,27 @@ func (m *Manager) initChannels() error { m.initChannel("telegram", "Telegram") } - if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" { - m.initChannel("whatsapp", "WhatsApp") + if m.config.Channels.WhatsApp.Enabled { + waCfg := m.config.Channels.WhatsApp + useNative := waCfg.UseNative + if useNative { + logger.DebugC("channels", "Attempting to initialize WhatsApp native channel (whatsmeow)") + storePath := waCfg.SessionStorePath + if storePath == "" { + storePath = filepath.Join(m.config.WorkspacePath(), "whatsapp") + } + ch, err := NewWhatsAppNativeChannel(waCfg, m.bus, storePath) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ + "error": err.Error(), + }) + } else { + m.channels["whatsapp"] = ch + logger.InfoC("channels", "WhatsApp native channel enabled successfully") + } + } else if waCfg.BridgeURL != "" { + m.initChannel("whatsapp", "WhatsApp") + } } if m.config.Channels.Feishu.Enabled { diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go new file mode 100644 index 000000000..cae89bb00 --- /dev/null +++ b/pkg/channels/whatsapp_native.go @@ -0,0 +1,235 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package channels + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/mdp/qrterminal/v3" + _ "modernc.org/sqlite" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" +) + +const ( + sqliteDriver = "sqlite" + whatsappDBName = "store.db" +) + +// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). +type WhatsAppNativeChannel struct { + *BaseChannel + config config.WhatsAppConfig + storePath string + client *whatsmeow.Client + container *sqlstore.Container + mu sync.Mutex +} + +// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. +// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (*WhatsAppNativeChannel, error) { + base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) + if storePath == "" { + storePath = "whatsapp" + } + return &WhatsAppNativeChannel{ + BaseChannel: base, + config: cfg, + storePath: storePath, + }, nil +} + +func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { + log.Printf("Starting WhatsApp native channel (whatsmeow), store: %s", c.storePath) + + if err := os.MkdirAll(c.storePath, 0700); err != nil { + return fmt.Errorf("create session store dir: %w", err) + } + + dbPath := filepath.Join(c.storePath, whatsappDBName) + connStr := "file:" + dbPath + "?_foreign_keys=on" + + // Open DB and enable foreign keys explicitly (modernc.org/sqlite does not set them from URI). + db, err := sql.Open(sqliteDriver, connStr) + if err != nil { + return fmt.Errorf("open whatsapp store: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + _ = db.Close() + return fmt.Errorf("enable foreign keys: %w", err) + } + + waLogger := waLog.Stdout("WhatsApp", "WARN", true) + container := sqlstore.NewWithDB(db, sqliteDriver, waLogger) + if err = container.Upgrade(ctx); err != nil { + _ = db.Close() + return fmt.Errorf("open whatsapp store: %w", err) + } + + deviceStore, err := container.GetFirstDevice(ctx) + if err != nil { + _ = container.Close() + return fmt.Errorf("get device store: %w", err) + } + + client := whatsmeow.NewClient(deviceStore, waLogger) + client.AddEventHandler(c.eventHandler) + + c.mu.Lock() + c.container = container + c.client = client + c.mu.Unlock() + + if client.Store.ID == nil { + qrChan, err := client.GetQRChannel(ctx) + if err != nil { + _ = container.Close() + return fmt.Errorf("get QR channel: %w", err) + } + if err := client.Connect(); err != nil { + _ = container.Close() + return fmt.Errorf("connect: %w", err) + } + for evt := range qrChan { + if evt.Event == "code" { + log.Println("Scan this QR code with WhatsApp (Linked Devices):") + qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + HalfBlocks: true, + }) + } else { + log.Printf("WhatsApp login event: %s", evt.Event) + } + } + } else { + if err := client.Connect(); err != nil { + _ = container.Close() + return fmt.Errorf("connect: %w", err) + } + } + + c.setRunning(true) + log.Println("WhatsApp native channel connected") + return nil +} + +func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { + log.Println("Stopping WhatsApp native channel...") + c.mu.Lock() + client := c.client + container := c.container + c.client = nil + c.container = nil + c.mu.Unlock() + + if client != nil { + client.Disconnect() + } + if container != nil { + _ = container.Close() + } + c.setRunning(false) + return nil +} + +func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { + switch v := evt.(type) { + case *events.Message: + c.handleIncoming(v) + } +} + +func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { + if evt.Message == nil { + return + } + senderID := evt.Info.Sender.String() + chatID := evt.Info.Chat.String() + content := evt.Message.GetConversation() + if content == "" && evt.Message.ExtendedTextMessage != nil { + content = evt.Message.ExtendedTextMessage.GetText() + } + + var mediaPaths []string + // Optional: resolve media to local paths if needed; for now we only forward text to the bus. + _ = mediaPaths + + metadata := make(map[string]string) + metadata["message_id"] = evt.Info.ID + if evt.Info.PushName != "" { + metadata["user_name"] = evt.Info.PushName + } + if evt.Info.Chat.Server == types.GroupServer { + metadata["peer_kind"] = "group" + metadata["peer_id"] = chatID + } else { + metadata["peer_kind"] = "direct" + metadata["peer_id"] = senderID + } + + log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) + c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) +} + +func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + c.mu.Lock() + client := c.client + c.mu.Unlock() + + if client == nil || !client.IsConnected() { + return fmt.Errorf("whatsapp connection not established") + } + + to, err := parseJID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) + } + + waMsg := &waE2E.Message{ + Conversation: proto.String(msg.Content), + } + + _, err = client.SendMessage(ctx, to, waMsg) + if err != nil { + return fmt.Errorf("send message: %w", err) + } + return nil +} + +// parseJID converts a chat ID (phone number or JID string) to types.JID. +func parseJID(s string) (types.JID, error) { + s = strings.TrimSpace(s) + if s == "" { + return types.JID{}, fmt.Errorf("empty chat id") + } + if strings.Contains(s, "@") { + return types.ParseJID(s) + } + // Assume phone number for user chat. + return types.NewJID(s, types.DefaultUserServer), nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index bdd4d8823..9a6fda58a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -223,9 +223,11 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index d19ce1d38..aa7f6de98 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -25,9 +25,11 @@ func DefaultConfig() *Config { }, Channels: ChannelsConfig{ WhatsApp: WhatsAppConfig{ - Enabled: false, - BridgeURL: "ws://localhost:3001", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + BridgeURL: "ws://localhost:3001", + UseNative: false, + SessionStorePath: "", + AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ Enabled: false, diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 869b39827..ea91565e8 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -165,6 +165,12 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) { if v, ok := getString(cMap, "bridge_url"); ok { cfg.Channels.WhatsApp.BridgeURL = v } + if v, ok := getBool(cMap, "use_native"); ok { + cfg.Channels.WhatsApp.UseNative = v + } + if v, ok := getString(cMap, "session_store_path"); ok { + cfg.Channels.WhatsApp.SessionStorePath = v + } case "feishu": cfg.Channels.Feishu.Enabled = enabled cfg.Channels.Feishu.AllowFrom = allowFrom From 81234f7e54146b9e95bceac81bc440701efeb813 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 18:20:24 -0800 Subject: [PATCH 02/18] Sanitize WhatsApp messages and remove extra log messages. --- pkg/channels/whatsapp_native.go | 3 +++ pkg/utils/string.go | 26 ++++++++++++++++++++++++++ pkg/utils/string_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index cae89bb00..4f40dc18c 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -174,6 +174,9 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { if content == "" && evt.Message.ExtendedTextMessage != nil { content = evt.Message.ExtendedTextMessage.GetText() } + content = utils.SanitizeMessageContent(content) + + if content == "" { return } // ignore empty messages var mediaPaths []string // Optional: resolve media to local paths if needed; for now we only forward text to the bus. diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 62d9beee0..edc413972 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -1,5 +1,31 @@ package utils +import ( + "strings" + "unicode" +) + +// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, +// zero-width characters), and other non-graphic characters that could confuse an LLM +// or cause display issues in the agent UI. +func SanitizeMessageContent(input string) string { + var sb strings.Builder + // Pre-allocate memory to avoid multiple allocations + sb.Grow(len(input)) + + for _, r := range input { + // unicode.IsGraphic returns true if the rune is a Unicode graphic character. + // This includes letters, marks, numbers, punctuation, and symbols. + // It excludes control characters (Cc), format characters (Cf), + // surrogates (Cs), and private use (Co). + if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' { + sb.WriteRune(r) + } + } + + return sb.String() +} + // Truncate returns a truncated version of s with at most maxLen runes. // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index a44ead228..fffa0cff3 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -104,3 +104,27 @@ func TestTruncate(t *testing.T) { }) } } + +func TestSanitizeMessageContent(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"empty", "", ""}, + {"plain text unchanged", "Hello world", "Hello world"}, + {"strip ZWSP", "Hello\u200bworld", "Helloworld"}, + {"strip RTL override", "Hi\u202eevil", "Hievil"}, + {"strip BOM", "\uFEFFcontent", "content"}, + {"strip multiple", "a\u200c\u202ab\u202cc", "abc"}, + {"unicode letters preserved", "café 日本語", "café 日本語"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeMessageContent(tt.input) + if got != tt.want { + t.Errorf("SanitizeMessageContent(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} From 91eff9b34cbc41bef4184cb26640f8192ffcb2bd Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 19:10:25 -0800 Subject: [PATCH 03/18] Changing the logging to use the logger package to be consistent. --- pkg/channels/whatsapp_native.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index 4f40dc18c..c3dcceeb4 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -9,7 +9,6 @@ import ( "context" "database/sql" "fmt" - "log" "os" "path/filepath" "strings" @@ -26,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" "go.mau.fi/whatsmeow/proto/waE2E" @@ -62,7 +62,7 @@ func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, st } func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { - log.Printf("Starting WhatsApp native channel (whatsmeow), store: %s", c.storePath) + logger.InfoCF("channels", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) if err := os.MkdirAll(c.storePath, 0700); err != nil { return fmt.Errorf("create session store dir: %w", err) @@ -116,14 +116,14 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } for evt := range qrChan { if evt.Event == "code" { - log.Println("Scan this QR code with WhatsApp (Linked Devices):") + logger.InfoCF("channels", "Scan this QR code with WhatsApp (Linked Devices):", nil) qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ Level: qrterminal.L, Writer: os.Stdout, HalfBlocks: true, }) } else { - log.Printf("WhatsApp login event: %s", evt.Event) + logger.InfoCF("channels", "WhatsApp login event", map[string]any{"event": evt.Event}) } } } else { @@ -134,12 +134,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } c.setRunning(true) - log.Println("WhatsApp native channel connected") + logger.InfoCF("channels", "WhatsApp native channel connected", nil) return nil } func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { - log.Println("Stopping WhatsApp native channel...") + logger.InfoCF("channels", "Stopping WhatsApp native channel", nil) c.mu.Lock() client := c.client container := c.container @@ -195,7 +195,7 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { metadata["peer_id"] = senderID } - log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) + logger.InfoCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) } From 76f8ab827f7fc6aef092a8aa104fc6cb309250cf Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 19:18:02 -0800 Subject: [PATCH 04/18] Handle dis --- pkg/channels/whatsapp_native.go | 83 ++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index c3dcceeb4..cbd02561f 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/mdp/qrterminal/v3" _ "modernc.org/sqlite" @@ -35,16 +36,24 @@ import ( const ( sqliteDriver = "sqlite" whatsappDBName = "store.db" + + reconnectInitial = 5 * time.Second + reconnectMax = 5 * time.Minute + reconnectMultiplier = 2.0 ) // WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). type WhatsAppNativeChannel struct { *BaseChannel - config config.WhatsAppConfig - storePath string - client *whatsmeow.Client - container *sqlstore.Container - mu sync.Mutex + config config.WhatsAppConfig + storePath string + client *whatsmeow.Client + container *sqlstore.Container + mu sync.Mutex + runCtx context.Context + runCancel context.CancelFunc + reconnectMu sync.Mutex + reconnecting bool } // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. @@ -133,6 +142,7 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } } + c.runCtx, c.runCancel = context.WithCancel(ctx) c.setRunning(true) logger.InfoCF("channels", "WhatsApp native channel connected", nil) return nil @@ -140,6 +150,9 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { logger.InfoCF("channels", "Stopping WhatsApp native channel", nil) + if c.runCancel != nil { + c.runCancel() + } c.mu.Lock() client := c.client container := c.container @@ -158,9 +171,65 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { } func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { - switch v := evt.(type) { + switch evt.(type) { case *events.Message: - c.handleIncoming(v) + c.handleIncoming(evt.(*events.Message)) + case *events.Disconnected: + logger.InfoCF("channels", "WhatsApp disconnected, will attempt reconnection", nil) + c.reconnectMu.Lock() + if c.reconnecting { + c.reconnectMu.Unlock() + return + } + c.reconnecting = true + c.reconnectMu.Unlock() + go c.reconnectWithBackoff() + } +} + +func (c *WhatsAppNativeChannel) reconnectWithBackoff() { + defer func() { + c.reconnectMu.Lock() + c.reconnecting = false + c.reconnectMu.Unlock() + }() + + backoff := reconnectInitial + for { + select { + case <-c.runCtx.Done(): + return + default: + } + + c.mu.Lock() + client := c.client + c.mu.Unlock() + if client == nil { + return + } + + logger.InfoCF("channels", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()}) + err := client.Connect() + if err == nil { + logger.InfoCF("channels", "WhatsApp reconnected", nil) + return + } + + logger.WarnCF("channels", "WhatsApp reconnect failed", map[string]any{"error": err.Error()}) + + select { + case <-c.runCtx.Done(): + return + case <-time.After(backoff): + if backoff < reconnectMax { + next := time.Duration(float64(backoff) * reconnectMultiplier) + if next > reconnectMax { + next = reconnectMax + } + backoff = next + } + } } } From 25362ec7632be24a3fb0867c35b33f28a183f9e0 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 19:22:32 -0800 Subject: [PATCH 05/18] Add new build tag for WhatsApp native support to keep the binary smaller. --- Makefile | 8 ++++++++ README.md | 2 +- pkg/channels/whatsapp_native.go | 9 ++++++--- pkg/channels/whatsapp_native_stub.go | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 pkg/channels/whatsapp_native_stub.go diff --git a/Makefile b/Makefile index ba8168617..99a633c0b 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,14 @@ build: generate @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) +## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary +build-whatsapp-native: generate + @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." + @mkdir -p $(BUILD_DIR) + @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + @echo "Build complete: $(BINARY_PATH)" + @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) + ## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit) build-linux-arm: generate @echo "Building for linux/arm (GOARM=7)..." diff --git a/README.md b/README.md index 35c62434f..31495176a 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ picoclaw gateway PicoClaw can connect to WhatsApp in two ways: -- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). +- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`). - **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. **Configure (native)** diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index cbd02561f..3099afdcc 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -1,3 +1,5 @@ +//go:build whatsapp_native + // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // @@ -58,16 +60,17 @@ type WhatsAppNativeChannel struct { // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (*WhatsAppNativeChannel, error) { +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) if storePath == "" { storePath = "whatsapp" } - return &WhatsAppNativeChannel{ + c := &WhatsAppNativeChannel{ BaseChannel: base, config: cfg, storePath: storePath, - }, nil + } + return c, nil } func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { diff --git a/pkg/channels/whatsapp_native_stub.go b/pkg/channels/whatsapp_native_stub.go new file mode 100644 index 000000000..b4a826fa4 --- /dev/null +++ b/pkg/channels/whatsapp_native_stub.go @@ -0,0 +1,16 @@ +//go:build !whatsapp_native + +package channels + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. +// Build with: go build -tags whatsapp_native ./cmd/... +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { + return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") +} From 16a36ea416117e1b86429cc5505f389ac362e408 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 20:58:59 -0800 Subject: [PATCH 06/18] Adding a new target to the Makefile to build for multiple platforms with WhatsApp native support. --- Makefile | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 99a633c0b..c7375a544 100644 --- a/Makefile +++ b/Makefile @@ -89,11 +89,19 @@ build: generate ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate - @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." +## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." + @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) - @echo "Build complete: $(BINARY_PATH)" - @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) + GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) +## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + @echo "Build complete" +## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) ## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit) build-linux-arm: generate From 071505e797a7ee8d38c753f1dec91312a008d8a7 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 21:03:42 -0800 Subject: [PATCH 07/18] Removing the agentMu mutex from the AgentLoop --- pkg/agent/loop.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 572561d0e..fc87c6fe5 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -321,8 +321,6 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } - al.agentMu.Lock() - defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: "heartbeat", Channel: channel, @@ -397,10 +395,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "agent_id": agent.ID, "session_key": sessionKey, "matched_by": route.MatchedBy, - }) + }) - al.agentMu.Lock() - defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: msg.Channel, @@ -460,8 +456,6 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe // Use the origin session for context sessionKey := routing.BuildAgentMainSessionKey(agent.ID) - al.agentMu.Lock() - defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: originChannel, From 04806bffe3df969d14ecacf23e968dc86cd56334 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Mon, 23 Feb 2026 15:50:55 -0800 Subject: [PATCH 08/18] Moving logging from INFO to DEBUG for messages Removing extrnaeous comments about mutex in loop.go Made-with: Cursor --- pkg/agent/loop.go | 2 +- pkg/channels/whatsapp_native.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index fc87c6fe5..aacc28093 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -218,6 +218,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } else { logger.DebugCF("agent", "Skipped outbound (message tool already sent)", map[string]any{"channel": msg.Channel}) } + } }() } } @@ -315,7 +316,6 @@ func (al *AgentLoop) ProcessDirectWithChannel( // ProcessHeartbeat processes a heartbeat request without session history. // Each heartbeat is independent and doesn't accumulate context. -// It uses the same mutex as processMessage so heartbeat and user messages never run concurrently. func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() if agent == nil { diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index 3099afdcc..b9ab2c188 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -267,7 +267,7 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { metadata["peer_id"] = senderID } - logger.InfoCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) + logger.DebugCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) } From 49612adf8ae67621ec7cce63d46bfd6989f08911 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Wed, 25 Feb 2026 20:50:40 -0800 Subject: [PATCH 09/18] Rebuilt after the refactoring of the base channel implementation. --- pkg/channels/whatsapp_native.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index b9ab2c188..72666cb35 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -146,7 +146,7 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } c.runCtx, c.runCancel = context.WithCancel(ctx) - c.setRunning(true) + c.SetRunning(true) logger.InfoCF("channels", "WhatsApp native channel connected", nil) return nil } @@ -169,7 +169,7 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { if container != nil { _ = container.Close() } - c.setRunning(false) + c.SetRunning(false) return nil } @@ -267,8 +267,21 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { metadata["peer_id"] = senderID } + peerKind := "direct" + if evt.Info.Chat.Server == types.GroupServer { + peerKind = "group" + } + peer := bus.Peer{Kind: peerKind, ID: chatID} + messageID := evt.Info.ID + sender := bus.SenderInfo{ + Platform: "whatsapp", + PlatformID: senderID, + CanonicalID: "whatsapp:" + senderID, + DisplayName: evt.Info.PushName, + } + logger.DebugCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { From a8644ca1c504792fe610293a0458dee407edab09 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Thu, 26 Feb 2026 22:35:20 -0800 Subject: [PATCH 10/18] Refactor whatsapp native channel based on the new channel interface --- pkg/channels/manager.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index dfc4fd9f9..7fae0d060 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -220,14 +220,19 @@ func (m *Manager) initChannels() error { if storePath == "" { storePath = filepath.Join(m.config.WorkspacePath(), "whatsapp") } - ch, err := NewWhatsAppNativeChannel(waCfg, m.bus, storePath) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ - "error": err.Error(), - }) + newNative := getWhatsAppNativeFactory() + if newNative == nil { + logger.ErrorCF("channels", "WhatsApp native not linked; import _ github.com/sipeed/picoclaw/pkg/channels/whatsapp or build with -tags whatsapp_native", nil) } else { - m.channels["whatsapp"] = ch - logger.InfoC("channels", "WhatsApp native channel enabled successfully") + ch, err := newNative(waCfg, m.bus, storePath) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ + "error": err.Error(), + }) + } else { + m.channels["whatsapp"] = ch + logger.InfoC("channels", "WhatsApp native channel enabled successfully") + } } } else if waCfg.BridgeURL != "" { m.initChannel("whatsapp", "WhatsApp") From 42ee9ab1e317c899229cc9c94d7a02688df76bd9 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Thu, 26 Feb 2026 22:35:52 -0800 Subject: [PATCH 11/18] Complete the whatsapp native channel implementation based on the new channel interface --- .gitignore | 3 + cmd/picoclaw/internal/gateway/helpers.go | 1 + pkg/channels/manager.go | 24 +------ pkg/channels/whatsapp_native/init.go | 20 ++++++ .../{ => whatsapp_native}/whatsapp_native.go | 69 +++++++++++-------- .../whatsapp_native_stub.go | 5 +- 6 files changed, 69 insertions(+), 53 deletions(-) create mode 100644 pkg/channels/whatsapp_native/init.go rename pkg/channels/{ => whatsapp_native}/whatsapp_native.go (82%) rename pkg/channels/{ => whatsapp_native}/whatsapp_native_stub.go (78%) diff --git a/.gitignore b/.gitignore index 3ff195fbf..06a8fce77 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ tasks/ # Added by goreleaser init: dist/ +akalro-dietpi.pub +akalro-dietpi +.gitignore diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index b1735c7ab..baa489b92 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -24,6 +24,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" + _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7fae0d060..31af9672c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -12,7 +12,6 @@ import ( "fmt" "math" "net/http" - "path/filepath" "sync" "time" @@ -213,27 +212,8 @@ func (m *Manager) initChannels() error { if m.config.Channels.WhatsApp.Enabled { waCfg := m.config.Channels.WhatsApp - useNative := waCfg.UseNative - if useNative { - logger.DebugC("channels", "Attempting to initialize WhatsApp native channel (whatsmeow)") - storePath := waCfg.SessionStorePath - if storePath == "" { - storePath = filepath.Join(m.config.WorkspacePath(), "whatsapp") - } - newNative := getWhatsAppNativeFactory() - if newNative == nil { - logger.ErrorCF("channels", "WhatsApp native not linked; import _ github.com/sipeed/picoclaw/pkg/channels/whatsapp or build with -tags whatsapp_native", nil) - } else { - ch, err := newNative(waCfg, m.bus, storePath) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["whatsapp"] = ch - logger.InfoC("channels", "WhatsApp native channel enabled successfully") - } - } + if waCfg.UseNative { + m.initChannel("whatsapp_native", "WhatsApp Native") } else if waCfg.BridgeURL != "" { m.initChannel("whatsapp", "WhatsApp") } diff --git a/pkg/channels/whatsapp_native/init.go b/pkg/channels/whatsapp_native/init.go new file mode 100644 index 000000000..df13e8539 --- /dev/null +++ b/pkg/channels/whatsapp_native/init.go @@ -0,0 +1,20 @@ +package whatsapp + +import ( + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + waCfg := cfg.Channels.WhatsApp + storePath := waCfg.SessionStorePath + if storePath == "" { + storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp") + } + return NewWhatsAppNativeChannel(waCfg, b, storePath) + }) +} diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go similarity index 82% rename from pkg/channels/whatsapp_native.go rename to pkg/channels/whatsapp_native/whatsapp_native.go index 72666cb35..254f15918 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -5,7 +5,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package channels +package whatsapp import ( "context" @@ -24,29 +24,30 @@ import ( "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" - - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" ) const ( sqliteDriver = "sqlite" whatsappDBName = "store.db" - reconnectInitial = 5 * time.Second - reconnectMax = 5 * time.Minute + reconnectInitial = 5 * time.Second + reconnectMax = 5 * time.Minute reconnectMultiplier = 2.0 ) // WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). type WhatsAppNativeChannel struct { - *BaseChannel + *channels.BaseChannel config config.WhatsAppConfig storePath string client *whatsmeow.Client @@ -60,8 +61,8 @@ type WhatsAppNativeChannel struct { // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { - base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { + base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) if storePath == "" { storePath = "whatsapp" } @@ -74,7 +75,7 @@ func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, st } func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { - logger.InfoCF("channels", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) + logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) if err := os.MkdirAll(c.storePath, 0700); err != nil { return fmt.Errorf("create session store dir: %w", err) @@ -83,7 +84,6 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { dbPath := filepath.Join(c.storePath, whatsappDBName) connStr := "file:" + dbPath + "?_foreign_keys=on" - // Open DB and enable foreign keys explicitly (modernc.org/sqlite does not set them from URI). db, err := sql.Open(sqliteDriver, connStr) if err != nil { return fmt.Errorf("open whatsapp store: %w", err) @@ -128,14 +128,14 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } for evt := range qrChan { if evt.Event == "code" { - logger.InfoCF("channels", "Scan this QR code with WhatsApp (Linked Devices):", nil) + logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ Level: qrterminal.L, Writer: os.Stdout, HalfBlocks: true, }) } else { - logger.InfoCF("channels", "WhatsApp login event", map[string]any{"event": evt.Event}) + logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event}) } } } else { @@ -147,12 +147,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { c.runCtx, c.runCancel = context.WithCancel(ctx) c.SetRunning(true) - logger.InfoCF("channels", "WhatsApp native channel connected", nil) + logger.InfoC("whatsapp", "WhatsApp native channel connected") return nil } func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { - logger.InfoCF("channels", "Stopping WhatsApp native channel", nil) + logger.InfoC("whatsapp", "Stopping WhatsApp native channel") if c.runCancel != nil { c.runCancel() } @@ -178,7 +178,7 @@ func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { case *events.Message: c.handleIncoming(evt.(*events.Message)) case *events.Disconnected: - logger.InfoCF("channels", "WhatsApp disconnected, will attempt reconnection", nil) + logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil) c.reconnectMu.Lock() if c.reconnecting { c.reconnectMu.Unlock() @@ -212,14 +212,14 @@ func (c *WhatsAppNativeChannel) reconnectWithBackoff() { return } - logger.InfoCF("channels", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()}) + logger.InfoCF("whatsapp", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()}) err := client.Connect() if err == nil { - logger.InfoCF("channels", "WhatsApp reconnected", nil) + logger.InfoC("whatsapp", "WhatsApp reconnected") return } - logger.WarnCF("channels", "WhatsApp reconnect failed", map[string]any{"error": err.Error()}) + logger.WarnCF("whatsapp", "WhatsApp reconnect failed", map[string]any{"error": err.Error()}) select { case <-c.runCtx.Done(): @@ -248,11 +248,11 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { } content = utils.SanitizeMessageContent(content) - if content == "" { return } // ignore empty messages + if content == "" { + return + } var mediaPaths []string - // Optional: resolve media to local paths if needed; for now we only forward text to the bus. - _ = mediaPaths metadata := make(map[string]string) metadata["message_id"] = evt.Info.ID @@ -276,21 +276,34 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { sender := bus.SenderInfo{ Platform: "whatsapp", PlatformID: senderID, - CanonicalID: "whatsapp:" + senderID, + CanonicalID: identity.BuildCanonicalID("whatsapp", senderID), DisplayName: evt.Info.PushName, } - logger.DebugCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) + if !c.IsAllowedSender(sender) { + return + } + + logger.DebugCF("whatsapp", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.mu.Lock() client := c.client c.mu.Unlock() if client == nil || !client.IsConnected() { - return fmt.Errorf("whatsapp connection not established") + return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } to, err := parseJID(msg.ChatID) @@ -302,9 +315,8 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag Conversation: proto.String(msg.Content), } - _, err = client.SendMessage(ctx, to, waMsg) - if err != nil { - return fmt.Errorf("send message: %w", err) + if _, err = client.SendMessage(ctx, to, waMsg); err != nil { + return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } return nil } @@ -318,6 +330,5 @@ func parseJID(s string) (types.JID, error) { if strings.Contains(s, "@") { return types.ParseJID(s) } - // Assume phone number for user chat. return types.NewJID(s, types.DefaultUserServer), nil } diff --git a/pkg/channels/whatsapp_native_stub.go b/pkg/channels/whatsapp_native/whatsapp_native_stub.go similarity index 78% rename from pkg/channels/whatsapp_native_stub.go rename to pkg/channels/whatsapp_native/whatsapp_native_stub.go index b4a826fa4..d57a3b6e7 100644 --- a/pkg/channels/whatsapp_native_stub.go +++ b/pkg/channels/whatsapp_native/whatsapp_native_stub.go @@ -1,16 +1,17 @@ //go:build !whatsapp_native -package channels +package whatsapp import ( "fmt" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) // NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. // Build with: go build -tags whatsapp_native ./cmd/... -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") } From d429dcdd7627f23f0c828be0f6c3db188d3e0d20 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 27 Feb 2026 17:35:50 +0800 Subject: [PATCH 12/18] chore: fix go fmt formatting issues after rebase Apply go fmt to files that had formatting inconsistencies (alignment, indentation) after rebasing onto refactor/channel-system. --- pkg/agent/loop.go | 12 ++++++---- .../whatsapp_native/whatsapp_native.go | 23 ++++++++++++------- .../whatsapp_native/whatsapp_native_stub.go | 6 ++++- pkg/config/config.go | 4 ++-- pkg/utils/string.go | 6 ++--- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index aacc28093..f9dead544 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -211,12 +211,16 @@ func (al *AgentLoop) Run(ctx context.Context) error { }) logger.InfoCF("agent", "Published outbound response", map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, + "channel": msg.Channel, + "chat_id": msg.ChatID, "content_len": len(response), }) } else { - logger.DebugCF("agent", "Skipped outbound (message tool already sent)", map[string]any{"channel": msg.Channel}) + logger.DebugCF( + "agent", + "Skipped outbound (message tool already sent)", + map[string]any{"channel": msg.Channel}, + ) } } }() @@ -395,7 +399,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "agent_id": agent.ID, "session_key": sessionKey, "matched_by": route.MatchedBy, - }) + }) return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 254f15918..23115bda7 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -18,15 +18,14 @@ import ( "time" "github.com/mdp/qrterminal/v3" - _ "modernc.org/sqlite" - "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" + _ "modernc.org/sqlite" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -61,7 +60,11 @@ type WhatsAppNativeChannel struct { // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { +func NewWhatsAppNativeChannel( + cfg config.WhatsAppConfig, + bus *bus.MessageBus, + storePath string, +) (channels.Channel, error) { base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) if storePath == "" { storePath = "whatsapp" @@ -77,7 +80,7 @@ func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, st func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) - if err := os.MkdirAll(c.storePath, 0700); err != nil { + if err := os.MkdirAll(c.storePath, 0o700); err != nil { return fmt.Errorf("create session store dir: %w", err) } @@ -173,7 +176,7 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { return nil } -func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { +func (c *WhatsAppNativeChannel) eventHandler(evt any) { switch evt.(type) { case *events.Message: c.handleIncoming(evt.(*events.Message)) @@ -284,7 +287,11 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { return } - logger.DebugCF("whatsapp", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) + logger.DebugCF( + "whatsapp", + "WhatsApp message received", + map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}, + ) c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } diff --git a/pkg/channels/whatsapp_native/whatsapp_native_stub.go b/pkg/channels/whatsapp_native/whatsapp_native_stub.go index d57a3b6e7..984af23e7 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native_stub.go +++ b/pkg/channels/whatsapp_native/whatsapp_native_stub.go @@ -12,6 +12,10 @@ import ( // NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. // Build with: go build -tags whatsapp_native ./cmd/... -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { +func NewWhatsAppNativeChannel( + cfg config.WhatsAppConfig, + bus *bus.MessageBus, + storePath string, +) (channels.Channel, error) { return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9a6fda58a..01511864b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,9 +224,9 @@ type PlaceholderConfig struct { type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } diff --git a/pkg/utils/string.go b/pkg/utils/string.go index edc413972..6a78f59fe 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -5,8 +5,8 @@ import ( "unicode" ) -// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, -// zero-width characters), and other non-graphic characters that could confuse an LLM +// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, +// zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. func SanitizeMessageContent(input string) string { var sb strings.Builder @@ -16,7 +16,7 @@ func SanitizeMessageContent(input string) string { for _, r := range input { // unicode.IsGraphic returns true if the rune is a Unicode graphic character. // This includes letters, marks, numbers, punctuation, and symbols. - // It excludes control characters (Cc), format characters (Cf), + // It excludes control characters (Cc), format characters (Cf), // surrogates (Cs), and private use (Co). if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' { sb.WriteRune(r) From 6fcc80bf44119d990200ad2d1728b6e09fb8d726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:20:00 +0800 Subject: [PATCH 13/18] Reformat WhatsAppConfig struct fields alignment --- pkg/config/config.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 46ffa2ecf..a774aadd3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,11 +224,11 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } From 3ad937f05bda61668a8db5e10e524e2dce49ce5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:20:25 +0800 Subject: [PATCH 14/18] Update function comment for SanitizeMessageContent --- pkg/utils/string.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 6a78f59fe..02f346db4 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -5,7 +5,7 @@ import ( "unicode" ) -// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, +// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, // zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. func SanitizeMessageContent(input string) string { From 67e1dab4089685f5717feb13c2ffe68671e0e3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:20:58 +0800 Subject: [PATCH 15/18] Update test case for unicode letters preservation --- pkg/utils/string_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index fffa0cff3..e3b5af052 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -117,7 +117,7 @@ func TestSanitizeMessageContent(t *testing.T) { {"strip RTL override", "Hi\u202eevil", "Hievil"}, {"strip BOM", "\uFEFFcontent", "content"}, {"strip multiple", "a\u200c\u202ab\u202cc", "abc"}, - {"unicode letters preserved", "café 日本語", "café 日本語"}, + {"unicode letters preserved", "café \u65e5\u672c\u8a9e", "café \u65e5\u672c\u8a9e"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 6b427afa4433cbed53643beff2665c2736996b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:22:40 +0800 Subject: [PATCH 16/18] Remove ignored files from .gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 06a8fce77..3ff195fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,3 @@ tasks/ # Added by goreleaser init: dist/ -akalro-dietpi.pub -akalro-dietpi -.gitignore From 75a86ebe036373a6a1f5c7dfe29e474c519c9ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:23:20 +0800 Subject: [PATCH 17/18] Resolve merge conflict in config.example.json Removed conflicting lines and kept 'allow_from' as an empty array. --- config/config.example.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index f9c12f61b..55a823009 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -79,14 +79,10 @@ "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", -<<<<<<< main "use_native": false, "session_store_path": "", - "allow_from": [] -======= "allow_from": [], "reasoning_channel_id": "" ->>>>>>> refactor/channel-system }, "feishu": { "enabled": false, @@ -269,4 +265,4 @@ "host": "127.0.0.1", "port": 18790 } -} \ No newline at end of file +} From f6c275f70c1db95daa3a0e3543d89dc2a426f5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:25:02 +0800 Subject: [PATCH 18/18] Fix formatting of WhatsAppConfig struct fields --- pkg/config/config.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index a774aadd3..2e0215278 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,11 +224,11 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` }