diff --git a/.github/pr-assets/730/after-chat-first-click-raw.png b/.github/pr-assets/730/after-chat-first-click-raw.png new file mode 100644 index 00000000..038bbb5f Binary files /dev/null and b/.github/pr-assets/730/after-chat-first-click-raw.png differ diff --git a/.github/pr-assets/730/after-chat-first-click.png b/.github/pr-assets/730/after-chat-first-click.png new file mode 100644 index 00000000..47d8e9f2 Binary files /dev/null and b/.github/pr-assets/730/after-chat-first-click.png differ diff --git a/.github/pr-assets/730/before-chat-first-click.png b/.github/pr-assets/730/before-chat-first-click.png new file mode 100644 index 00000000..ff3679dd Binary files /dev/null and b/.github/pr-assets/730/before-chat-first-click.png differ diff --git a/.github/pr-assets/730/index.html b/.github/pr-assets/730/index.html new file mode 100644 index 00000000..7e33c9b5 --- /dev/null +++ b/.github/pr-assets/730/index.html @@ -0,0 +1,71 @@ + + + + + +PR #730 — fix(chat): first conversation click + + + + + +

fix(chat): load message history on first conversation click

+

PR #730 — Closes #729

+ +
+
+

Bug

+

First conversation click from clean state shows empty placeholder instead of messages

+
+
+

Root Cause

+

skipNextHistoryRef unconditionally set on empty→non-empty transition, skipping loadHistory()

+
+
+

Fix

+

Guard skip with expectingRunRef.current — only active during new-chat send flow

+
+
+ +
+ Same instance (claw:3000 backend), same user (kai/Master), same agent (glm thục bô), same conversation (Chat a95c8ffa), light theme. Callout borders mark the review area. +
+ +
+

Chat — First Click from Clean State

+
+
+
BEFORE
+ Before: empty placeholder on first click +

Before: Clicking "Chat a95c8ffa" from initial state — conversation highlighted but main area shows "Start a conversation" placeholder. Messages never load.

+
+
+
AFTER
+ After: messages load on first click +

After: Same click — conversation messages load immediately. The 5-message history is displayed correctly on first click.

+
+
+
+ + diff --git a/.github/pr-assets/748/after-worktree.txt b/.github/pr-assets/748/after-worktree.txt new file mode 100644 index 00000000..f4f3d2a3 --- /dev/null +++ b/.github/pr-assets/748/after-worktree.txt @@ -0,0 +1,25 @@ +=== RUN TestSetupToolRegistryExecWorkspacePaths +2026/04/07 21:24:13 INFO memory + knowledge graph tools registered (PG-backed) +2026/04/07 21:24:13 INFO web_search tool enabled +2026/04/07 21:24:13 INFO web_fetch tool enabled policy="" blocked=0 +2026/04/07 21:24:13 INFO tts enabled provider=edge auto=off +2026/04/07 21:24:13 INFO tool rate limiting enabled per_hour=150 +2026/04/07 21:24:13 INFO exec approval enabled security=full ask=off +=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed +2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="printf '%s' file=@\"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/personal/.uploads/Quarterly Report.png\"" +=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed +2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="printf '%s' --input=\"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/teams/team-123/Quarterly Report.png\"" +=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed +2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="cp \"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/.goclaw/goclaw-workspace/ws/system/uploads/Quarterly Report.png\" \"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/002/partner.png\"" +=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied +=== RUN TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied +=== RUN TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied +--- PASS: TestSetupToolRegistryExecWorkspacePaths (0.02s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed (0.01s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed (0.00s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed (0.01s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied (0.00s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied (0.00s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied (0.00s) +PASS +ok github.com/nextlevelbuilder/goclaw/cmd 0.041s diff --git a/.github/pr-assets/748/before-claw.txt b/.github/pr-assets/748/before-claw.txt new file mode 100644 index 00000000..72d4dbde --- /dev/null +++ b/.github/pr-assets/748/before-claw.txt @@ -0,0 +1,94 @@ +go: downloading github.com/golang-migrate/migrate/v4 v4.19.1 +go: downloading github.com/jackc/pgx/v5 v5.6.0 +go: downloading github.com/google/uuid v1.6.0 +go: downloading github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 +go: downloading github.com/charmbracelet/huh v0.8.0 +go: downloading github.com/spf13/cobra v1.10.2 +go: downloading github.com/bwmarrin/discordgo v0.29.0 +go: downloading golang.org/x/sync v0.19.0 +go: downloading github.com/slack-go/slack v0.19.0 +go: downloading github.com/mattn/go-runewidth v0.0.16 +go: downloading github.com/mymmrac/telego v1.6.0 +go: downloading github.com/fsnotify/fsnotify v1.9.0 +go: downloading github.com/titanous/json5 v1.0.0 +go: downloading github.com/disintegration/imaging v1.6.2 +go: downloading golang.org/x/image v0.27.0 +go: downloading golang.org/x/time v0.14.0 +go: downloading github.com/mark3labs/mcp-go v0.44.0 +go: downloading github.com/adhocore/gronx v1.19.6 +go: downloading github.com/go-rod/rod v0.116.2 +go: downloading github.com/lib/pq v1.10.9 +go: downloading github.com/mattn/go-shellwords v1.0.12 +go: downloading golang.org/x/net v0.49.0 +go: downloading golang.org/x/text v0.33.0 +go: downloading github.com/rivo/uniseg v0.4.7 +go: downloading github.com/catppuccin/go v0.3.0 +go: downloading github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 +go: downloading github.com/charmbracelet/bubbletea v1.3.6 +go: downloading github.com/charmbracelet/lipgloss v1.1.0 +go: downloading github.com/charmbracelet/x/cellbuf v0.0.13 +go: downloading github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 +go: downloading github.com/mitchellh/hashstructure/v2 v2.0.2 +go: downloading github.com/spf13/pflag v1.0.9 +go: downloading golang.org/x/crypto v0.47.0 +go: downloading golang.org/x/sys v0.42.0 +go: downloading github.com/valyala/fasthttp v1.69.0 +go: downloading github.com/invopop/jsonschema v0.13.0 +go: downloading github.com/spf13/cast v1.7.1 +go: downloading github.com/yosida95/uritemplate/v3 v3.0.2 +go: downloading github.com/dustin/go-humanize v1.0.1 +go: downloading github.com/charmbracelet/x/ansi v0.9.3 +go: downloading github.com/charmbracelet/x/term v0.2.1 +go: downloading github.com/atotto/clipboard v0.1.4 +go: downloading github.com/grbit/go-json v0.11.0 +go: downloading github.com/valyala/fastjson v1.6.7 +go: downloading github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 +go: downloading github.com/muesli/cancelreader v0.2.2 +go: downloading github.com/muesli/termenv v0.16.0 +go: downloading github.com/wk8/go-ordered-map/v2 v2.1.8 +go: downloading github.com/ysmood/goob v0.4.0 +go: downloading github.com/ysmood/got v0.40.0 +go: downloading github.com/ysmood/gson v0.7.3 +go: downloading github.com/ysmood/fetchup v0.2.3 +go: downloading github.com/ysmood/leakless v0.9.0 +go: downloading github.com/andybalholm/brotli v1.2.0 +go: downloading github.com/klauspost/compress v1.18.2 +go: downloading github.com/valyala/bytebufferpool v1.0.0 +go: downloading github.com/jackc/pgpassfile v1.0.0 +go: downloading github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 +go: downloading github.com/jackc/puddle/v2 v2.2.2 +go: downloading github.com/lucasb-eyer/go-colorful v1.2.0 +go: downloading github.com/bahlo/generic-list-go v0.2.0 +go: downloading github.com/buger/jsonparser v1.1.1 +go: downloading github.com/mailru/easyjson v0.7.7 +go: downloading gopkg.in/yaml.v3 v3.0.1 +go: downloading github.com/aymanbagabas/go-osc52/v2 v2.0.1 +go: downloading github.com/mattn/go-isatty v0.0.20 +go: downloading github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc +go: downloading github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e +=== RUN TestSetupToolRegistryExecWorkspacePaths +2026/04/08 01:24:40 INFO memory + knowledge graph tools registered (PG-backed) +2026/04/08 01:24:40 INFO web_search tool enabled +2026/04/08 01:24:40 INFO web_fetch tool enabled policy="" blocked=0 +2026/04/08 01:24:40 INFO tts enabled provider=edge auto=off +2026/04/08 01:24:40 INFO tool rate limiting enabled per_hour=150 +2026/04/08 01:24:40 INFO exec approval enabled security=full ask=off +=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed + gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001 +=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed + gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001 +=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed + gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001 +=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied +=== RUN TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied +=== RUN TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied +--- FAIL: TestSetupToolRegistryExecWorkspacePaths (0.00s) + --- FAIL: TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed (0.00s) + --- FAIL: TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed (0.00s) + --- FAIL: TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed (0.00s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied (0.00s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied (0.00s) + --- PASS: TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied (0.00s) +FAIL +FAIL github.com/nextlevelbuilder/goclaw/cmd 0.019s +FAIL diff --git a/.github/pr-assets/748/claw-live-glm-thuc-bo-before.png b/.github/pr-assets/748/claw-live-glm-thuc-bo-before.png new file mode 100644 index 00000000..b940d58c Binary files /dev/null and b/.github/pr-assets/748/claw-live-glm-thuc-bo-before.png differ diff --git a/.github/pr-assets/748/index.html b/.github/pr-assets/748/index.html new file mode 100644 index 00000000..f48afca2 --- /dev/null +++ b/.github/pr-assets/748/index.html @@ -0,0 +1,228 @@ + + + + + +PR #748 — fix(exec): allow uploaded files in active workspaces + + + + + +

fix(exec): allow uploaded files in active workspaces

+

PR #748 — visual proof pack for issue #739

+ +
+
+

Bug

+

Active workspace files under deny-roots were rejected by exec even when they belonged to the current upload/team scope.

+
+
+

Root Cause

+

Path exemptions were static and non-shell-aware, so quoted, prefixed, and legacy screenshot-era upload paths still matched the deny root.

+
+
+

Fix

+

Shell-aware parsing + canonical path checks now exempt the active personal .uploads subtree, the legacy uploads subtree, and the active team workspace.

+
+
+ +
+ Before source: stable claw:3000 backed by the unfixed /opt/goclaw deployment (v2.7.1). After source: temporary PR deployment on claw:3001, backed by goclaw-pr748:local built from the fixed code commit 83fa1342. Both UI captures use fresh chats with the same user and GLM thục bô. Callout border marks the reviewed exec row. +
+ +
+

UI Before / After

+
+
+
Before
+
+ Before: stable claw blocks absolute .goclaw upload path +
+

Stable claw release, fresh chat with GLM thục bô: the agent runs the exact absolute /app/.goclaw/.../.uploads/...jpg copy command and the exec row fails with the safety-policy .goclaw/ denial.

+
+
+
After
+
+ After: fixed temporary claw deployment allows absolute .goclaw upload path +
+

Temporary fixed deployment on claw:3001, fresh chat with the same agent: the same absolute /app/.goclaw/.../.uploads/...jpg copy command now completes successfully and returns /tmp/partner-abs.png.

+
+
+
+ +
+

Gateway Reproduction

+
+
+
Before
+
+
$ ssh claw 'docker run --rm -v /tmp/goclaw-739-before:/src -w /src golang:1.26 sh -lc "/usr/local/go/bin/go test ./cmd -run TestSetupToolRegistryExecWorkspacePaths -count=1 -v"'
+=== RUN   TestSetupToolRegistryExecWorkspacePaths
+=== RUN   TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
+gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001
+=== RUN   TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
+gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001
+=== RUN   TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
+gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001
+FAIL
+
+

Baseline source on claw: the exact gateway wiring still rejects all three representative workspace-path forms.

+
+
+
After
+
+
$ go test ./cmd -run TestSetupToolRegistryExecWorkspacePaths -count=1 -v
+=== RUN   TestSetupToolRegistryExecWorkspacePaths
+=== RUN   TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
+=== RUN   TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
+=== RUN   TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
+--- PASS: TestSetupToolRegistryExecWorkspacePaths (0.01s)
+    --- PASS: personal_workspace_prefixed_uploads_allowed
+    --- PASS: team_workspace_quoted_input_allowed
+    --- PASS: legacy_dotgoclaw_uploads_layout_allowed
+PASS
+
+

Current worktree: the same gateway path handling now passes for personal uploads, team workspace paths, and the screenshot-era legacy .goclaw/.../uploads layout.

+
+
+
+ +
+

Supporting Live Recreation on claw

+
+ Live claw recreation with GLM thục bô +
+

+ This earlier live run documents the same production agent and workspace context on stable claw. The report’s primary Before/After pair above is the clean isolated proof you asked for; this screenshot is kept as supporting context for the reproduction journey. +

+
+ +
+

What This Proves

+ +
+ + diff --git a/.github/pr-assets/748/pr748-after-absolute-goclaw-allowed.png b/.github/pr-assets/748/pr748-after-absolute-goclaw-allowed.png new file mode 100644 index 00000000..2e36d929 Binary files /dev/null and b/.github/pr-assets/748/pr748-after-absolute-goclaw-allowed.png differ diff --git a/.github/pr-assets/748/pr748-before-absolute-goclaw-blocked.png b/.github/pr-assets/748/pr748-before-absolute-goclaw-blocked.png new file mode 100644 index 00000000..6e73455d Binary files /dev/null and b/.github/pr-assets/748/pr748-before-absolute-goclaw-blocked.png differ diff --git a/cmd/channels_cmd.go b/cmd/channels_cmd.go index 47155090..4353bbfc 100644 --- a/cmd/channels_cmd.go +++ b/cmd/channels_cmd.go @@ -44,7 +44,7 @@ func channelsListCmd() *cobra.Command { {"discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != ""}, {"zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != ""}, {"feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != ""}, - {"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != ""}, + {"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled}, } if jsonOutput { diff --git a/cmd/doctor.go b/cmd/doctor.go index 1752216a..055d554e 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -120,7 +120,7 @@ func runDoctor() { checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "") checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "") checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "") - checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "") + checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled) } // External tools diff --git a/cmd/gateway.go b/cmd/gateway.go index f05b2a03..3e300716 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -563,7 +563,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory) instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) - instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory) + instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages, "pgx")) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 64cc6cd6..77976099 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -68,9 +68,12 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } if cfg.Channels.WhatsApp.Enabled { - if cfg.Channels.WhatsApp.BridgeURL == "" { - recordMissingConfig(channels.TypeWhatsApp, "Set channels.whatsapp.bridge_url in config.") - } else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil); err != nil { + waDialect := "pgx" + if strings.Contains(fmt.Sprintf("%T", pgStores.DB.Driver()), "sqlite") { + waDialect = "sqlite3" + } + wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages, waDialect) + if err != nil { channelMgr.RecordFailure(channels.TypeWhatsApp, "", err) slog.Error("failed to initialize whatsapp channel", "error", err) } else { @@ -143,6 +146,7 @@ func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, chann methods.NewChannelInstancesMethods(pgStores.ChannelInstances, msgBus, msgBus).Register(server.Router()) zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router()) zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router()) + whatsapp.NewQRMethods(pgStores.ChannelInstances, channelMgr).Register(server.Router()) } // Register agent links WS RPC methods diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 463d778b..f05593cc 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -165,6 +165,7 @@ func wireExtras( ModelPricing: appCfg.Telemetry.ModelPricing, TracingStore: stores.Tracing, MemoryStore: stores.Memory, + ContactStore: stores.Contacts, TenantStore: stores.Tenants, BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs, SkillTenantCfgs: stores.SkillTenantCfgs, diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index c4e365a9..2ad00776 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -294,6 +294,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi continue } var cliOpts []providers.ClaudeCLIOption + cliOpts = append(cliOpts, providers.WithClaudeCLIName(p.Name)) cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true)) if gatewayAddr != "" { mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken) @@ -341,6 +342,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi registry.RegisterForTenant(p.TenantID, codex) case store.ProviderAnthropicNative: registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, + providers.WithAnthropicName(p.Name), providers.WithAnthropicBaseURL(p.APIBase))) case store.ProviderDashScope: registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) @@ -483,6 +485,7 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) { } registry.RegisterForTenant(p.TenantID, providers.NewACPProvider( binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), + providers.WithACPName(p.Name), providers.WithACPModel(p.Name), )) slog.Info("registered provider from DB", "name", p.Name, "type", "acp") diff --git a/cmd/gateway_setup_exec_workspace_test.go b/cmd/gateway_setup_exec_workspace_test.go new file mode 100644 index 00000000..6fc79ba8 --- /dev/null +++ b/cmd/gateway_setup_exec_workspace_test.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +func testExecToolFromGatewaySetup(t *testing.T, workspace, dataDir string) *tools.ExecTool { + t.Helper() + + cfg := config.Default() + cfg.DataDir = dataDir + cfg.Agents.Defaults.Workspace = workspace + cfg.Tools.Browser.Enabled = false + + providerRegistry := providers.NewRegistry(nil) + toolsReg, _, _, _, _, _, _, _, _, _, _ := setupToolRegistry(cfg, workspace, providerRegistry) + + execToolAny, ok := toolsReg.Get("exec") + if !ok { + t.Fatal("exec tool not registered") + } + execTool, ok := execToolAny.(*tools.ExecTool) + if !ok { + t.Fatalf("exec tool type = %T, want *tools.ExecTool", execToolAny) + } + return execTool +} + +func TestSetupToolRegistryExecWorkspacePaths(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink setup requires elevated privileges on Windows") + } + dataDir := t.TempDir() + workspace := filepath.Join(dataDir, "personal") + teamWorkspace := filepath.Join(dataDir, "teams", "team-123") + for _, dir := range []string{workspace, teamWorkspace} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", dir, err) + } + } + + execTool := testExecToolFromGatewaySetup(t, workspace, dataDir) + + uploadPath := filepath.Join(workspace, ".uploads", "Quarterly Report.png") + if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(uploadPath, []byte("ok"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + teamFilePath := filepath.Join(teamWorkspace, "Quarterly Report.png") + if err := os.WriteFile(teamFilePath, []byte("ok"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + protectedPath := filepath.Join(dataDir, "config.json") + if err := os.WriteFile(protectedPath, []byte("secret"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + symlinkPath := filepath.Join(teamWorkspace, "leak.txt") + if err := os.Symlink(protectedPath, symlinkPath); err != nil { + t.Fatalf("Symlink() error = %v", err) + } + legacyWorkspace := filepath.Join(dataDir, ".goclaw", "goclaw-workspace", "ws", "system") + legacyUploadPath := filepath.Join(legacyWorkspace, "uploads", "Quarterly Report.png") + legacyCopyTarget := filepath.Join(t.TempDir(), "partner.png") + if err := os.MkdirAll(filepath.Dir(legacyUploadPath), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(legacyUploadPath, []byte("ok"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + tests := []struct { + name string + ctx context.Context + command string + wantDenied bool + wantPath string + }{ + { + name: "personal_workspace_prefixed_uploads_allowed", + ctx: tools.WithToolWorkspace(context.Background(), workspace), + command: "printf '%s' file=@\"" + uploadPath + "\"", + }, + { + name: "team_workspace_quoted_input_allowed", + ctx: tools.WithToolTeamWorkspace(tools.WithToolWorkspace(context.Background(), workspace), teamWorkspace), + command: "printf '%s' --input=\"" + teamFilePath + "\"", + }, + { + name: "legacy_dotgoclaw_uploads_layout_allowed", + ctx: tools.WithToolWorkspace(context.Background(), legacyWorkspace), + command: "cp \"" + legacyUploadPath + "\" \"" + legacyCopyTarget + "\"", + wantPath: legacyCopyTarget, + }, + { + name: "team_workspace_symlink_escape_denied", + ctx: tools.WithToolTeamWorkspace(tools.WithToolWorkspace(context.Background(), workspace), teamWorkspace), + command: "printf '%s' " + symlinkPath, + wantDenied: true, + }, + { + name: "unrelated_data_dir_path_denied", + ctx: tools.WithToolWorkspace(context.Background(), workspace), + command: "printf '%s' " + protectedPath, + wantDenied: true, + }, + { + name: "workspace_local_dotgoclaw_denied", + ctx: tools.WithToolWorkspace(context.Background(), workspace), + command: "printf '%s' " + filepath.Join(workspace, ".goclaw", "secrets.json"), + wantDenied: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := execTool.Execute(tc.ctx, map[string]any{ + "command": tc.command, + }) + + denied := strings.Contains(result.ForLLM, "command denied by safety policy") + if denied != tc.wantDenied { + t.Fatalf("denied = %v, want %v; output = %s", denied, tc.wantDenied, result.ForLLM) + } + if tc.wantPath != "" { + if _, err := os.Stat(tc.wantPath); err != nil { + t.Fatalf("expected copied file at %q, got stat error: %v", tc.wantPath, err) + } + } else if !tc.wantDenied && !strings.Contains(result.ForLLM, "Quarterly Report.png") { + t.Fatalf("expected output to contain quoted file path, got: %s", result.ForLLM) + } + }) + } +} diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index ff8cffc0..59aaf28b 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -156,13 +156,13 @@ flowchart TD | Feature | Telegram | Feishu/Lark | Discord | Slack | WhatsApp | Zalo OA | Zalo Personal | |---------|----------|-------------|---------|-------|----------|---------|---------------| -| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | External WS bridge | Long polling | Internal protocol | +| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Direct protocol (in-process) | Long polling | Internal protocol | | DM support | Yes | Yes | Yes | Yes | Yes | Yes (DM only) | Yes | | Group support | Yes (mention gating) | Yes | Yes | Yes (mention gating + thread cache) | Yes | No | Yes | | Forum/Topics | Yes (per-topic config) | Yes (topic session mode) | -- | -- | -- | -- | -- | -| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | N/A (bridge) | 2,000 chars | 2,000 chars | +| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | WhatsApp native limit | 2,000 chars | 2,000 chars | | Streaming | Typing indicator | Streaming message cards | Edit "Thinking..." | Edit "Thinking..." (throttled 1s) | No | No | No | -| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | JSON messages | Images (5 MB) | -- | +| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | Images, audio, video, documents | Images (5 MB) | -- | | Speech-to-text | Yes (STT proxy) | -- | -- | -- | -- | -- | -- | | Voice routing | Yes (VoiceAgentID) | -- | -- | -- | -- | -- | -- | | Rich formatting | Markdown → HTML | Card messages | Markdown | Markdown → mrkdwn | Plain text | Plain text | Plain text | @@ -430,15 +430,18 @@ Auto-enables when both bot_token and app_token are set. ## 9. WhatsApp -The WhatsApp channel communicates through an external WebSocket bridge (e.g., whatsapp-web.js based). GoClaw does not implement the WhatsApp protocol directly. +The WhatsApp channel connects directly to the WhatsApp network via the multi-device protocol. Authentication state is stored in the database (PostgreSQL standard, SQLite for desktop edition). ### Key Behaviors -- **Bridge connection**: Connects to configurable `bridge_url` via WebSocket -- **JSON format**: Messages sent/received as JSON objects -- **Auto-reconnect**: Exponential backoff (1s → 30s max) -- **DM and group support**: Group detection via `@g.us` suffix in chat ID -- **Media handling**: Array of file paths from bridge protocol +- **Direct connection**: In-process WhatsApp client (direct to WhatsApp servers, no external bridge) +- **Database auth store**: Persists auth state, keys, and device info in the database +- **QR code authentication**: Interactive QR code for initial pairing, served via WebSocket API +- **Auto-reconnect**: Built-in reconnection with exponential backoff +- **DM and group support**: Full group messaging with mention detection via JID format +- **Media handling**: Direct media download/upload to WhatsApp servers with type detection +- **Typing indicators**: Typing state managed per chat with auto-refresh +- **Group mention gating**: Detects when bot is mentioned via LID (Local ID) and JID (standard format) --- @@ -590,7 +593,10 @@ flowchart TD | `internal/channels/slack/format.go` | Markdown → Slack mrkdwn pipeline | | `internal/channels/slack/reactions.go` | Status emoji reactions on messages | | `internal/channels/slack/stream.go` | Streaming message updates via placeholder editing | -| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: external WS bridge | +| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: direct protocol client, QR auth, database persistence | +| `internal/channels/whatsapp/factory.go` | Channel factory, database dialect detection | +| `internal/channels/whatsapp/qr_methods.go` | QR code generation and authentication flow | +| `internal/channels/whatsapp/format.go` | Message formatting (HTML-to-WhatsApp) | | `internal/channels/zalo/zalo.go` | Zalo OA: Bot API, long polling | | `internal/channels/zalo/personal/channel.go` | Zalo Personal: reverse-engineered protocol | | `internal/store/pg/pairing.go` | Pairing: code generation, approval, persistence (database-backed) | diff --git a/docs/17-changelog.md b/docs/17-changelog.md index 6ae4e2da..5e37c9c4 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -34,6 +34,21 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Added +#### WhatsApp Native Protocol Integration (2026-04-06) +- **Direct protocol migration**: Replaced Node.js Baileys bridge with direct in-process WhatsApp connectivity +- **Database auth persistence**: Auth state, device keys, and client metadata stored in PostgreSQL (standard) or SQLite (desktop) +- **QR authentication**: Interactive QR code authentication for device linking without external bridge relay +- **No more bridge_url**: Removed `bridge_url` configuration, eliminated `docker-compose.whatsapp.yml`, removed `bridge/whatsapp/` sidecar service +- **Enhanced media handling**: Direct media download/upload to WhatsApp servers with automatic type detection and streaming +- **Improved mention detection**: Group mention detection now uses LID (Local ID) + JID (standard format) for robust message routing +- **Files added**: + - `internal/channels/whatsapp/factory.go` — Dialect detection and channel factory + - `internal/channels/whatsapp/qr_methods.go` — QR code generation and authentication flow + - `internal/channels/whatsapp/format.go` — HTML-to-WhatsApp message formatting + - Database-backed auth persistence for cross-platform support + +### Refactored + #### Parallel Sub-Agent Enhancement (#600) (2026-03-31) - **Smart leader delegation**: Conditional leader delegation prompt instead of forced delegation for all subagent spawns - **Compaction prompt persistence**: Preserves pending subagent and team task state across context summarization to maintain work continuity diff --git a/go.mod b/go.mod index d622076f..0d749fa5 100644 --- a/go.mod +++ b/go.mod @@ -17,11 +17,13 @@ require ( github.com/mattn/go-shellwords v1.0.12 github.com/mymmrac/telego v1.6.0 github.com/redis/go-redis/v9 v9.18.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/slack-go/slack v0.19.0 github.com/spf13/cobra v1.10.2 github.com/titanous/json5 v1.0.0 github.com/wailsapp/wails/v2 v2.11.0 github.com/zalando/go-keyring v0.2.8 + go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 @@ -53,6 +55,7 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -64,11 +67,12 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/coder/websocket v1.8.12 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/creachadair/msync v0.7.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect @@ -91,7 +95,7 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.0 // indirect @@ -101,11 +105,13 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/samber/lo v1.49.1 // indirect github.com/spf13/cast v1.7.1 // indirect @@ -117,17 +123,20 @@ require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/util v0.9.6 // indirect go.uber.org/atomic v1.11.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/term v0.39.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -175,14 +184,14 @@ require ( go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.49.0 + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.33.0 + golang.org/x/text v0.34.0 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 00c05f1d..0ecd917d 100644 --- a/go.sum +++ b/go.sum @@ -8,16 +8,22 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -60,6 +66,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= +github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -114,14 +122,15 @@ github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= @@ -159,6 +168,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= +github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -188,6 +199,7 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -288,9 +300,11 @@ github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9a github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -299,6 +313,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= @@ -337,6 +353,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= @@ -367,11 +385,18 @@ github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGG github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44= github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= @@ -433,6 +458,8 @@ github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpB github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= +github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= @@ -469,6 +496,12 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPc github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= +go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 h1:E4A6eca9vMJQctC9DIfzUIg27TrJ8IrDHgkJwJ8WPUQ= +go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -505,10 +538,10 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -518,8 +551,8 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -532,16 +565,17 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 345550dd..cb2eef2c 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -228,8 +228,9 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (result *RunResult, var allowedTools map[string]bool // Resolve per-user MCP tools (servers requiring user credentials). // Must run before buildFilteredTools so tools are in the Registry for policy filtering. - if req.UserID != "" { - l.getUserMCPTools(iterCtx, req.UserID) + // Uses resolved credential user ID so merged tenant users get correct MCP creds. + if mcpUserID := store.CredentialUserIDFromContext(iterCtx); mcpUserID != "" { + l.getUserMCPTools(iterCtx, mcpUserID) } toolDefs, allowedTools, messages = l.buildFilteredTools(&req, hadBootstrap, rs.iteration, maxIter, messages) diff --git a/internal/agent/loop_cancel_test.go b/internal/agent/loop_cancel_test.go index e31ad470..731758af 100644 --- a/internal/agent/loop_cancel_test.go +++ b/internal/agent/loop_cancel_test.go @@ -18,7 +18,7 @@ func TestParallelToolCollection_ContextCancel(t *testing.T) { var err error collectLoop: - for i := 0; i < 3; i++ { + for range 3 { select { case r, ok := <-resultCh: if !ok { @@ -50,7 +50,7 @@ func TestParallelToolCollection_AllComplete(t *testing.T) { collected := make([]indexedResult, 0, 3) collectLoop: - for i := 0; i < 3; i++ { + for range 3 { select { case r, ok := <-resultCh: if !ok { diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 6706b630..97b04ae1 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -41,6 +41,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup if req.UserID != "" { ctx = store.WithUserID(ctx, req.UserID) } + // Resolve merged tenant user identity for credential lookups. + // Keeps UserID unchanged (session/workspace scoping) but sets a separate + // CredentialUserID for SecureCLI, MCP, and other per-user features. + if l.userResolver != nil && req.UserID != "" { + credUserID := l.resolveCredentialUserID(ctx, *req) + if credUserID != "" && credUserID != req.UserID { + ctx = store.WithCredentialUserID(ctx, credUserID) + } + } // Inject agent type into context for interceptor routing if l.agentType != "" { ctx = store.WithAgentType(ctx, l.agentType) @@ -237,11 +246,14 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup if l.provider != nil { providerName = l.provider.Name() } + // Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved). + credUserID, _ := ctx.Value(store.CredentialUserIDKey).(string) rc := &store.RunContext{ AgentID: l.agentUUID, AgentKey: l.id, TenantID: l.tenantID, UserID: req.UserID, + CredentialUserID: credUserID, AgentType: l.agentType, SenderID: req.SenderID, SelfEvolve: l.selfEvolve, diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 90e39cf3..260ea925 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -438,10 +438,7 @@ func (l *Loop) resolveSkillsSummary(ctx context.Context, skillFilter []string) s // Cap description length to match BuildSummary() truncation (skillDescMaxLen=200 runes). totalChars := 0 for _, s := range filtered { - descLen := len(s.Description) - if descLen > 200 { - descLen = 200 - } + descLen := min(len(s.Description), 200) totalChars += len(s.Name) + descLen + 10 // +10 for XML tags overhead } estimatedTokens := totalChars / 4 diff --git a/internal/agent/loop_run.go b/internal/agent/loop_run.go index 37f80372..d308a9f0 100644 --- a/internal/agent/loop_run.go +++ b/internal/agent/loop_run.go @@ -118,6 +118,25 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { runStart := time.Now().UTC() + // Safety net: ensure root traces are ALWAYS finalized, even on panic or goroutine leak. + // Normal-path finalization sets traceFinalized=true; this defer only acts if it wasn't. + var traceFinalized bool + if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { + defer func() { + if traceFinalized { + return + } + slog.Warn("tracing: safety-net finalizing orphan trace", + "trace_id", traceID, "agent", l.id, "session", req.SessionKey) + safeCtx := context.WithoutCancel(ctx) + if agentSpanID != uuid.Nil { + l.emitAgentSpanEnd(safeCtx, agentSpanID, runStart, nil, context.Canceled) + } + l.traceCollector.FinishTrace(safeCtx, traceID, store.TraceStatusError, + "trace finalized by safety net (likely panic or goroutine leak)", "") + }() + } + // Emit running agent span immediately so it's visible in the trace UI. if agentSpanID != uuid.Nil { var agentSpanOpts []spanOption @@ -183,6 +202,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { // Use background context when the run context is cancelled (/stop command) // so the DB update still succeeds. if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { + traceFinalized = true traceCtx := ctx traceStatus := store.TraceStatusError if ctx.Err() != nil { @@ -214,6 +234,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { Payload: completedPayload, }) if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { + traceFinalized = true if result != nil { l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, l.traceCollector.PreviewMaxLen())) } else { diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 60d588f3..a034d3b5 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -184,6 +184,9 @@ type Loop struct { // Memory store for extractive memory fallback (writes directly when LLM flush fails) memStore store.MemoryStore + + // User identity resolver: maps channel contacts to merged tenant users for credential lookups. + userResolver UserIdentityResolver } // AgentEvent is emitted during agent execution for WS broadcasting. @@ -321,6 +324,9 @@ type LoopConfig struct { MCPStore store.MCPServerStore // for credential lookup MCPPool *mcpbridge.Pool // user-keyed connection pool MCPUserCredSrvs []store.MCPAccessInfo // servers needing per-user creds + + // User identity resolver for credential lookups (maps channel contacts → tenant users) + UserResolver UserIdentityResolver } const defaultMaxTokens = config.DefaultMaxTokens @@ -419,6 +425,7 @@ func NewLoop(cfg LoopConfig) *Loop { mcpStore: cfg.MCPStore, mcpPool: cfg.MCPPool, mcpUserCredSrvs: cfg.MCPUserCredSrvs, + userResolver: cfg.UserResolver, } } diff --git a/internal/agent/pruning.go b/internal/agent/pruning.go index bc5793be..9d19deea 100644 --- a/internal/agent/pruning.go +++ b/internal/agent/pruning.go @@ -205,8 +205,8 @@ func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg tailChars := settings.softTrimTailChars if hasImportantTail(msg.Content) { totalBudget := headChars + tailChars - headChars = totalBudget * 7 / 10 - tailChars = totalBudget - headChars + tailChars = totalBudget * 7 / 10 + headChars = totalBudget - tailChars } head := takeHead(msg.Content, headChars) tail := takeTail(msg.Content, tailChars) @@ -296,10 +296,7 @@ func estimateMessageChars(m providers.Message) int { // hasImportantTail checks if the last ~500 chars of content contain error/summary keywords. func hasImportantTail(content string) bool { runes := []rune(content) - checkLen := 500 - if checkLen > len(runes) { - checkLen = len(runes) - } + checkLen := min(500, len(runes)) tail := string(runes[len(runes)-checkLen:]) return importantTailRe.MatchString(tail) } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 5ba0fc7e..d96960f6 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -92,6 +92,9 @@ type ResolverDeps struct { // Memory store for extractive memory fallback MemoryStore store.MemoryStore + // Contact store for user identity resolution (channel contacts → tenant users) + ContactStore store.ContactStore + // Tenant store for workspace path resolution TenantStore store.TenantStore @@ -417,6 +420,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { MCPStore: deps.MCPStore, MCPPool: deps.MCPPool, MCPUserCredSrvs: mcpUserCredSrvs, + UserResolver: newContactResolver(deps.ContactStore), }) slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider) diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 60f68108..bf94ce57 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -217,8 +217,9 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { lines = append(lines, buildToolCallStyleSection()...) } - // 2.5. Credentialed CLI context (appended after tooling, before safety) — skip during bootstrap - if !cfg.IsBootstrap && cfg.CredentialCLIContext != "" { + // 2.5. Credentialed CLI context (appended after tooling, before safety) — skip during bootstrap. + // Only inject when exec tool is available — CLI tools require exec to run. + if !cfg.IsBootstrap && cfg.CredentialCLIContext != "" && slices.Contains(cfg.ToolNames, "exec") { lines = append(lines, cfg.CredentialCLIContext, "") } diff --git a/internal/agent/user_identity_resolver.go b/internal/agent/user_identity_resolver.go new file mode 100644 index 00000000..b12fdcd7 --- /dev/null +++ b/internal/agent/user_identity_resolver.go @@ -0,0 +1,102 @@ +package agent + +import ( + "context" + "log/slog" + "strings" +) + +// UserIdentityResolver resolves raw user IDs to merged tenant user identities. +// Used by the agent loop to set CredentialUserID in context before tool execution. +type UserIdentityResolver interface { + ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) +} + +// contactStoreResolver wraps a ContactStore for nil-safe resolution. +type contactStoreResolver struct { + store interface { + ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) + } +} + +func (r *contactStoreResolver) ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) { + if r.store == nil { + return "", nil + } + return r.store.ResolveTenantUserID(ctx, channelType, senderID) +} + +// newContactResolver creates a UserIdentityResolver from a ContactStore. +// Returns nil if the store is nil (resolver is optional). +func newContactResolver(cs interface { + ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) +}) UserIdentityResolver { + if cs == nil { + return nil + } + return &contactStoreResolver{store: cs} +} + +// resolveCredentialUserID determines the best tenant user identity for +// credential lookups. For DMs, tries resolving the user ID directly. +// For groups, tries the individual sender first, then the group contact. +func (l *Loop) resolveCredentialUserID(ctx context.Context, req RunRequest) string { + if l.userResolver == nil { + return req.UserID + } + + channelType := req.ChannelType + + // Non-group: try resolving UserID directly (covers unresolved DMs, HTTP API, cron) + if req.PeerKind != "group" { + if channelType != "" && req.UserID != "" { + resolved, err := l.userResolver.ResolveTenantUserID(ctx, channelType, req.UserID) + if err != nil { + slog.Debug("credential_resolve.dm_failed", "user", req.UserID, "channel", channelType, "error", err) + } else if resolved != "" { + return resolved + } + } + return req.UserID + } + + // Group: try individual sender first (most specific) + if req.SenderID != "" && channelType != "" { + senderNumeric := req.SenderID + if idx := strings.IndexByte(senderNumeric, '|'); idx > 0 { + senderNumeric = senderNumeric[:idx] + } + resolved, err := l.userResolver.ResolveTenantUserID(ctx, channelType, senderNumeric) + if err != nil { + slog.Debug("credential_resolve.group_sender_failed", "sender", senderNumeric, "channel", channelType, "error", err) + } else if resolved != "" { + return resolved + } + } + + // Group: try group contact resolution (group chatID merged to tenant user) + if chatID := extractGroupChatID(req.UserID); chatID != "" && channelType != "" { + resolved, err := l.userResolver.ResolveTenantUserID(ctx, channelType, chatID) + if err != nil { + slog.Debug("credential_resolve.group_contact_failed", "chatID", chatID, "channel", channelType, "error", err) + } else if resolved != "" { + return resolved + } + } + + return req.UserID +} + +// extractGroupChatID extracts the chat ID from a group composite user ID. +// Format: "group:{channel}:{chatID}" → returns chatID. +// Returns "" if format doesn't match. +func extractGroupChatID(userID string) string { + if !strings.HasPrefix(userID, "group:") { + return "" + } + parts := strings.SplitN(userID, ":", 3) + if len(parts) < 3 { + return "" + } + return parts[2] +} diff --git a/internal/agent/user_identity_resolver_test.go b/internal/agent/user_identity_resolver_test.go new file mode 100644 index 00000000..c28108c6 --- /dev/null +++ b/internal/agent/user_identity_resolver_test.go @@ -0,0 +1,171 @@ +package agent + +import ( + "context" + "fmt" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// mockResolver is a test double that returns pre-configured resolutions. +type mockResolver struct { + mergedMap map[string]string // "channelType:senderID" → resolved tenant user ID +} + +func (m *mockResolver) ResolveTenantUserID(_ context.Context, channelType, senderID string) (string, error) { + key := channelType + ":" + senderID + if resolved, ok := m.mergedMap[key]; ok { + return resolved, nil + } + return "", nil +} + +func TestResolveCredentialUserID(t *testing.T) { + tests := []struct { + name string + req RunRequest + mergedMap map[string]string // nil signals nil resolver + wantUserID string + }{ + // --- DM scenarios --- + { + name: "DM: sender merged to tenant user", + req: RunRequest{UserID: "12345", ChannelType: "telegram", PeerKind: "direct"}, + mergedMap: map[string]string{"telegram:12345": "john@co.com"}, + wantUserID: "john@co.com", + }, + { + name: "DM: sender not merged — returns raw UserID", + req: RunRequest{UserID: "12345", ChannelType: "telegram", PeerKind: "direct"}, + mergedMap: map[string]string{}, + wantUserID: "12345", + }, + { + name: "DM: already resolved by consumer_normal — no double resolve", + req: RunRequest{UserID: "john@co.com", ChannelType: "telegram", PeerKind: "direct"}, + mergedMap: map[string]string{}, + wantUserID: "john@co.com", + }, + + // --- Group scenarios --- + { + name: "Group: individual sender merged", + req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345", ChannelType: "telegram", PeerKind: "group"}, + mergedMap: map[string]string{"telegram:12345": "john@co.com"}, + wantUserID: "john@co.com", + }, + { + name: "Group: group contact merged (sender not merged)", + req: RunRequest{UserID: "group:tg:-100456", SenderID: "99999", ChannelType: "telegram", PeerKind: "group"}, + mergedMap: map[string]string{"telegram:-100456": "team-acme@co.com"}, + wantUserID: "team-acme@co.com", + }, + { + name: "Group: both sender and group merged — sender takes priority", + req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345", ChannelType: "telegram", PeerKind: "group"}, + mergedMap: map[string]string{ + "telegram:12345": "john@co.com", + "telegram:-100456": "team-acme@co.com", + }, + wantUserID: "john@co.com", // individual > group + }, + { + name: "Group: nobody merged — returns group composite", + req: RunRequest{UserID: "group:tg:-100456", SenderID: "99999", ChannelType: "telegram", PeerKind: "group"}, + mergedMap: map[string]string{}, + wantUserID: "group:tg:-100456", + }, + + // --- WS/HTTP/cron scenarios --- + { + name: "WS chat: no channelType — skip resolve, return UserID", + req: RunRequest{UserID: "john@co.com", PeerKind: ""}, + mergedMap: map[string]string{}, + wantUserID: "john@co.com", + }, + { + name: "Cron: no channelType — skip resolve", + req: RunRequest{UserID: "12345"}, + mergedMap: map[string]string{}, + wantUserID: "12345", + }, + + // --- Edge cases --- + { + name: "SenderID with pipe suffix stripped", + req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345|username", ChannelType: "telegram", PeerKind: "group"}, + mergedMap: map[string]string{"telegram:12345": "john@co.com"}, + wantUserID: "john@co.com", + }, + { + name: "Nil resolver — returns raw UserID", + req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345", ChannelType: "telegram", PeerKind: "group"}, + mergedMap: nil, // signals nil resolver + wantUserID: "group:tg:-100456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loop := &Loop{} + if tt.mergedMap != nil { + loop.userResolver = &mockResolver{mergedMap: tt.mergedMap} + } + + got := loop.resolveCredentialUserID(context.Background(), tt.req) + if got != tt.wantUserID { + t.Errorf("resolveCredentialUserID() = %q, want %q", got, tt.wantUserID) + } + }) + } +} + +func TestExtractGroupChatID(t *testing.T) { + tests := []struct { + input, want string + }{ + {"group:telegram:-100456", "-100456"}, + {"group:discord:123", "123"}, + {"guild:123:user:456", ""}, // not group: prefix + {"12345", ""}, // plain user ID + {"group:", ""}, // incomplete + {"group:channel", ""}, // missing third part + {"group:tg:-100456", "-100456"}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("input=%s", tt.input), func(t *testing.T) { + got := extractGroupChatID(tt.input) + if got != tt.want { + t.Errorf("extractGroupChatID(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestCredentialUserIDFromContext(t *testing.T) { + t.Run("both set — returns CredentialUserID", func(t *testing.T) { + ctx := store.WithUserID(context.Background(), "raw-user") + ctx = store.WithCredentialUserID(ctx, "resolved-user") + got := store.CredentialUserIDFromContext(ctx) + if got != "resolved-user" { + t.Errorf("got %q, want %q", got, "resolved-user") + } + }) + + t.Run("only UserID set — falls back to UserID", func(t *testing.T) { + ctx := store.WithUserID(context.Background(), "raw-user") + got := store.CredentialUserIDFromContext(ctx) + if got != "raw-user" { + t.Errorf("got %q, want %q", got, "raw-user") + } + }) + + t.Run("neither set — returns empty", func(t *testing.T) { + got := store.CredentialUserIDFromContext(context.Background()) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} diff --git a/internal/channels/whatsapp/auth.go b/internal/channels/whatsapp/auth.go new file mode 100644 index 00000000..76deb6f3 --- /dev/null +++ b/internal/channels/whatsapp/auth.go @@ -0,0 +1,102 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + + "go.mau.fi/whatsmeow" +) + +// StartQRFlow initiates the QR authentication flow. +// Returns a channel that emits QR code strings and auth events. +// Lazily initializes the whatsmeow client if Start() hasn't been called yet +// (handles timing race between async instance reload and wizard auto-start). +// Serialized with Reauth via reauthMu to prevent races on rapid double-clicks. +func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() + if c.client == nil { + // Lazy init: wizard may request QR before Start() is called. + c.mu.Lock() + if c.client == nil { + if c.ctx == nil { + c.ctx, c.cancel = context.WithCancel(context.Background()) + } + deviceStore, err := c.container.GetFirstDevice(ctx) + if err != nil { + c.mu.Unlock() + return nil, fmt.Errorf("whatsapp get device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + } + c.mu.Unlock() + } + + if c.IsAuthenticated() { + return nil, nil // caller checks this + } + + qrChan, err := c.client.GetQRChannel(ctx) + if err != nil { + return nil, fmt.Errorf("whatsapp get QR channel: %w", err) + } + + if !c.client.IsConnected() { + if err := c.client.Connect(); err != nil { + return nil, fmt.Errorf("whatsapp connect for QR: %w", err) + } + } + + return qrChan, nil +} + +// Reauth clears the current session and prepares for a fresh QR scan. +// Serialized with StartQRFlow via reauthMu to prevent races on rapid double-clicks. +func (c *Channel) Reauth() error { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() + + slog.Info("whatsapp: reauth requested", "channel", c.Name()) + + c.lastQRMu.Lock() + c.waAuthenticated = false + c.lastQRB64 = "" + c.lastQRMu.Unlock() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + c.client.Disconnect() + } + + // Delete device from store to force fresh QR on next connect. + if c.client != nil && c.client.Store.ID != nil { + if err := c.client.Store.Delete(context.Background()); err != nil { + slog.Warn("whatsapp: failed to delete device store", "error", err) + } + } + + // Reset context so the new client gets a fresh lifecycle. + if c.cancel != nil { + c.cancel() + } + // Use parentCtx if available so the new lifecycle is still bound to the gateway. + parent := c.parentCtx + if parent == nil { + parent = context.Background() + } + c.ctx, c.cancel = context.WithCancel(parent) + + // Re-create client with fresh device store. + deviceStore, err := c.container.GetFirstDevice(context.Background()) + if err != nil { + return fmt.Errorf("whatsapp: get fresh device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + + return nil +} diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index 3a039f33..e49801eb 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -1,6 +1,7 @@ package whatsapp import ( + "database/sql" "encoding/json" "fmt" @@ -10,59 +11,64 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -// whatsappCreds maps the credentials JSON from the channel_instances table. -type whatsappCreds struct { - BridgeURL string `json:"bridge_url"` -} - // whatsappInstanceConfig maps the non-secret config JSONB from the channel_instances table. type whatsappInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` } -// Factory creates a WhatsApp channel from DB instance data. -func Factory(name string, creds json.RawMessage, cfg json.RawMessage, - msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { +// FactoryWithDB returns a ChannelFactory with DB access for whatsmeow auth state. +// dialect must be "pgx" (PostgreSQL) or "sqlite3" (SQLite/desktop). +func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore, dialect string) channels.ChannelFactory { + return func(name string, creds json.RawMessage, cfg json.RawMessage, + msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { - var c whatsappCreds - if len(creds) > 0 { - if err := json.Unmarshal(creds, &c); err != nil { - return nil, fmt.Errorf("decode whatsapp credentials: %w", err) + var ic whatsappInstanceConfig + if len(cfg) > 0 { + if err := json.Unmarshal(cfg, &ic); err != nil { + return nil, fmt.Errorf("decode whatsapp config: %w", err) + } } - } - if c.BridgeURL == "" { - return nil, fmt.Errorf("whatsapp bridge_url is required") - } - var ic whatsappInstanceConfig - if len(cfg) > 0 { - if err := json.Unmarshal(cfg, &ic); err != nil { - return nil, fmt.Errorf("decode whatsapp config: %w", err) + // Detect old bridge_url config and give clear migration error. + if len(cfg) > 0 { + var legacy struct{ BridgeURL string `json:"bridge_url"` } + if json.Unmarshal(cfg, &legacy) == nil && legacy.BridgeURL != "" { + return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from config") + } + } + if len(creds) > 0 { + var legacy struct{ BridgeURL string `json:"bridge_url"` } + if json.Unmarshal(creds, &legacy) == nil && legacy.BridgeURL != "" { + return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from credentials") + } } - } - waCfg := config.WhatsAppConfig{ - Enabled: true, - BridgeURL: c.BridgeURL, - AllowFrom: ic.AllowFrom, - DMPolicy: ic.DMPolicy, - GroupPolicy: ic.GroupPolicy, - BlockReply: ic.BlockReply, - } + 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 } diff --git a/internal/channels/whatsapp/format.go b/internal/channels/whatsapp/format.go new file mode 100644 index 00000000..0ac14f9d --- /dev/null +++ b/internal/channels/whatsapp/format.go @@ -0,0 +1,137 @@ +package whatsapp + +import ( + "fmt" + "regexp" + "strings" +) + +// markdownToWhatsApp converts Markdown-formatted LLM output to WhatsApp's native +// formatting syntax. WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```. +// Unsupported features are simplified: headers → bold, links → "text url", tables → plain. +func markdownToWhatsApp(text string) string { + if text == "" { + return "" + } + + // Pre-process: convert HTML tags from LLM output to Markdown equivalents. + text = htmlTagToWaMd(text) + + // Extract and protect fenced code blocks — WhatsApp renders ``` the same way. + codeBlocks := waExtractCodeBlocks(text) + text = codeBlocks.text + + // Headers (##, ###, etc.) → *bold text* (WhatsApp has no header concept). + text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "*$1*") + + // Blockquotes → plain text. + text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1") + + // Links [text](url) → "text url" (WhatsApp doesn't support markdown links). + text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, "$1 $2") + + // Bold: **text** or __text__ → *text* + text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "*$1*") + text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "*$1*") + + // Strikethrough: ~~text~~ → ~text~ + text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "~$1~") + + // Inline code: `code` → ```code``` (WhatsApp has no inline code, only blocks). + text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "```$1```") + + // List items: leading - or * → bullet • + text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ") + + // Restore code blocks as ``` … ``` preserving original content. + for i, code := range codeBlocks.codes { + // Trim trailing newline from extracted content — we add our own. + code = strings.TrimRight(code, "\n") + text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), "```\n"+code+"\n```") + } + + // Collapse 3+ blank lines to 2. + text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") + + return strings.TrimSpace(text) +} + +// htmlTagToWaMd converts common HTML tags in LLM output to Markdown equivalents +// so they are then processed by the markdown → WhatsApp pipeline above. +var htmlToWaMdReplacers = []struct { + re *regexp.Regexp + repl string +}{ + {regexp.MustCompile(`(?i)`), "\n"}, + {regexp.MustCompile(`(?i)`), "\n"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**${1}**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**${1}**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "`${1}`"}, + {regexp.MustCompile(`(?i)]*>([\s\S]*?)`), "[${2}](${1})"}, +} + +func htmlTagToWaMd(text string) string { + for _, r := range htmlToWaMdReplacers { + text = r.re.ReplaceAllString(text, r.repl) + } + return text +} + +type waCodeBlockMatch struct { + text string + codes []string +} + +// waExtractCodeBlocks pulls fenced code blocks out of text and replaces them with +// \x00CB{n}\x00 placeholders so other regex passes don't mangle their contents. +func waExtractCodeBlocks(text string) waCodeBlockMatch { + re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + matches := re.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, m := range matches { + codes = append(codes, m[1]) + } + + i := 0 + text = re.ReplaceAllStringFunc(text, func(_ string) string { + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder + }) + + return waCodeBlockMatch{text: text, codes: codes} +} + +// chunkText splits text into pieces that fit within maxLen, +// preferring to split at paragraph (\n\n) or line (\n) boundaries. +func chunkText(text string, maxLen int) []string { + if len(text) <= maxLen { + return []string{text} + } + + var chunks []string + for len(text) > 0 { + if len(text) <= maxLen { + chunks = append(chunks, text) + break + } + // Find the best split point: paragraph > line > space > hard cut. + cutAt := maxLen + if idx := strings.LastIndex(text[:maxLen], "\n\n"); idx > 0 { + cutAt = idx + } else if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 { + cutAt = idx + } else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 { + cutAt = idx + } + chunks = append(chunks, strings.TrimRight(text[:cutAt], " \n")) + text = strings.TrimLeft(text[cutAt:], " \n") + } + return chunks +} diff --git a/internal/channels/whatsapp/format_test.go b/internal/channels/whatsapp/format_test.go new file mode 100644 index 00000000..9b93ba86 --- /dev/null +++ b/internal/channels/whatsapp/format_test.go @@ -0,0 +1,49 @@ +package whatsapp + +import "testing" + +func TestMarkdownToWhatsApp(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"plain text", "hello world", "hello world"}, + {"header h1", "# Title", "*Title*"}, + {"header h3", "### Sub", "*Sub*"}, + {"bold stars", "this is **bold** text", "this is *bold* text"}, + {"bold underscores", "this is __bold__ text", "this is *bold* text"}, + {"strikethrough", "~~deleted~~", "~deleted~"}, + {"inline code", "use `fmt.Println`", "use ```fmt.Println```"}, + {"link", "[Go](https://go.dev)", "Go https://go.dev"}, + {"unordered list dash", "- item one\n- item two", "• item one\n• item two"}, + {"unordered list star", "* item one\n* item two", "• item one\n• item two"}, + {"blockquote", "> quoted text", "quoted text"}, + { + "fenced code block preserved", + "```go\nfmt.Println(\"hi\")\n```", + "```\nfmt.Println(\"hi\")\n```", + }, + { + "code block not mangled by bold regex", + "```\n**not bold**\n```", + "```\n**not bold**\n```", + }, + {"collapse blank lines", "a\n\n\n\nb", "a\n\nb"}, + {"html bold", "bold", "*bold*"}, + {"html italic", "italic", "_italic_"}, + {"html strikethrough", "removed", "~removed~"}, + {"html br", "line1
line2", "line1\nline2"}, + {"html link", `link`, "link https://x.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := markdownToWhatsApp(tt.in) + if got != tt.want { + t.Errorf("markdownToWhatsApp(%q)\n got: %q\nwant: %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/channels/whatsapp/inbound.go b/internal/channels/whatsapp/inbound.go new file mode 100644 index 00000000..ecc78b4f --- /dev/null +++ b/internal/channels/whatsapp/inbound.go @@ -0,0 +1,266 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/channels/media" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const emptyMessageSentinel = "[empty message]" + +// handleIncomingMessage processes an incoming WhatsApp message. +func (c *Channel) handleIncomingMessage(evt *events.Message) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) + + if evt.Info.IsFromMe { + return + } + + senderJID := evt.Info.Sender + chatJID := evt.Info.Chat + + // WhatsApp uses dual identity: phone JID (@s.whatsapp.net) and LID (@lid). + // Groups may use LID addressing. Normalize to phone JID for consistent + // policy checks, pairing lookups, allowlists, and contact collection. + if evt.Info.AddressingMode == types.AddressingModeLID && !evt.Info.SenderAlt.IsEmpty() { + senderJID = evt.Info.SenderAlt + } + + senderID := senderJID.String() + chatID := chatJID.String() + + peerKind := "direct" + if chatJID.Server == types.GroupServer { + peerKind = "group" + } + + slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, + "addressing", evt.Info.AddressingMode, "policy", c.config.GroupPolicy) + + // DM/Group policy check. + if peerKind == "direct" { + if !c.checkDMPolicy(ctx, senderID, chatID) { + return + } + } else { + if !c.checkGroupPolicy(ctx, senderID, chatID) { + slog.Info("whatsapp group message rejected by policy", "sender_id", senderID, "chat_id", chatID, "policy", c.config.GroupPolicy) + return + } + } + + if !c.IsAllowed(senderID) { + slog.Info("whatsapp message rejected by allowlist", "sender_id", senderID) + return + } + + content := extractTextContent(evt.Message) + + var mediaList []media.MediaInfo + mediaList = c.downloadMedia(evt) + + if content == "" && len(mediaList) == 0 { + return + } + if content == "" { + content = emptyMessageSentinel + } + + // Group history + mention detection. + historyLimit := c.config.HistoryLimit + if historyLimit == 0 { + historyLimit = channels.DefaultGroupHistoryLimit + } + if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { + if !c.isMentioned(evt) { + // Not mentioned — record for context and skip. + senderLabel := evt.Info.PushName + if senderLabel == "" { + senderLabel = senderID + } + c.groupHistory.Record(chatID, channels.HistoryEntry{ + Sender: senderLabel, + SenderID: senderID, + Body: content, + Timestamp: evt.Info.Timestamp, + MessageID: string(evt.Info.ID), + }, historyLimit) + return + } + // Mentioned — prepend accumulated group context. + content = c.groupHistory.BuildContext(chatID, content, historyLimit) + c.groupHistory.Clear(chatID) + } + + metadata := map[string]string{ + "message_id": string(evt.Info.ID), + } + if evt.Info.PushName != "" { + metadata["user_name"] = evt.Info.PushName + } + + // Build media tags and bus.MediaFile list. + var mediaFiles []bus.MediaFile + if len(mediaList) > 0 { + mediaTags := media.BuildMediaTags(mediaList) + if mediaTags != "" { + if content != emptyMessageSentinel { + content = mediaTags + "\n\n" + content + } else { + content = mediaTags + } + } + for _, m := range mediaList { + if m.FilePath != "" { + mediaFiles = append(mediaFiles, bus.MediaFile{ + Path: m.FilePath, MimeType: m.ContentType, + }) + } + } + } + + // Annotate with sender identity. + if senderName := metadata["user_name"]; senderName != "" { + content = fmt.Sprintf("[From: %s]\n%s", senderName, content) + } + + // Collect contact. + if cc := c.ContactCollector(); cc != nil { + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, + metadata["user_name"], "", peerKind, "user", "", "") + } + + // Typing indicator. + if prevCancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { + if fn, ok := prevCancel.(context.CancelFunc); ok { + fn() + } + } + typingCtx, typingCancel := context.WithCancel(context.Background()) + c.typingCancel.Store(chatID, typingCancel) + go c.keepTyping(typingCtx, chatJID) + + // Derive userID from senderID. + userID := senderID + if idx := strings.IndexByte(senderID, '|'); idx > 0 { + userID = senderID[:idx] + } + + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: mediaFiles, + PeerKind: peerKind, + UserID: userID, + AgentID: c.AgentID(), + TenantID: c.TenantID(), + Metadata: metadata, + }) + + // Schedule temp media file cleanup after agent pipeline has had time to process. + var tmpPaths []string + for _, mf := range mediaFiles { + tmpPaths = append(tmpPaths, mf.Path) + } + scheduleMediaCleanup(c.ctx, tmpPaths, 5*time.Minute) +} + +// extractTextContent extracts text from any WhatsApp message variant. +// Includes quoted message context when present (reply-to messages). +func extractTextContent(msg *waE2E.Message) string { + if msg == nil { + return "" + } + + var text string + var quotedText string + + if msg.GetConversation() != "" { + text = msg.GetConversation() + } else if ext := msg.GetExtendedTextMessage(); ext != nil { + text = ext.GetText() + // Extract quoted (replied-to) message text. + if ci := ext.GetContextInfo(); ci != nil { + if qm := ci.GetQuotedMessage(); qm != nil { + quotedText = extractQuotedText(qm) + } + } + } else if img := msg.GetImageMessage(); img != nil { + text = img.GetCaption() + } else if vid := msg.GetVideoMessage(); vid != nil { + text = vid.GetCaption() + } else if doc := msg.GetDocumentMessage(); doc != nil { + text = doc.GetCaption() + } + + if quotedText != "" && text != "" { + return fmt.Sprintf("[Replying to: %s]\n%s", quotedText, text) + } + if quotedText != "" { + return fmt.Sprintf("[Replying to: %s]", quotedText) + } + return text +} + +// extractQuotedText extracts plain text from a quoted message (no recursion). +func extractQuotedText(msg *waE2E.Message) string { + if msg == nil { + return "" + } + if msg.GetConversation() != "" { + return msg.GetConversation() + } + if ext := msg.GetExtendedTextMessage(); ext != nil { + return ext.GetText() + } + if img := msg.GetImageMessage(); img != nil && img.GetCaption() != "" { + return img.GetCaption() + } + if vid := msg.GetVideoMessage(); vid != nil && vid.GetCaption() != "" { + return vid.GetCaption() + } + return "" +} + +// isMentioned checks if the linked account is @mentioned in a group message. +// WhatsApp uses dual identity: phone JID and LID. Mentions may use either format. +func (c *Channel) isMentioned(evt *events.Message) bool { + c.lastQRMu.RLock() + myJID := c.myJID + myLID := c.myLID + c.lastQRMu.RUnlock() + + if myJID.IsEmpty() && myLID.IsEmpty() { + return false // fail closed: unknown identity = not mentioned + } + + // Check mentioned JIDs from extended text. + if ext := evt.Message.GetExtendedTextMessage(); ext != nil { + if ci := ext.GetContextInfo(); ci != nil { + for _, jidStr := range ci.GetMentionedJID() { + mentioned, _ := types.ParseJID(jidStr) + if !myJID.IsEmpty() && mentioned.User == myJID.User { + return true + } + if !myLID.IsEmpty() && mentioned.User == myLID.User { + return true + } + } + } + } + return false +} diff --git a/internal/channels/whatsapp/media_download.go b/internal/channels/whatsapp/media_download.go new file mode 100644 index 00000000..cd69cc77 --- /dev/null +++ b/internal/channels/whatsapp/media_download.go @@ -0,0 +1,140 @@ +package whatsapp + +import ( + "context" + "log/slog" + "os" + "strings" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types/events" + + "github.com/nextlevelbuilder/goclaw/internal/channels/media" +) + +// downloadMedia downloads media attachments from a WhatsApp message. +func (c *Channel) downloadMedia(evt *events.Message) []media.MediaInfo { + msg := evt.Message + if msg == nil { + return nil + } + + type mediaItem struct { + mediaType string + mimetype string + filename string + download whatsmeow.DownloadableMessage + } + + var items []mediaItem + if img := msg.GetImageMessage(); img != nil { + items = append(items, mediaItem{"image", img.GetMimetype(), "", img}) + } + if vid := msg.GetVideoMessage(); vid != nil { + items = append(items, mediaItem{"video", vid.GetMimetype(), "", vid}) + } + if aud := msg.GetAudioMessage(); aud != nil { + items = append(items, mediaItem{"audio", aud.GetMimetype(), "", aud}) + } + if doc := msg.GetDocumentMessage(); doc != nil { + items = append(items, mediaItem{"document", doc.GetMimetype(), doc.GetFileName(), doc}) + } + if stk := msg.GetStickerMessage(); stk != nil { + items = append(items, mediaItem{"sticker", stk.GetMimetype(), "", stk}) + } + + if len(items) == 0 { + return nil + } + + var result []media.MediaInfo + for _, item := range items { + data, err := c.client.Download(c.ctx, item.download) + if err != nil { + reason := classifyDownloadError(err) + slog.Warn("whatsapp: media download failed", "type", item.mediaType, "reason", reason, "error", err) + continue + } + if len(data) > 20*1024*1024 { // 20MB limit + slog.Warn("whatsapp: media too large, skipping", "type", item.mediaType, + "size_mb", len(data)/(1024*1024)) + continue + } + + ext := mimeToExt(item.mimetype) + tmpFile, err := os.CreateTemp("", "goclaw_wa_*"+ext) + if err != nil { + slog.Warn("whatsapp: temp file creation failed", "error", err) + continue + } + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + continue + } + tmpFile.Close() + + result = append(result, media.MediaInfo{ + Type: item.mediaType, + FilePath: tmpFile.Name(), + ContentType: item.mimetype, + FileName: item.filename, + }) + } + return result +} + +// mimeToExt maps MIME types to file extensions. +func mimeToExt(mime string) string { + switch { + case strings.HasPrefix(mime, "image/jpeg"): + return ".jpg" + case strings.HasPrefix(mime, "image/png"): + return ".png" + case strings.HasPrefix(mime, "image/webp"): + return ".webp" + case strings.HasPrefix(mime, "video/mp4"): + return ".mp4" + case strings.HasPrefix(mime, "audio/ogg"): + return ".ogg" + case strings.HasPrefix(mime, "audio/mpeg"): + return ".mp3" + case strings.HasPrefix(mime, "application/pdf"): + return ".pdf" + default: + return ".bin" + } +} + +// classifyDownloadError returns a human-readable reason for a media download failure. +func classifyDownloadError(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"): + return "timeout" + case strings.Contains(msg, "decrypt") || strings.Contains(msg, "cipher"): + return "decrypt_error" + case strings.Contains(msg, "404") || strings.Contains(msg, "not found"): + return "expired" + case strings.Contains(msg, "unsupported"): + return "unsupported" + default: + return "unknown" + } +} + +// scheduleMediaCleanup removes temp media files after a delay. +// Uses time.AfterFunc so it does not block and respects the provided context for logging only. +func scheduleMediaCleanup(ctx context.Context, paths []string, delay time.Duration) { + if len(paths) == 0 { + return + } + time.AfterFunc(delay, func() { + for _, p := range paths { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + slog.Debug("whatsapp: temp media cleanup failed", "path", p, "error", err) + } + } + }) +} diff --git a/internal/channels/whatsapp/mention_test.go b/internal/channels/whatsapp/mention_test.go new file mode 100644 index 00000000..7d4caa13 --- /dev/null +++ b/internal/channels/whatsapp/mention_test.go @@ -0,0 +1,131 @@ +package whatsapp + +import ( + "testing" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +func TestIsMentioned(t *testing.T) { + // Helper to build an events.Message with mentioned JIDs in extended text. + makeEvt := func(mentionedJIDs []string) *events.Message { + return &events.Message{ + Message: &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String("hello @bot"), + ContextInfo: &waE2E.ContextInfo{ + MentionedJID: mentionedJIDs, + }, + }, + }, + } + } + + tests := []struct { + name string + myJID string // bot's phone JID + myLID string // bot's LID + mentions []string + want bool + }{ + { + name: "mentioned by phone JID", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: true, + }, + { + name: "mentioned by LID", + myLID: "9876543210@lid", + mentions: []string{"9876543210@lid"}, + want: true, + }, + { + name: "mentioned by JID with device suffix", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"1234567890:42@s.whatsapp.net"}, + want: true, + }, + { + name: "mentioned by LID with device suffix", + myLID: "9876543210@lid", + mentions: []string{"9876543210:5@lid"}, + want: true, + }, + { + name: "dual identity — mentioned via LID when JID also set", + myJID: "1234567890@s.whatsapp.net", + myLID: "9876543210@lid", + mentions: []string{"9876543210@lid"}, + want: true, + }, + { + name: "dual identity — mentioned via JID when LID also set", + myJID: "1234567890@s.whatsapp.net", + myLID: "9876543210@lid", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: true, + }, + { + name: "not mentioned — different user", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"9999999999@s.whatsapp.net"}, + want: false, + }, + { + name: "not mentioned — empty mentions", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{}, + want: false, + }, + { + name: "unknown identity — fail closed", + myJID: "", + myLID: "", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: false, + }, + { + name: "no extended text message", + myJID: "1234567890@s.whatsapp.net", + mentions: nil, // will use plain conversation message + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := &Channel{} + + // Set bot identity. + if tt.myJID != "" { + jid, _ := types.ParseJID(tt.myJID) + ch.myJID = jid + } + if tt.myLID != "" { + lid, _ := types.ParseJID(tt.myLID) + ch.myLID = lid + } + + var evt *events.Message + if tt.mentions == nil { + // Plain conversation message — no extended text. + evt = &events.Message{ + Message: &waE2E.Message{ + Conversation: proto.String("hello"), + }, + } + } else { + evt = makeEvt(tt.mentions) + } + + got := ch.isMentioned(evt) + if got != tt.want { + t.Errorf("isMentioned() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/channels/whatsapp/outbound.go b/internal/channels/whatsapp/outbound.go new file mode 100644 index 00000000..fa2e692d --- /dev/null +++ b/internal/channels/whatsapp/outbound.go @@ -0,0 +1,182 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// Send delivers an outbound message to WhatsApp via whatsmeow. +func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { + if c.client == nil || !c.client.IsConnected() { + return fmt.Errorf("whatsapp not connected") + } + + chatJID, err := types.ParseJID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid whatsapp JID %q: %w", msg.ChatID, err) + } + + // Send media attachments first. + if len(msg.Media) > 0 { + for i, m := range msg.Media { + caption := m.Caption + if caption == "" && i == 0 && msg.Content != "" { + caption = markdownToWhatsApp(msg.Content) + } + + data, readErr := os.ReadFile(m.URL) + if readErr != nil { + return fmt.Errorf("read media file: %w", readErr) + } + + waMsg, buildErr := c.buildMediaMessage(data, m.ContentType, caption) + if buildErr != nil { + return fmt.Errorf("build media message: %w", buildErr) + } + + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + return fmt.Errorf("send whatsapp media: %w", sendErr) + } + } + // Skip text if caption was used on first media. + if msg.Media[0].Caption == "" && msg.Content != "" { + msg.Content = "" + } + } + + // Send text (chunked if exceeding limit). + if msg.Content != "" { + formatted := markdownToWhatsApp(msg.Content) + chunks := chunkText(formatted, maxMessageLen) + for _, chunk := range chunks { + waMsg := &waE2E.Message{ + Conversation: proto.String(chunk), + } + if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { + return fmt.Errorf("send whatsapp message: %w", err) + } + } + } + + // Stop typing indicator. + if cancel, ok := c.typingCancel.LoadAndDelete(msg.ChatID); ok { + if fn, ok := cancel.(context.CancelFunc); ok { + fn() + } + } + go c.sendPresence(chatJID, types.ChatPresencePaused) + + return nil +} + +// buildMediaMessage uploads media to WhatsApp and returns the message proto. +func (c *Channel) buildMediaMessage(data []byte, mime, caption string) (*waE2E.Message, error) { + switch { + case strings.HasPrefix(mime, "image/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaImage) + if err != nil { + return nil, err + } + return &waE2E.Message{ + ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "video/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaVideo) + if err != nil { + return nil, err + } + return &waE2E.Message{ + VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "audio/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaAudio) + if err != nil { + return nil, err + } + return &waE2E.Message{ + AudioMessage: &waE2E.AudioMessage{ + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + default: // document + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaDocument) + if err != nil { + return nil, err + } + return &waE2E.Message{ + DocumentMessage: &waE2E.DocumentMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + } +} + +// keepTyping sends "composing" presence repeatedly until ctx is cancelled. +func (c *Channel) keepTyping(ctx context.Context, chatJID types.JID) { + c.sendPresence(chatJID, types.ChatPresenceComposing) + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.sendPresence(chatJID, types.ChatPresenceComposing) + } + } +} + +// sendPresence sends a WhatsApp chat presence update. +func (c *Channel) sendPresence(to types.JID, state types.ChatPresence) { + if c.client == nil || !c.client.IsConnected() { + return + } + if err := c.client.SendChatPresence(c.ctx, to, state, ""); err != nil { + slog.Debug("whatsapp: presence update failed", "state", state, "error", err) + } +} diff --git a/internal/channels/whatsapp/policy.go b/internal/channels/whatsapp/policy.go new file mode 100644 index 00000000..d305bc34 --- /dev/null +++ b/internal/channels/whatsapp/policy.go @@ -0,0 +1,141 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" +) + +// checkGroupPolicy evaluates the group policy for a sender. +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { + groupPolicy := c.config.GroupPolicy + if groupPolicy == "" { + groupPolicy = "open" + } + + switch groupPolicy { + case "disabled": + return false + case "allowlist": + return c.IsAllowed(senderID) + case "pairing": + if c.HasAllowList() && c.IsAllowed(senderID) { + return true + } + if _, cached := c.approvedGroups.Load(chatID); cached { + return true + } + groupSenderID := fmt.Sprintf("group:%s", chatID) + if c.pairingService != nil { + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) + if err != nil { + slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + "group_sender", groupSenderID, "channel", c.Name(), "error", err) + paired = true + } + if paired { + c.approvedGroups.Store(chatID, true) + return true + } + } + c.sendPairingReply(ctx, groupSenderID, chatID) + return false + default: // "open" + return true + } +} + +// checkDMPolicy evaluates the DM policy for a sender. +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { + dmPolicy := c.config.DMPolicy + if dmPolicy == "" { + dmPolicy = "pairing" + } + + switch dmPolicy { + case "disabled": + slog.Debug("whatsapp DM rejected: disabled", "sender_id", senderID) + return false + case "open": + return true + case "allowlist": + if !c.IsAllowed(senderID) { + slog.Debug("whatsapp DM rejected by allowlist", "sender_id", senderID) + return false + } + return true + default: // "pairing" + paired := false + if c.pairingService != nil { + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) + if err != nil { + slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + "sender_id", senderID, "channel", c.Name(), "error", err) + paired = true + } else { + paired = p + } + } + inAllowList := c.HasAllowList() && c.IsAllowed(senderID) + + if paired || inAllowList { + return true + } + + c.sendPairingReply(ctx, senderID, chatID) + return false + } +} + +// sendPairingReply sends a pairing code to the user via WhatsApp. +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { + if c.pairingService == nil { + slog.Warn("whatsapp pairing: no pairing service configured") + return + } + + // Debounce. + if lastSent, ok := c.pairingDebounce.Load(senderID); ok { + if time.Since(lastSent.(time.Time)) < pairingDebounceTime { + slog.Info("whatsapp pairing: debounced", "sender_id", senderID) + return + } + } + + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) + if err != nil { + slog.Warn("whatsapp pairing request failed", "sender_id", senderID, "channel", c.Name(), "error", err) + return + } + + replyText := fmt.Sprintf( + "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the account owner to approve with:\n goclaw pairing approve %s", + senderID, code, code, + ) + + if c.client == nil || !c.client.IsConnected() { + slog.Warn("whatsapp not connected, cannot send pairing reply") + return + } + + chatJID, parseErr := types.ParseJID(chatID) + if parseErr != nil { + slog.Warn("whatsapp pairing: invalid chatID JID", "chatID", chatID, "error", parseErr) + return + } + + waMsg := &waE2E.Message{ + Conversation: proto.String(replyText), + } + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + slog.Warn("failed to send whatsapp pairing reply", "error", sendErr) + } else { + c.pairingDebounce.Store(senderID, time.Now()) + slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code) + } +} diff --git a/internal/channels/whatsapp/qr_methods.go b/internal/channels/whatsapp/qr_methods.go new file mode 100644 index 00000000..60a0b10e --- /dev/null +++ b/internal/channels/whatsapp/qr_methods.go @@ -0,0 +1,243 @@ +package whatsapp + +import ( + "context" + "encoding/base64" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + qrcode "github.com/skip2/go-qrcode" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + goclawprotocol "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +const qrSessionTimeout = 3 * time.Minute + +// cancelEntry wraps a CancelFunc so it can be stored in sync.Map.CompareAndDelete. +type cancelEntry struct { + cancel context.CancelFunc +} + +// QRMethods handles whatsapp.qr.start — delivers QR codes to the UI wizard. +type QRMethods struct { + instanceStore store.ChannelInstanceStore + manager *channels.Manager + activeSessions sync.Map // instanceID (string) -> *cancelEntry +} + +func NewQRMethods(instanceStore store.ChannelInstanceStore, manager *channels.Manager) *QRMethods { + return &QRMethods{instanceStore: instanceStore, manager: manager} +} + +func (m *QRMethods) Register(router *gateway.MethodRouter) { + router.Register(goclawprotocol.MethodWhatsAppQRStart, m.handleQRStart) +} + +func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) { + var params struct { + InstanceID string `json:"instance_id"` + ForceReauth bool `json:"force_reauth"` + } + if req.Params != nil { + _ = json.Unmarshal(req.Params, ¶ms) + } + + instID, err := uuid.Parse(params.InstanceID) + if err != nil { + client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "invalid instance_id")) + return + } + + inst, err := m.instanceStore.Get(ctx, instID) + if err != nil || inst.ChannelType != channels.TypeWhatsApp { + client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrNotFound, "whatsapp instance not found")) + return + } + + qrCtx, cancel := context.WithTimeout(ctx, qrSessionTimeout) + entry := &cancelEntry{cancel: cancel} + + // Cancel any previous QR session for this instance. + if prev, loaded := m.activeSessions.Swap(params.InstanceID, entry); loaded { + if prevEntry, ok := prev.(*cancelEntry); ok { + prevEntry.cancel() + } + } + + // ACK immediately — QR/done events arrive asynchronously. + client.SendResponse(goclawprotocol.NewOKResponse(req.ID, map[string]any{"status": "started"})) + + go m.runQRSession(qrCtx, entry, client, params.InstanceID, inst.Name, params.ForceReauth) +} + +func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry, + client *gateway.Client, instanceIDStr, channelName string, forceReauth bool) { + + defer entry.cancel() + defer m.activeSessions.CompareAndDelete(instanceIDStr, entry) + + // Wait for channel to appear in manager — instance creation triggers an async + // reload, so the channel may not be registered yet when the wizard fires QR start. + var wa *Channel + for attempt := 0; attempt < 10; attempt++ { + if ch, ok := m.manager.GetChannel(channelName); ok { + if w, ok := ch.(*Channel); ok { + wa = w + break + } + } + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } + } + if wa == nil { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "channel not found", + }, + }) + return + } + + // Already authenticated and no force-reauth → signal connected. + if wa.IsAuthenticated() && !forceReauth { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + "already_connected": true, + }, + }) + return + } + + // Force reauth: clear session and prepare for fresh QR. + if forceReauth { + if err := wa.Reauth(); err != nil { + slog.Warn("whatsapp QR: reauth failed", "error", err) + } + } + + // Deliver cached QR if available. + if cached := wa.GetLastQRB64(); cached != "" { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": cached, + }, + }) + } + + // Start QR flow — get QR channel from whatsmeow. + qrChan, err := wa.StartQRFlow(ctx) + if err != nil { + slog.Warn("whatsapp QR: start flow failed", "error", err) + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": err.Error(), + }, + }) + return + } + + if qrChan == nil { + // Already authenticated (StartQRFlow returned nil). + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + "already_connected": true, + }, + }) + return + } + + // Process QR events from whatsmeow. + for { + select { + case <-ctx.Done(): + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "QR session timed out — restart to try again", + }, + }) + return + + case evt, ok := <-qrChan: + if !ok { + return // channel closed + } + + switch evt.Event { + case "code": + png, qrErr := qrcode.Encode(evt.Code, qrcode.Medium, 256) + if qrErr != nil { + slog.Warn("whatsapp: QR PNG encode failed", "error", qrErr) + continue + } + pngB64 := base64.StdEncoding.EncodeToString(png) + + wa.cacheQR(pngB64) + + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": pngB64, + }, + }) + + case "success": + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + }, + }) + slog.Info("whatsapp QR session completed", "instance", instanceIDStr) + return + + case "timeout": + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "QR code expired — restart to try again", + }, + }) + return + } + } + } +} diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 3fb305ef..ac1e1d01 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -2,14 +2,18 @@ package whatsapp import ( "context" - "encoding/json" + "database/sql" "fmt" "log/slog" - "strings" "sync" "time" - "github.com/gorilla/websocket" + "go.mau.fi/whatsmeow" + wastore "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" @@ -17,52 +21,118 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -const pairingDebounceTime = 60 * time.Second +const ( + pairingDebounceTime = 60 * time.Second + maxMessageLen = 4096 // WhatsApp practical message length limit +) -// 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) +} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index a45de45a..0f31e5f1 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -132,12 +132,14 @@ type SlackConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled"` - BridgeURL string `json:"bridge_url"` - AllowFrom FlexibleStringSlice `json:"allow_from"` - DMPolicy string `json:"dm_policy,omitempty"` // "open" (default), "allowlist", "disabled" - GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + Enabled bool `json:"enabled"` + AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop) + AllowFrom FlexibleStringSlice `json:"allow_from"` + DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default for DB instances), "open", "allowlist", "disabled" + GroupPolicy string `json:"group_policy,omitempty"` // "pairing" (default for DB instances), "open" (default for config), "allowlist", "disabled" + RequireMention *bool `json:"require_mention,omitempty"` // only respond in groups when bot is @mentioned (default false) + HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 200, 0=disabled) + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) } type ZaloConfig struct { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 38b3319e..0c5c7f64 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -120,7 +120,7 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_LARK_APP_SECRET", &c.Channels.Feishu.AppSecret) envStr("GOCLAW_LARK_ENCRYPT_KEY", &c.Channels.Feishu.EncryptKey) envStr("GOCLAW_LARK_VERIFICATION_TOKEN", &c.Channels.Feishu.VerificationToken) - envStr("GOCLAW_WHATSAPP_BRIDGE_URL", &c.Channels.WhatsApp.BridgeURL) + // WhatsApp no longer needs bridge_url — runs natively via whatsmeow. envStr("GOCLAW_SLACK_BOT_TOKEN", &c.Channels.Slack.BotToken) envStr("GOCLAW_SLACK_APP_TOKEN", &c.Channels.Slack.AppToken) envStr("GOCLAW_SLACK_USER_TOKEN", &c.Channels.Slack.UserToken) @@ -144,9 +144,7 @@ func (c *Config) applyEnvOverrides() { if c.Channels.Feishu.AppID != "" && c.Channels.Feishu.AppSecret != "" { c.Channels.Feishu.Enabled = true } - if c.Channels.WhatsApp.BridgeURL != "" { - c.Channels.WhatsApp.Enabled = true - } + // WhatsApp is enabled via config or DB instances (no bridge_url needed). if c.Channels.Slack.BotToken != "" && c.Channels.Slack.AppToken != "" { c.Channels.Slack.Enabled = true } diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go index 1615ac86..e7ac9d01 100644 --- a/internal/gateway/event_filter.go +++ b/internal/gateway/event_filter.go @@ -116,6 +116,11 @@ func clientCanReceiveEvent(c *Client, event bus.Event) bool { return false } + // WhatsApp QR events: delivered directly to the requesting client, not broadcast. + if strings.HasPrefix(event.Name, "whatsapp.") { + return false + } + // Skill dep events → broadcast (non-sensitive, skill names only). if strings.HasPrefix(event.Name, "skill.") { return true diff --git a/internal/providers/acp_provider.go b/internal/providers/acp_provider.go index 55b10df8..1cc2e10c 100644 --- a/internal/providers/acp_provider.go +++ b/internal/providers/acp_provider.go @@ -16,6 +16,7 @@ import ( // ACPProvider implements Provider by orchestrating ACP-compatible agent subprocesses. // It delegates to a ProcessPool that manages agent lifecycle over JSON-RPC 2.0 stdio. type ACPProvider struct { + name string // provider name (default: "acp") pool *acp.ProcessPool bridge *acp.ToolBridge defaultModel string @@ -26,6 +27,15 @@ type ACPProvider struct { // ACPOption configures an ACPProvider. type ACPOption func(*ACPProvider) +// WithACPName overrides the provider name (default: "acp"). +func WithACPName(name string) ACPOption { + return func(p *ACPProvider) { + if name != "" { + p.name = name + } + } +} + // WithACPModel sets the default model/agent name. func WithACPModel(model string) ACPOption { return func(p *ACPProvider) { @@ -47,6 +57,7 @@ func WithACPPermMode(mode string) ACPOption { // NewACPProvider creates a provider that orchestrates ACP agents as subprocesses. func NewACPProvider(binary string, args []string, workDir string, idleTTL time.Duration, denyPatterns []*regexp.Regexp, opts ...ACPOption) *ACPProvider { p := &ACPProvider{ + name: "acp", defaultModel: "claude", } for _, opt := range opts { @@ -70,7 +81,7 @@ func NewACPProvider(binary string, args []string, workDir string, idleTTL time.D return p } -func (p *ACPProvider) Name() string { return "acp" } +func (p *ACPProvider) Name() string { return p.name } func (p *ACPProvider) DefaultModel() string { return p.defaultModel } // Chat sends a prompt and returns the complete response (non-streaming). diff --git a/internal/providers/anthropic.go b/internal/providers/anthropic.go index 0615e424..433f1cb4 100644 --- a/internal/providers/anthropic.go +++ b/internal/providers/anthropic.go @@ -38,6 +38,7 @@ func resolveAnthropicModel(model, defaultModel string) string { // AnthropicProvider implements Provider using the Anthropic Claude API via net/http. type AnthropicProvider struct { + name string // provider name (default: "anthropic") apiKey string baseURL string defaultModel string @@ -48,6 +49,7 @@ type AnthropicProvider struct { // NewAnthropicProvider creates a new Anthropic provider. func NewAnthropicProvider(apiKey string, opts ...AnthropicOption) *AnthropicProvider { p := &AnthropicProvider{ + name: "anthropic", apiKey: apiKey, baseURL: anthropicAPIBase, defaultModel: defaultClaudeModel, @@ -62,6 +64,15 @@ func NewAnthropicProvider(apiKey string, opts ...AnthropicOption) *AnthropicProv type AnthropicOption func(*AnthropicProvider) +// WithAnthropicName overrides the provider name (default: "anthropic"). +func WithAnthropicName(name string) AnthropicOption { + return func(p *AnthropicProvider) { + if name != "" { + p.name = name + } + } +} + func WithAnthropicModel(model string) AnthropicOption { return func(p *AnthropicProvider) { p.defaultModel = model } } @@ -74,7 +85,7 @@ func WithAnthropicBaseURL(baseURL string) AnthropicOption { } } -func (p *AnthropicProvider) Name() string { return "anthropic" } +func (p *AnthropicProvider) Name() string { return p.name } func (p *AnthropicProvider) DefaultModel() string { return p.defaultModel } func (p *AnthropicProvider) SupportsThinking() bool { return true } diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go index 49849133..2f8048d7 100644 --- a/internal/providers/claude_cli.go +++ b/internal/providers/claude_cli.go @@ -39,6 +39,7 @@ const OptTenantID = "tenant_id" // It acts as a thin proxy: CLI manages session history, tool execution, and context. // GoClaw only forwards the latest user message and streams back the response. type ClaudeCLIProvider struct { + name string // provider name (default: "claude-cli") cliPath string // path to claude binary (default: "claude") defaultModel string // default: "sonnet" baseWorkDir string // base dir for agent workspaces @@ -54,6 +55,15 @@ type ClaudeCLIProvider struct { // ClaudeCLIOption configures the provider. type ClaudeCLIOption func(*ClaudeCLIProvider) +// WithClaudeCLIName overrides the provider name (default: "claude-cli"). +func WithClaudeCLIName(name string) ClaudeCLIOption { + return func(p *ClaudeCLIProvider) { + if name != "" { + p.name = name + } + } +} + // WithClaudeCLIModel sets the default model alias. func WithClaudeCLIModel(model string) ClaudeCLIOption { return func(p *ClaudeCLIProvider) { @@ -110,6 +120,7 @@ func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIPro cliPath = "claude" } p := &ClaudeCLIProvider{ + name: "claude-cli", cliPath: cliPath, defaultModel: "sonnet", baseWorkDir: defaultCLIWorkDir(), @@ -122,7 +133,7 @@ func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIPro return p } -func (p *ClaudeCLIProvider) Name() string { return "claude-cli" } +func (p *ClaudeCLIProvider) Name() string { return p.name } func (p *ClaudeCLIProvider) DefaultModel() string { return p.defaultModel } // Close cleans up temp files (per-session MCP configs, hooks settings). Implements io.Closer. diff --git a/internal/providers/claude_cli_chat.go b/internal/providers/claude_cli_chat.go index cb8ec571..a1278c11 100644 --- a/internal/providers/claude_cli_chat.go +++ b/internal/providers/claude_cli_chat.go @@ -112,6 +112,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC } cmd := exec.CommandContext(ctx, p.cliPath, args...) + cmd.WaitDelay = 5 * time.Second // force-close pipes if process lingers after kill cmd.Dir = workDir cmd.Env = filterCLIEnv(os.Environ()) if stdin != nil { @@ -151,6 +152,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC var contentBuf strings.Builder for scanner.Scan() { + if ctx.Err() != nil { + break // context cancelled (abort) → exit immediately + } line := scanner.Bytes() if len(line) == 0 { continue @@ -201,6 +205,12 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC } } + // Context cancelled (abort): best-effort reap (bounded by WaitDelay), then return. + if ctx.Err() != nil { + _ = cmd.Wait() + return nil, ctx.Err() + } + if err := scanner.Err(); err != nil { return nil, fmt.Errorf("claude-cli: stream read error: %w", err) } diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index 63d3307b..744f37c8 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -28,7 +28,7 @@ func IsDefaultChannelInstance(name string) bool { if strings.HasSuffix(name, "/default") { return true } - // Legacy Telegram default uses bare name "telegram" + // Legacy config-based defaults that were seeded with bare channel-type names. switch name { case "telegram", "discord", "feishu", "zalo_oa", "whatsapp": return true diff --git a/internal/store/config_permission_store.go b/internal/store/config_permission_store.go index aa0d82f3..d3ace7e6 100644 --- a/internal/store/config_permission_store.go +++ b/internal/store/config_permission_store.go @@ -14,6 +14,7 @@ import ( const ( ConfigTypeFileWriter = "file_writer" // Group file write access ConfigTypeHeartbeat = "heartbeat" // Heartbeat config access + ConfigTypeCron = "cron" // Cron job management access ) // ConfigPermission represents an allow/deny rule for agent configuration. @@ -74,3 +75,43 @@ func CheckFileWriterPermission(ctx context.Context, permStore ConfigPermissionSt } return nil } + +// CheckCronPermission returns an error if the caller is in a group context +// and does not have cron or file_writer permission. Returns nil if allowed. +// Fail-open: returns nil on DB errors or missing context (cron, subagent). +func CheckCronPermission(ctx context.Context, permStore ConfigPermissionStore) error { + if permStore == nil { + return nil + } + userID := UserIDFromContext(ctx) + if !strings.HasPrefix(userID, "group:") && !strings.HasPrefix(userID, "guild:") { + return nil // not a group context + } + agentID := AgentIDFromContext(ctx) + if agentID == uuid.Nil { + return nil // no agent context + } + senderID := SenderIDFromContext(ctx) + if senderID == "" { + return nil // system context (cron, subagent) + } + numericID := strings.SplitN(senderID, "|", 2)[0] + + // Check cron-specific permission first. + allowed, err := permStore.CheckPermission(ctx, agentID, userID, ConfigTypeCron, numericID) + if err != nil { + return nil // fail-open + } + if allowed { + return nil + } + // Fall back to file_writer (implies full mutation access). + allowed, err = permStore.CheckPermission(ctx, agentID, userID, ConfigTypeFileWriter, numericID) + if err != nil { + return nil // fail-open + } + if !allowed { + return fmt.Errorf("permission denied: only users with cron or file_writer permission can manage cron jobs in group chats") + } + return nil +} diff --git a/internal/store/context.go b/internal/store/context.go index afaa9fa1..2b0cbe7d 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -39,6 +39,9 @@ const ( TenantSlugKey contextKey = "goclaw_tenant_slug" // RoleKey is the context key for the caller's permission role (e.g. "admin", "operator", "viewer"). RoleKey contextKey = "goclaw_role" + // CredentialUserIDKey holds the resolved tenant user identity for credential lookups. + // Falls back to UserIDFromContext if not set. + CredentialUserIDKey contextKey = "goclaw_credential_user_id" ) // WithShellDenyGroups returns a new context with shell deny group overrides. @@ -73,6 +76,23 @@ func UserIDFromContext(ctx context.Context) string { return "" } +// WithCredentialUserID returns a new context with the resolved tenant user identity for credential lookups. +func WithCredentialUserID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, CredentialUserIDKey, id) +} + +// CredentialUserIDFromContext returns the resolved identity for credential lookups. +// Falls back to RunContext.CredentialUserID, then UserIDFromContext. +func CredentialUserIDFromContext(ctx context.Context) string { + if v, ok := ctx.Value(CredentialUserIDKey).(string); ok && v != "" { + return v + } + if rc := RunContextFromCtx(ctx); rc != nil && rc.CredentialUserID != "" { + return rc.CredentialUserID + } + return UserIDFromContext(ctx) +} + // WithAgentID returns a new context with the given agent UUID. func WithAgentID(ctx context.Context, id uuid.UUID) context.Context { return context.WithValue(ctx, AgentIDKey, id) diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index 706a4925..545d19a3 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -564,3 +564,26 @@ func (s *PGTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time. } return res.RowsAffected() } + +// RecoverStaleRunningTraces marks traces stuck in "running" since before cutoff as "error". +// Also recovers their stuck spans. Called on startup to fix orphans from crashes. +func (s *PGTracingStore) RecoverStaleRunningTraces(ctx context.Context, cutoff time.Time) (int64, error) { + // Recover stuck spans first. + _, err := s.db.ExecContext(ctx, + `UPDATE spans SET status = 'error', error = 'recovered: server restart', + end_time = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - start_time))::int * 1000 + WHERE status = 'running' AND start_time < $1`, cutoff) + if err != nil { + return 0, fmt.Errorf("recover stale spans: %w", err) + } + + res, err := s.db.ExecContext(ctx, + `UPDATE traces SET status = 'error', + error = 'recovered: stuck in running state (server restart)', + end_time = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - start_time))::int * 1000 + WHERE status = 'running' AND start_time < $1`, cutoff) + if err != nil { + return 0, fmt.Errorf("recover stale running traces: %w", err) + } + return res.RowsAffected() +} diff --git a/internal/store/run_context.go b/internal/store/run_context.go index 46c79216..b95d2a62 100644 --- a/internal/store/run_context.go +++ b/internal/store/run_context.go @@ -21,12 +21,13 @@ type runContextKey struct{} // accessor functions (which fall back to individual keys when RunContext is absent). type RunContext struct { // Identity - AgentID uuid.UUID - AgentKey string - TenantID uuid.UUID - UserID string - AgentType string - SenderID string + AgentID uuid.UUID + AgentKey string + TenantID uuid.UUID + UserID string + CredentialUserID string // resolved tenant user for credential lookups (empty = use UserID) + AgentType string + SenderID string // Flags SelfEvolve bool diff --git a/internal/store/sqlitestore/tracing.go b/internal/store/sqlitestore/tracing.go index c0947893..e7be4f19 100644 --- a/internal/store/sqlitestore/tracing.go +++ b/internal/store/sqlitestore/tracing.go @@ -251,6 +251,29 @@ func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff t return res.RowsAffected() } +// RecoverStaleRunningTraces marks traces stuck in "running" since before cutoff as "error". +// Also recovers their stuck spans. Called on startup to fix orphans from crashes. +func (s *SQLiteTracingStore) RecoverStaleRunningTraces(ctx context.Context, cutoff time.Time) (int64, error) { + // Recover stuck spans first. + _, err := s.db.ExecContext(ctx, + `UPDATE spans SET status = 'error', error = 'recovered: server restart', + end_time = datetime('now'), duration_ms = CAST((julianday('now') - julianday(start_time)) * 86400000 AS INTEGER) + WHERE status = 'running' AND start_time < ?`, cutoff) + if err != nil { + return 0, fmt.Errorf("recover stale spans: %w", err) + } + + res, err := s.db.ExecContext(ctx, + `UPDATE traces SET status = 'error', + error = 'recovered: stuck in running state (server restart)', + end_time = datetime('now'), duration_ms = CAST((julianday('now') - julianday(start_time)) * 86400000 AS INTEGER) + WHERE status = 'running' AND start_time < ?`, cutoff) + if err != nil { + return 0, fmt.Errorf("recover stale running traces: %w", err) + } + return res.RowsAffected() +} + // ListCodexPoolSpans is not supported in SQLite (Codex pool is a standard-edition feature). func (s *SQLiteTracingStore) ListCodexPoolSpans(_ context.Context, _, _ uuid.UUID, _ []string, _ int) ([]store.CodexPoolSpan, error) { return nil, nil diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 961028e1..93a2af04 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -159,6 +159,9 @@ type TracingStore interface { // Maintenance DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) + // RecoverStaleRunningTraces marks traces stuck in "running" since before cutoff as "error". + // Returns count of recovered traces. Called on startup to fix orphans from crashes. + RecoverStaleRunningTraces(ctx context.Context, cutoff time.Time) (int64, error) // ListCodexPoolSpans returns recent LLM call spans for agents using Codex OAuth pool providers. ListCodexPoolSpans(ctx context.Context, agentID, tenantID uuid.UUID, poolProviders []string, limit int) ([]CodexPoolSpan, error) diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index a38d3833..aa5e47ab 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -42,8 +42,71 @@ func parseCommandBinary(command string) (binary string, args []string, err error return words[0], words[1:], nil } +// detectUnquotedShellOperators scans a command string for shell metacharacters +// that appear OUTSIDE of single or double quotes. This prevents false positives +// when argument values contain characters like | (e.g. --jq '.[0] | .name'). +// Returns the list of detected operators, or nil if the command is clean. +func detectUnquotedShellOperators(command string) []string { + unquoted := extractUnquotedSegments(command) + if unquoted == "" { + return nil + } + return detectShellOperators(unquoted) +} + +// extractUnquotedSegments returns a string containing only the characters +// from command that are outside of single-quoted and double-quoted segments. +// Backslash escaping is handled both inside double quotes (\") and outside +// quotes (\' \" \\) to match go-shellwords parsing behavior — without this, +// \" outside quotes would incorrectly enter double-quote mode and hide +// subsequent shell operators from detection. +func extractUnquotedSegments(command string) string { + var buf strings.Builder + buf.Grow(len(command)) + + inSingle := false + inDouble := false + for i := 0; i < len(command); i++ { + ch := command[i] + switch { + case inSingle: + if ch == '\'' { + inSingle = false + } + case inDouble: + if ch == '\\' && i+1 < len(command) { + i++ // skip escaped character inside double quotes + } else if ch == '"' { + inDouble = false + } + default: + switch ch { + case '\\': + // Backslash outside quotes escapes the next character, preventing + // it from being treated as a quote delimiter. Both the backslash + // and the escaped character are emitted as unquoted content so + // that operator detection still sees them (e.g. \; remains visible). + buf.WriteByte(ch) + if i+1 < len(command) { + i++ + buf.WriteByte(command[i]) + } + case '\'': + inSingle = true + case '"': + inDouble = true + default: + buf.WriteByte(ch) + } + } + } + return buf.String() +} + // detectShellOperators scans a raw command string for shell metacharacters. // Returns the list of detected operators, or nil if the command is clean. +// NOTE: This function does not respect quoting — use detectUnquotedShellOperators +// for credentialed exec where argument values may contain metacharacters. func detectShellOperators(command string) []string { matches := shellOperatorPattern.FindAllString(command, -1) if len(matches) == 0 { @@ -102,17 +165,20 @@ func matchesBinaryDeny(args []string, denyPatternsJSON json.RawMessage) string { // executeCredentialed runs a CLI command in Direct Exec Mode (no shell). // Credentials are injected as env vars into the child process only. +// rawCommand is the original command string before shell-word parsing (preserves quoting). func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCLIBinary, - binary string, args []string, cwd string, sandboxKey string) *Result { + binary string, args []string, cwd string, sandboxKey string, rawCommand string) *Result { // Step 0: Reject NUL bytes (defense-in-depth — also checked in Execute()). - rawCommand := binary + " " + strings.Join(args, " ") if strings.ContainsRune(rawCommand, '\x00') { return ErrorResult("command contains invalid NUL byte") } - // Step 1: Check for shell operators (early detection for clear error) - if ops := detectShellOperators(rawCommand); len(ops) > 0 { + // Step 1: Check for shell operators in the ORIGINAL command (preserves quoting). + // We check the raw command string (before shell-word parsing) so that characters + // inside quoted argument values (e.g. | in --jq '.[0] | ...') are not falsely flagged. + // Only top-level (unquoted) shell operators indicate actual command chaining attempts. + if ops := detectUnquotedShellOperators(rawCommand); len(ops) > 0 { return credentialedShellOperatorError(rawCommand, ops) } @@ -296,17 +362,19 @@ func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string) agentIDPtr = &agentID } // Pass userID for per-user credential resolution (LEFT JOIN, zero extra queries). - userID := store.UserIDFromContext(ctx) + // Uses CredentialUserIDFromContext to pick up merged tenant user identity + // (falls back to UserIDFromContext when not set). + userID := store.CredentialUserIDFromContext(ctx) cred, err := t.secureCLIStore.LookupByBinary(ctx, binary, agentIDPtr, userID) if err != nil { slog.Warn("secure_cli.lookup: query failed", "binary", binary, "agent_id", agentID, "error", err) return nil, "", nil } if cred == nil { - slog.Warn("secure_cli.lookup: no credential found", "binary", binary, "agent_id", agentID) + slog.Debug("secure_cli.lookup: no credential found", "binary", binary, "agent_id", agentID) return nil, "", nil } - slog.Info("secure_cli.lookup: found credential", "binary", binary, "cred_id", cred.ID, "env_size", len(cred.EncryptedEnv)) + slog.Debug("secure_cli.lookup: found credential", "binary", binary, "cred_id", cred.ID, "env_size", len(cred.EncryptedEnv)) return cred, binary, args } diff --git a/internal/tools/credentialed_exec_test.go b/internal/tools/credentialed_exec_test.go new file mode 100644 index 00000000..6e950867 --- /dev/null +++ b/internal/tools/credentialed_exec_test.go @@ -0,0 +1,122 @@ +package tools + +import "testing" + +func TestDetectShellOperators(t *testing.T) { + tests := []struct { + name string + command string + want int // number of detected operators + }{ + {"clean command", "gh api repos/foo/bar", 0}, + {"pipe operator", "gh api foo | jq .", 1}, + {"semicolon", "echo a; echo b", 1}, + {"ampersand", "cmd1 && cmd2", 1}, + {"redirect", "cmd > /tmp/out", 1}, + {"backtick", "echo `whoami`", 1}, + {"subshell", "echo $(whoami)", 1}, + {"multiple operators", "cmd1 | cmd2 && cmd3", 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ops := detectShellOperators(tt.command) + if len(ops) != tt.want { + t.Errorf("detectShellOperators(%q) = %v (len %d), want len %d", tt.command, ops, len(ops), tt.want) + } + }) + } +} + +func TestExtractUnquotedSegments(t *testing.T) { + tests := []struct { + name string + command string + want string + }{ + {"no quotes", "gh api foo", "gh api foo"}, + {"single quoted pipe", "gh --jq '.[0] | .name'", "gh --jq "}, + {"double quoted pipe", `gh --jq ".[0] | .name"`, "gh --jq "}, + {"mixed quotes", `gh --jq '.[0] | .a' --format "b | c"`, "gh --jq --format "}, + {"escaped quote in double", `gh "say \"hello\""`, "gh "}, + {"empty single quotes", "gh ''", "gh "}, + {"unquoted metachar", "gh api foo | jq", "gh api foo | jq"}, + // Backslash escape outside quotes: \" should NOT start double-quoting + {"escaped dquote outside", `gh api \"foo | bar\"`, `gh api \"foo | bar\"`}, + {"escaped squote outside", `gh api \'foo | bar\'`, `gh api \'foo | bar\'`}, + {"double backslash", `gh api \\arg`, `gh api \\arg`}, + {"backslash at end", `gh api foo\`, `gh api foo\`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractUnquotedSegments(tt.command) + if got != tt.want { + t.Errorf("extractUnquotedSegments(%q) = %q, want %q", tt.command, got, tt.want) + } + }) + } +} + +func TestDetectUnquotedShellOperators(t *testing.T) { + tests := []struct { + name string + command string + want int + }{ + // Should NOT detect (inside quotes) + {"pipe in single quotes", "gh api repos/foo --jq '.[0] | .name'", 0}, + {"pipe in double quotes", `gh api repos/foo --jq ".[0] | .name"`, 0}, + {"semicolon in quotes", `echo 'a; b'`, 0}, + {"backtick in single quotes", "echo 'hello `world`'", 0}, + {"complex jq", `gh api repos/org/repo/commits --jq '.[0] | "SHA: \(.sha)\nAuthor: \(.commit.author.name)"'`, 0}, + // Should detect (outside quotes) + {"unquoted pipe", "gh api foo | jq .", 1}, + {"unquoted semicolon", "echo a; echo b", 1}, + {"mixed: quoted safe + unquoted unsafe", "gh --jq '.[0] | .x' | cat", 1}, + {"redirect after quotes", "gh api foo --jq '.x' > out.json", 1}, + // Escaped quotes outside quotes: operators after \" must still be detected + // (backslash prevents " from starting a quoted section) + {"escaped dquote then pipe", `gh \"arg\" | env`, 1}, + {"escaped dquote with content pipe", `gh api \"foo | bar\"`, 1}, + {"escaped squote then pipe", `gh api \'foo | bar\'`, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ops := detectUnquotedShellOperators(tt.command) + if len(ops) != tt.want { + t.Errorf("detectUnquotedShellOperators(%q) = %v (len %d), want len %d", tt.command, ops, len(ops), tt.want) + } + }) + } +} + +func TestParseCommandBinary(t *testing.T) { + tests := []struct { + name string + command string + wantBinary string + wantArgs int + wantErr bool + }{ + {"simple", "gh api foo", "gh", 2, false}, + {"with quotes", "gh api --jq '.[0] | .name'", "gh", 3, false}, + {"empty", "", "", 0, true}, + {"single binary", "gh", "gh", 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + binary, args, err := parseCommandBinary(tt.command) + if (err != nil) != tt.wantErr { + t.Errorf("parseCommandBinary(%q) err = %v, wantErr %v", tt.command, err, tt.wantErr) + return + } + if !tt.wantErr { + if binary != tt.wantBinary { + t.Errorf("binary = %q, want %q", binary, tt.wantBinary) + } + if len(args) != tt.wantArgs { + t.Errorf("args len = %d, want %d (args: %v)", len(args), tt.wantArgs, args) + } + } + }) + } +} diff --git a/internal/tools/cron.go b/internal/tools/cron.go index 7eaab3ad..f8a59b5e 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -142,10 +142,10 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result { return ErrorResult("action parameter is required") } - // Group write permission check for mutation actions + // Group cron permission check for mutation actions if t.permStore != nil && (action == "add" || action == "update" || action == "remove") { - if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil { - return ErrorResult("permission denied: only file writers can manage cron jobs in group chats") + if err := store.CheckCronPermission(ctx, t.permStore); err != nil { + return ErrorResult("permission denied: only users with cron or file_writer permission can manage cron jobs in group chats") } } diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 33b2b9db..dbc9bb8e 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -31,30 +31,31 @@ func DefaultDenyPatterns() []*regexp.Regexp { // ExecTool executes shell commands, optionally inside a sandbox container. type ExecTool struct { - workspace string + workspace string timeout time.Duration - pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths) - denyExemptions []string // substrings that exempt a command from deny + pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths) + pathDenyRoots []string // raw deny roots for nested workspace exemptions + denyExemptions []string // substrings that exempt a command from deny restrict bool sandboxMgr sandbox.Manager // nil = no sandbox, execute on host approvalMgr *ExecApprovalManager // nil = no approval needed agentID string // for approval request context - secureCLIStore store.SecureCLIStore // nil = no credentialed exec + secureCLIStore store.SecureCLIStore // nil = no credentialed exec } // NewExecTool creates an exec tool that runs commands directly on the host. func NewExecTool(workspace string, restrict bool) *ExecTool { return &ExecTool{ workspace: workspace, - timeout: 60 * time.Second, - restrict: restrict, + timeout: 60 * time.Second, + restrict: restrict, } } // NewSandboxedExecTool creates an exec tool that routes commands through a sandbox container. func NewSandboxedExecTool(workspace string, restrict bool, mgr sandbox.Manager) *ExecTool { return &ExecTool{ - workspace: workspace, + workspace: workspace, timeout: 300 * time.Second, // sandbox allows longer timeout restrict: restrict, sandboxMgr: mgr, @@ -70,6 +71,7 @@ func (t *ExecTool) DenyPaths(paths ...string) { for _, p := range paths { escaped := regexp.QuoteMeta(p) t.pathDenyPatterns = append(t.pathDenyPatterns, regexp.MustCompile(escaped)) + t.pathDenyRoots = append(t.pathDenyRoots, p) } } @@ -155,8 +157,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { allPatterns := make([]*regexp.Regexp, 0, len(groupPatterns)+len(t.pathDenyPatterns)) allPatterns = append(allPatterns, groupPatterns...) allPatterns = append(allPatterns, t.pathDenyPatterns...) + exemptions := append([]string{}, t.denyExemptions...) + exemptions = append(exemptions, t.dynamicPathExemptions(ctx)...) // Check for dangerous commands (applies to both host and sandbox). + wordFields := parseExecCommandWords(normalizedCommand) + pathBaseDir := ToolWorkspaceFromCtx(ctx) + if pathBaseDir == "" { + pathBaseDir = t.workspace + } for _, pattern := range allPatterns { if pattern.MatchString(normalizedCommand) { // Check if exemption applies. Only exempt if EVERY field that @@ -167,23 +176,20 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { // path traversal ("..") to prevent exemption escape. exempt := false trimmed := strings.TrimSpace(normalizedCommand) - fields := strings.Fields(trimmed) + fields := wordFields + if len(fields) == 0 { + fields = strings.Fields(trimmed) + } matchingFields := 0 exemptFields := 0 for _, field := range fields { - clean := strings.Trim(field, `"'`) + clean := strings.TrimSpace(field) if !pattern.MatchString(clean) { continue // field doesn't trigger this deny pattern } matchingFields++ - if strings.Contains(clean, "..") { - continue // path traversal — never exempt - } - for _, ex := range t.denyExemptions { - if strings.HasPrefix(clean, ex) { - exemptFields++ - break - } + if matchesAnyPathExemption(clean, exemptions, pathBaseDir) { + exemptFields++ } } // Exempt only if at least one field matched AND all matched fields are exempt. @@ -235,7 +241,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { } } sandboxKey := ToolSandboxKeyFromCtx(ctx) - return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey) + return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey, command) } // Exec approval check (matching TS exec-approval.ts pipeline) diff --git a/internal/tools/shell_deny_test.go b/internal/tools/shell_deny_test.go index 17b100cd..d32a58ab 100644 --- a/internal/tools/shell_deny_test.go +++ b/internal/tools/shell_deny_test.go @@ -2,7 +2,10 @@ package tools import ( "context" + "os" + "path/filepath" "regexp" + "slices" "strings" "testing" ) @@ -19,10 +22,10 @@ func TestBase64DecodeShellDeny(t *testing.T) { } allowed := []string{ - "base64 -w0 file.txt", // encode, no pipe to shell - "base64 -d file.txt", // decode without pipe to shell - "echo hello | base64", // encode - "base64 --decode file.txt", // decode without pipe to shell + "base64 -w0 file.txt", // encode, no pipe to shell + "base64 -d file.txt", // decode without pipe to shell + "echo hello | base64", // encode + "base64 --decode file.txt", // decode without pipe to shell } for _, cmd := range denied { @@ -98,7 +101,7 @@ func TestDestructiveOpsGaps(t *testing.T) { ) mustAllow(t, patterns, - "halting the process", // "halt" inside word + "halting the process", // "halt" inside word "initialize", // "init" inside word "initial setup", // "init" inside word "init_db", // no space+digit after init @@ -171,12 +174,18 @@ func TestExecute_RejectsNULByte(t *testing.T) { } func TestPathExemptions(t *testing.T) { + dataDir := t.TempDir() + workspace := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + tool := &ExecTool{ - workspace: "/workspace", + workspace: workspace, restrict: false, } - tool.DenyPaths("/app/data", ".goclaw/") - tool.AllowPathExemptions(".goclaw/skills-store/", "/app/data/skills-store/") + tool.DenyPaths(dataDir, ".goclaw/") + tool.AllowPathExemptions(".goclaw/skills-store/", filepath.Join(dataDir, "skills-store")+"/") cases := []struct { name string @@ -334,30 +343,24 @@ func TestPathExemptions(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - normalizedCmd := normalizeCommand(tc.cmd) + normalizedCmd := strings.ReplaceAll(normalizeCommand(tc.cmd), "/app/data", dataDir) denied := false for _, pattern := range allPatterns { if !pattern.MatchString(normalizedCmd) { continue } // Replicate per-field exemption logic from Execute() - fields := strings.Fields(strings.TrimSpace(normalizedCmd)) + fields := parseExecCommandWords(strings.TrimSpace(normalizedCmd)) matchingFields := 0 exemptFields := 0 for _, field := range fields { - clean := strings.Trim(field, `"'`) + clean := strings.TrimSpace(field) if !pattern.MatchString(clean) { continue } matchingFields++ - if strings.Contains(clean, "..") { - continue // traversal — never exempt - } - for _, ex := range tool.denyExemptions { - if strings.HasPrefix(clean, ex) { - exemptFields++ - break - } + if matchesAnyPathExemption(clean, tool.denyExemptions, workspace) { + exemptFields++ } } exempt := matchingFields > 0 && exemptFields == matchingFields @@ -381,11 +384,16 @@ func TestPathExemptions(t *testing.T) { // path and an exempt path in different arguments is correctly denied. // Per-field matching ensures the non-exempt field causes denial. func TestPathExemptions_MixedArgs(t *testing.T) { + dataDir := t.TempDir() + workspace := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } tool := &ExecTool{} - tool.DenyPaths("/app/data") - tool.AllowPathExemptions("/app/data/skills-store/") + tool.DenyPaths(dataDir) + tool.AllowPathExemptions(filepath.Join(dataDir, "skills-store") + "/") - cmd := "cat /app/data/config.json /app/data/skills-store/tool.py" + cmd := "cat " + filepath.Join(dataDir, "config.json") + " " + filepath.Join(dataDir, "skills-store", "tool.py") normalizedCmd := normalizeCommand(cmd) denied := false @@ -393,23 +401,17 @@ func TestPathExemptions_MixedArgs(t *testing.T) { if !pattern.MatchString(normalizedCmd) { continue } - fields := strings.Fields(strings.TrimSpace(normalizedCmd)) + fields := parseExecCommandWords(strings.TrimSpace(normalizedCmd)) matchingFields := 0 exemptFields := 0 for _, field := range fields { - clean := strings.Trim(field, `"'`) + clean := strings.TrimSpace(field) if !pattern.MatchString(clean) { continue } matchingFields++ - if strings.Contains(clean, "..") { - continue - } - for _, ex := range tool.denyExemptions { - if strings.HasPrefix(clean, ex) { - exemptFields++ - break - } + if matchesAnyPathExemption(clean, tool.denyExemptions, workspace) { + exemptFields++ } } if matchingFields == 0 || exemptFields != matchingFields { @@ -422,6 +424,195 @@ func TestPathExemptions_MixedArgs(t *testing.T) { } } +func TestExecute_AllowsCurrentWorkspaceNestedUnderDeniedRoot(t *testing.T) { + dataDir := t.TempDir() + workspace := filepath.Join(dataDir, "teams", "team-123") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + tool := NewExecTool("/workspace", false) + tool.DenyPaths(dataDir) + + target := filepath.Join(workspace, ".uploads", "report.png") + ctx := WithToolWorkspace(context.Background(), workspace) + result := tool.Execute(ctx, map[string]any{ + "command": "printf '%s' " + target, + }) + + if strings.Contains(result.ForLLM, "command denied by safety policy") { + t.Fatalf("expected nested workspace path to bypass dataDir deny, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, target) { + t.Fatalf("expected command output to include workspace path, got: %s", result.ForLLM) + } +} + +func TestExecute_AllowsCurrentTeamWorkspaceNestedUnderDeniedRoot(t *testing.T) { + dataDir := t.TempDir() + teamWorkspace := filepath.Join(dataDir, "tenants", "acme", "teams", "team-123") + if err := os.MkdirAll(teamWorkspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + tool := NewExecTool("/workspace", false) + tool.DenyPaths(dataDir) + + target := filepath.Join(teamWorkspace, "report.png") + ctx := WithToolWorkspace(context.Background(), t.TempDir()) + ctx = WithToolTeamWorkspace(ctx, teamWorkspace) + result := tool.Execute(ctx, map[string]any{ + "command": "printf '%s' " + target, + }) + + if strings.Contains(result.ForLLM, "command denied by safety policy") { + t.Fatalf("expected nested team workspace path to bypass dataDir deny, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, target) { + t.Fatalf("expected command output to include team workspace path, got: %s", result.ForLLM) + } +} + +func TestExecute_DoesNotExemptOtherDataDirPaths(t *testing.T) { + dataDir := t.TempDir() + workspace := filepath.Join(dataDir, "teams", "team-123") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + tool := NewExecTool("/workspace", false) + tool.DenyPaths(dataDir) + + ctx := WithToolWorkspace(context.Background(), workspace) + result := tool.Execute(ctx, map[string]any{ + "command": "printf '%s' " + filepath.Join(dataDir, "config.json"), + }) + + if !strings.Contains(result.ForLLM, "command denied by safety policy") { + t.Fatalf("expected unrelated dataDir path to remain denied, got: %s", result.ForLLM) + } +} + +func TestExecute_DoesNotExemptWorkspaceLocalDotGoclawPaths(t *testing.T) { + dataDir := t.TempDir() + workspace := filepath.Join(dataDir, "teams", "team-123") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + tool := NewExecTool("/workspace", false) + tool.DenyPaths(dataDir, ".goclaw/") + + ctx := WithToolWorkspace(context.Background(), workspace) + result := tool.Execute(ctx, map[string]any{ + "command": "printf '%s' " + filepath.Join(workspace, ".goclaw", "secrets.json"), + }) + + if !strings.Contains(result.ForLLM, "command denied by safety policy") { + t.Fatalf("expected workspace-local .goclaw path to remain denied, got: %s", result.ForLLM) + } +} + +func TestExecute_AllowsQuotedAndPrefixedUploadArguments(t *testing.T) { + dataDir := t.TempDir() + workspace := filepath.Join(dataDir, "teams", "team-123") + if err := os.MkdirAll(filepath.Join(workspace, ".uploads"), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := filepath.Join(workspace, ".uploads", "Quarterly Report.png") + if err := os.WriteFile(target, []byte("ok"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + tool := NewExecTool("/workspace", false) + tool.DenyPaths(dataDir) + + ctx := WithToolWorkspace(context.Background(), workspace) + result := tool.Execute(ctx, map[string]any{ + "command": "printf '%s' file=@\"" + target + "\"", + }) + if strings.Contains(result.ForLLM, "command denied by safety policy") { + t.Fatalf("expected quoted/prefixed upload argument to bypass deny, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "file=@"+target) { + t.Fatalf("expected output to contain prefixed path, got: %s", result.ForLLM) + } +} + +func TestExecute_DoesNotExemptSymlinkEscapeInsideTeamWorkspace(t *testing.T) { + if err := os.MkdirAll(t.TempDir(), 0755); err != nil { + t.Fatalf("TempDir setup error = %v", err) + } + dataDir := t.TempDir() + workspace := filepath.Join(dataDir, "personal") + teamWorkspace := filepath.Join(dataDir, "teams", "team-123") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.MkdirAll(teamWorkspace, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + protected := filepath.Join(dataDir, "config.json") + if err := os.WriteFile(protected, []byte("secret"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + linkPath := filepath.Join(teamWorkspace, "leak.txt") + if err := os.Symlink(protected, linkPath); err != nil { + t.Fatalf("Symlink() error = %v", err) + } + + tool := NewExecTool("/workspace", false) + tool.DenyPaths(dataDir) + + ctx := WithToolWorkspace(context.Background(), workspace) + ctx = WithToolTeamWorkspace(ctx, teamWorkspace) + result := tool.Execute(ctx, map[string]any{ + "command": "printf '%s' " + linkPath, + }) + + if !strings.Contains(result.ForLLM, "command denied by safety policy") { + t.Fatalf("expected symlink escape to remain denied, got: %s", result.ForLLM) + } +} + +func TestExecute_AllowsLegacyWorkspaceUploadsLayout(t *testing.T) { + dataDir := t.TempDir() + workspace := filepath.Join(dataDir, ".goclaw", "goclaw-workspace", "ws", "system") + legacyUploads := filepath.Join(workspace, "uploads") + if err := os.MkdirAll(legacyUploads, 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := filepath.Join(legacyUploads, "Quarterly Report.png") + if err := os.WriteFile(target, []byte("ok"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + tool := NewExecTool("/workspace", false) + tool.DenyPaths(dataDir, ".goclaw/") + + ctx := WithToolWorkspace(context.Background(), workspace) + result := tool.Execute(ctx, map[string]any{ + "command": "cp \"" + target + "\" /tmp/partner.png", + }) + + if strings.Contains(result.ForLLM, "command denied by safety policy") { + t.Fatalf("expected legacy uploads layout to bypass deny, got: %s", result.ForLLM) + } +} + +func TestPathAliasVariants_AppWorkspaceMirror(t *testing.T) { + got := pathAliasVariants("/app/workspace/glm-thuc-bo/ws/user/.uploads") + want := "/app/.goclaw/glm-thuc-bo/ws/user/.uploads" + if !slices.Contains(got, want) { + t.Fatalf("expected mirror variant %q in %v", want, got) + } +} + +func TestIsNestedUnderDeniedRoot_RelativeDotGoclaw(t *testing.T) { + tool := &ExecTool{pathDenyRoots: []string{".goclaw/"}} + if !tool.isNestedUnderDeniedRoot("/app/.goclaw/glm-thuc-bo/ws/user/.uploads") { + t.Fatal("expected absolute .goclaw path to be treated as nested under relative deny root") + } +} + func TestLimitedBuffer(t *testing.T) { t.Run("under limit", func(t *testing.T) { lb := &limitedBuffer{max: 100} diff --git a/internal/tools/shell_path_exemption.go b/internal/tools/shell_path_exemption.go new file mode 100644 index 00000000..36466ed8 --- /dev/null +++ b/internal/tools/shell_path_exemption.go @@ -0,0 +1,284 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + + shellwords "github.com/mattn/go-shellwords" +) + +// dynamicPathExemptions builds runtime exemptions for the active user's workspace +// upload directories and team workspace root. Only exempts paths that are nested +// under a denied root — paths outside deny roots don't need exemptions. +func (t *ExecTool) dynamicPathExemptions(ctx context.Context) []string { + var exemptions []string + seen := make(map[string]struct{}, 4) + workspace := ToolWorkspaceFromCtx(ctx) + teamWorkspace := ToolTeamWorkspaceFromCtx(ctx) + + var dirs []string + if teamWorkspace != "" { + dirs = append(dirs, teamWorkspace) + } + if workspace != "" && filepath.Clean(workspace) != filepath.Clean(teamWorkspace) { + dirs = append(dirs, filepath.Join(workspace, ".uploads")) + dirs = append(dirs, filepath.Join(workspace, "uploads")) + } + + for _, dir := range dirs { + if dir == "" || strings.Contains(dir, "..") { + continue + } + for _, variant := range pathAliasVariants(filepath.Clean(dir)) { + if !t.isNestedUnderDeniedRoot(variant) { + continue + } + for _, ex := range []string{variant, variant + string(filepath.Separator)} { + if _, ok := seen[ex]; ok { + continue + } + seen[ex] = struct{}{} + exemptions = append(exemptions, ex) + } + } + } + return exemptions +} + +// pathAliasVariants returns the path plus any known runtime alias mappings. +// On the claw server, /app/workspace is symlinked to /app/.goclaw at runtime, +// so both forms may appear in LLM-generated commands for the same physical path. +func pathAliasVariants(path string) []string { + variants := []string{path} + for _, mapping := range [][2]string{ + {"/app/workspace", "/app/.goclaw"}, + {"/app/.goclaw", "/app/workspace"}, + } { + from, to := mapping[0], mapping[1] + if path == from { + variants = append(variants, to) + continue + } + if strings.HasPrefix(path, from+string(filepath.Separator)) { + variants = append(variants, to+strings.TrimPrefix(path, from)) + } + } + return variants +} + +// isNestedUnderDeniedRoot checks whether path sits inside any of the configured +// deny roots. Supports both absolute roots (prefix match) and relative roots +// (e.g. ".goclaw/" — checked as a path component marker anywhere in path). +func (t *ExecTool) isNestedUnderDeniedRoot(path string) bool { + for _, root := range t.pathDenyRoots { + cleanRoot := filepath.Clean(root) + if cleanRoot == "." || cleanRoot == string(filepath.Separator) { + continue + } + if !filepath.IsAbs(cleanRoot) { + marker := string(filepath.Separator) + cleanRoot + string(filepath.Separator) + if strings.Contains(path, marker) { + return true + } + continue + } + if path == cleanRoot { + continue + } + if strings.HasPrefix(path, cleanRoot+string(filepath.Separator)) { + return true + } + } + return false +} + +// matchesPathExemption checks if a resolved path falls under any exemption prefix. +func matchesPathExemption(path string, exemptions []string) bool { + sep := string(filepath.Separator) + for _, ex := range exemptions { + if ex == "" { + continue + } + if path == ex { + return true + } + if strings.HasSuffix(ex, sep) { + if strings.HasPrefix(path, ex) { + return true + } + continue + } + if strings.HasPrefix(path, ex+sep) { + return true + } + } + return false +} + +// parseExecCommandWords splits a shell command into words using go-shellwords, +// handling quoted strings correctly. The command is first segmented by shell +// operators (;|&<>) to avoid cross-segment quoting confusion. +func parseExecCommandWords(command string) []string { + var words []string + for _, segment := range splitExecCommandSegments(command) { + parser := shellwords.NewParser() + parser.ParseBacktick = false + parser.ParseEnv = false + + segmentWords, err := parser.Parse(segment) + if err != nil || len(segmentWords) == 0 { + words = append(words, strings.Fields(segment)...) + continue + } + words = append(words, segmentWords...) + } + if len(words) == 0 { + return strings.Fields(command) + } + return words +} + +// splitExecCommandSegments splits a command string at shell operators (;|&<>) +// while respecting single and double quotes. Each segment can then be safely +// parsed by go-shellwords independently. +func splitExecCommandSegments(command string) []string { + var segments []string + start := 0 + inSingle := false + inDouble := false + + for i := 0; i < len(command); i++ { + ch := command[i] + switch { + case inSingle: + if ch == '\'' { + inSingle = false + } + case inDouble: + if ch == '\\' && i+1 < len(command) { + i++ + } else if ch == '"' { + inDouble = false + } + default: + switch ch { + case '\\': + if i+1 < len(command) { + i++ + } + case '\'': + inSingle = true + case '"': + inDouble = true + case ';', '|', '&', '<', '>', '\n', '\r': + if segment := strings.TrimSpace(command[start:i]); segment != "" { + segments = append(segments, segment) + } + start = i + 1 + } + } + } + + if tail := strings.TrimSpace(command[start:]); tail != "" { + segments = append(segments, tail) + } + return segments +} + +// extractPathCandidates extracts potential file paths from a shell word, +// stripping prefixes like "file=@" or "--input=" to find the actual path. +func extractPathCandidates(word string) []string { + if word == "" { + return nil + } + + queue := []string{word} + seen := make(map[string]struct{}, 4) + var out []string + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if current == "" { + continue + } + if _, ok := seen[current]; ok { + continue + } + seen[current] = struct{}{} + if looksLikePathCandidate(current) { + out = append(out, current) + } + for _, sep := range []string{"=", "@"} { + if idx := strings.Index(current, sep); idx >= 0 && idx+1 < len(current) { + queue = append(queue, current[idx+1:]) + } + } + } + return out +} + +// looksLikePathCandidate returns true if the string looks like a filesystem path. +func looksLikePathCandidate(s string) bool { + if s == "" { + return false + } + if filepath.IsAbs(s) { + return true + } + return strings.HasPrefix(s, "./") || + strings.HasPrefix(s, "../") || + strings.HasPrefix(s, ".uploads/") || + strings.HasPrefix(s, ".goclaw/") || + strings.HasPrefix(s, "teams/") || + strings.HasPrefix(s, "tenants/") || + strings.HasPrefix(s, "~/") || + strings.Contains(s, string(filepath.Separator)) +} + +// canonicalizeExecPath resolves a path to its canonical absolute form, +// following symlinks where possible. Falls back to ancestor-based resolution +// for paths that don't fully exist yet. +func canonicalizeExecPath(path, baseDir string) (string, error) { + if strings.HasPrefix(path, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(homeDir, strings.TrimPrefix(path, "~/")) + } + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + absPath, _ := filepath.Abs(filepath.Clean(path)) + if real, err := filepath.EvalSymlinks(absPath); err == nil { + return real, nil + } + return resolveThroughExistingAncestors(absPath) +} + +// matchesAnyPathExemption checks if any path candidate extracted from a shell +// word matches any exemption after canonicalization. Rejects path traversal. +func matchesAnyPathExemption(word string, exemptions []string, baseDir string) bool { + for _, candidate := range extractPathCandidates(word) { + if strings.Contains(candidate, "..") { + continue + } + realCandidate, err := canonicalizeExecPath(candidate, baseDir) + if err != nil { + continue + } + for _, exemption := range exemptions { + realExemption, err := canonicalizeExecPath(exemption, baseDir) + if err != nil { + continue + } + if matchesPathExemption(realCandidate, []string{realExemption}) { + return true + } + } + } + return false +} diff --git a/internal/tracing/collector.go b/internal/tracing/collector.go index a6ba4ec2..5b84c3f5 100644 --- a/internal/tracing/collector.go +++ b/internal/tracing/collector.go @@ -101,6 +101,7 @@ func (c *Collector) SetExporter(exp SpanExporter) { func (c *Collector) Start() { c.wg.Add(1) go c.flushLoop() + go c.recoverStaleTraces() // fix orphan traces from previous crash slog.Info("tracing collector started") } @@ -224,6 +225,26 @@ func (c *Collector) flushLoop() { } } +// recoverStaleTraces marks "running" traces older than 30 min as "error". +// Called once on startup to fix orphans left by a previous crash or stuck goroutine. +func (c *Collector) recoverStaleTraces() { + const staleThreshold = 30 * time.Minute + cutoff := time.Now().UTC().Add(-staleThreshold) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + recovered, err := c.store.RecoverStaleRunningTraces(ctx, cutoff) + if err != nil { + slog.Warn("tracing: failed to recover stale running traces", "error", err) + return + } + if recovered > 0 { + slog.Info("tracing: recovered stale running traces on startup", + "count", recovered, "older_than", cutoff.Format(time.RFC3339)) + } +} + // pruneOldTraces deletes traces and spans older than traceRetention. func (c *Collector) pruneOldTraces() { cutoff := time.Now().UTC().Add(-traceRetention) diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index c6ee4146..6a51dac7 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -15,6 +15,7 @@ import ( type Manager struct { mu sync.Mutex browser *rod.Browser + launcher *launcher.Launcher // retained for PID-based cleanup on crash refs *RefStore pages map[string]*rod.Page // targetID → page console map[string][]ConsoleMessage // targetID → console messages @@ -107,12 +108,7 @@ func (m *Manager) Start(ctx context.Context) error { } // Connection dead — clean up and reconnect m.logger.Info("browser connection lost, reconnecting") - m.closeTenantContextsLocked() - m.browser = nil - m.pages = make(map[string]*rod.Page) - m.console = make(map[string][]ConsoleMessage) - m.pageTenants = make(map[string]string) - m.refs = NewRefStore() + m.cleanupDeadBrowserLocked() } var controlURL string @@ -126,23 +122,45 @@ func (m *Manager) Start(ctx context.Context) error { controlURL = u m.logger.Info("connecting to remote Chrome", "cdp", controlURL, "remote", m.remoteURL) } else { - // Local Chrome — launch via rod launcher + // Local Chrome — launch via rod launcher with stability flags + launchCtx, launchCancel := context.WithTimeout(ctx, 30*time.Second) + defer launchCancel() + l := launcher.New(). + Context(launchCtx). + Leakless(true). Headless(m.headless). Set("disable-gpu"). Set("no-first-run"). - Set("no-default-browser-check") + Set("no-default-browser-check"). + Set("disable-dev-shm-usage"). + Set("disable-software-rasterizer"). + Set("disable-extensions"). + Set("disable-background-networking"). + Set("disable-renderer-backgrounding"). + Set("disable-background-timer-throttling"). + Set("disable-backgrounding-occluded-windows") u, err := l.Launch() if err != nil { return fmt.Errorf("launch Chrome: %w", err) } controlURL = u - m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless) + m.launcher = l + m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless, "pid", l.PID()) } - b := rod.New().ControlURL(controlURL) + connectCtx, connectCancel := context.WithTimeout(ctx, 15*time.Second) + defer connectCancel() + + b := rod.New().Context(connectCtx).ControlURL(controlURL) if err := b.Connect(); err != nil { + // If local launch succeeded but connect failed, kill the orphan process + if m.launcher != nil { + m.launcher.Kill() + m.launcher.Cleanup() + m.launcher = nil + } return fmt.Errorf("connect to Chrome: %w", err) } @@ -182,6 +200,12 @@ func (m *Manager) Stop(ctx context.Context) error { if m.remoteURL == "" { // Local Chrome — close the browser process err = m.browser.Close() + // Force-kill via launcher if retained + if m.launcher != nil { + m.launcher.Kill() + m.launcher.Cleanup() + m.launcher = nil + } } // Remote Chrome — just drop the connection; sidecar stays alive @@ -203,6 +227,23 @@ func (m *Manager) closeTenantContextsLocked() { m.tenantCtxs = make(map[string]*rod.Browser) } +// cleanupDeadBrowserLocked resets all state and kills any orphan Chrome process. +// Must be called with mu held. +func (m *Manager) cleanupDeadBrowserLocked() { + m.closeTenantContextsLocked() + if m.launcher != nil { + m.launcher.Kill() + m.launcher.Cleanup() + m.launcher = nil + } + m.browser = nil + m.pages = make(map[string]*rod.Page) + m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) + m.pageLastUsed = make(map[string]time.Time) + m.refs = NewRefStore() +} + // MasterTenantID is the well-known master tenant UUID string. // Pages opened without a tenant context or by the master tenant use the main browser directly. const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001" diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index c9ed2e68..52ddea7f 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -99,6 +99,10 @@ const ( EventZaloPersonalQRCode = "zalo.personal.qr.code" EventZaloPersonalQRDone = "zalo.personal.qr.done" + // WhatsApp QR login events (client-scoped, not broadcast). + EventWhatsAppQRCode = "whatsapp.qr.code" + EventWhatsAppQRDone = "whatsapp.qr.done" + // Tenant access revocation — forces affected user's UI to logout. EventTenantAccessRevoked = "tenant.access.revoked" ) diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 2d26490a..a76a74cd 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -183,4 +183,7 @@ const ( // Zalo Personal MethodZaloPersonalQRStart = "zalo.personal.qr.start" MethodZaloPersonalContacts = "zalo.personal.contacts" + + // WhatsApp + MethodWhatsAppQRStart = "whatsapp.qr.start" ) diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index a9067358..a401060b 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -161,7 +161,6 @@ "extraHint": "Only enabled and signed-in OpenAI Codex OAuth aliases can participate.", "emptyExtras": "Sign in another OpenAI Codex OAuth alias to unlock pooling.", "noReadyExtras": "Sign in and enable another OpenAI Codex OAuth alias to add it here.", - "clickToAdd": "Click to add", "extraSelectableHint": "Only enabled, signed-in OpenAI Codex OAuth aliases can enter the active pool.", "selectedAccountsLabel": "Accounts in Pool", "emptySelected": "No extra pool members selected yet.", @@ -607,10 +606,14 @@ "userIdPlaceholder": "Enter user ID (e.g. telegram:386246614)", "warning": "When sharing is enabled, all shared users can read and write files in the same workspace directory. Memory sharing is controlled separately. Context files remain isolated per user.", "memoryGroupLabel": "Memory & Knowledge Graph", + "memoryGroupDescription": "Control memory and knowledge graph isolation between users", "folderGroupLabel": "Workspace Folders", - "shareMemory": "Shared Memory & KG", - "shareMemoryTip": "All users access the same memory and knowledge graph. Independent of workspace folder sharing. When off, each user has their own isolated memory space.", - "shareMemoryNote": "Toggling this does not migrate data — existing per-user memories become inaccessible in shared mode." + "shareMemory": "Shared Memory", + "shareMemoryTip": "All users access the same memory store. When off, each user has their own isolated memory space. KG sharing is controlled separately below.", + "shareMemoryNote": "Toggling this does not migrate data — existing per-user memories become inaccessible in shared mode.", + "shareKG": "Shared Knowledge Graph", + "shareKGTip": "All users access the same knowledge graph. When off, each user has their own isolated KG with global canonical fallback for read operations.", + "shareKGNote": "Toggling this does not migrate data — existing per-user KG entities become inaccessible in shared mode." }, "compaction": { "title": "Compaction", diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index fe33498d..f9cca4b7 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -223,7 +223,6 @@ "help": "For webhook mode" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "Bridge URL" }, "api_server": { "label": "API Server URL", "help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default." @@ -416,6 +415,10 @@ "zaloPersonal": { "createLabel": "Create & Authenticate", "formBanner": "After creating, you'll authenticate via QR code and configure allowed users." + }, + "whatsapp": { + "createLabel": "Create & Scan QR", + "formBanner": "After creating, scan the QR code with WhatsApp to authenticate." } }, "fallback": { @@ -464,5 +467,21 @@ "retry": "Retry", "close": "Close", "skip": "Skip" + }, + "whatsapp": { + "loginSuccessLoading": "✅ WhatsApp connected! Loading channel...", + "waitingForQr": "Waiting for QR code...", + "scanHint": "Open WhatsApp → tap You → Linked Devices → Link a Device", + "initializing": "Initializing...", + "skip": "Skip", + "retry": "Retry", + "close": "Close", + "reauthTitle": "Link WhatsApp — {{name}}", + "reauthDescription": "Scan the QR code with WhatsApp to link your device.", + "alreadyLinked": "✅ Device already linked", + "alreadyLinkedDetail": "WhatsApp is connected. To link a different device, click Re-link — this will log out the current session.", + "relinkDevice": "Re-link Device", + "connectedSuccess": "✅ WhatsApp connected successfully!", + "tabQrCode": "QR Code" } } diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index bf57ebb9..2a1a042b 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -161,7 +161,6 @@ "extraHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được tham gia.", "emptyExtras": "Đăng nhập thêm một bí danh OpenAI Codex OAuth để bật pooling.", "noReadyExtras": "Đăng nhập và bật thêm một bí danh OpenAI Codex OAuth để thêm vào đây.", - "clickToAdd": "Bấm để thêm", "extraSelectableHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được vào active pool.", "selectedAccountsLabel": "Tài khoản trong pool", "emptySelected": "Chưa chọn pool member bổ sung nào.", @@ -607,10 +606,14 @@ "userIdPlaceholder": "Nhập user ID (vd: telegram:386246614)", "warning": "Khi bật chia sẻ, tất cả người dùng được chia sẻ có thể đọc và ghi file trong cùng thư mục workspace. Chia sẻ bộ nhớ được cài đặt riêng. File ngữ cảnh vẫn được cô lập theo từng người dùng.", "memoryGroupLabel": "Memory & Knowledge Graph", + "memoryGroupDescription": "Kiểm soát cô lập memory và knowledge graph giữa các người dùng", "folderGroupLabel": "Thư mục Workspace", - "shareMemory": "Chia sẻ Memory & KG", - "shareMemoryTip": "Tất cả người dùng truy cập chung Memory và Knowledge Graph. Độc lập với việc chia sẻ thư mục workspace. Khi tắt, mỗi người dùng có vùng nhớ riêng.", - "shareMemoryNote": "Bật/tắt không di chuyển dữ liệu — memory per-user trước đó sẽ không truy cập được trong chế độ shared." + "shareMemory": "Chia sẻ Memory", + "shareMemoryTip": "Tất cả người dùng truy cập chung Memory. Khi tắt, mỗi người dùng có vùng nhớ riêng. Chia sẻ KG được cài đặt riêng bên dưới.", + "shareMemoryNote": "Bật/tắt không di chuyển dữ liệu — memory per-user trước đó sẽ không truy cập được trong chế độ shared.", + "shareKG": "Chia sẻ Knowledge Graph", + "shareKGTip": "Tất cả người dùng truy cập chung Knowledge Graph. Khi tắt, mỗi người dùng có KG riêng với fallback đọc từ global canonical.", + "shareKGNote": "Bật/tắt không di chuyển dữ liệu — KG per-user trước đó sẽ không truy cập được trong chế độ shared." }, "compaction": { "title": "Nén ngữ cảnh", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 32517a95..b545d811 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -213,7 +213,6 @@ "encrypt_key": { "label": "Khóa mã hóa", "help": "Cho chế độ webhook" }, "verification_token": { "label": "Token xác minh", "help": "Cho chế độ webhook" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "URL Bridge" }, "api_server": { "label": "URL máy chủ API", "help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định." }, "proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" }, "dm_policy": { "label": "Chính sách DM" }, @@ -331,6 +330,10 @@ "zaloPersonal": { "createLabel": "Tạo & Xác thực", "formBanner": "Sau khi tạo, bạn sẽ xác thực bằng mã QR và cấu hình người dùng được phép." + }, + "whatsapp": { + "createLabel": "Tạo & Quét QR", + "formBanner": "Sau khi tạo, quét mã QR bằng WhatsApp để xác thực." } }, "fallback": { @@ -379,5 +382,21 @@ "retry": "Thử lại", "close": "Đóng", "skip": "Bỏ qua" + }, + "whatsapp": { + "loginSuccessLoading": "✅ Đã kết nối WhatsApp! Đang tải channel...", + "waitingForQr": "Đang chờ mã QR...", + "scanHint": "Mở WhatsApp → nhấn Bạn → Thiết bị đã liên kết → Liên kết thiết bị", + "initializing": "Đang khởi tạo...", + "skip": "Bỏ qua", + "retry": "Thử lại", + "close": "Đóng", + "reauthTitle": "Liên kết WhatsApp — {{name}}", + "reauthDescription": "Quét mã QR bằng WhatsApp để liên kết thiết bị.", + "alreadyLinked": "✅ Thiết bị đã được liên kết", + "alreadyLinkedDetail": "WhatsApp đã kết nối. Để liên kết thiết bị khác, nhấn Liên kết lại — thao tác này sẽ đăng xuất phiên hiện tại.", + "relinkDevice": "Liên kết lại", + "connectedSuccess": "✅ Kết nối WhatsApp thành công!", + "tabQrCode": "Mã QR" } } diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 3277cba5..422eb1c3 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -161,7 +161,6 @@ "extraHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能参与。", "emptyExtras": "再登录一个 OpenAI Codex OAuth 别名即可启用 pooling。", "noReadyExtras": "请先登录并启用另一个 OpenAI Codex OAuth 别名,然后再添加到这里。", - "clickToAdd": "点击添加", "extraSelectableHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能进入 active pool。", "selectedAccountsLabel": "池中的账户", "emptySelected": "还没有选择额外的池成员。", @@ -607,10 +606,14 @@ "userIdPlaceholder": "输入用户ID(例如 telegram:386246614)", "warning": "启用共享后,所有共享用户可以读写同一工作区目录中的文件。记忆共享单独控制。上下文文件仍按用户隔离。", "memoryGroupLabel": "记忆与知识图谱", + "memoryGroupDescription": "控制用户之间的记忆和知识图谱隔离", "folderGroupLabel": "工作区文件夹", - "shareMemory": "共享记忆与知识图谱", - "shareMemoryTip": "所有用户访问相同的记忆和知识图谱。与工作区文件夹共享无关。关闭时,每个用户拥有独立的记忆空间。", - "shareMemoryNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立记忆将无法访问。" + "shareMemory": "共享记忆", + "shareMemoryTip": "所有用户访问相同的记忆存储。关闭时,每个用户拥有独立的记忆空间。知识图谱共享在下方单独控制。", + "shareMemoryNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立记忆将无法访问。", + "shareKG": "共享知识图谱", + "shareKGTip": "所有用户访问相同的知识图谱。关闭时,每个用户拥有独立的知识图谱,读取操作会回退到全局规范数据。", + "shareKGNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立知识图谱实体将无法访问。" }, "compaction": { "title": "压缩", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 7dd7a31c..7742cb31 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -213,7 +213,6 @@ "encrypt_key": { "label": "加密密钥", "help": "用于Webhook模式" }, "verification_token": { "label": "验证Token", "help": "用于Webhook模式" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "Bridge URL" }, "api_server": { "label": "API 服务器 URL", "help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。" }, "proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" }, "dm_policy": { "label": "私聊策略" }, @@ -331,6 +330,10 @@ "zaloPersonal": { "createLabel": "创建并认证", "formBanner": "创建后,您将通过二维码进行认证并配置允许的用户。" + }, + "whatsapp": { + "createLabel": "创建并扫码", + "formBanner": "创建后,请用 WhatsApp 扫描二维码完成认证。" } }, "fallback": { @@ -379,5 +382,21 @@ "retry": "重试", "close": "关闭", "skip": "跳过" + }, + "whatsapp": { + "loginSuccessLoading": "✅ WhatsApp 已连接!正在加载 channel...", + "waitingForQr": "正在等待二维码...", + "scanHint": "打开 WhatsApp → 点击【您】→【已连接的设备】→【连接设备】", + "initializing": "初始化中...", + "skip": "跳过", + "retry": "重试", + "close": "关闭", + "reauthTitle": "连接 WhatsApp — {{name}}", + "reauthDescription": "使用 WhatsApp 扫描二维码以连接设备。", + "alreadyLinked": "✅ 设备已连接", + "alreadyLinkedDetail": "WhatsApp 已连接。如需连接其他设备,请点击【重新连接】——这将退出当前会话。", + "relinkDevice": "重新连接", + "connectedSuccess": "✅ WhatsApp 连接成功!", + "tabQrCode": "二维码" } } diff --git a/ui/web/src/lib/format-user-label.ts b/ui/web/src/lib/format-user-label.ts index 704878ae..c63750fe 100644 --- a/ui/web/src/lib/format-user-label.ts +++ b/ui/web/src/lib/format-user-label.ts @@ -25,7 +25,11 @@ export function formatUserLabel(userId: string, resolve?: Resolver): string { } } - // Fallback: prefix numeric IDs with #, string IDs with @ - if (/^\d+$/.test(userId)) return `#${userId}`; + // Fallback: prefix numeric IDs with # + if (/^-?\d+$/.test(userId)) return `#${userId}`; + + // Truncate long opaque IDs (e.g. oc_295eb80d325c976cbeb4a779e2010518) + if (userId.length > 20) return `${userId.slice(0, 10)}…${userId.slice(-4)}`; + return userId; } diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx index b00a0e8d..450a11a6 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx @@ -52,23 +52,40 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec

{t(`${s}.memoryGroupLabel`)}

-

{t(`${s}.shareMemoryTip`)}

+

{t(`${s}.memoryGroupDescription`)}

-
-
- {t(`${s}.shareMemory`)} - onChange({ ...value, share_memory: v })} - /> +
+
+
+ {t(`${s}.shareMemory`)} + onChange({ ...value, share_memory: v })} + /> +
+ {value.share_memory && ( +

+ {t(`${s}.shareMemoryNote`)} +

+ )} +
+ +
+
+ {t(`${s}.shareKG`)} + onChange({ ...value, share_knowledge_graph: v })} + /> +
+ {value.share_knowledge_graph && ( +

+ {t(`${s}.shareKGNote`)} +

+ )}
- {value.share_memory && ( -

- {t(`${s}.shareMemoryNote`)} -

- )}
diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index ed27d368..ba92f452 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -68,9 +68,7 @@ export const credentialsSchema: Record = { { key: "webhook_secret", label: "Webhook Secret", type: "password" }, ], zalo_personal: [], - whatsapp: [ - { key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "http://bridge:3000" }, - ], + whatsapp: [], }; // --- Config schemas --- @@ -151,6 +149,7 @@ export const configSchema: Record = { whatsapp: [ { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, { key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" }, + { key: "require_mention", label: "Require @Mention in Groups", type: "boolean", help: "Only respond in group chats when the bot is explicitly @mentioned" }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, ], @@ -223,4 +222,9 @@ export const wizardConfig: Partial> = { formBanner: "wizard.zaloPersonal.formBanner", excludeConfigFields: ["allow_from"], }, + whatsapp: { + steps: ["auth"], + createLabel: "wizard.whatsapp.createLabel", + formBanner: "wizard.whatsapp.formBanner", + }, }; diff --git a/ui/web/src/pages/channels/channel-wizard-registry.tsx b/ui/web/src/pages/channels/channel-wizard-registry.tsx index 6435bb34..e8fb316a 100644 --- a/ui/web/src/pages/channels/channel-wizard-registry.tsx +++ b/ui/web/src/pages/channels/channel-wizard-registry.tsx @@ -48,11 +48,14 @@ export interface ReauthDialogProps { import { ZaloAuthStep, ZaloConfigStep, ZaloEditConfig } from "./zalo/zalo-wizard-steps"; import { ZaloPersonalQRDialog } from "./zalo/zalo-personal-qr-dialog"; +import { WhatsAppAuthStep } from "./whatsapp/whatsapp-wizard-steps"; +import { WhatsAppReauthDialog } from "./whatsapp/whatsapp-reauth-dialog"; // --- Component registries --- export const wizardAuthSteps: Record> = { zalo_personal: ZaloAuthStep, + whatsapp: WhatsAppAuthStep, }; export const wizardConfigSteps: Record> = { @@ -66,6 +69,7 @@ export const wizardEditConfigs: Record> = { zalo_personal: ZaloPersonalQRDialog, + whatsapp: WhatsAppReauthDialog, }; /** Set of channel types that support re-authentication from the table */ diff --git a/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts b/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts new file mode 100644 index 00000000..b2c73d26 --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts @@ -0,0 +1,69 @@ +import { useState, useCallback } from "react"; +import { useWsCall } from "@/hooks/use-ws-call"; +import { useWsEvent } from "@/hooks/use-ws-event"; + +export type QrStatus = "idle" | "waiting" | "done" | "connected" | "error"; + +export function useWhatsAppQrLogin(instanceId: string | null) { + const [qrPng, setQrPng] = useState(null); + const [status, setStatus] = useState("idle"); + const [errorMsg, setErrorMsg] = useState(""); + const { call: startQR, loading } = useWsCall("whatsapp.qr.start"); + + const start = useCallback(async (forceReauth = false) => { + if (!instanceId) return; + setStatus("waiting"); + setQrPng(null); + setErrorMsg(""); + try { + await startQR({ instance_id: instanceId, force_reauth: forceReauth }); + } catch (err) { + setStatus("error"); + setErrorMsg(err instanceof Error ? err.message : "Failed to start QR session"); + } + }, [startQR, instanceId]); + + /** Logout current WhatsApp session and start a fresh QR scan flow. */ + const triggerReauth = useCallback(() => start(true), [start]); + + const reset = useCallback(() => { + setStatus("idle"); + setQrPng(null); + setErrorMsg(""); + }, []); + + useWsEvent( + "whatsapp.qr.code", + useCallback( + (payload: unknown) => { + const p = payload as { instance_id: string; png_b64: string }; + if (p.instance_id !== instanceId) return; + setQrPng(p.png_b64); + setStatus("waiting"); + }, + [instanceId], + ), + ); + + useWsEvent( + "whatsapp.qr.done", + useCallback( + (payload: unknown) => { + const p = payload as { instance_id: string; success: boolean; already_connected?: boolean; error?: string }; + if (p.instance_id !== instanceId) return; + if (p.success) { + // Distinguish: already connected before any QR vs. freshly authenticated via QR scan. + setStatus(p.already_connected ? "connected" : "done"); + } else { + setStatus("error"); + setErrorMsg(p.error ?? "QR authentication failed"); + } + }, + [instanceId], + ), + ); + + return { + qrPng, status, errorMsg, loading, start, reset, retry: start, triggerReauth, + }; +} diff --git a/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx b/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx new file mode 100644 index 00000000..eb654748 --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx @@ -0,0 +1,118 @@ +// Re-authentication dialog for WhatsApp — triggered from the channels table. +// QR code scan only. + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useWhatsAppQrLogin } from "./use-whatsapp-qr-login"; +import type { ReauthDialogProps } from "../channel-wizard-registry"; + +export function WhatsAppReauthDialog({ + open, + onOpenChange, + instanceId, + instanceName, + onSuccess, +}: ReauthDialogProps) { + const { t } = useTranslation("channels"); + const { + qrPng, status, errorMsg, loading, start, reset, retry, triggerReauth, + } = useWhatsAppQrLogin(instanceId); + + // Auto-start QR when dialog opens; intentionally omits `start` from deps + // because we only want to trigger on open/close transitions, not on identity changes. + useEffect(() => { + if (open && status === "idle") start(); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + // Reset state when dialog closes + useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + // Auto-close after a fresh QR scan completes (not "already connected") + useEffect(() => { + if (status !== "done") return; + onSuccess(); + const id = setTimeout(() => onOpenChange(false), 1500); + return () => clearTimeout(id); + }, [status, onSuccess, onOpenChange]); + + return ( + { if (!loading) onOpenChange(v); }}> + + + {t("whatsapp.reauthTitle", { name: instanceName })} + + {t("whatsapp.scanHint")} + + + + {/* Already connected state */} + {status === "connected" && ( +
+
+

{t("whatsapp.alreadyLinked")}

+

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

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

{t("whatsapp.connectedSuccess")}

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

{errorMsg}

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

{t("whatsapp.waitingForQr")}

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

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

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

{t("whatsapp.initializing")}

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

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

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

{errorMsg}

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

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

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

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

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

{t("whatsapp.initializing")}

+ )} +
+ + + {status === "error" && ( + + )} + + + ); +} diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index 6bee88fc..a95067ec 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -58,7 +58,14 @@ export function useChatMessages(sessionKey: string, agentId: string) { if (sessionKey === prevKeyRef.current) return; const wasEmpty = !prevKeyRef.current; prevKeyRef.current = sessionKey; - if (wasEmpty) { skipNextHistoryRef.current = true; return; } // new-chat send flow, don't reset + if (wasEmpty) { + // Only skip history when a send is in flight (expectingRunRef). Selecting + // an existing conversation from the sidebar must still load messages. + if (expectingRunRef.current) { + skipNextHistoryRef.current = true; + } + return; + } setStreamText(null); setThinkingText(null); setToolStream([]); setIsRunning(false); setActivity(null); setBlockReplies([]); setTeamTasks([]); diff --git a/ui/web/src/pages/memory/kg-entities-tab.tsx b/ui/web/src/pages/memory/kg-entities-tab.tsx index 472637b0..e565082b 100644 --- a/ui/web/src/pages/memory/kg-entities-tab.tsx +++ b/ui/web/src/pages/memory/kg-entities-tab.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Network, Trash2, Search, GitFork, Sparkles, RefreshCw, LayoutGrid, Share2, Merge } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -42,6 +42,24 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) { const graphData = useKGGraph(agentId, userId); const showSkeleton = useDeferredLoading(loading && entities.length === 0); + // Filter graph data by search query (client-side) + const filteredGraphData = useMemo(() => { + if (!appliedQuery) return graphData; + const q = appliedQuery.toLowerCase(); + const matchedIds = new Set(); + const matched = graphData.entities.filter((e) => { + const hit = e.name.toLowerCase().includes(q) + || e.entity_type.toLowerCase().includes(q) + || (e.description ?? "").toLowerCase().includes(q); + if (hit) matchedIds.add(e.id); + return hit; + }); + const relations = graphData.relations.filter( + (r) => matchedIds.has(r.source_entity_id) && matchedIds.has(r.target_entity_id), + ); + return { entities: matched, relations }; + }, [graphData, appliedQuery]); + const handleSearch = () => setAppliedQuery(searchQuery.trim()); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") handleSearch(); @@ -130,8 +148,8 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
{viewMode === "graph" ? ( ) : showSkeleton ? ( diff --git a/ui/web/src/pages/memory/kg-graph-view.tsx b/ui/web/src/pages/memory/kg-graph-view.tsx index 65f50005..111d73bb 100644 --- a/ui/web/src/pages/memory/kg-graph-view.tsx +++ b/ui/web/src/pages/memory/kg-graph-view.tsx @@ -1,6 +1,6 @@ import { useRef, useMemo, useCallback, useState, useEffect, useLayoutEffect } from "react"; import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d"; -import { Maximize2 } from "lucide-react"; +import { Maximize2, ZoomIn, ZoomOut } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; import { useUiStore } from "@/stores/use-ui-store"; @@ -30,6 +30,21 @@ export function KGGraphView({ entities: allEntities, relations: allRelations, on const [selectedNodeId, setSelectedNodeId] = useState(null); const [nodeLimit, setNodeLimit] = useState(DEFAULT_NODE_LIMIT); const [ready, setReady] = useState(false); + const [zoomLevel, setZoomLevel] = useState(1); + + const ZOOM_STEP = 1.5; + + const handleZoomIn = useCallback(() => { + const fg = graphRef.current; + if (!fg) return; + fg.zoom(Math.min((fg.zoom() ?? 1) * ZOOM_STEP, 8), 300); + }, []); + + const handleZoomOut = useCallback(() => { + const fg = graphRef.current; + if (!fg) return; + fg.zoom(Math.max((fg.zoom() ?? 1) / ZOOM_STEP, 0.1), 300); + }, []); // Double-click detection refs const clickTimerRef = useRef | null>(null); @@ -223,6 +238,7 @@ export function KGGraphView({ entities: allEntities, relations: allRelations, on linkDirectionalParticleSpeed={0.004} onNodeClick={handleNodeClick} onBackgroundClick={handleBackgroundClick} + onZoom={(transform) => setZoomLevel(transform.k)} onEngineStop={handleEngineStop} backgroundColor="transparent" d3AlphaDecay={0.04} @@ -243,6 +259,15 @@ export function KGGraphView({ entities: allEntities, relations: allRelations, on · {t("kg.graphView.limitNote", { limit: nodeLimit, total: totalCount })} )}
+
+ + {Math.round(zoomLevel * 100)}% + +