diff --git a/docs/core-concepts/channels.md b/docs/core-concepts/channels.md index 6ef444ac..fcee5f16 100644 --- a/docs/core-concepts/channels.md +++ b/docs/core-concepts/channels.md @@ -20,6 +20,8 @@ Both channels use **outbound-only connections** — no public URLs, no ngrok, no |---------|---------|------|-------------| | Slack | `slack.Plugin` | Socket Mode | 3000 | | Telegram | `telegram.Plugin` | Polling or Webhook | 3001 | +| MS Teams | `msteams.Plugin` | Graph API polling | — (outbound only) | +| WhatsApp | `whatsapp.Plugin` | WhatsApp Web (paired session) | — (outbound only) | > **Note:** Slack uses Socket Mode — an outbound WebSocket connection from the agent to Slack's servers. No public URL or ngrok is needed for local development. @@ -31,6 +33,9 @@ forge channel add slack # Add Telegram adapter forge channel add telegram + +# Add WhatsApp adapter (then pair: forge channel whatsapp-login) +forge channel add whatsapp ``` This command: @@ -175,6 +180,148 @@ Mode options: - `polling` (default) — Long-polling via `getUpdates` - `webhook` — Receives updates via HTTP webhook (loopback-only binding with secret token verification) +### WhatsApp (`whatsapp-config.yaml`) + +```yaml +adapter: whatsapp +settings: + session_path: .forge/channels/whatsapp-session.db + admit: dm_or_group_mention # dm | group_mention | dm_or_group_mention + allowed_groups: "" # comma/newline-separated group JIDs; empty = all + allowed_senders: "" # empty = OWNER ONLY; "anyone" opens it up + self_chat: true # answer your own "Message Yourself" chat + self_chat_prefix: "⚒ Forge: " # marks replies in the self-chat; "" disables + include_recent_history: true + recent_history_count: 20 +``` + +| Setting | Default | Notes | +|---|---|---| +| `session_path` | `.forge/channels/whatsapp-session.db` | The paired session. **This file is the credential.** | +| `admit` | `dm_or_group_mention` | Group traffic always requires an explicit @-mention. | +| `allowed_groups` | *(all)* | Group JIDs; the `@g.us` suffix may be omitted. | +| `allowed_senders` | *(owner only)* | Empty admits only the paired account. List numbers to add people, or set `anyone` to open it up. The owner always passes. | +| `self_chat` | `true` | Answer messages you send yourself. | +| `self_chat_prefix` | `⚒ Forge: ` | Marks the agent's replies in the self-chat. `""` disables. | +| `include_recent_history` | `true` | Injects observed chat context into the prompt. | +| `recent_history_count` | `20` | Per-chat window; block soft-capped at ~5000 chars. | + +Unlike every other adapter's list settings, **a space is not a separator** in +`allowed_senders` — a space is part of a written phone number +(`+1 (415) 555-0100`). Use commas or newlines. + +### Talking to your own agent + +The paired number *is* the agent. The simplest way to use it is WhatsApp's +**Message Yourself** chat: open it on the paired phone and type. No second +account needed. + +That works because `self_chat` is on by default. Your own messages arrive +flagged as self-sent, and so do the agent's replies — the loop guard is the +dedup ring, which records every message the agent sends before it can come +back around. Self-messages are accepted **only** in that chat, never in groups. + +In that chat the agent sends **as you**, so WhatsApp renders its replies on the +same side, in the same colour, as your own messages — the sender is identical, +and nothing on the wire can change that. Replies are therefore prefixed: + +``` +what is 2+2? +⚒ Forge: 4 +``` + +Change the marker with `self_chat_prefix`, or set it to `""` to turn it off. +It is only applied in the self-chat; a normal DM already distinguishes sender +from recipient. For real visual separation, message the agent from a second +WhatsApp account instead — a group works too, but note a group containing only +your own number will not: self-messages are accepted in the self-chat only. + +To have the agent serve other people instead, list them: + +```yaml +allowed_senders: "+1 (415) 555-0100, +44 7700 900123" +``` + +`allowed_senders` is **owner-only when empty** — a stranger who happens to have +the number gets nothing. That is deliberate: an open agent spends your LLM +budget and reaches whatever tools it has. Opening it up is an explicit choice: + +```yaml +allowed_senders: anyone +``` + +### Messages from before the agent started + +Reconnecting delivers a backlog, and a restart begins with an empty dedup ring. +Messages dated more than five minutes before the process started are dropped +rather than answered, so a restart doesn't reply to a replayed conversation — +and, in the self-chat, doesn't answer the agent's own replayed replies. + +A brief restart still picks up anything sent while the agent was down. + +## WhatsApp Setup + +WhatsApp has no bot token. The adapter authenticates by linking itself as a +WhatsApp Web device, exactly like the desktop client: + +```bash +forge channel add whatsapp +forge channel whatsapp-login # renders a QR code in the terminal +# phone: WhatsApp → Settings → Linked Devices → Link a Device → scan +forge run --with whatsapp +``` + +The pairing is written to `session_path`. Keep it out of version control and +off shared volumes — anyone holding that file can send messages as the linked +account. Re-pairing requires `--force`, so re-running the login command by +accident cannot revoke a working session. + +### Terms of Service and ban risk + +This adapter speaks the **WhatsApp Web multidevice protocol** (via +[whatsmeow](https://pkg.go.dev/go.mau.fi/whatsmeow)), not the official WhatsApp +Cloud API and not Twilio. Automating that protocol is against WhatsApp's Terms +of Service and can get the linked number banned. The ban attaches to the phone +number, not the machine, and is not reliably reversible. + +**Pair a dedicated number, never a personal one.** If you need a +ToS-sanctioned path, the WhatsApp Cloud API is a different integration and is +not what this adapter implements. + +### Identities: phone numbers and LIDs + +WhatsApp is migrating group participants to hidden-number identifiers (LIDs, +`@lid`) instead of phone numbers. The adapter tracks both identities for +the paired account, so an @-mention resolves under either. For +`allowed_senders`, a LID sender is matched via the phone-number alternate the +server supplies; when no alternate is available the message is dropped and the +log line names the LID so you can add it to the list directly. + +### Group history is observed, not fetched + +`include_recent_history` works differently here than in the Teams adapter. +Microsoft Graph exposes `/chats/{id}/messages`, so Teams can fetch prior +messages on demand. WhatsApp has no equivalent for a linked device — history +reaches one only through a sync push at pairing time. The adapter therefore +builds its context window from traffic it observes while running. + +The consequence: **context covers messages seen since the adapter started, and +is lost on restart.** An agent restarted mid-conversation will not see what +came before. + +### Not supported + +- **DEFER approvals and MCP delegated consent.** WhatsApp's interactive + message types are unreliable over the Web protocol, so the adapter + implements neither `ApprovalDeliverer` nor `ConsentDeliverer`. A + `security.defer` route naming `whatsapp` will warn at startup; resolve those + approvals via `POST /tasks/{id}/decisions` instead. +- **`UserEmail` on inbound events.** WhatsApp has no email identity, so + delegated (`auth.type: user`) MCP tools cannot resolve an on-behalf-of + subject on this channel. +- **Media.** Attachments are not downloaded or sent; captions on inbound media + are read as prompt text. + ### Telegram Webhook Security When running in webhook mode, the Telegram adapter applies multiple security controls: diff --git a/forge-cli/cmd/channel.go b/forge-cli/cmd/channel.go index 0718b823..2003dc64 100644 --- a/forge-cli/cmd/channel.go +++ b/forge-cli/cmd/channel.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "path/filepath" + "slices" "strings" "syscall" @@ -18,29 +19,38 @@ import ( "github.com/initializ/forge/forge-plugins/channels/msteams" "github.com/initializ/forge/forge-plugins/channels/slack" "github.com/initializ/forge/forge-plugins/channels/telegram" + "github.com/initializ/forge/forge-plugins/channels/whatsapp" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) +// supportedAdapters is the canonical list of channel adapters `forge channel` +// accepts. Keep in sync with createPlugin and defaultRegistry below. +var supportedAdapters = []string{"slack", "telegram", "msteams", "whatsapp"} + +func isSupportedAdapter(name string) bool { + return slices.Contains(supportedAdapters, name) +} + var channelCmd = &cobra.Command{ Use: "channel", Short: "Manage agent communication channels", - Long: "Add and serve channel adapters (Slack, Telegram, MS Teams) for your agent.", + Long: "Add and serve channel adapters (Slack, Telegram, MS Teams, WhatsApp) for your agent.", } var channelAddCmd = &cobra.Command{ - Use: "add ", + Use: "add ", Short: "Add a channel adapter to the project", Args: cobra.ExactArgs(1), - ValidArgs: []string{"slack", "telegram", "msteams"}, + ValidArgs: []string{"slack", "telegram", "msteams", "whatsapp"}, RunE: runChannelAdd, } var channelServeCmd = &cobra.Command{ - Use: "serve ", + Use: "serve ", Short: "Run a standalone channel adapter (for container use)", Args: cobra.ExactArgs(1), - ValidArgs: []string{"slack", "telegram", "msteams"}, + ValidArgs: []string{"slack", "telegram", "msteams", "whatsapp"}, RunE: runChannelServe, } @@ -107,8 +117,8 @@ func init() { func runChannelAdd(cmd *cobra.Command, args []string) error { adapter := args[0] - if adapter != "slack" && adapter != "telegram" && adapter != "msteams" { - return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram, msteams)", adapter) + if !isSupportedAdapter(adapter) { + return fmt.Errorf("unsupported adapter: %s (supported: %s)", adapter, strings.Join(supportedAdapters, ", ")) } wd, err := os.Getwd() @@ -160,8 +170,8 @@ func runChannelAdd(cmd *cobra.Command, args []string) error { func runChannelServe(cmd *cobra.Command, args []string) error { adapter := args[0] - if adapter != "slack" && adapter != "telegram" && adapter != "msteams" { - return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram, msteams)", adapter) + if !isSupportedAdapter(adapter) { + return fmt.Errorf("unsupported adapter: %s (supported: %s)", adapter, strings.Join(supportedAdapters, ", ")) } // Honor every layer's denied_channels list (issue #90 / FWS-6 @@ -241,6 +251,8 @@ func createPlugin(name string) corechannels.ChannelPlugin { return telegram.New() case "msteams": return msteams.New() + case "whatsapp": + return whatsapp.New() default: return nil } @@ -252,6 +264,7 @@ func defaultRegistry() *corechannels.Registry { r.Register(slack.New()) r.Register(telegram.New()) r.Register(msteams.New()) + r.Register(whatsapp.New()) return r } @@ -391,6 +404,32 @@ func addChannelEgressToForgeYAML(path, adapter string) error { } egressMap["allowed_domains"] = domainsAny + case "whatsapp": + // Add "whatsapp" to egress.capabilities (same pattern as slack). + // The capability resolves to web.whatsapp.com + *.whatsapp.net via + // DefaultCapabilityBundles in forge-core/security/capabilities.go. + var caps []string + if existing, ok := egressMap["capabilities"]; ok { + if arr, ok := existing.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + caps = append(caps, s) + } + } + } + } + for _, c := range caps { + if c == "whatsapp" { + return nil // already present + } + } + caps = append(caps, "whatsapp") + capsAny := make([]any, len(caps)) + for i, s := range caps { + capsAny[i] = s + } + egressMap["capabilities"] = capsAny + case "msteams": // Add "msteams" to egress.capabilities (same pattern as slack). // The capability resolves to graph.microsoft.com + login.microsoftonline.com @@ -466,6 +505,23 @@ func printSetupInstructions(adapter string) { fmt.Println() fmt.Println(" This adapter is outbound-only — no public endpoint required.") fmt.Println(" Default poll cadence is 5s (configurable in msteams-config.yaml).") + case "whatsapp": + fmt.Println("WhatsApp setup instructions:") + fmt.Println(" 1. Use a DEDICATED phone number, not a personal one (see the warning below)") + fmt.Println(" 2. Run: forge channel whatsapp-login") + fmt.Println(" 3. On the phone, open WhatsApp → Settings → Linked Devices →") + fmt.Println(" Link a Device, and scan the QR code shown in your terminal") + fmt.Println(" 4. Run: forge run --with whatsapp") + fmt.Println() + fmt.Println(" There is no bot token. The pairing is stored at") + fmt.Println(" .forge/channels/whatsapp-session.db — that file IS the credential;") + fmt.Println(" keep it out of version control.") + fmt.Println() + fmt.Println(" WARNING: this uses WhatsApp Web (the same protocol as a linked") + fmt.Println(" desktop client), not the official WhatsApp Cloud API. Automating it") + fmt.Println(" is against WhatsApp's Terms of Service and can get the linked number") + fmt.Println(" banned. The ban applies to the number, not this machine, and is not") + fmt.Println(" reliably reversible.") } fmt.Println() fmt.Println(strings.Repeat("─", 40)) diff --git a/forge-cli/cmd/channel_whatsapp_login.go b/forge-cli/cmd/channel_whatsapp_login.go new file mode 100644 index 00000000..dafcddf8 --- /dev/null +++ b/forge-cli/cmd/channel_whatsapp_login.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/initializ/forge/forge-cli/internal/wapair" + corechannels "github.com/initializ/forge/forge-core/channels" + "github.com/initializ/forge/forge-plugins/channels/whatsapp" +) + +// channelWhatsappLoginCmd pairs a WhatsApp account with this agent by linking +// it as a WhatsApp Web device. +// +// The flow has two halves, like the MS Teams device-code login: a user half +// (scan the QR code on the phone) and a client half (hold the socket open +// until WhatsApp confirms the pairing). This command runs both so the operator +// only has to do the visible part. +var channelWhatsappLoginCmd = &cobra.Command{ + Use: "whatsapp-login", + Short: "Pair a WhatsApp account by scanning a QR code", + Long: `Link a WhatsApp account to this agent by scanning a QR code, the same way +you link WhatsApp Web or the WhatsApp desktop app. + +This command: + 1. Opens (creating if absent) the session store + 2. Renders a QR code in the terminal, refreshing it as each one expires + 3. Waits for you to scan it from the phone + 4. Persists the paired session so ` + "`forge run --with whatsapp`" + ` can connect + +On the phone: WhatsApp → Settings → Linked Devices → Link a Device. + +The session store is written to the session_path in whatsapp-config.yaml +(default .forge/channels/whatsapp-session.db). That file IS the credential — +anyone holding it can send messages as the linked account. Keep it out of +version control. + +WARNING: this uses the WhatsApp Web protocol, not the official WhatsApp Cloud +API. Automating it is against WhatsApp's Terms of Service and can get the +linked number banned. Pair a dedicated number, never a personal one.`, + RunE: runChannelWhatsappLogin, +} + +var ( + whatsappLoginSessionPath string + whatsappLoginTimeoutSecs int + whatsappLoginForce bool +) + +func init() { + channelWhatsappLoginCmd.Flags().StringVar(&whatsappLoginSessionPath, "session-path", "", + "Path to the session store (defaults to session_path in whatsapp-config.yaml, else .forge/channels/whatsapp-session.db)") + channelWhatsappLoginCmd.Flags().IntVar(&whatsappLoginTimeoutSecs, "timeout-seconds", 300, + "Maximum time to wait for the QR code to be scanned (default 300 / 5 minutes)") + channelWhatsappLoginCmd.Flags().BoolVar(&whatsappLoginForce, "force", false, + "Re-pair even if the session store already holds a paired account") + channelCmd.AddCommand(channelWhatsappLoginCmd) +} + +func runChannelWhatsappLogin(cmd *cobra.Command, args []string) error { + sessionPath := resolveWhatsappSessionPath(whatsappLoginSessionPath) + + stderr := cmd.ErrOrStderr() + writeln := func(s string) { _, _ = io.WriteString(stderr, s+"\n") } + writef := func(format string, a ...any) { _, _ = fmt.Fprintf(stderr, format, a...) } + + // A paired session already present is almost always the operator running + // this twice, not a request to re-pair. Re-pairing silently would revoke + // the working link, so require an explicit --force. + if !whatsappLoginForce && whatsapp.SessionExists(cmd.Context(), sessionPath) { + writef("A paired WhatsApp session already exists at %s\n", sessionPath) + writeln("Pass --force to discard it and pair again.") + return nil + } + + ctx, cancel := context.WithTimeout(cmd.Context(), time.Duration(whatsappLoginTimeoutSecs)*time.Second) + defer cancel() + + // A --force re-pair must start from a clean store: whatsmeow will not + // re-issue QR codes for a device row left half-registered by the previous + // pairing. + if whatsappLoginForce { + if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("whatsapp: clearing existing session: %w", err) + } + } + + session, err := wapair.Start(ctx, sessionPath) + if err != nil { + return err + } + defer session.Close() //nolint:errcheck + + writeln("") + writeln("───────────────────────────────────────────────────────────") + writeln(" On your phone: WhatsApp → Settings → Linked Devices →") + writeln(" Link a Device, then scan this code:") + writeln("───────────────────────────────────────────────────────────") + + for evt := range session.Events() { + switch evt.Kind { + case wapair.EventQR: + wapair.RenderQR(evt.Code, stderr) + writef(" Code expires in %s — a new one will appear automatically.\n", evt.Timeout.Round(time.Second)) + + case wapair.EventScanned: + writeln("") + writeln(" Scanned — finalizing the link with WhatsApp...") + writeln(" (Do not interrupt; the pairing is not complete yet.)") + + case wapair.EventPaired: + writeln("") + writeln("✓ Paired.") + if evt.JID != "" { + writef(" Account: %s\n", evt.JID) + } + writef(" Session: %s\n", sessionPath) + writeln("") + writeln(" This file is the credential — keep it out of version control.") + writeln(" Start the agent with: forge run --with whatsapp") + return nil + + case wapair.EventError: + // A deadline here is the operator not scanning in time, not a + // protocol failure — say so in those terms. + if errors.Is(evt.Err, context.DeadlineExceeded) { + return fmt.Errorf("whatsapp: timed out after %ds waiting for the QR code to be scanned — re-run with --timeout-seconds to allow longer", + whatsappLoginTimeoutSecs) + } + return fmt.Errorf("whatsapp: %w", evt.Err) + } + } + + return errors.New("whatsapp: pairing ended without completing — re-run `forge channel whatsapp-login` to retry") +} + +// resolveWhatsappSessionPath picks the session store location: the --session-path +// flag, else session_path from whatsapp-config.yaml in the working directory, +// else the adapter default. Relative paths resolve against the working +// directory so the login command and the adapter agree on the location. +func resolveWhatsappSessionPath(flagValue string) string { + path := strings.TrimSpace(flagValue) + if path == "" { + path = whatsappSessionPathFromConfig("whatsapp-config.yaml") + } + if path == "" { + path = ".forge/channels/whatsapp-session.db" + } + if filepath.IsAbs(path) { + return path + } + wd, err := os.Getwd() + if err != nil { + return path + } + return filepath.Join(wd, path) +} + +// whatsappSessionPathFromConfig reads session_path out of a channel config, +// resolving the _env indirection the way the adapter does. Returns "" when the +// file is absent or holds no session_path — the caller falls back to the +// default. +func whatsappSessionPathFromConfig(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var cfg corechannels.ChannelConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return "" + } + return strings.TrimSpace(corechannels.ResolveEnvVars(&cfg)["session_path"]) +} diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 69a6a39f..61112c32 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -711,6 +711,14 @@ func parseSkillsFile(path string) ([]toolEntry, error) { return tools, nil } +// initTemplateFuncs are the helpers available to the init templates. +// yamlScalar quotes values that would otherwise be misparsed — notably a +// wildcard egress domain ("*.whatsapp.net"), whose leading "*" YAML reads as +// an alias reference. +var initTemplateFuncs = template.FuncMap{ + "yamlScalar": yamlScalar, +} + func scaffold(opts *initOptions) error { normalizeCustomProvider(opts) @@ -733,6 +741,11 @@ func scaffold(opts *initOptions) error { } } + // Move a session paired inline by the wizard into the project. Consumes + // the synthetic key BEFORE buildTemplateData so the temp path can never + // reach the generated .env. + whatsappPaired, whatsappRelocErr := relocateWhatsappSession(opts, dir) + data := buildTemplateData(opts) manifest := getFileManifest(opts) @@ -742,7 +755,7 @@ func scaffold(opts *initOptions) error { return fmt.Errorf("reading template %s: %w", f.TemplatePath, err) } - tmpl, err := template.New(f.TemplatePath).Parse(tmplContent) + tmpl, err := template.New(f.TemplatePath).Funcs(initTemplateFuncs).Parse(tmplContent) if err != nil { return fmt.Errorf("parsing template %s: %w", f.TemplatePath, err) } @@ -860,6 +873,13 @@ func scaffold(opts *initOptions) error { return nil } + // Surface a failed session relocation here rather than aborting: the + // project is scaffolded and usable, only the pairing is missing. + if whatsappRelocErr != nil { + fmt.Printf("\n Warning: could not install the paired WhatsApp session: %v\n", whatsappRelocErr) + fmt.Printf(" Re-pair with: forge channel whatsapp-login\n") + } + fmt.Printf("\nCreated agent project in ./%s\n", opts.AgentID) // Show channel-specific reminders @@ -868,6 +888,16 @@ func scaffold(opts *initOptions) error { fmt.Println() fmt.Println(" Slack reminder: /invite @YourBot in each channel you want it active in.") } + if ch == "whatsapp" { + fmt.Println() + if whatsappPaired { + fmt.Println(" WhatsApp: paired. The session is at .forge/channels/whatsapp-session.db") + fmt.Println(" — that file is the credential; keep it out of version control.") + } else { + fmt.Println(" WhatsApp: not paired yet. Run `forge channel whatsapp-login`") + fmt.Println(" from the project directory before `forge run --with whatsapp`.") + } + } } // In non-interactive mode, just print the command diff --git a/forge-cli/cmd/init_whatsapp.go b/forge-cli/cmd/init_whatsapp.go new file mode 100644 index 00000000..e3831bba --- /dev/null +++ b/forge-cli/cmd/init_whatsapp.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-cli/internal/tui/steps" +) + +// whatsappSessionRelativePath is where the adapter expects the paired session, +// matching session_path in whatsapp-config.yaml.tmpl. +const whatsappSessionRelativePath = ".forge/channels/whatsapp-session.db" + +// relocateWhatsappSession moves a session paired during the init wizard into +// the freshly scaffolded project. +// +// The wizard pairs before the project directory exists, so it writes to a temp +// store and passes the path through a synthetic env key. This consumes that +// key — the path must never reach the generated .env, where it would both leak +// a temp path and be wrong the moment the temp dir is cleaned. +// +// Failure is reported but not fatal: the project is already scaffolded and +// usable, and the operator can re-pair with `forge channel whatsapp-login`. +func relocateWhatsappSession(opts *initOptions, dir string) (paired bool, err error) { + src := opts.EnvVars[steps.WhatsappSessionTokenKey] + delete(opts.EnvVars, steps.WhatsappSessionTokenKey) + if src == "" { + return false, nil + } + // The temp dir is ours to remove either way — a failed copy leaves nothing + // worth keeping behind. + defer os.RemoveAll(filepath.Dir(src)) //nolint:errcheck + + if _, err := os.Stat(src); err != nil { + return false, fmt.Errorf("paired session missing at %s: %w", src, err) + } + + dst := filepath.Join(dir, whatsappSessionRelativePath) + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return false, fmt.Errorf("creating session directory: %w", err) + } + // copyFileMode (cmd/skill_import.go) honours the umask, so a restrictive + // mode can come out looser than asked. This file is the WhatsApp + // credential, so force it afterwards. + // + // A rename would be cheaper than a copy but cannot be relied on: the temp + // dir and the project often sit on different filesystems (/var/folders vs + // $HOME on macOS, tmpfs vs the workspace in a container), where rename + // fails with EXDEV. + if err := copyFileMode(src, dst, 0o600); err != nil { + return false, fmt.Errorf("writing session to %s: %w", dst, err) + } + if err := os.Chmod(dst, 0o600); err != nil { + return false, fmt.Errorf("securing session file %s: %w", dst, err) + } + return true, nil +} diff --git a/forge-cli/cmd/init_whatsapp_test.go b/forge-cli/cmd/init_whatsapp_test.go new file mode 100644 index 00000000..94501052 --- /dev/null +++ b/forge-cli/cmd/init_whatsapp_test.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-cli/internal/tui/steps" +) + +// stagePairedSession writes a stand-in for the session the wizard would have +// paired into a temp store, and returns its path. +func stagePairedSession(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "whatsapp-session.db") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("staging session: %v", err) + } + return path +} + +func TestRelocateWhatsappSession_NoTokenIsNoop(t *testing.T) { + opts := &initOptions{EnvVars: map[string]string{}} + paired, err := relocateWhatsappSession(opts, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if paired { + t.Error("expected paired=false with no session token") + } +} + +func TestRelocateWhatsappSession_MovesSessionIntoProject(t *testing.T) { + src := stagePairedSession(t, "pairing-bytes") + project := t.TempDir() + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + paired, err := relocateWhatsappSession(opts, project) + if err != nil { + t.Fatalf("relocate: %v", err) + } + if !paired { + t.Error("expected paired=true") + } + + dst := filepath.Join(project, whatsappSessionRelativePath) + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("reading relocated session: %v", err) + } + if string(got) != "pairing-bytes" { + t.Errorf("session content = %q, want the staged bytes", got) + } +} + +// The temp path must never survive into the generated .env — it would leak a +// temp location and be wrong the moment the temp dir is cleaned. +func TestRelocateWhatsappSession_ConsumesSyntheticKey(t *testing.T) { + src := stagePairedSession(t, "x") + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, t.TempDir()); err != nil { + t.Fatalf("relocate: %v", err) + } + if _, ok := opts.EnvVars[steps.WhatsappSessionTokenKey]; ok { + t.Error("synthetic session key must be stripped from EnvVars") + } +} + +// The key must be consumed even when relocation fails, or a stale temp path +// reaches .env. +func TestRelocateWhatsappSession_ConsumesKeyOnFailure(t *testing.T) { + opts := &initOptions{EnvVars: map[string]string{ + steps.WhatsappSessionTokenKey: filepath.Join(t.TempDir(), "absent", "gone.db"), + }} + + paired, err := relocateWhatsappSession(opts, t.TempDir()) + if err == nil { + t.Fatal("expected an error for a missing session file") + } + if paired { + t.Error("expected paired=false on failure") + } + if _, ok := opts.EnvVars[steps.WhatsappSessionTokenKey]; ok { + t.Error("synthetic key must be stripped even when relocation fails") + } +} + +// The session is the WhatsApp credential; a world-readable copy is a leak. +func TestRelocateWhatsappSession_RelocatedFileIsOwnerOnly(t *testing.T) { + src := stagePairedSession(t, "secret") + project := t.TempDir() + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, project); err != nil { + t.Fatalf("relocate: %v", err) + } + + info, err := os.Stat(filepath.Join(project, whatsappSessionRelativePath)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("relocated session mode = %04o, want 0600", perm) + } +} + +func TestRelocateWhatsappSession_RemovesTempDir(t *testing.T) { + src := stagePairedSession(t, "x") + tempDir := filepath.Dir(src) + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, t.TempDir()); err != nil { + t.Fatalf("relocate: %v", err) + } + if _, err := os.Stat(tempDir); !os.IsNotExist(err) { + t.Errorf("temp store should be removed after relocation, stat err = %v", err) + } +} + +// .forge/channels/ does not exist in a fresh project. +func TestRelocateWhatsappSession_CreatesNestedDirs(t *testing.T) { + src := stagePairedSession(t, "x") + project := filepath.Join(t.TempDir(), "brand-new") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + opts := &initOptions{EnvVars: map[string]string{steps.WhatsappSessionTokenKey: src}} + + if _, err := relocateWhatsappSession(opts, project); err != nil { + t.Fatalf("relocate: %v", err) + } + if _, err := os.Stat(filepath.Join(project, whatsappSessionRelativePath)); err != nil { + t.Errorf("expected nested dirs created: %v", err) + } +} + +// The destination must match session_path in whatsapp-config.yaml.tmpl, or the +// adapter looks somewhere the wizard never wrote. +func TestRelocateWhatsappSession_PathMatchesAdapterDefault(t *testing.T) { + const want = ".forge/channels/whatsapp-session.db" + if whatsappSessionRelativePath != want { + t.Errorf("whatsappSessionRelativePath = %q, want %q (must match whatsapp-config.yaml.tmpl)", + whatsappSessionRelativePath, want) + } +} diff --git a/forge-cli/cmd/init_yaml_test.go b/forge-cli/cmd/init_yaml_test.go new file mode 100644 index 00000000..6626408a --- /dev/null +++ b/forge-cli/cmd/init_yaml_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "strings" + "testing" + "text/template" + + "gopkg.in/yaml.v3" +) + +// Regression: the WhatsApp egress bundle introduced the first wildcard domain +// in DefaultCapabilityBundles. Rendered unquoted, its leading "*" is a YAML +// alias indicator, and `forge init` failed with "did not find expected +// alphabetic or numeric character" on the generated forge.yaml. +func TestInitTemplateFuncs_QuotesWildcardDomain(t *testing.T) { + if got := yamlScalar("*.whatsapp.net"); got != `"*.whatsapp.net"` { + t.Errorf("yamlScalar(%q) = %q, want it quoted", "*.whatsapp.net", got) + } +} + +// Ordinary domains must stay unquoted so existing generated configs keep +// their current formatting. +func TestInitTemplateFuncs_LeavesPlainDomainsAlone(t *testing.T) { + for _, s := range []string{"api.openai.com", "web.whatsapp.com", "api.telegram.org", "slack", "web_search"} { + if got := yamlScalar(s); got != s { + t.Errorf("yamlScalar(%q) = %q, want it unchanged", s, got) + } + } +} + +// The template must actually have the helper registered — a missing FuncMap +// entry fails at Parse, which is exactly the wiring this guards. +func TestInitTemplateFuncs_RegisteredOnTemplate(t *testing.T) { + tmpl, err := template.New("t").Funcs(initTemplateFuncs).Parse(`{{yamlScalar .}}`) + if err != nil { + t.Fatalf("parsing with initTemplateFuncs: %v", err) + } + var b strings.Builder + if err := tmpl.Execute(&b, "*.whatsapp.net"); err != nil { + t.Fatalf("execute: %v", err) + } + if b.String() != `"*.whatsapp.net"` { + t.Errorf("rendered %q, want %q", b.String(), `"*.whatsapp.net"`) + } +} + +// End-to-end shape check: the rendered egress block parses and round-trips +// the wildcard intact. +func TestInitTemplate_EgressBlockWithWildcardParses(t *testing.T) { + domains := []string{"*.whatsapp.net", "api.openai.com", "web.whatsapp.com"} + + tmpl := template.Must(template.New("egress").Funcs(initTemplateFuncs).Parse( + "egress:\n mode: allowlist\n allowed_domains:\n{{- range .}}\n - {{yamlScalar .}}\n{{- end}}\n")) + + var b strings.Builder + if err := tmpl.Execute(&b, domains); err != nil { + t.Fatalf("execute: %v", err) + } + + var doc struct { + Egress struct { + Mode string `yaml:"mode"` + AllowedDomains []string `yaml:"allowed_domains"` + } `yaml:"egress"` + } + if err := yaml.Unmarshal([]byte(b.String()), &doc); err != nil { + t.Fatalf("rendered egress block does not parse: %v\n%s", err, b.String()) + } + if len(doc.Egress.AllowedDomains) != len(domains) { + t.Fatalf("got %d domains, want %d: %v", len(doc.Egress.AllowedDomains), len(domains), doc.Egress.AllowedDomains) + } + for i, want := range domains { + if doc.Egress.AllowedDomains[i] != want { + t.Errorf("domain %d = %q, want %q", i, doc.Egress.AllowedDomains[i], want) + } + } +} diff --git a/forge-cli/go.mod b/forge-cli/go.mod index 0d9b8dd1..06e2e54c 100644 --- a/forge-cli/go.mod +++ b/forge-cli/go.mod @@ -13,11 +13,13 @@ require ( github.com/initializ/forge/forge-skills v0.0.0 github.com/initializ/forge/forge-ui v0.0.0 github.com/initializ/guardrails v0.12.0 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/spf13/cobra v1.10.2 + go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/term v0.43.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.1 @@ -26,8 +28,10 @@ require ( ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beeper/argo-go v1.1.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect @@ -38,7 +42,10 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -67,7 +74,8 @@ require ( github.com/klauspost/compress v1.16.7 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -77,9 +85,14 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/zerolog v1.35.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect @@ -90,6 +103,8 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.etcd.io/bbolt v1.5.0 // indirect + go.mau.fi/libsignal v0.2.2 // indirect + go.mau.fi/util v0.10.0 // indirect go.mongodb.org/mongo-driver v1.17.7 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect @@ -100,21 +115,27 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.82.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.58.0 // indirect + rsc.io/qr v0.2.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/forge-cli/go.sum b/forge-cli/go.sum index 71c11303..740fbf47 100644 --- a/forge-cli/go.sum +++ b/forge-cli/go.sum @@ -1,7 +1,17 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +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/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -32,11 +42,17 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -81,8 +97,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -91,6 +107,8 @@ github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/initializ/ctxzip v0.3.0 h1:qG+Li/qRCL/Bsx3eD3E6q2y2QDWUlIGXhQmt8ViPSsQ= @@ -118,12 +136,18 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -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-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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= +github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -140,21 +164,31 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b h1:sS7HLzwS+dO+gxATgQfeZDEdUZe2pKAB3nGoUwP5zU0= +github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -172,6 +206,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= @@ -195,6 +231,16 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= +go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= +go.mau.fi/util v0.10.0 h1:vH9IXZmfBKa96p47HxrVqEPkrj02zDJg3o4EF172+Lk= +go.mau.fi/util v0.10.0/go.mod h1:uZwpm9sK4wO2Qqy+t6QoVq29szMsRxWXp9/BkQLG4xk= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde h1:eMHY9dMDkNuDMWhfTbMZHbbsxj7G6mfujjKei1HaFQM= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde/go.mod h1:z0ZZNt4hq3FZbUKnunexE/QscCx7VkLvQSvtggc/aE8= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 h1:xJ13dqFcK/oPNImltlk7sumI/uiQ9EON/6g/iwpT1Nc= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837/go.mod h1:UFP0D7aj+biuFejsllcoNiHTQorh0ODFUyG4Txpqfks= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1 h1:4EyoinbJqFq4qozxem5nklT8+pI4OaokUuG+ayE3nKQ= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1/go.mod h1:aMd13H2xFFGH9cskcvxo4Aae+TmyFN38yw+HvsrpwVg= go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -229,29 +275,31 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -261,18 +309,18 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= 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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -280,8 +328,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -294,8 +342,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -318,6 +366,36 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/forge-cli/internal/tui/steps/channel_step.go b/forge-cli/internal/tui/steps/channel_step.go index 3f35a2bb..9147ed5f 100644 --- a/forge-cli/internal/tui/steps/channel_step.go +++ b/forge-cli/internal/tui/steps/channel_step.go @@ -11,6 +11,7 @@ import ( "github.com/initializ/forge/forge-cli/internal/devicecode" "github.com/initializ/forge/forge-cli/internal/tui" "github.com/initializ/forge/forge-cli/internal/tui/components" + "github.com/initializ/forge/forge-cli/internal/wapair" "github.com/initializ/forge/forge-core/catalog" ) @@ -24,6 +25,7 @@ const ( channelMsteamsClientIDPhase channelMsteamsClientSecretPhase channelMsteamsDeviceLoginPhase + channelWhatsappPairPhase channelDonePhase ) @@ -47,6 +49,26 @@ type msteamsRefreshTokenReadyMsg struct { err error } +// whatsapp QR-pairing sub-states inside channelWhatsappPairPhase. +type whatsappPairStatus int + +const ( + whatsappPairConnecting whatsappPairStatus = iota // opening the socket + whatsappPairScanning // showing a QR, waiting for the scan + whatsappPairFinalizing // scanned; post-pair handshake running + whatsappPairErr // failed; show retry/skip +) + +// Tea messages produced by the pairing goroutine. +type whatsappSessionStartedMsg struct { + session *wapair.Session + err error +} +type whatsappPairEventMsg struct { + event wapair.Event + ok bool // false once the event stream closes +} + // ChannelStep handles channel connector selection. type ChannelStep struct { styles *tui.StyleSet @@ -62,6 +84,17 @@ type ChannelStep struct { loginErr string channel string tokens map[string]string + + // whatsapp QR-pairing state. The session is paired into a temp store + // because the project directory does not exist yet at wizard time; + // scaffold moves the file into place afterwards. + pairStatus whatsappPairStatus + pairSession *wapair.Session + pairSessionPath string + pairQR string + pairErr string + pairJID string + pairTempDir string } // channelSelectItems projects the catalog channels into TUI select items. @@ -140,6 +173,8 @@ func (s *ChannelStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s.updateMsteamsClientSecretPhase(msg) case channelMsteamsDeviceLoginPhase: return s.updateMsteamsDeviceLoginPhase(msg) + case channelWhatsappPairPhase: + return s.updateWhatsappPairPhase(msg) } return s, nil @@ -157,6 +192,13 @@ func (s *ChannelStep) updateSelectPhase(msg tea.Msg) (tui.Step, tea.Cmd) { case "none": s.complete = true return s, func() tea.Msg { return tui.StepCompleteMsg{} } + case "whatsapp": + // No token to collect — WhatsApp authenticates by QR pairing, run + // inline here against a live socket. + s.phase = channelWhatsappPairPhase + s.pairStatus = whatsappPairConnecting + s.pairErr = "" + return s, s.startWhatsappPairCmd() case "telegram": s.phase = channelTokenPhase s.keyInput = components.NewSecretInput( @@ -496,6 +538,8 @@ func (s *ChannelStep) View(width int) string { return ins + s.keyInput.View(width) case channelMsteamsDeviceLoginPhase: return s.viewMsteamsDeviceLogin() + case channelWhatsappPairPhase: + return s.viewWhatsappPair() } return "" } @@ -552,6 +596,8 @@ func (s *ChannelStep) Summary() string { return "Slack" case "msteams": return "MS Teams" + case "whatsapp": + return "WhatsApp" } return s.channel } diff --git a/forge-cli/internal/tui/steps/channel_step_whatsapp.go b/forge-cli/internal/tui/steps/channel_step_whatsapp.go new file mode 100644 index 00000000..d3da4296 --- /dev/null +++ b/forge-cli/internal/tui/steps/channel_step_whatsapp.go @@ -0,0 +1,234 @@ +package steps + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/wapair" +) + +// WhatsappSessionTokenKey is the synthetic key the channel step uses to hand +// the paired session's temp path to scaffold, which moves the file into the +// project and strips the key before .env is written. Mirrors the existing +// __egress_domains / __custom_shape convention in cmd/init.go. +const WhatsappSessionTokenKey = "__whatsapp_session" + +// qrMaxRows is the tallest QR we will draw inline. WhatsApp pairing payloads +// render to roughly 29 rows at QuietZone 1; anything beyond this plus the +// wizard's own chrome will not fit a normal terminal, and a wrapped QR is +// unscannable noise rather than a degraded picture. +const qrMaxRows = 34 + +// updateWhatsappPairPhase is the state machine for inline QR pairing. It +// handles the two async events (session started, pairing event) plus the +// retry/skip keys available in the error state. +func (s *ChannelStep) updateWhatsappPairPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + switch m := msg.(type) { + case whatsappSessionStartedMsg: + if m.err != nil { + s.failWhatsappPair(m.err.Error()) + return s, nil + } + s.pairSession = m.session + return s, s.nextWhatsappEventCmd() + + case whatsappPairEventMsg: + if !m.ok { + // Stream closed without a terminal event. + s.failWhatsappPair("pairing ended unexpectedly") + return s, nil + } + switch m.event.Kind { + case wapair.EventScanned: + // Stop showing a code the phone has already taken, and stop + // offering skip: tearing the socket down now would abort the + // handshake and leave a half-registered device. + s.pairStatus = whatsappPairFinalizing + s.pairQR = "" + return s, s.nextWhatsappEventCmd() + + case wapair.EventQR: + s.pairStatus = whatsappPairScanning + s.pairQR = m.event.Code + return s, s.nextWhatsappEventCmd() + + case wapair.EventPaired: + s.pairJID = m.event.JID + // Hand the temp session path to scaffold, which relocates it into + // the project once the directory exists. Read from the step rather + // than the session so the transition stays independent of whether + // the socket is still open. + s.tokens[WhatsappSessionTokenKey] = s.pairSessionPath + s.closeWhatsappSession() + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + + case wapair.EventError: + s.failWhatsappPair(m.event.Err.Error()) + return s, nil + } + return s, nil + + case tea.KeyMsg: + switch m.String() { + case "s", "S": + // Skip — available while scanning as well as after a failure, since + // a QR too tall for the terminal is only escapable this way. + // Finishing unpaired is fine: the agent scaffolds, and the operator + // pairs later with `forge channel whatsapp-login`. + // Ignore while connecting (nothing to tear down yet) and while + // finalizing (aborting mid-handshake is what produces a + // half-registered device the server later rejects with 401). + if s.pairStatus == whatsappPairConnecting || s.pairStatus == whatsappPairFinalizing { + return s, nil + } + s.cleanupWhatsappTemp() + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + case "r", "R": + if s.pairStatus != whatsappPairErr { + return s, nil + } + s.pairStatus = whatsappPairConnecting + s.pairErr = "" + s.pairQR = "" + return s, s.startWhatsappPairCmd() + } + } + + return s, nil +} + +// failWhatsappPair moves into the error state, releasing the socket first so a +// retry doesn't stack a second live connection on the same store. +func (s *ChannelStep) failWhatsappPair(msg string) { + s.closeWhatsappSession() + s.pairStatus = whatsappPairErr + s.pairErr = msg + s.pairQR = "" +} + +// closeWhatsappSession releases the pairing socket and SQLite handle. The temp +// directory is deliberately left in place: on success scaffold still needs to +// read the session file out of it. +func (s *ChannelStep) closeWhatsappSession() { + if s.pairSession != nil { + _ = s.pairSession.Close() + s.pairSession = nil + } +} + +// cleanupWhatsappTemp removes the temp store. Only safe on the abandon paths — +// never after a successful pairing, whose file scaffold has yet to copy. +func (s *ChannelStep) cleanupWhatsappTemp() { + s.closeWhatsappSession() + if s.pairTempDir != "" { + _ = os.RemoveAll(s.pairTempDir) + s.pairTempDir = "" + } + delete(s.tokens, WhatsappSessionTokenKey) +} + +// startWhatsappPairCmd opens a pairing session against a temp store. +// +// The project directory does not exist yet — the wizard runs before scaffold — +// so the pairing cannot be written to its final location. It lands in a temp +// dir and scaffold relocates it. +func (s *ChannelStep) startWhatsappPairCmd() tea.Cmd { + // Reuse the temp dir across retries so a failed attempt doesn't leak one. + if s.pairTempDir == "" { + dir, err := os.MkdirTemp("", "forge-whatsapp-pair-") + if err != nil { + return func() tea.Msg { + return whatsappSessionStartedMsg{err: fmt.Errorf("creating temp session dir: %w", err)} + } + } + s.pairTempDir = dir + } + // A retry must start from a clean store: whatsmeow will not re-issue QR + // codes for a device row that is half-registered from a failed attempt. + sessionPath := filepath.Join(s.pairTempDir, "whatsapp-session.db") + _ = os.Remove(sessionPath) + s.pairSessionPath = sessionPath + + return func() tea.Msg { + sess, err := wapair.Start(context.Background(), sessionPath) + return whatsappSessionStartedMsg{session: sess, err: err} + } +} + +// nextWhatsappEventCmd waits for one pairing event. Each event re-issues this +// command, so the QR refreshes as WhatsApp rotates codes. +func (s *ChannelStep) nextWhatsappEventCmd() tea.Cmd { + sess := s.pairSession + if sess == nil { + return nil + } + return func() tea.Msg { + evt, ok := <-sess.Events() + return whatsappPairEventMsg{event: evt, ok: ok} + } +} + +func (s *ChannelStep) viewWhatsappPair() string { + header := s.styles.SecondaryTxt.Render("WhatsApp Setup — link a device:") + + switch s.pairStatus { + case whatsappPairConnecting: + return fmt.Sprintf(" %s\n %s\n\n", + header, + s.styles.AccentTxt.Render("⣾ Connecting to WhatsApp..."), + ) + + case whatsappPairScanning: + var b strings.Builder + fmt.Fprintf(&b, " %s\n\n", header) + fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("On your phone: WhatsApp → Settings → Linked Devices →")) + fmt.Fprintf(&b, " %s\n\n", s.styles.DimTxt.Render("Link a Device, then scan this code:")) + + rows, cols := wapair.QRDimensions(s.pairQR) + if rows > qrMaxRows { + // Drawing it anyway would wrap into unscannable noise and push the + // rest of the wizard off-screen. Point at the standalone command, + // which owns the full terminal. + fmt.Fprintf(&b, " %s\n", s.styles.ErrorTxt.Render( + fmt.Sprintf("The QR code needs %d×%d and does not fit here.", rows, cols))) + fmt.Fprintf(&b, " %s\n\n", s.styles.DimTxt.Render( + "Press S to skip, then run `forge channel whatsapp-login`.")) + } else { + var qr strings.Builder + wapair.RenderQR(s.pairQR, &qr) + for _, line := range strings.Split(strings.TrimRight(qr.String(), "\n"), "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString("\n") + } + + fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("⣾ Waiting for the scan. The code refreshes automatically.")) + fmt.Fprintf(&b, " %s\n", s.styles.DimTxt.Render("(Press S to skip and pair later.)")) + return b.String() + + case whatsappPairFinalizing: + return fmt.Sprintf(" %s\n\n %s\n %s\n\n", + header, + s.styles.AccentTxt.Render("⣾ Scanned — finalizing the link with WhatsApp..."), + s.styles.DimTxt.Render("Keep this running; interrupting now would leave the pairing incomplete."), + ) + + case whatsappPairErr: + return fmt.Sprintf(" %s\n\n %s\n %s\n\n %s\n %s\n", + header, + s.styles.ErrorTxt.Render("✗ Pairing failed:"), + s.styles.DimTxt.Render(" "+s.pairErr), + s.styles.DimTxt.Render("Press R to retry, or S to skip and pair later with"), + s.styles.DimTxt.Render("`forge channel whatsapp-login`."), + ) + } + return "" +} diff --git a/forge-cli/internal/tui/steps/channel_step_whatsapp_test.go b/forge-cli/internal/tui/steps/channel_step_whatsapp_test.go new file mode 100644 index 00000000..5a80d83d --- /dev/null +++ b/forge-cli/internal/tui/steps/channel_step_whatsapp_test.go @@ -0,0 +1,309 @@ +package steps + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/wapair" +) + +// newPairStep builds a step already sitting in the pairing phase, with the +// temp-store bookkeeping the network path would normally have set up. +func newPairStep(t *testing.T) *ChannelStep { + t.Helper() + s := NewChannelStep(tui.NewStyleSet(tui.DarkTheme)) + s.channel = "whatsapp" + s.phase = channelWhatsappPairPhase + s.pairStatus = whatsappPairConnecting + s.pairTempDir = t.TempDir() + s.pairSessionPath = filepath.Join(s.pairTempDir, "whatsapp-session.db") + return s +} + +func TestChannelStep_SelectingWhatsappEntersPairPhase(t *testing.T) { + s := NewChannelStep(tui.NewStyleSet(tui.DarkTheme)) + s.channel = "whatsapp" + s.phase = channelWhatsappPairPhase + s.pairStatus = whatsappPairConnecting + + if got := s.View(80); !strings.Contains(got, "Connecting to WhatsApp") { + t.Errorf("connecting view should say so, got %q", got) + } + if s.Complete() { + t.Error("step must not be complete while connecting") + } +} + +func TestWhatsappPair_QREventRendersCode(t *testing.T) { + s := newPairStep(t) + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, + event: wapair.Event{Kind: wapair.EventQR, Code: "2@abc123,def456,ghi789"}, + }) + + if s.pairStatus != whatsappPairScanning { + t.Fatalf("status = %v, want scanning", s.pairStatus) + } + view := s.View(80) + if !strings.Contains(view, "Linked Devices") { + t.Errorf("view should carry scan instructions, got %q", view) + } + if !strings.Contains(view, "█") && !strings.Contains(view, "▀") { + t.Errorf("view should contain a rendered QR, got %q", view) + } +} + +// A refreshed code must replace the previous one, not stack. +func TestWhatsappPair_QRRefreshReplacesCode(t *testing.T) { + s := newPairStep(t) + for _, code := range []string{"2@first", "2@second"} { + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, event: wapair.Event{Kind: wapair.EventQR, Code: code}, + }) + } + if s.pairQR != "2@second" { + t.Errorf("pairQR = %q, want the latest code", s.pairQR) + } +} + +func TestWhatsappPair_PairedSetsSessionTokenAndCompletes(t *testing.T) { + s := newPairStep(t) + + _, cmd := s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, + event: wapair.Event{Kind: wapair.EventPaired, JID: "14155550100@s.whatsapp.net"}, + }) + + if !s.Complete() { + t.Error("step should be complete after pairing") + } + if got := s.tokens[WhatsappSessionTokenKey]; got != s.pairSessionPath { + t.Errorf("session token = %q, want %q", got, s.pairSessionPath) + } + if cmd == nil { + t.Fatal("expected a StepCompleteMsg command") + } + if _, ok := cmd().(tui.StepCompleteMsg); !ok { + t.Error("expected StepCompleteMsg") + } +} + +// The temp store must survive a successful pairing — scaffold still has to +// copy the file out of it. +func TestWhatsappPair_PairedKeepsTempStore(t *testing.T) { + s := newPairStep(t) + tempDir := s.pairTempDir + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, event: wapair.Event{Kind: wapair.EventPaired}, + }) + + if _, err := os.Stat(tempDir); err != nil { + t.Errorf("temp store removed after pairing, scaffold cannot read it: %v", err) + } +} + +func TestWhatsappPair_ErrorEntersErrorState(t *testing.T) { + s := newPairStep(t) + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, + event: wapair.Event{Kind: wapair.EventError, Err: errors.New("boom")}, + }) + + if s.pairStatus != whatsappPairErr { + t.Fatalf("status = %v, want error", s.pairStatus) + } + if s.Complete() { + t.Error("an error must not complete the step") + } + view := s.View(80) + for _, want := range []string{"Pairing failed", "boom", "whatsapp-login"} { + if !strings.Contains(view, want) { + t.Errorf("error view missing %q, got %q", want, view) + } + } +} + +func TestWhatsappPair_SessionStartFailureEntersErrorState(t *testing.T) { + s := newPairStep(t) + _, _ = s.updateWhatsappPairPhase(whatsappSessionStartedMsg{err: errors.New("no socket")}) + + if s.pairStatus != whatsappPairErr { + t.Fatalf("status = %v, want error", s.pairStatus) + } + if !strings.Contains(s.pairErr, "no socket") { + t.Errorf("pairErr = %q, want the underlying cause", s.pairErr) + } +} + +// A closed event stream with no terminal event must not hang the wizard. +func TestWhatsappPair_ClosedStreamFailsRatherThanHangs(t *testing.T) { + s := newPairStep(t) + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ok: false}) + + if s.pairStatus != whatsappPairErr { + t.Fatalf("status = %v, want error", s.pairStatus) + } +} + +func TestWhatsappPair_SkipCompletesWithoutToken(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairScanning + tempDir := s.pairTempDir + + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + + if !s.Complete() { + t.Error("skip should complete the step") + } + if _, ok := s.tokens[WhatsappSessionTokenKey]; ok { + t.Error("skip must not leave a session token behind") + } + if _, err := os.Stat(tempDir); !os.IsNotExist(err) { + t.Errorf("skip should remove the temp store, stat err = %v", err) + } +} + +// Skip is offered in the scanning view, so it has to work there — not only +// after a failure. A QR too tall for the terminal is escapable no other way. +func TestWhatsappPair_SkipWorksWhileScanning(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairScanning + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'S'}}) + if !s.Complete() { + t.Error("skip should work while scanning") + } +} + +// While connecting there is no session to tear down yet; a keypress then must +// be ignored rather than completing the step in a half-built state. +func TestWhatsappPair_SkipIgnoredWhileConnecting(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairConnecting + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if s.Complete() { + t.Error("skip must be ignored while still connecting") + } +} + +func TestWhatsappPair_RetryOnlyFromErrorState(t *testing.T) { + s := newPairStep(t) + + s.pairStatus = whatsappPairScanning + if _, cmd := s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}); cmd != nil { + t.Error("retry must be ignored while scanning") + } + + s.pairStatus = whatsappPairErr + s.pairErr = "boom" + _, cmd := s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + if cmd == nil { + t.Fatal("retry from the error state should start a new attempt") + } + if s.pairStatus != whatsappPairConnecting { + t.Errorf("status = %v, want connecting after retry", s.pairStatus) + } + if s.pairErr != "" { + t.Errorf("retry should clear the previous error, got %q", s.pairErr) + } +} + +// Retries must reuse one temp dir rather than leaking a new one each time. +func TestWhatsappPair_RetryReusesTempDir(t *testing.T) { + s := newPairStep(t) + first := s.pairTempDir + + s.pairStatus = whatsappPairErr + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + + if s.pairTempDir != first { + t.Errorf("temp dir changed on retry: %q -> %q", first, s.pairTempDir) + } +} + +func TestChannelStep_WhatsappSummary(t *testing.T) { + s := NewChannelStep(tui.NewStyleSet(tui.DarkTheme)) + s.channel = "whatsapp" + if got := s.Summary(); got != "WhatsApp" { + t.Errorf("Summary() = %q, want %q", got, "WhatsApp") + } +} + +// The wizard has limited vertical room. A realistic WhatsApp pairing payload +// must render small enough to draw inline, or the QR guard silently degrades +// every pairing to the standalone command. +func TestWhatsappPair_RealisticQRFitsInline(t *testing.T) { + // Shape and length match a real pairing code: four comma-separated + // base64 segments, ~200 chars total. + code := "2@" + strings.Repeat("A1b2C3d4", 8) + "," + strings.Repeat("E5f6G7h8", 6) + + "," + strings.Repeat("I9j0K1l2", 5) + "," + strings.Repeat("M3n4O5p6", 2) + + rows, cols := wapair.QRDimensions(code) + if rows > qrMaxRows { + t.Errorf("QR renders %d rows, over the %d inline budget — pairing would always fall back", rows, qrMaxRows) + } + if cols > 80 { + t.Errorf("QR renders %d cols, wider than an 80-col terminal", cols) + } + t.Logf("realistic pairing QR: %d rows x %d cols (payload %d chars)", rows, cols, len(code)) +} + +// After the phone accepts the code the QR must disappear — leaving it on +// screen invites a second scan of a dead code. +func TestWhatsappPair_ScannedEntersFinalizing(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairScanning + s.pairQR = "2@stale" + + _, _ = s.updateWhatsappPairPhase(whatsappPairEventMsg{ + ok: true, event: wapair.Event{Kind: wapair.EventScanned}, + }) + + if s.pairStatus != whatsappPairFinalizing { + t.Fatalf("status = %v, want finalizing", s.pairStatus) + } + if s.pairQR != "" { + t.Error("the scanned QR must be cleared") + } + view := s.View(80) + if !strings.Contains(view, "finalizing") { + t.Errorf("view should say it is finalizing, got %q", view) + } + if strings.Contains(view, "█") { + t.Error("view must not still draw the scanned QR") + } +} + +// Skipping mid-handshake closes the socket before the post-pair login lands, +// which is exactly what produces a device the server rejects with 401. The +// key must be ignored in this window. +func TestWhatsappPair_SkipIgnoredWhileFinalizing(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairFinalizing + + _, _ = s.updateWhatsappPairPhase(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + + if s.Complete() { + t.Error("skip must be ignored while the pairing handshake is running") + } + if _, ok := s.tokens[WhatsappSessionTokenKey]; ok { + t.Error("no session token should be set mid-handshake") + } +} + +// The finalizing view must not promise a skip key that is deliberately inert. +func TestWhatsappPair_FinalizingViewDoesNotOfferSkip(t *testing.T) { + s := newPairStep(t) + s.pairStatus = whatsappPairFinalizing + if view := s.View(80); strings.Contains(view, "Press S") { + t.Errorf("finalizing view must not offer skip, got %q", view) + } +} diff --git a/forge-cli/internal/tui/steps/egress_step.go b/forge-cli/internal/tui/steps/egress_step.go index 7a5849c7..2aac89c1 100644 --- a/forge-cli/internal/tui/steps/egress_step.go +++ b/forge-cli/internal/tui/steps/egress_step.go @@ -166,6 +166,8 @@ func inferSource(domain string, ctx *tui.WizardContext) string { "wss-primary.slack.com": "channel", "api.slack.com": "channel", "files.slack.com": "channel", + "web.whatsapp.com": "channel", + "*.whatsapp.net": "channel", } if src, ok := channelDomains[domain]; ok { return src diff --git a/forge-cli/internal/wapair/wapair.go b/forge-cli/internal/wapair/wapair.go new file mode 100644 index 00000000..0a8c6b05 --- /dev/null +++ b/forge-cli/internal/wapair/wapair.go @@ -0,0 +1,342 @@ +// Package wapair drives the WhatsApp Web QR pairing flow. +// +// It is the WhatsApp counterpart to internal/devicecode: a small, UI-agnostic +// driver that both `forge channel whatsapp-login` and the init wizard's +// channel step use, so the pairing state machine lives in exactly one place. +package wapair + +import ( + "context" + "fmt" + "io" + "sync" + "time" + + "github.com/mdp/qrterminal/v3" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types/events" + + "github.com/initializ/forge/forge-plugins/channels/whatsapp" +) + +// EventKind discriminates the events a pairing session emits. +type EventKind int + +const ( + // EventQR carries a new code to display. WhatsApp rotates codes every + // ~20s, so several of these arrive before the user finishes scanning. + EventQR EventKind = iota + // EventScanned means the phone accepted the code and the post-pair + // handshake is running. NOT yet usable — EventPaired is the completion. + EventScanned + // EventPaired means the scan succeeded and the session is persisted. + EventPaired + // EventError is terminal: the session cannot complete. + EventError +) + +// Event is one step of the pairing flow. +type Event struct { + Kind EventKind + Code string // EventQR: the raw payload to encode + Timeout time.Duration // EventQR: how long until the next code + JID string // EventPaired: the linked account + Err error // EventError +} + +// Session is an in-flight pairing attempt. Close it when done — it holds an +// open socket and a SQLite handle. +type Session struct { + sessionPath string + container io.Closer + client *whatsmeow.Client + events chan Event + cancel context.CancelFunc + closeOnce sync.Once + + // Post-pair completion signals. PairSuccess is NOT the end of pairing: + // the server drops the socket, the client reconnects and completes a + // login handshake, and only then is the device registered server-side. + connected chan struct{} + connOnce sync.Once + fatal chan error +} + +// Start opens (creating if absent) the session store at sessionPath and begins +// a pairing attempt. Events are delivered on Events() until the channel closes. +// +// The caller owns the returned Session and must Close it. +func Start(ctx context.Context, sessionPath string) (*Session, error) { + ctx, cancel := context.WithCancel(ctx) + + container, device, err := whatsapp.NewSessionDevice(ctx, sessionPath) + if err != nil { + cancel() + return nil, err + } + + client := whatsmeow.NewClient(device, nil) + + s := &Session{ + sessionPath: sessionPath, + container: container, + client: client, + events: make(chan Event, 4), + cancel: cancel, + connected: make(chan struct{}), + fatal: make(chan error, 1), + } + + // Watch for the post-pair reconnect. The QR channel cannot report it: it + // closes on PairSuccess and treats a later Connected as an unexpected + // event, so completion has to be observed on the client itself. + client.AddEventHandler(func(evt any) { + switch e := evt.(type) { + case *events.Connected: + s.connOnce.Do(func() { close(s.connected) }) + case *events.LoggedOut: + s.failFast(fmt.Errorf("server rejected the new pairing (%s)", e.Reason)) + case *events.ConnectFailure: + s.failFast(fmt.Errorf("connection failed after pairing (%s)", e.Reason)) + } + }) + + // GetQRChannel must be called BEFORE Connect: pairing codes arrive on the + // socket immediately after it opens, and a channel registered afterwards + // misses them. + qrChan, err := client.GetQRChannel(ctx) + if err != nil { + cancel() + _ = container.Close() + return nil, fmt.Errorf("whatsapp: opening QR channel: %w", err) + } + if err := client.Connect(); err != nil { + cancel() + _ = container.Close() + return nil, fmt.Errorf("whatsapp: connecting: %w", err) + } + + go s.pump(ctx, qrChan) + return s, nil +} + +// failFast records a terminal post-pair failure without blocking the emitter. +func (s *Session) failFast(err error) { + select { + case s.fatal <- err: + default: + } +} + +// pump translates whatsmeow's QR items into Events and closes the channel when +// the flow reaches a terminal state. +func (s *Session) pump(ctx context.Context, qrChan <-chan whatsmeow.QRChannelItem) { + defer close(s.events) + + for evt := range qrChan { + switch evt.Event { + case "code": + s.emit(ctx, Event{Kind: EventQR, Code: evt.Code, Timeout: evt.Timeout}) + + case "success": + // The phone accepted the code, but pairing is NOT done — see + // awaitPairCompletion. Tell the caller so it can stop showing a + // QR that has already been scanned. + s.emit(ctx, Event{Kind: EventScanned}) + s.emit(ctx, s.awaitPairCompletion(ctx)) + return + + case "timeout": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("QR code expired without being scanned")}) + return + + case "err-client-outdated": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("WhatsApp rejected this client as outdated — the pinned whatsmeow version needs updating")}) + return + + case "err-scanned-without-multidevice": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("the account scanned the code without multi-device enabled — enable it in WhatsApp → Settings → Linked Devices")}) + return + + case "error": + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("pairing failed: %w", evt.Error)}) + return + + default: + // Every other "err-" event is terminal (e.g. err-unexpected-state). + // Reporting it as informational would leave the caller waiting on a + // pairing that can never complete. + if len(evt.Event) > 4 && evt.Event[:4] == "err-" { + if evt.Error != nil { + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("pairing failed (%s): %w", evt.Event, evt.Error)}) + } else { + s.emit(ctx, Event{Kind: EventError, Err: fmt.Errorf("pairing failed (%s)", evt.Event)}) + } + return + } + } + } + + // The channel closed with no terminal event: the context was cancelled or + // the socket dropped mid-pairing. + if ctx.Err() != nil { + s.emit(context.Background(), Event{Kind: EventError, Err: ctx.Err()}) + return + } + s.emit(context.Background(), Event{Kind: EventError, Err: fmt.Errorf("pairing ended without completing")}) +} + +// emit delivers an event unless the caller has gone away. +func (s *Session) emit(ctx context.Context, e Event) { + select { + case s.events <- e: + case <-ctx.Done(): + } +} + +// Events returns the pairing event stream. It closes when the flow reaches a +// terminal state. +func (s *Session) Events() <-chan Event { return s.events } + +// SessionPath is where the pairing is persisted. +func (s *Session) SessionPath() string { return s.sessionPath } + +// Close tears down the socket and the session store. Safe to call repeatedly. +func (s *Session) Close() error { + var err error + s.closeOnce.Do(func() { + s.cancel() + if s.client != nil { + s.client.Disconnect() + } + if s.container != nil { + err = s.container.Close() + } + }) + return err +} + +// RenderQR writes code as a scannable QR block. +// +// Half-block rendering keeps the code square in a terminal whose cells are +// taller than they are wide; a full-block render is stretched and many phones +// fail to lock onto it. QuietZone is 1 rather than qrterminal's default 4 — +// the standard 4-module margin costs 6 rows and 6 columns, which is the +// difference between fitting and not fitting in a typical 80x30 terminal. +// Phones scan reliably at 1 against a contrasting terminal background. +func RenderQR(code string, w io.Writer) { + qrterminal.GenerateWithConfig(code, qrterminal.Config{ + Level: qrterminal.L, + Writer: w, + HalfBlocks: true, + BlackChar: qrterminal.BLACK_BLACK, + WhiteChar: qrterminal.WHITE_WHITE, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + QuietZone: 1, + }) +} + +// QRDimensions reports the rows and columns RenderQR needs for code, so a +// caller can warn before drawing something the terminal will wrap into +// unscannable noise. +func QRDimensions(code string) (rows, cols int) { + var c countingWriter + RenderQR(code, &c) + return c.rows, c.cols +} + +// countingWriter measures rendered output without retaining it. +type countingWriter struct { + rows int + cols int + cur int +} + +func (c *countingWriter) Write(p []byte) (int, error) { + for _, b := range p { + if b == '\n' { + if c.cur > c.cols { + c.cols = c.cur + } + c.cur = 0 + c.rows++ + continue + } + // Count runes, not bytes — the block glyphs are multi-byte. + if b&0xC0 != 0x80 { + c.cur++ + } + } + return len(p), nil +} + +// pairCompletionTimeout bounds the post-scan handshake. The reconnect is +// usually sub-second; this only guards against a server that never completes. +const pairCompletionTimeout = 90 * time.Second + +// pairSettleDelay is how long the socket is held open after the post-pair +// login lands. +// +// whatsmeow uploads prekeys and kicks off app-state sync asynchronously once +// logged in. Tearing the connection down the instant Connected fires can cut +// that short, leaving a device the server considers half-registered. +const pairSettleDelay = 4 * time.Second + +// awaitPairCompletion blocks until the pairing is actually usable, and returns +// the Event to report. +// +// PairSuccess (the QR channel's "success") is NOT the end of pairing. At that +// point whatsmeow has written the device locally, but the server then drops +// the socket; the client reconnects and completes a login handshake, and only +// after that is the device registered server-side. +// +// Disconnecting on PairSuccess is what produced the field failure this guards +// against: pairing appeared to succeed, and the very next `forge run` was +// dropped with 401 "logged out from another device", because the registration +// was never finished. +func (s *Session) awaitPairCompletion(ctx context.Context) Event { + return awaitCompletion(ctx, s.connected, s.fatal, s.client.IsLoggedIn, func() string { + if s.client.Store.ID != nil { + return s.client.Store.ID.ToNonAD().String() + } + return "" + }, pairSettleDelay, pairCompletionTimeout) +} + +// awaitCompletion is the socket-free core of awaitPairCompletion, so the +// completion rules can be tested without a live WhatsApp connection. +func awaitCompletion( + ctx context.Context, + connected <-chan struct{}, + fatal <-chan error, + isLoggedIn func() bool, + jid func() string, + settle, timeout time.Duration, +) Event { + select { + case <-connected: + // Logged in post-pair. Hold the socket briefly so the asynchronous + // prekey upload and app-state sync can finish. + select { + case err := <-fatal: + return Event{Kind: EventError, Err: err} + case <-time.After(settle): + case <-ctx.Done(): + return Event{Kind: EventError, Err: ctx.Err()} + } + if !isLoggedIn() { + return Event{Kind: EventError, Err: fmt.Errorf("pairing did not complete: the server did not confirm the new device")} + } + return Event{Kind: EventPaired, JID: jid()} + + case err := <-fatal: + return Event{Kind: EventError, Err: err} + + case <-time.After(timeout): + return Event{Kind: EventError, Err: fmt.Errorf("timed out waiting for WhatsApp to confirm the new device")} + + case <-ctx.Done(): + return Event{Kind: EventError, Err: ctx.Err()} + } +} diff --git a/forge-cli/internal/wapair/wapair_test.go b/forge-cli/internal/wapair/wapair_test.go new file mode 100644 index 00000000..573818c8 --- /dev/null +++ b/forge-cli/internal/wapair/wapair_test.go @@ -0,0 +1,218 @@ +package wapair + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +const ( + testSettle = 20 * time.Millisecond + testTimeout = 500 * time.Millisecond +) + +func loggedIn() bool { return true } +func loggedOut() bool { return false } +func testJID() string { return "14155550100@s.whatsapp.net" } + +// The regression this guards: PairSuccess is not the end of pairing. Reporting +// success before the post-pair reconnect leaves the device half-registered, +// and the next connection is dropped with 401 "logged out from another +// device". Completion must wait for the reconnect. +func TestAwaitCompletion_WaitsForPostPairConnect(t *testing.T) { + connected := make(chan struct{}) + fatal := make(chan error, 1) + + var got Event + done := make(chan struct{}) + go func() { + got = awaitCompletion(context.Background(), connected, fatal, loggedIn, testJID, testSettle, testTimeout) + close(done) + }() + + // Nothing may be reported while the reconnect is still outstanding. + select { + case <-done: + t.Fatal("reported completion before the post-pair reconnect") + case <-time.After(50 * time.Millisecond): + } + + close(connected) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("did not complete after the reconnect") + } + + if got.Kind != EventPaired { + t.Fatalf("kind = %v, want EventPaired (err=%v)", got.Kind, got.Err) + } + if got.JID != testJID() { + t.Errorf("JID = %q, want %q", got.JID, testJID()) + } +} + +// The settle window exists so the asynchronous prekey upload can finish; +// completion must not be reported before it elapses. +func TestAwaitCompletion_HoldsSocketForSettleWindow(t *testing.T) { + connected := make(chan struct{}) + close(connected) + + start := time.Now() + got := awaitCompletion(context.Background(), connected, make(chan error, 1), + loggedIn, testJID, 150*time.Millisecond, testTimeout) + + if elapsed := time.Since(start); elapsed < 150*time.Millisecond { + t.Errorf("returned after %v, expected to hold for the settle window", elapsed) + } + if got.Kind != EventPaired { + t.Errorf("kind = %v, want EventPaired", got.Kind) + } +} + +// A logout arriving during the wait is the server rejecting the pairing. +func TestAwaitCompletion_LogoutBeforeConnectIsFatal(t *testing.T) { + fatal := make(chan error, 1) + fatal <- errors.New("server rejected the new pairing (401)") + + got := awaitCompletion(context.Background(), make(chan struct{}), fatal, + loggedIn, testJID, testSettle, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "rejected") { + t.Errorf("err = %v, want the rejection reason", got.Err) + } +} + +// A logout can also land inside the settle window, after a good connect. +func TestAwaitCompletion_LogoutDuringSettleIsFatal(t *testing.T) { + connected := make(chan struct{}) + close(connected) + fatal := make(chan error, 1) + + go func() { + time.Sleep(20 * time.Millisecond) + fatal <- errors.New("logged out from another device") + }() + + got := awaitCompletion(context.Background(), connected, fatal, + loggedIn, testJID, 2*time.Second, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "logged out") { + t.Errorf("err = %v, want the logout reason", got.Err) + } +} + +// Connected then not-logged-in means the handshake did not actually land. +// Reporting success there would hand back an unusable session. +func TestAwaitCompletion_NotLoggedInAfterSettleFails(t *testing.T) { + connected := make(chan struct{}) + close(connected) + + got := awaitCompletion(context.Background(), connected, make(chan error, 1), + loggedOut, testJID, testSettle, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "did not complete") { + t.Errorf("err = %v, want a completion failure", got.Err) + } +} + +func TestAwaitCompletion_TimesOutWaitingForConnect(t *testing.T) { + got := awaitCompletion(context.Background(), make(chan struct{}), make(chan error, 1), + loggedIn, testJID, testSettle, 60*time.Millisecond) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !strings.Contains(got.Err.Error(), "timed out") { + t.Errorf("err = %v, want a timeout", got.Err) + } +} + +func TestAwaitCompletion_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got := awaitCompletion(ctx, make(chan struct{}), make(chan error, 1), + loggedIn, testJID, testSettle, testTimeout) + + if got.Kind != EventError { + t.Fatalf("kind = %v, want EventError", got.Kind) + } + if !errors.Is(got.Err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", got.Err) + } +} + +// The real settle window has to be long enough to be meaningful and short +// enough not to look like a hang. +func TestPairTimings_AreSane(t *testing.T) { + if pairSettleDelay < time.Second { + t.Errorf("pairSettleDelay = %v, too short for the prekey upload to land", pairSettleDelay) + } + if pairSettleDelay > 15*time.Second { + t.Errorf("pairSettleDelay = %v, long enough to read as a hang", pairSettleDelay) + } + if pairCompletionTimeout <= pairSettleDelay { + t.Errorf("pairCompletionTimeout (%v) must exceed pairSettleDelay (%v)", pairCompletionTimeout, pairSettleDelay) + } +} + +func TestRenderQR_ProducesBlockGlyphs(t *testing.T) { + var b strings.Builder + RenderQR("2@test,payload,here", &b) + out := b.String() + if !strings.Contains(out, "█") && !strings.Contains(out, "▀") { + t.Errorf("expected block glyphs, got %q", out[:min(80, len(out))]) + } +} + +func TestQRDimensions_MatchesRenderedOutput(t *testing.T) { + const code = "2@abcdefgh,ijklmnop,qrstuvwx" + + rows, cols := QRDimensions(code) + + var b strings.Builder + RenderQR(code, &b) + lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") + + if rows != len(lines) { + t.Errorf("QRDimensions rows = %d, rendered %d lines", rows, len(lines)) + } + widest := 0 + for _, l := range lines { + if n := len([]rune(l)); n > widest { + widest = n + } + } + if cols != widest { + t.Errorf("QRDimensions cols = %d, widest rendered line = %d", cols, widest) + } +} + +// A tighter quiet zone is what makes the QR fit inside the init wizard. +func TestRenderQR_UsesTightQuietZone(t *testing.T) { + code := "2@" + strings.Repeat("A1b2C3d4", 20) + rows, cols := QRDimensions(code) + if rows > 34 || cols > 80 { + t.Errorf("QR is %dx%d — too large to draw inline in the wizard", rows, cols) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/forge-cli/templates/init/env-whatsapp.tmpl b/forge-cli/templates/init/env-whatsapp.tmpl new file mode 100644 index 00000000..53de345e --- /dev/null +++ b/forge-cli/templates/init/env-whatsapp.tmpl @@ -0,0 +1,11 @@ +# WhatsApp channel adapter. +# +# There is no bot token: WhatsApp Web authenticates by QR pairing, and the +# resulting session lives in the file named by session_path in +# whatsapp-config.yaml (default .forge/channels/whatsapp-session.db). +# Pair with: forge channel whatsapp-login +# +# Optional — override the session store location. Uncomment and set to use a +# path other than the one in whatsapp-config.yaml (for example a mounted +# volume in a container deployment). +# WHATSAPP_SESSION_PATH= diff --git a/forge-cli/templates/init/forge.yaml.tmpl b/forge-cli/templates/init/forge.yaml.tmpl index cca69161..ef2341ad 100644 --- a/forge-cli/templates/init/forge.yaml.tmpl +++ b/forge-cli/templates/init/forge.yaml.tmpl @@ -31,14 +31,14 @@ tools: channels: {{- range .Channels}} - - {{.}} + - {{yamlScalar .}} {{- end}} {{- end}} {{- if .BuiltinTools}} builtin_tools: {{- range .BuiltinTools}} - - {{.}} + - {{yamlScalar .}} {{- end}} {{- end}} {{- if .Compression}} @@ -55,7 +55,7 @@ egress: mode: allowlist allowed_domains: {{- range .EgressDomains}} - - {{.}} + - {{yamlScalar .}} {{- end}} {{- end}} diff --git a/forge-cli/templates/init/whatsapp-config.yaml.tmpl b/forge-cli/templates/init/whatsapp-config.yaml.tmpl new file mode 100644 index 00000000..16825cb5 --- /dev/null +++ b/forge-cli/templates/init/whatsapp-config.yaml.tmpl @@ -0,0 +1,75 @@ +adapter: whatsapp + +settings: + # Paired session store, written by `forge channel whatsapp-login`. + # Relative paths resolve against the project root. + # + # This file IS the credential — anyone holding it can send messages as the + # linked account. Keep it out of version control and off shared volumes. + session_path: .forge/channels/whatsapp-session.db + + # Admission policy: which messages reach the agent. + # - "dm": only 1:1 chats + # - "group_mention": only group messages that @-mention the agent + # - "dm_or_group_mention" (default, recommended): either + # + # An agent in a group must not answer every line of an unrelated human + # conversation, so group traffic always requires an explicit mention. + admit: dm_or_group_mention + + # Restrict which groups the agent acts in. Empty = every group it is a + # member of, still subject to `admit`. Comma- or newline-separated group + # JIDs ("120363000000000000@g.us"); the "@g.us" suffix may be omitted. + allowed_groups: "" + + # Who may invoke the agent. + # + # EMPTY MEANS OWNER ONLY — the paired account is the sole permitted sender. + # Anyone who has the number can otherwise reach the agent, spend its LLM + # budget and use whatever it can access, so the closed default is the safe + # one. The owner always passes regardless of this list. + # + # To admit other people, list them: comma- or newline-separated phone + # numbers in any written form ("+1 (415) 555-0100") or full JIDs. + # To open the agent to anyone with the number, set: allowed_senders: anyone + # + # NOTE: space is NOT a separator here, unlike other adapters' list settings — + # a space is part of a written phone number. + # + # WhatsApp is migrating group participants to hidden-number LIDs. A LID + # sender is matched via its phone-number alternate when the server supplies + # one; when it doesn't, the message is dropped and the reason names the LID + # so you can add it to this list directly. + allowed_senders: "" + + # Answer messages you send to yourself, in WhatsApp's "Message Yourself" + # chat. This is the personal-agent flow: pair once, then talk to the agent + # from the same phone, with no second account. + # + # Your own messages are what the agent reads there, and its own replies look + # identical on the wire — the loop guard is the dedup ring, which records + # every message the agent sends before it can come back. Self-messages are + # only ever accepted in this chat, never in groups. + self_chat: true + + # Marker prepended to the agent's replies in the self-chat. + # + # WhatsApp picks which side of the thread a message renders on from its + # sender, and in the self-chat the agent sends AS you — so without a marker + # its replies look exactly like your own messages. Set to "" to disable. + # Only ever applied in the self-chat; a normal DM already shows who sent what. + self_chat_prefix: "⚒ Forge: " + + # Prepend recently observed messages from the chat to each dispatched + # prompt, so the agent can answer "summarise the above" in a group. + # + # LIMITATION: unlike the MS Teams adapter, this window is built from traffic + # the adapter itself observed — WhatsApp has no server-side "fetch recent + # messages" API for a linked device. Context therefore covers messages seen + # since the adapter started and is lost on restart. + include_recent_history: true + + # How many recent messages to keep per chat and inject as context. The + # rendered block is soft-capped at ~5000 chars to protect the LLM context + # budget, so very long messages may be trimmed before this count is reached. + recent_history_count: 20 diff --git a/forge-core/catalog/channels.go b/forge-core/catalog/channels.go index 8f729143..5b7de9ff 100644 --- a/forge-core/catalog/channels.go +++ b/forge-core/catalog/channels.go @@ -40,6 +40,16 @@ var channels = []Channel{ {EnvVar: "MSTEAMS_CLIENT_SECRET", Prompt: "MS Teams Client Secret (from Entra app)", Secret: true}, }, }, + { + ID: "whatsapp", + Label: "WhatsApp", + Description: "QR pairing via WhatsApp Web, no public URL needed", + Icon: "\U0001F4AC", + // WhatsApp has no bot token. The session is a QR pairing captured by + // an interactive device-login flow, so there is nothing to prompt for + // here — same reason MSTEAMS_REFRESH_TOKEN is absent above. + Credentials: nil, + }, } // AllChannels returns the catalog of messaging channels in display order. diff --git a/forge-core/security/capabilities.go b/forge-core/security/capabilities.go index b210d729..9a7d06ab 100644 --- a/forge-core/security/capabilities.go +++ b/forge-core/security/capabilities.go @@ -9,6 +9,11 @@ var DefaultCapabilityBundles = map[string][]string{ // (graph.microsoft.us / microsoftgraph.chinacloudapi.cn / their respective // login hosts) via egress.allowed_domains. "msteams": {"graph.microsoft.com", "login.microsoftonline.com"}, + // WhatsApp Web multidevice. The websocket is a fixed host; media hosts are + // handed to the client at runtime by the server (mmg, mmg-fallback and + // regional media-*.cdn names), so the media side has to be a wildcard — + // pinning today's hostnames would break on the next CDN reshuffle. + "whatsapp": {"web.whatsapp.com", "*.whatsapp.net"}, } // ResolveCapabilities returns a deduplicated list of domains for the given capability names. diff --git a/forge-plugins/channels/markdown/whatsapp.go b/forge-plugins/channels/markdown/whatsapp.go new file mode 100644 index 00000000..90ebcbd9 --- /dev/null +++ b/forge-plugins/channels/markdown/whatsapp.go @@ -0,0 +1,227 @@ +package markdown + +import ( + "regexp" + "strings" +) + +// WhatsApp accepts up to 65536 characters in a text message body, but a wall +// that long is unreadable on a phone and the client truncates it behind a +// "Read more" fold. We split at 4000 — the same threshold Slack uses — so +// each chunk stays a scannable message. +const whatsappBodyLimit = 4000 + +// ToWhatsAppText converts standard markdown to WhatsApp's formatting subset. +// +// WhatsApp supports only *bold*, _italic_, ~strikethrough~, `inline code` and +// ```fenced code```. There are no headings, no links-with-text, no tables and +// no native lists. Everything else degrades to plain text rather than leaking +// raw markdown syntax at the reader: +// +// # Heading → *Heading* +// - item → • item +// [text](url) → text: url +// | a | b | → a — b +// --- → ────────── +// +// Note the asterisk inversion: markdown **bold** is WhatsApp *bold*, and +// markdown *italic* is WhatsApp _italic_. Bold is therefore converted through +// a placeholder so the italic pass can't reclaim the single asterisks it +// just produced. +func ToWhatsAppText(md string) string { + lines := strings.Split(md, "\n") + out := make([]string, 0, len(lines)) + inFence := false + + for _, line := range lines { + // Fenced code delimiters and bodies pass through verbatim — WhatsApp + // renders ``` fences natively and the contents must not be rewritten. + if strings.HasPrefix(line, "```") { + inFence = !inFence + out = append(out, line) + continue + } + if inFence { + out = append(out, line) + continue + } + + out = append(out, convertWhatsAppBlockLine(line)) + } + + return strings.Join(out, "\n") +} + +// convertWhatsAppBlockLine handles block-level elements and inline transforms +// for a single non-fenced line. +func convertWhatsAppBlockLine(line string) string { + // Table separator rows ("|---|:--:|") carry no content — drop them + // entirely rather than rendering a row of dashes. + if tableSepRe.MatchString(line) { + return "" + } + + // Table rows: "| a | b |" → "a — b". Done before the inline pass so cell + // contents are transformed but the pipes themselves are not. + if tableRowRe.MatchString(line) { + trimmed := strings.Trim(strings.TrimSpace(line), "|") + cells := strings.Split(trimmed, "|") + for i, c := range cells { + cells[i] = applyWhatsAppInline(strings.TrimSpace(c)) + } + return strings.Join(cells, " — ") + } + + // Horizontal rule. WhatsApp has no
, so draw one. + if hrRe.MatchString(line) { + return "──────────" + } + + // Headings: "# Title" → "*Title*". Inline transforms are deliberately NOT + // applied to the heading text — nesting a *bold* span inside the wrapping + // asterisks produces unbalanced markers that WhatsApp renders literally. + // Mirrors convertSlackBlockLine's handling for the same reason. + if m := headerRe.FindStringSubmatch(line); m != nil { + return "*" + m[2] + "*" + } + + // Blockquote. WhatsApp added native "> " quoting; keep the marker. + if m := blockquoteRe.FindStringSubmatch(line); m != nil { + return "> " + applyWhatsAppInline(m[1]) + } + + // Ordered list: keep the numbering, which WhatsApp renders natively. + if m := orderedListRe.FindStringSubmatch(line); m != nil { + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + num := orderedNumRe.FindString(strings.TrimSpace(line)) + return indent + num + " " + applyWhatsAppInline(m[1]) + } + + // Unordered list: "- item" → "• item". A leading "*" would otherwise be + // read as an unbalanced bold marker. Indentation is preserved so nested + // lists — common in agent output — keep their shape. + if m := whatsappBulletRe.FindStringSubmatch(line); m != nil { + return m[1] + "• " + applyWhatsAppInline(m[2]) + } + + return applyWhatsAppInline(line) +} + +// applyWhatsAppInline applies inline markdown transforms for WhatsApp text. +// +// Inline code spans are lifted out first and restored last, so their contents +// are never rewritten — a literal "**" inside `code` must survive intact. +func applyWhatsAppInline(line string) string { + line, codes := liftInlineCode(line) + + // Images before links: "![alt](url)" shares a suffix with "[text](url)", + // so running linkRe first would leave a stray "!" behind. + line = imageRe.ReplaceAllStringFunc(line, func(m string) string { + g := imageRe.FindStringSubmatch(m) + return flattenLink(g[1], g[2]) + }) + line = linkRe.ReplaceAllStringFunc(line, func(m string) string { + g := linkRe.FindStringSubmatch(m) + return flattenLink(g[1], g[2]) + }) + + // Bold: **text** → placeholder, so the italic pass below cannot match the + // single asterisks this produces. Restored after italic. + line = boldRe.ReplaceAllStringFunc(line, func(m string) string { + return "\x01" + boldRe.FindStringSubmatch(m)[1] + "\x02" + }) + + // Strikethrough: ~~text~~ → ~text~ + line = strikethroughRe.ReplaceAllString(line, "~${1}~") + + // Italic: *text* → _text_ (placeholders shield the converted bold spans) + line = italicRe.ReplaceAllString(line, "_${1}_") + + line = strings.ReplaceAll(line, "\x01", "*") + line = strings.ReplaceAll(line, "\x02", "*") + + return restoreInlineCode(line, codes) +} + +// flattenLink renders a markdown link as WhatsApp-safe text. WhatsApp +// auto-links bare URLs but has no anchor syntax, so the label has to be +// spelled out beside the target. A label identical to (or contained in) the +// URL would just be noise, so the URL alone is emitted. +func flattenLink(text, url string) string { + text = strings.TrimSpace(text) + url = strings.TrimSpace(url) + if text == "" || text == url || strings.Contains(url, text) { + return url + } + return text + ": " + url +} + +// liftInlineCode replaces each `...` span with an index sentinel and returns +// the extracted spans. The sentinel uses \x00 delimiters, which cannot occur +// in agent output that has already been through the LLM. +func liftInlineCode(line string) (string, []string) { + if !strings.Contains(line, "`") { + return line, nil + } + var codes []string + out := inlineCodeRe.ReplaceAllStringFunc(line, func(m string) string { + codes = append(codes, m) + return "\x00" + itoa(len(codes)-1) + "\x00" + }) + return out, codes +} + +// restoreInlineCode substitutes the sentinels planted by liftInlineCode back +// to their original `...` spans. +func restoreInlineCode(line string, codes []string) string { + for i, c := range codes { + line = strings.ReplaceAll(line, "\x00"+itoa(i)+"\x00", c) + } + return line +} + +// SplitMessageWhatsApp splits text into chunks that each fit within the +// WhatsApp readability limit. Prefers paragraph boundaries, then newlines, +// then hard splits — same strategy as the Teams and Slack splitters. +func SplitMessageWhatsApp(text string) []string { + return SplitMessage(text, whatsappBodyLimit) +} + +// StripWhatsAppMention removes a leading "@" or "@" the sender +// typed to invoke the agent in a group, so the prompt handed to the LLM +// doesn't start with the agent's own handle. +// +// WhatsApp mentions are wire-encoded as the mentioned party's E.164 number +// prefixed with "@" (the client substitutes the display name at render time), +// so both forms are matched. Case-insensitive, leading position only. +func StripWhatsAppMention(text string, handles ...string) string { + trimmed := strings.TrimSpace(text) + for _, h := range handles { + h = strings.TrimSpace(strings.TrimPrefix(h, "@")) + if h == "" { + continue + } + prefix := "@" + h + if !strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(prefix)) { + continue + } + stripped := strings.TrimSpace(trimmed[len(prefix):]) + return strings.TrimLeft(stripped, ":, ") + } + return text +} + +// Regexes specific to the WhatsApp text subset. The markdown package already +// defines headerRe / blockquoteRe / orderedListRe / boldRe / italicRe / +// inlineCodeRe / linkRe / strikethroughRe — reuse those. +var ( + imageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) + // whatsappBulletRe allows leading indentation, unlike the package-level + // bulletRe, so nested bullets survive the conversion. Group 1 is the + // indent, group 2 the item text. + whatsappBulletRe = regexp.MustCompile(`^([ \t]*)[*\-]\s+(.+)$`) + tableRowRe = regexp.MustCompile(`^\s*\|.*\|\s*$`) + tableSepRe = regexp.MustCompile(`^\s*\|[\s:|-]+\|\s*$`) + hrRe = regexp.MustCompile(`^\s*(?:-{3,}|\*{3,}|_{3,})\s*$`) + orderedNumRe = regexp.MustCompile(`^\d+\.`) +) diff --git a/forge-plugins/channels/markdown/whatsapp_test.go b/forge-plugins/channels/markdown/whatsapp_test.go new file mode 100644 index 00000000..11475a8f --- /dev/null +++ b/forge-plugins/channels/markdown/whatsapp_test.go @@ -0,0 +1,273 @@ +package markdown + +import ( + "strings" + "testing" +) + +func TestToWhatsAppText_Bold(t *testing.T) { + got := ToWhatsAppText("**hello** world") + want := "*hello* world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Italic(t *testing.T) { + got := ToWhatsAppText("be *brave*") + want := "be _brave_" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// The asterisk inversion is the whole risk in this converter: markdown bold +// becomes a single asterisk, which is exactly what the italic rule matches. +func TestToWhatsAppText_BoldNotReclaimedByItalic(t *testing.T) { + got := ToWhatsAppText("**bold** and *italic*") + want := "*bold* and _italic_" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Strikethrough(t *testing.T) { + got := ToWhatsAppText("~~gone~~") + want := "~gone~" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_InlineCodeUnchanged(t *testing.T) { + got := ToWhatsAppText("call `foo()` here") + want := "call `foo()` here" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// Markdown syntax inside a code span is literal and must survive untouched. +func TestToWhatsAppText_InlineCodeProtectsMarkers(t *testing.T) { + got := ToWhatsAppText("use `a **b** c` verbatim") + want := "use `a **b** c` verbatim" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_MultipleInlineCodeSpans(t *testing.T) { + got := ToWhatsAppText("`one` then **bold** then `two`") + want := "`one` then *bold* then `two`" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_FencedCodeVerbatim(t *testing.T) { + in := "```go\nx := **notbold**\n```" + got := ToWhatsAppText(in) + if got != in { + t.Errorf("fenced code must pass through verbatim: got %q, want %q", got, in) + } +} + +func TestToWhatsAppText_Heading(t *testing.T) { + got := ToWhatsAppText("# Title") + want := "*Title*" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_HeadingAllLevels(t *testing.T) { + got := ToWhatsAppText("### Deep") + want := "*Deep*" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Bullets(t *testing.T) { + got := ToWhatsAppText("- one\n* two") + want := "• one\n• two" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_NestedBulletsKeepIndent(t *testing.T) { + got := ToWhatsAppText("- top\n - nested") + want := "• top\n • nested" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_OrderedListKeepsNumbering(t *testing.T) { + got := ToWhatsAppText("1. first\n2. second") + want := "1. first\n2. second" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Blockquote(t *testing.T) { + got := ToWhatsAppText("> quoted **text**") + want := "> quoted *text*" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Link(t *testing.T) { + got := ToWhatsAppText("see [the docs](https://example.com/x)") + want := "see the docs: https://example.com/x" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// A label that merely repeats the URL adds nothing — emit the URL alone. +func TestToWhatsAppText_LinkLabelSameAsURL(t *testing.T) { + got := ToWhatsAppText("[https://example.com](https://example.com)") + want := "https://example.com" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Image(t *testing.T) { + got := ToWhatsAppText("![a chart](https://example.com/c.png)") + want := "a chart: https://example.com/c.png" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// Images must be flattened before links, or the "!" is orphaned. +func TestToWhatsAppText_ImageLeavesNoStrayBang(t *testing.T) { + got := ToWhatsAppText("![alt](u)") + if strings.Contains(got, "!") { + t.Errorf("stray %q in image conversion: %q", "!", got) + } +} + +func TestToWhatsAppText_Table(t *testing.T) { + in := "| Name | Count |\n|------|-------|\n| foo | 3 |" + got := ToWhatsAppText(in) + want := "Name — Count\n\nfoo — 3" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_TableCellsGetInlineTransforms(t *testing.T) { + got := ToWhatsAppText("| **a** | b |") + want := "*a* — b" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_HorizontalRule(t *testing.T) { + got := ToWhatsAppText("---") + want := "──────────" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_PlainTextUnchanged(t *testing.T) { + got := ToWhatsAppText("nothing special here") + want := "nothing special here" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToWhatsAppText_Empty(t *testing.T) { + if got := ToWhatsAppText(""); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +// No output path may leak raw markdown bold/link syntax at the reader. +func TestToWhatsAppText_NoRawMarkdownLeaks(t *testing.T) { + in := "# H\n\n**b** _i_ [t](u)\n\n- x\n\n| a | b |\n|---|---|\n| 1 | 2 |" + got := ToWhatsAppText(in) + for _, bad := range []string{"**", "](", "|---|"} { + if strings.Contains(got, bad) { + t.Errorf("output leaks %q: %q", bad, got) + } + } +} + +func TestSplitMessageWhatsApp_ShortStaysWhole(t *testing.T) { + chunks := SplitMessageWhatsApp("short") + if len(chunks) != 1 || chunks[0] != "short" { + t.Errorf("got %v, want single chunk", chunks) + } +} + +func TestSplitMessageWhatsApp_SplitsAtLimit(t *testing.T) { + long := strings.Repeat("a", whatsappBodyLimit+100) + chunks := SplitMessageWhatsApp(long) + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks, got %d", len(chunks)) + } + for i, c := range chunks { + if len(c) > whatsappBodyLimit { + t.Errorf("chunk %d over limit: %d > %d", i, len(c), whatsappBodyLimit) + } + } +} + +func TestStripWhatsAppMention_ByNumber(t *testing.T) { + got := StripWhatsAppMention("@14155550100 what is the status?", "14155550100", "forge") + want := "what is the status?" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStripWhatsAppMention_ByDisplayName(t *testing.T) { + got := StripWhatsAppMention("@Forge Bot: deploy please", "14155550100", "Forge Bot") + want := "deploy please" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStripWhatsAppMention_CaseInsensitive(t *testing.T) { + got := StripWhatsAppMention("@forge bot ping", "Forge Bot") + want := "ping" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// A mention in the middle of a sentence is part of the prompt, not an +// invocation prefix — leave it alone. +func TestStripWhatsAppMention_OnlyLeading(t *testing.T) { + in := "tell @forge to stop" + if got := StripWhatsAppMention(in, "forge"); got != in { + t.Errorf("got %q, want unchanged %q", got, in) + } +} + +func TestStripWhatsAppMention_NoMatch(t *testing.T) { + in := "plain message" + if got := StripWhatsAppMention(in, "forge"); got != in { + t.Errorf("got %q, want unchanged %q", got, in) + } +} + +func TestStripWhatsAppMention_EmptyHandlesIgnored(t *testing.T) { + in := "@forge hello" + got := StripWhatsAppMention(in, "", " ", "forge") + want := "hello" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/forge-plugins/channels/whatsapp/admission.go b/forge-plugins/channels/whatsapp/admission.go new file mode 100644 index 00000000..738c172b --- /dev/null +++ b/forge-plugins/channels/whatsapp/admission.go @@ -0,0 +1,222 @@ +package whatsapp + +// AdmitMode is the inbound message gating policy. +type AdmitMode string + +const ( + // AdmitDM admits only 1:1 chat messages; every group message is dropped. + AdmitDM AdmitMode = "dm" + // AdmitGroupMention admits only group messages that @-mention the agent. + AdmitGroupMention AdmitMode = "group_mention" + // AdmitDMOrGroupMention admits 1:1 messages and @-mentions in groups. This + // is the default: an agent sitting in a group must not answer every line + // of an unrelated human conversation. + AdmitDMOrGroupMention AdmitMode = "dm_or_group_mention" +) + +// admissionConfig is the resolved gating policy, built once at Init. +type admissionConfig struct { + Mode AdmitMode + // AllowedGroups restricts which groups the agent acts in. Empty = every + // group, still subject to Mode. + AllowedGroups map[string]bool + // AllowedSenders restricts who may invoke the agent. Empty means OWNER + // ONLY: the paired account is the sole permitted sender. Anyone with the + // number can otherwise reach the agent, spend its LLM budget and use + // whatever it can access, so the closed default is the safe one. + // AllowAnySender opens it up explicitly. + AllowedSenders map[string]bool + // AllowAnySender disables the sender allowlist entirely (`allowed_senders: + // anyone`). Deliberately verbose: it is the setting that exposes the agent + // to the whole world. + AllowAnySender bool + // SelfChat accepts messages the owner sends in their own "Message + // Yourself" chat, so the paired phone can talk to its own agent without a + // second account. + SelfChat bool + // OwnJIDs are the paired account's identities (phone JID, and LID when + // known). Used to recognise the self-chat and the owner as a sender. + OwnJIDs []string +} + +// isOwner reports whether either of a sender's identities is the paired +// account. +func (c admissionConfig) isOwner(senderJID, senderAlt string) bool { + for _, own := range c.OwnJIDs { + if own == "" { + continue + } + n := NormalizeJID(own) + if n == NormalizeJID(senderJID) || (senderAlt != "" && n == NormalizeJID(senderAlt)) { + return true + } + } + return false +} + +// isSelfChat reports whether chatJID is the owner's own chat — WhatsApp's +// "Message Yourself" conversation, whose JID is the account's own. +func (c admissionConfig) isSelfChat(chatJID string) bool { + if IsGroupJID(chatJID) { + return false + } + for _, own := range c.OwnJIDs { + if own != "" && NormalizeJID(own) == NormalizeJID(chatJID) { + return true + } + } + return false +} + +// admissionResult is the verdict the gate returns to the caller. When admit +// is false, reason is the structured log line the caller should emit at DEBUG +// so operators can diagnose silent drops. +type admissionResult struct { + admit bool + reason string +} + +// admit applies the admission gate to one inbound message. The order matters: +// +// 1. Conversation kind — newsletters, broadcast lists and the status feed are +// never answerable. +// 2. Self-loop — our own outbound echo. +// 3. Sender allowlist. +// 4. Group allowlist. +// 5. Mode filter. +// +// Dedup is the caller's responsibility: it must run for ALL inbound messages, +// not only admitted ones, or a re-delivered message that was dropped the first +// time can slip through on a later pass. +// +// Unlike the Teams adapter, the self-loop check here is authoritative. +// whatsmeow sets IsFromMe on anything sent by the paired account, including +// messages this adapter sent, so it distinguishes the agent's own output from +// a human's on the same account. +// +// senderAlt is whatsmeow's alternate address for the sender (MessageSource. +// SenderAlt): a phone-number sender carries its LID there and a LID sender +// carries its phone number. Both are checked against the allowlist so a group +// that has migrated to hidden numbers doesn't lock out an operator who wrote +// the list in phone numbers. Pass "" when there is no alternate. +func admit(chatJID, senderJID, senderAlt string, isFromMe, mentioned bool, cfg admissionConfig) admissionResult { + // 1. Conversation kind. + if !IsConversationJID(chatJID) { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping non-conversation message (chat=" + chatJID + "); newsletters, broadcast lists and status are not answerable", + } + } + + selfChat := cfg.isSelfChat(chatJID) + + // 2. Own messages. + // + // In the owner's self-chat these are the whole point: the paired phone + // talks to its own agent, so IsFromMe is how the prompt arrives. The + // agent's OWN replies are also IsFromMe there, and the loop guard for + // those is the dedup ring, which marks every outbound id before + // SendResponse returns and runs before this gate. Anywhere else, an own + // message is our echo and answering it would recurse until rate-limited. + if isFromMe { + if !cfg.SelfChat || !selfChat { + return admissionResult{admit: false, reason: "whatsapp: dropping own outbound message (is_from_me)"} + } + } + + // 3. Sender allowlist. The owner always passes — in the self-chat there is + // no one else, and locking the paired account out of its own agent would + // be nonsense. + if !cfg.AllowAnySender && !cfg.isOwner(senderJID, senderAlt) { + if len(cfg.AllowedSenders) == 0 { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping non-owner sender (" + senderJID + ") — allowed_senders is empty, which means owner-only; list the number, or set allowed_senders: anyone to open the agent up", + } + } + if !senderAllowed(senderJID, senderAlt, cfg.AllowedSenders) { + if IsLIDJID(senderJID) && senderAlt == "" { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping hidden-number sender (lid=" + senderJID + ") — allowed_senders is keyed on phone numbers and no phone-number alternate was supplied; add the LID itself to admit this sender", + } + } + return admissionResult{ + admit: false, + reason: "whatsapp: dropping sender not in allowed_senders (sender=" + senderJID + ")", + } + } + } + + isGroup := IsGroupJID(chatJID) + + // 4. Group allowlist. + if isGroup && len(cfg.AllowedGroups) > 0 && !cfg.AllowedGroups[NormalizeJID(chatJID)] { + return admissionResult{ + admit: false, + reason: "whatsapp: dropping group not in allowed_groups (chat=" + chatJID + ")", + } + } + + // 5. Mode filter. + switch cfg.Mode { + case AdmitDM: + if isGroup { + return admissionResult{admit: false, reason: "whatsapp: dropping group message (admit=dm)"} + } + case AdmitGroupMention: + if !isGroup { + return admissionResult{admit: false, reason: "whatsapp: dropping dm (admit=group_mention)"} + } + if !mentioned { + return admissionResult{admit: false, reason: "whatsapp: dropping non-mention group message (admit=group_mention)"} + } + case AdmitDMOrGroupMention: + if isGroup && !mentioned { + return admissionResult{admit: false, reason: "whatsapp: dropping non-mention group message (admit=dm_or_group_mention)"} + } + default: + // Unknown mode — fall back to dm_or_group_mention semantics rather + // than admitting everything. + if isGroup && !mentioned { + return admissionResult{admit: false, reason: "whatsapp: dropping non-mention group message under default dm_or_group_mention gate"} + } + } + + return admissionResult{admit: true} +} + +// senderAllowed reports whether either of a sender's identities is listed. +func senderAllowed(senderJID, senderAlt string, allowed map[string]bool) bool { + if allowed[NormalizeJID(senderJID)] { + return true + } + return senderAlt != "" && allowed[NormalizeJID(senderAlt)] +} + +// isMentioned reports whether ownJID appears in a message's mention list. +// +// WhatsApp carries mentions out-of-band in contextInfo.mentionedJid rather +// than as markup in the body, so this is an exact JID-set membership test, not +// a text scan. Comparison is on the normalized JID because the mention list +// holds bare user JIDs while the paired account may be device-qualified. +func isMentioned(mentionedJIDs []string, ownJIDs ...string) bool { + if len(mentionedJIDs) == 0 { + return false + } + own := make(map[string]bool, len(ownJIDs)) + for _, j := range ownJIDs { + if j = NormalizeJID(j); j != "" { + own[j] = true + } + } + if len(own) == 0 { + return false + } + for _, m := range mentionedJIDs { + if own[NormalizeJID(m)] { + return true + } + } + return false +} diff --git a/forge-plugins/channels/whatsapp/admission_test.go b/forge-plugins/channels/whatsapp/admission_test.go new file mode 100644 index 00000000..40e1334d --- /dev/null +++ b/forge-plugins/channels/whatsapp/admission_test.go @@ -0,0 +1,361 @@ +package whatsapp + +import ( + "strings" + "testing" +) + +const ( + testDM = "14155550100@s.whatsapp.net" + testGroup = "120363000000000000@g.us" + testSender = "14155550100@s.whatsapp.net" + testOwn = "14155550999@s.whatsapp.net" +) + +const testOwnJID = "14155550999@s.whatsapp.net" + +// defaultCfg is the open-sender baseline most of these cases assume. The +// shipped default is owner-only; ownerOnlyCfg covers that. +func defaultCfg() admissionConfig { + return admissionConfig{ + Mode: AdmitDMOrGroupMention, + AllowAnySender: true, + SelfChat: true, + OwnJIDs: []string{testOwnJID}, + } +} + +// ownerOnlyCfg is the shipped default: no allowlist entries, no opt-out. +func ownerOnlyCfg() admissionConfig { + return admissionConfig{ + Mode: AdmitDMOrGroupMention, + SelfChat: true, + OwnJIDs: []string{testOwnJID}, + } +} + +func TestAdmit_DMAdmittedByDefault(t *testing.T) { + got := admit(testDM, testSender, "", false, false, defaultCfg()) + if !got.admit { + t.Errorf("expected DM admitted, got drop: %s", got.reason) + } +} + +func TestAdmit_GroupMentionAdmittedByDefault(t *testing.T) { + got := admit(testGroup, testSender, "", false, true, defaultCfg()) + if !got.admit { + t.Errorf("expected mentioned group message admitted, got drop: %s", got.reason) + } +} + +func TestAdmit_GroupWithoutMentionDropped(t *testing.T) { + got := admit(testGroup, testSender, "", false, false, defaultCfg()) + if got.admit { + t.Error("expected non-mention group message dropped") + } + if !strings.Contains(got.reason, "non-mention") { + t.Errorf("reason should name the gate, got %q", got.reason) + } +} + +// The agent answering its own output recurses until the server rate-limits it. +func TestAdmit_SelfLoopDropped(t *testing.T) { + got := admit(testDM, testOwn, "", true, false, defaultCfg()) + if got.admit { + t.Error("expected own outbound message dropped") + } + if !strings.Contains(got.reason, "is_from_me") { + t.Errorf("reason should name the self-loop guard, got %q", got.reason) + } +} + +// Replying into a newsletter or the status feed either fails or fans out to +// every contact — neither is ever admissible. +func TestAdmit_NonConversationJIDsDropped(t *testing.T) { + for _, chat := range []string{"123@newsletter", "status@broadcast", "list@broadcast"} { + got := admit(chat, testSender, "", false, true, defaultCfg()) + if got.admit { + t.Errorf("expected %q dropped", chat) + } + if !strings.Contains(got.reason, "non-conversation") { + t.Errorf("reason for %q should name the kind gate, got %q", chat, got.reason) + } + } +} + +func TestAdmit_ModeDM_DropsGroups(t *testing.T) { + cfg := admissionConfig{Mode: AdmitDM, AllowAnySender: true, OwnJIDs: []string{testOwnJID}} + if got := admit(testGroup, testSender, "", false, true, cfg); got.admit { + t.Error("expected group dropped under admit=dm even when mentioned") + } + if got := admit(testDM, testSender, "", false, false, cfg); !got.admit { + t.Errorf("expected DM admitted under admit=dm, got drop: %s", got.reason) + } +} + +func TestAdmit_ModeGroupMention_DropsDMs(t *testing.T) { + cfg := admissionConfig{Mode: AdmitGroupMention, AllowAnySender: true, OwnJIDs: []string{testOwnJID}} + if got := admit(testDM, testSender, "", false, false, cfg); got.admit { + t.Error("expected DM dropped under admit=group_mention") + } + if got := admit(testGroup, testSender, "", false, true, cfg); !got.admit { + t.Errorf("expected mentioned group message admitted, got drop: %s", got.reason) + } + if got := admit(testGroup, testSender, "", false, false, cfg); got.admit { + t.Error("expected non-mention group message dropped under admit=group_mention") + } +} + +// An unrecognised mode must not open the gate wider than the default. +func TestAdmit_UnknownModeFallsBackToDefault(t *testing.T) { + cfg := admissionConfig{Mode: AdmitMode("nonsense"), AllowAnySender: true, OwnJIDs: []string{testOwnJID}} + if got := admit(testGroup, testSender, "", false, false, cfg); got.admit { + t.Error("expected unknown mode to keep the non-mention group gate closed") + } + if got := admit(testDM, testSender, "", false, false, cfg); !got.admit { + t.Errorf("expected unknown mode to still admit DMs, got drop: %s", got.reason) + } +} + +func TestAdmit_SenderAllowlist(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + + if got := admit(testDM, "14155550100@s.whatsapp.net", "", false, false, cfg); !got.admit { + t.Errorf("expected listed sender admitted, got drop: %s", got.reason) + } + if got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, cfg); got.admit { + t.Error("expected unlisted sender dropped") + } +} + +// A sender on a second linked device must still match a bare-number entry. +func TestAdmit_SenderAllowlistIgnoresDeviceSuffix(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + if got := admit(testDM, "14155550100:4@s.whatsapp.net", "", false, false, cfg); !got.admit { + t.Errorf("expected device-qualified sender admitted, got drop: %s", got.reason) + } +} + +// A phone-number allowlist can never match a hidden-number sender. Fail closed +// with a reason that says so, rather than one that reads like a plain miss. +func TestAdmit_LIDSenderFailsClosedWithExplanation(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + got := admit(testGroup, "98765@lid", "", false, true, cfg) + if got.admit { + t.Error("expected LID sender dropped against a number-keyed allowlist") + } + if !strings.Contains(got.reason, "lid") { + t.Errorf("reason should explain the LID mismatch, got %q", got.reason) + } +} + +// A group that has migrated to hidden numbers reports a LID sender with the +// phone number in SenderAlt. An operator who wrote the allowlist in phone +// numbers must not be locked out by that migration. +func TestAdmit_LIDSenderMatchesViaPhoneAlternate(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + got := admit(testGroup, "98765@lid", "14155550100@s.whatsapp.net", false, true, cfg) + if !got.admit { + t.Errorf("expected LID sender admitted via phone alternate, got drop: %s", got.reason) + } +} + +// The reverse migration: allowlist written in LIDs, sender arrives by number. +func TestAdmit_PhoneSenderMatchesViaLIDAlternate(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("98765@lid", serverUser) + got := admit(testGroup, "14155550100@s.whatsapp.net", "98765@lid", false, true, cfg) + if !got.admit { + t.Errorf("expected sender admitted via LID alternate, got drop: %s", got.reason) + } +} + +// An alternate that is itself unlisted must not widen the gate. +func TestAdmit_UnlistedAlternateStillDropped(t *testing.T) { + cfg := defaultCfg() + cfg.AllowAnySender = false + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + got := admit(testGroup, "98765@lid", "14155550777@s.whatsapp.net", false, true, cfg) + if got.admit { + t.Error("expected drop when neither identity is listed") + } +} + +// With no sender allowlist configured, a LID sender is ordinary traffic. +func TestAdmit_LIDSenderAdmittedWithoutAllowlist(t *testing.T) { + if got := admit(testGroup, "98765@lid", "", false, true, defaultCfg()); !got.admit { + t.Errorf("expected LID sender admitted with no allowlist, got drop: %s", got.reason) + } +} + +func TestAdmit_GroupAllowlist(t *testing.T) { + cfg := defaultCfg() + cfg.AllowedGroups = parseJIDSet("120363000000000000", serverGroup) + + if got := admit(testGroup, testSender, "", false, true, cfg); !got.admit { + t.Errorf("expected listed group admitted, got drop: %s", got.reason) + } + if got := admit("120363000000000009@g.us", testSender, "", false, true, cfg); got.admit { + t.Error("expected unlisted group dropped") + } +} + +// The group allowlist gates groups only; it must not silently block DMs. +func TestAdmit_GroupAllowlistDoesNotAffectDMs(t *testing.T) { + cfg := defaultCfg() + cfg.AllowedGroups = parseJIDSet("120363000000000000", serverGroup) + if got := admit(testDM, testSender, "", false, false, cfg); !got.admit { + t.Errorf("expected DM admitted despite group allowlist, got drop: %s", got.reason) + } +} + +func TestIsMentioned(t *testing.T) { + own := "14155550999@s.whatsapp.net" + + if !isMentioned([]string{"14155550100@s.whatsapp.net", own}, own) { + t.Error("expected mention detected") + } + if isMentioned([]string{"14155550100@s.whatsapp.net"}, own) { + t.Error("expected no mention when own JID absent") + } + if isMentioned(nil, own) { + t.Error("expected no mention for empty list") + } + if isMentioned([]string{own}) { + t.Error("expected no mention when no own JID supplied") + } +} + +// The mention list holds bare JIDs while the paired account is device- +// qualified; comparison has to normalize both sides. +func TestIsMentioned_NormalizesBothSides(t *testing.T) { + if !isMentioned([]string{"14155550999@s.whatsapp.net"}, "14155550999:12@s.whatsapp.net") { + t.Error("expected device-qualified own JID to match a bare mention") + } +} + +// A LID-migrated group reports the agent's LID, not its number — both +// identities must be accepted. +func TestIsMentioned_MatchesEitherIdentity(t *testing.T) { + if !isMentioned([]string{"55555@lid"}, "14155550999@s.whatsapp.net", "55555@lid") { + t.Error("expected LID mention to match the agent's LID identity") + } +} + +// --- self-chat (the personal-agent flow) --- + +// The owner's "Message Yourself" chat: the chat JID is the owner's own, and +// the prompt arrives with IsFromMe set. This is the whole feature. +func TestAdmit_SelfChatAcceptsOwnMessage(t *testing.T) { + got := admit(testOwnJID, testOwnJID, "", true, false, ownerOnlyCfg()) + if !got.admit { + t.Errorf("expected own message admitted in the self-chat, got drop: %s", got.reason) + } +} + +func TestAdmit_SelfChatDisabledDropsOwnMessage(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.SelfChat = false + got := admit(testOwnJID, testOwnJID, "", true, false, cfg) + if got.admit { + t.Error("expected own message dropped when self_chat is off") + } +} + +// Outside the self-chat an own message is our echo. Answering it recurses. +func TestAdmit_OwnMessageElsewhereStillDropped(t *testing.T) { + cfg := ownerOnlyCfg() + for _, chat := range []string{testDM, testGroup} { + if got := admit(chat, testOwnJID, "", true, true, cfg); got.admit { + t.Errorf("expected own message dropped in %q", chat) + } + } +} + +// A group is never a self-chat, even one the owner created. +func TestAdmit_SelfChatNeverAppliesToGroups(t *testing.T) { + cfg := ownerOnlyCfg() + if cfg.isSelfChat(testGroup) { + t.Error("a group must never count as the self-chat") + } +} + +// The owner's LID is also their identity, so the self-chat must resolve +// under it too. +func TestAdmit_SelfChatMatchesLIDIdentity(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.OwnJIDs = []string{testOwnJID, "55555@lid"} + if got := admit("55555@lid", "55555@lid", "", true, false, cfg); !got.admit { + t.Errorf("expected self-chat under the LID identity admitted, got drop: %s", got.reason) + } +} + +// --- owner-only default --- + +// The shipped default must not let a stranger with the number reach the agent. +func TestAdmit_EmptyAllowlistMeansOwnerOnly(t *testing.T) { + cfg := ownerOnlyCfg() + + if got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, cfg); got.admit { + t.Error("expected a stranger dropped under the owner-only default") + } + if got := admit(testDM, testOwnJID, "", false, false, cfg); !got.admit { + t.Errorf("expected the owner admitted, got drop: %s", got.reason) + } +} + +// The drop reason must explain the default, not read like a bare miss. +func TestAdmit_OwnerOnlyReasonIsActionable(t *testing.T) { + got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, ownerOnlyCfg()) + for _, want := range []string{"owner-only", "allowed_senders"} { + if !strings.Contains(got.reason, want) { + t.Errorf("reason should mention %q, got %q", want, got.reason) + } + } +} + +func TestAdmit_AllowAnySenderOpensItUp(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.AllowAnySender = true + if got := admit(testDM, "14155550777@s.whatsapp.net", "", false, false, cfg); !got.admit { + t.Errorf("expected any sender admitted with the opt-out, got drop: %s", got.reason) + } +} + +// Listing others must not lock the owner out of their own agent. +func TestAdmit_OwnerPassesEvenWhenNotListed(t *testing.T) { + cfg := ownerOnlyCfg() + cfg.AllowedSenders = parseJIDSet("14155550100", serverUser) + if got := admit(testDM, testOwnJID, "", false, false, cfg); !got.admit { + t.Errorf("owner must always pass, got drop: %s", got.reason) + } +} + +func TestAdmit_OwnerMatchedViaAlternateIdentity(t *testing.T) { + cfg := ownerOnlyCfg() + if got := admit(testGroup, "99999@lid", testOwnJID, false, true, cfg); !got.admit { + t.Errorf("owner should be recognised via SenderAlt, got drop: %s", got.reason) + } +} + +func TestIsAnySender(t *testing.T) { + for _, s := range []string{"anyone", "any", "*", " ANYONE ", "Any"} { + if !isAnySender(s) { + t.Errorf("isAnySender(%q) = false, want true", s) + } + } + for _, s := range []string{"", " ", "+14155550100", "anyone@s.whatsapp.net"} { + if isAnySender(s) { + t.Errorf("isAnySender(%q) = true, want false", s) + } + } +} diff --git a/forge-plugins/channels/whatsapp/dedup.go b/forge-plugins/channels/whatsapp/dedup.go new file mode 100644 index 00000000..35030bd2 --- /dev/null +++ b/forge-plugins/channels/whatsapp/dedup.go @@ -0,0 +1,94 @@ +package whatsapp + +import "sync" + +// dedup is a sliding-window deduplicator for WhatsApp message IDs. +// +// Two inbound paths can deliver the same message twice: a history sync after +// reconnect replays recent messages, and an unacknowledged delivery is retried +// by the server. The ring filters both so the agent isn't invoked twice for +// one prompt. +// +// It also carries the outbound echo guard. Every message this adapter sends is +// marked before the send returns, so if it comes back through history sync it +// is dropped before admission. That is belt-and-braces alongside the IsFromMe +// check in admit() — IsFromMe covers the live path, the ring covers replay, +// where a message sent by the human operator on their own phone and one sent +// by the agent are otherwise indistinguishable. +// +// Capacity defaults to 1000 entries. Evicts the oldest entry when full. +// All operations are safe for concurrent use. +type dedup struct { + mu sync.Mutex + cap int + order []string // insertion order — order[head] is the oldest + head int // index of the next slot to overwrite + set map[string]bool // membership lookup +} + +func newDedup(capacity int) *dedup { + if capacity <= 0 { + capacity = 1000 + } + return &dedup{ + cap: capacity, + order: make([]string, 0, capacity), + set: make(map[string]bool, capacity), + } +} + +// seen reports whether id was previously marked. +func (d *dedup) seen(id string) bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.set[id] +} + +// mark records id and evicts the oldest entry if the ring is full. +func (d *dedup) mark(id string) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.set[id] { + return + } + + if len(d.order) < d.cap { + d.order = append(d.order, id) + } else { + // Evict the entry at head, then overwrite. + delete(d.set, d.order[d.head]) + d.order[d.head] = id + d.head = (d.head + 1) % d.cap + } + d.set[id] = true +} + +// markSeen records id and reports whether it had already been marked. It is +// the single-call form of seen-then-mark, so two goroutines racing on the same +// redelivered message cannot both observe it as new. +func (d *dedup) markSeen(id string) bool { + d.mu.Lock() + defer d.mu.Unlock() + + if d.set[id] { + return true + } + + if len(d.order) < d.cap { + d.order = append(d.order, id) + } else { + delete(d.set, d.order[d.head]) + d.order[d.head] = id + d.head = (d.head + 1) % d.cap + } + d.set[id] = true + return false +} + +// size returns the current number of tracked IDs (for tests). +func (d *dedup) size() int { + d.mu.Lock() + defer d.mu.Unlock() + return len(d.order) +} diff --git a/forge-plugins/channels/whatsapp/dedup_test.go b/forge-plugins/channels/whatsapp/dedup_test.go new file mode 100644 index 00000000..ab83e0d9 --- /dev/null +++ b/forge-plugins/channels/whatsapp/dedup_test.go @@ -0,0 +1,122 @@ +package whatsapp + +import ( + "strconv" + "sync" + "testing" +) + +func TestDedup_MarkAndSeen(t *testing.T) { + d := newDedup(10) + if d.seen("a") { + t.Error("expected unseen id") + } + d.mark("a") + if !d.seen("a") { + t.Error("expected id seen after mark") + } +} + +func TestDedup_MarkIsIdempotent(t *testing.T) { + d := newDedup(10) + d.mark("a") + d.mark("a") + if d.size() != 1 { + t.Errorf("expected size 1 after duplicate mark, got %d", d.size()) + } +} + +func TestDedup_EvictsOldestWhenFull(t *testing.T) { + d := newDedup(3) + d.mark("a") + d.mark("b") + d.mark("c") + d.mark("d") // evicts "a" + + if d.seen("a") { + t.Error("expected oldest entry evicted") + } + for _, id := range []string{"b", "c", "d"} { + if !d.seen(id) { + t.Errorf("expected %q retained", id) + } + } + if d.size() != 3 { + t.Errorf("expected size capped at 3, got %d", d.size()) + } +} + +func TestDedup_DefaultCapacity(t *testing.T) { + for _, c := range []int{0, -1} { + d := newDedup(c) + for i := range 1000 { + d.mark(strconv.Itoa(i)) + } + if d.size() != 1000 { + t.Errorf("newDedup(%d): expected default capacity 1000, got size %d", c, d.size()) + } + } +} + +func TestDedup_MarkSeenReportsPriorState(t *testing.T) { + d := newDedup(10) + if d.markSeen("a") { + t.Error("expected first markSeen to report unseen") + } + if !d.markSeen("a") { + t.Error("expected second markSeen to report seen") + } + if d.size() != 1 { + t.Errorf("expected size 1, got %d", d.size()) + } +} + +// Two goroutines racing on the same redelivered message must not both treat it +// as new — exactly one may win. +func TestDedup_MarkSeenIsAtomicUnderRace(t *testing.T) { + d := newDedup(100) + const goroutines = 50 + + var wg sync.WaitGroup + var mu sync.Mutex + newCount := 0 + + for range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + if !d.markSeen("same-id") { + mu.Lock() + newCount++ + mu.Unlock() + } + }() + } + wg.Wait() + + if newCount != 1 { + t.Errorf("expected exactly one goroutine to observe the id as new, got %d", newCount) + } +} + +func TestDedup_ConcurrentMarkAndSeen(t *testing.T) { + d := newDedup(500) + var wg sync.WaitGroup + + for i := range 200 { + wg.Add(2) + go func() { + defer wg.Done() + d.mark(strconv.Itoa(i)) + }() + go func() { + defer wg.Done() + _ = d.seen(strconv.Itoa(i)) + }() + } + wg.Wait() + + if d.size() != 200 { + t.Errorf("expected 200 distinct ids, got %d", d.size()) + } +} diff --git a/forge-plugins/channels/whatsapp/history.go b/forge-plugins/channels/whatsapp/history.go new file mode 100644 index 00000000..bf3689d4 --- /dev/null +++ b/forge-plugins/channels/whatsapp/history.go @@ -0,0 +1,151 @@ +package whatsapp + +import ( + "fmt" + "strings" + "sync" + "time" +) + +// maxTrackedChats bounds how many conversations the history ring holds before +// evicting the least recently active. Without it, an account in many groups +// grows the ring without limit. +const maxTrackedChats = 200 + +// historyEntry is one observed message, kept for conversational context. +type historyEntry struct { + ID string + Author string + Text string + At time.Time +} + +// chatHistory is a bounded, in-memory record of recently observed messages, +// keyed by chat JID. +// +// This exists because WhatsApp has no server-side "fetch recent messages" call +// the way Microsoft Graph does (/chats/{id}/messages). Prior history reaches a +// linked device only through a history-sync push at pairing time, and +// whatsmeow does not retain it. So the adapter accumulates its own window from +// the traffic it sees. +// +// The consequence is worth stating plainly: context covers messages observed +// SINCE THE ADAPTER STARTED, and is lost on restart. An agent restarted +// mid-conversation will not see what came before. That is a real limitation of +// the protocol, not a shortcut — persisting it would mean writing a message +// store, which is out of scope for the channel adapter. +type chatHistory struct { + mu sync.Mutex + perChat map[string][]historyEntry + touched map[string]time.Time + max int +} + +func newChatHistory(maxPerChat int) *chatHistory { + if maxPerChat <= 0 { + maxPerChat = 20 + } + return &chatHistory{ + perChat: make(map[string][]historyEntry), + touched: make(map[string]time.Time), + max: maxPerChat, + } +} + +// record appends an entry to a chat's window, trimming the oldest beyond the +// per-chat cap and evicting the least recently active chat when tracking too +// many. +func (h *chatHistory) record(chat string, e historyEntry) { + if chat == "" || strings.TrimSpace(e.Text) == "" { + return + } + h.mu.Lock() + defer h.mu.Unlock() + + entries := append(h.perChat[chat], e) + if len(entries) > h.max { + entries = entries[len(entries)-h.max:] + } + h.perChat[chat] = entries + h.touched[chat] = time.Now() + + if len(h.perChat) > maxTrackedChats { + h.evictOldestLocked() + } +} + +// evictOldestLocked drops the least recently active chat. Caller holds mu. +func (h *chatHistory) evictOldestLocked() { + var oldest string + var oldestAt time.Time + for chat, at := range h.touched { + if oldest == "" || at.Before(oldestAt) { + oldest, oldestAt = chat, at + } + } + if oldest != "" { + delete(h.perChat, oldest) + delete(h.touched, oldest) + } +} + +// recent returns up to n of a chat's most recent entries, oldest first. +func (h *chatHistory) recent(chat string, n int) []historyEntry { + h.mu.Lock() + defer h.mu.Unlock() + + entries := h.perChat[chat] + if n <= 0 || len(entries) == 0 { + return nil + } + if len(entries) > n { + entries = entries[len(entries)-n:] + } + out := make([]historyEntry, len(entries)) + copy(out, entries) + return out +} + +// prependHistory formats a chat's recent messages chronologically and prepends +// them as a context block before the user's current prompt. skipID drops the +// current message so it isn't duplicated inside its own context. +// +// The block is soft-capped at historySoftCap characters, counted from the most +// recent message backwards, so a chatty group can't crowd out the prompt. +func prependHistory(entries []historyEntry, skipID, prompt string) string { + if len(entries) == 0 { + return prompt + } + + // Walk newest→oldest accumulating under the cap, then emit oldest→newest. + var kept []string + total := 0 + for i := len(entries) - 1; i >= 0; i-- { + e := entries[i] + if e.ID == skipID { + continue + } + text := strings.TrimSpace(e.Text) + if text == "" { + continue + } + line := fmt.Sprintf("%s %s: %s\n", e.At.Format("01-02 15:04"), e.Author, text) + if total+len(line) > historySoftCap { + break + } + total += len(line) + kept = append(kept, line) + } + if len(kept) == 0 { + return prompt + } + + var b strings.Builder + b.WriteString("[Recent chat history for context — most recent message at the bottom:]\n") + for i := len(kept) - 1; i >= 0; i-- { + b.WriteString(kept[i]) + } + b.WriteString("[End of history. The user's current message follows:]\n\n") + b.WriteString(prompt) + return b.String() +} diff --git a/forge-plugins/channels/whatsapp/history_test.go b/forge-plugins/channels/whatsapp/history_test.go new file mode 100644 index 00000000..4bd4a364 --- /dev/null +++ b/forge-plugins/channels/whatsapp/history_test.go @@ -0,0 +1,196 @@ +package whatsapp + +import ( + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func entry(id, author, text string) historyEntry { + return historyEntry{ID: id, Author: author, Text: text, At: time.Date(2026, 9, 9, 14, 30, 0, 0, time.UTC)} +} + +func TestChatHistory_RecordAndRecent(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", "hello")) + h.record("chat1", entry("2", "bob", "hi")) + + got := h.recent("chat1", 5) + if len(got) != 2 { + t.Fatalf("expected 2 entries, got %d", len(got)) + } + if got[0].Text != "hello" || got[1].Text != "hi" { + t.Errorf("expected oldest-first order, got %v", got) + } +} + +func TestChatHistory_IsolatedPerChat(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", "in one")) + h.record("chat2", entry("2", "bob", "in two")) + + if got := h.recent("chat1", 5); len(got) != 1 || got[0].Text != "in one" { + t.Errorf("chat1 leaked: %v", got) + } + if got := h.recent("chat2", 5); len(got) != 1 || got[0].Text != "in two" { + t.Errorf("chat2 leaked: %v", got) + } +} + +func TestChatHistory_TrimsToPerChatCap(t *testing.T) { + h := newChatHistory(3) + for i := range 10 { + h.record("chat1", entry(strconv.Itoa(i), "alice", "msg"+strconv.Itoa(i))) + } + got := h.recent("chat1", 10) + if len(got) != 3 { + t.Fatalf("expected cap of 3, got %d", len(got)) + } + if got[0].Text != "msg7" || got[2].Text != "msg9" { + t.Errorf("expected the newest three, got %v", got) + } +} + +func TestChatHistory_RecentHonoursN(t *testing.T) { + h := newChatHistory(10) + for i := range 5 { + h.record("chat1", entry(strconv.Itoa(i), "alice", "msg"+strconv.Itoa(i))) + } + if got := h.recent("chat1", 2); len(got) != 2 || got[1].Text != "msg4" { + t.Errorf("expected the newest two, got %v", got) + } + if got := h.recent("chat1", 0); got != nil { + t.Errorf("expected nil for n=0, got %v", got) + } +} + +func TestChatHistory_SkipsEmptyText(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", " ")) + h.record("", entry("2", "alice", "no chat")) + if got := h.recent("chat1", 5); len(got) != 0 { + t.Errorf("expected empty text skipped, got %v", got) + } +} + +// An account in many groups must not grow the ring without bound. +func TestChatHistory_EvictsLeastRecentChat(t *testing.T) { + h := newChatHistory(2) + for i := range maxTrackedChats + 10 { + h.record("chat"+strconv.Itoa(i), entry("m", "alice", "text")) + // Distinct touch times so eviction order is deterministic. + h.mu.Lock() + h.touched["chat"+strconv.Itoa(i)] = time.Now().Add(time.Duration(i) * time.Millisecond) + h.mu.Unlock() + } + h.mu.Lock() + tracked := len(h.perChat) + h.mu.Unlock() + + if tracked > maxTrackedChats { + t.Errorf("expected at most %d chats tracked, got %d", maxTrackedChats, tracked) + } + if got := h.recent("chat0", 5); len(got) != 0 { + t.Errorf("expected the oldest chat evicted, got %v", got) + } +} + +// recent returns a copy; a caller mutating it must not corrupt the ring. +func TestChatHistory_RecentReturnsCopy(t *testing.T) { + h := newChatHistory(5) + h.record("chat1", entry("1", "alice", "original")) + + got := h.recent("chat1", 5) + got[0].Text = "mutated" + + if again := h.recent("chat1", 5); again[0].Text != "original" { + t.Errorf("caller mutation leaked into the ring: %q", again[0].Text) + } +} + +func TestChatHistory_ConcurrentRecord(t *testing.T) { + h := newChatHistory(50) + var wg sync.WaitGroup + for i := range 100 { + wg.Add(1) + go func() { + defer wg.Done() + h.record("chat1", entry(strconv.Itoa(i), "alice", "msg")) + }() + } + wg.Wait() + if got := h.recent("chat1", 100); len(got) != 50 { + t.Errorf("expected 50 entries after cap, got %d", len(got)) + } +} + +func TestPrependHistory_BuildsContextBlock(t *testing.T) { + entries := []historyEntry{ + entry("1", "alice", "deploy failed"), + entry("2", "bob", "looking now"), + entry("3", "alice", "@agent summarise"), + } + got := prependHistory(entries, "3", "summarise") + + if !strings.Contains(got, "deploy failed") || !strings.Contains(got, "looking now") { + t.Errorf("expected prior messages in block, got %q", got) + } + if strings.Contains(got, "@agent summarise") { + t.Errorf("current message must be skipped inside its own context, got %q", got) + } + if !strings.HasSuffix(got, "summarise") { + t.Errorf("prompt must come last, got %q", got) + } + if strings.Index(got, "deploy failed") > strings.Index(got, "looking now") { + t.Errorf("expected oldest-first ordering, got %q", got) + } +} + +func TestPrependHistory_EmptyReturnsPromptUnchanged(t *testing.T) { + if got := prependHistory(nil, "1", "hello"); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +// Skipping the only entry leaves nothing to prepend. +func TestPrependHistory_OnlyCurrentMessage(t *testing.T) { + entries := []historyEntry{entry("1", "alice", "hello")} + if got := prependHistory(entries, "1", "hello"); got != "hello" { + t.Errorf("got %q, want the bare prompt", got) + } +} + +// A chatty group must not crowd the prompt out of the context budget. +func TestPrependHistory_SoftCap(t *testing.T) { + var entries []historyEntry + for i := range 500 { + entries = append(entries, entry(strconv.Itoa(i), "alice", strings.Repeat("x", 100))) + } + got := prependHistory(entries, "none", "the prompt") + + if len(got) > historySoftCap+500 { + t.Errorf("block exceeded soft cap: %d chars", len(got)) + } + if !strings.HasSuffix(got, "the prompt") { + t.Error("prompt must survive the cap") + } +} + +// The cap counts backwards from the newest, so the most recent context is the +// part that survives. +func TestPrependHistory_CapKeepsNewest(t *testing.T) { + var entries []historyEntry + for i := range 500 { + entries = append(entries, entry(strconv.Itoa(i), "alice", strings.Repeat("x", 100)+strconv.Itoa(i))) + } + got := prependHistory(entries, "none", "prompt") + + if !strings.Contains(got, "x499") { + t.Error("expected the newest entry retained under the cap") + } + if strings.Contains(got, "x0\n") { + t.Error("expected the oldest entry dropped under the cap") + } +} diff --git a/forge-plugins/channels/whatsapp/jid.go b/forge-plugins/channels/whatsapp/jid.go new file mode 100644 index 00000000..29a5c6b5 --- /dev/null +++ b/forge-plugins/channels/whatsapp/jid.go @@ -0,0 +1,189 @@ +// Package whatsapp implements the WhatsApp channel plugin via the WhatsApp +// Web multidevice protocol (whatsmeow). It is a paired-session adapter — no +// inbound webhooks, no public endpoint, no bot token. Authentication is a QR +// pairing captured by `forge channel whatsapp-login` and persisted to a local +// session store. +package whatsapp + +import ( + "strings" +) + +// WhatsApp JID server suffixes. A JID is "@", where the server +// determines what kind of conversation it addresses. +const ( + // serverUser addresses an individual by phone number: "14155550100@s.whatsapp.net". + serverUser = "s.whatsapp.net" + // serverGroup addresses a group: "120363000000000000@g.us". + serverGroup = "g.us" + // serverNewsletter addresses a Channel (one-way broadcast feed). The agent + // never participates in these. + serverNewsletter = "newsletter" + // serverBroadcast addresses a broadcast list or the status feed + // ("status@broadcast"). Never a conversation the agent should answer. + serverBroadcast = "broadcast" + // serverLID addresses a user by their hidden-number identifier. WhatsApp + // is migrating group participants to LIDs, so an inbound sender may arrive + // as a LID with no phone number attached. + serverLID = "lid" +) + +// SplitJID separates a JID into its user and server parts. A JID with no "@" +// yields the whole string as the user and an empty server. +func SplitJID(jid string) (user, server string) { + jid = strings.TrimSpace(jid) + at := strings.LastIndex(jid, "@") + if at < 0 { + return jid, "" + } + return jid[:at], strings.ToLower(jid[at+1:]) +} + +// JIDServer returns the server portion of a JID, lowercased. +func JIDServer(jid string) string { + _, server := SplitJID(jid) + return server +} + +// JIDUser returns the user portion of a JID with the device and agent +// suffixes removed. +// +// whatsmeow renders a specific linked device as ":@server" and +// newer builds add an agent ordinal as ".". Neither belongs in an +// identity comparison — the same person messaging from phone and desktop must +// compare equal — so both are stripped. +func JIDUser(jid string) string { + user, _ := SplitJID(jid) + if i := strings.IndexByte(user, ':'); i >= 0 { + user = user[:i] + } + if i := strings.IndexByte(user, '.'); i >= 0 { + user = user[:i] + } + return user +} + +// NormalizeJID reduces a JID to its comparable form: lowercased server, and a +// user with device and agent suffixes stripped. Use it before any equality +// check or allowlist lookup. +func NormalizeJID(jid string) string { + server := JIDServer(jid) + if server == "" { + return JIDUser(jid) + } + return JIDUser(jid) + "@" + server +} + +// IsGroupJID reports whether the JID addresses a group chat. +func IsGroupJID(jid string) bool { return JIDServer(jid) == serverGroup } + +// IsNewsletterJID reports whether the JID addresses a Channel / newsletter. +func IsNewsletterJID(jid string) bool { return JIDServer(jid) == serverNewsletter } + +// IsBroadcastJID reports whether the JID addresses a broadcast list or the +// status feed. +func IsBroadcastJID(jid string) bool { return JIDServer(jid) == serverBroadcast } + +// IsUserJID reports whether the JID addresses an individual, by either phone +// number or hidden-number LID. +func IsUserJID(jid string) bool { + switch JIDServer(jid) { + case serverUser, serverLID: + return true + } + return false +} + +// IsLIDJID reports whether the JID is a hidden-number identifier rather than a +// phone number. A LID sender has no E.164 form, so an allowlist keyed on phone +// numbers cannot match it. +func IsLIDJID(jid string) bool { return JIDServer(jid) == serverLID } + +// IsConversationJID reports whether the JID addresses something the agent may +// hold a conversation in — a DM or a group. Newsletters, broadcast lists and +// the status feed are excluded: they are one-way feeds, and replying to one +// either fails or fans a message out to every contact. +func IsConversationJID(jid string) bool { + return IsGroupJID(jid) || IsUserJID(jid) +} + +// E164ToJID builds a user JID from a phone number in any common written form +// ("+1 (415) 555-0100", "1-415-555-0100", "14155550100"). Returns "" when the +// input holds no digits. +// +// An input that already looks like a JID is normalized and returned as-is, so +// operators may write either form in an allowlist. +func E164ToJID(number string) string { + number = strings.TrimSpace(number) + if strings.Contains(number, "@") { + return NormalizeJID(number) + } + digits := digitsOnly(number) + if digits == "" { + return "" + } + return digits + "@" + serverUser +} + +// JIDToE164 renders a user JID as a "+"-prefixed phone number. Returns "" for +// a group, newsletter, broadcast or LID JID — none of which carry a number. +func JIDToE164(jid string) string { + if !IsUserJID(jid) || IsLIDJID(jid) { + return "" + } + digits := digitsOnly(JIDUser(jid)) + if digits == "" { + return "" + } + return "+" + digits +} + +// digitsOnly strips every non-digit rune. Used to make written phone numbers +// comparable regardless of the punctuation an operator typed. +func digitsOnly(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +// parseJIDSet builds a lookup set from a comma- or newline-separated config +// list. Entries may be written as JIDs or as bare phone numbers; both +// normalize to the same key, so a caller looks up with NormalizeJID. +// +// Group IDs have no phone-number form, so a bare group id ("120363...") is +// accepted and qualified with the group server. +// +// Note the separator set is narrower than the Teams adapter's +// parseAllowBotIDs, which also splits on spaces. A space is legitimate INSIDE +// a written phone number ("+1 (415) 555-0100"), so treating it as a separator +// would shred one entry into four bogus ones. +func parseJIDSet(s string, defaultServer string) map[string]bool { + out := map[string]bool{} + if strings.TrimSpace(s) == "" { + return out + } + for _, raw := range strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + if !strings.Contains(entry, "@") { + if defaultServer == serverUser { + if jid := E164ToJID(entry); jid != "" { + out[jid] = true + } + continue + } + entry += "@" + defaultServer + } + out[NormalizeJID(entry)] = true + } + return out +} diff --git a/forge-plugins/channels/whatsapp/jid_test.go b/forge-plugins/channels/whatsapp/jid_test.go new file mode 100644 index 00000000..0063ce79 --- /dev/null +++ b/forge-plugins/channels/whatsapp/jid_test.go @@ -0,0 +1,174 @@ +package whatsapp + +import "testing" + +func TestSplitJID(t *testing.T) { + tests := []struct { + in string + wantUser string + wantServer string + }{ + {"14155550100@s.whatsapp.net", "14155550100", "s.whatsapp.net"}, + {"120363000000000000@g.us", "120363000000000000", "g.us"}, + {"14155550100@S.WhatsApp.Net", "14155550100", "s.whatsapp.net"}, + {"bare", "bare", ""}, + {"", "", ""}, + } + for _, tt := range tests { + user, server := SplitJID(tt.in) + if user != tt.wantUser || server != tt.wantServer { + t.Errorf("SplitJID(%q) = (%q, %q), want (%q, %q)", tt.in, user, server, tt.wantUser, tt.wantServer) + } + } +} + +// A device-qualified JID must compare equal to the bare one — the same person +// messaging from phone and desktop is one identity. +func TestJIDUser_StripsDeviceAndAgent(t *testing.T) { + tests := map[string]string{ + "14155550100@s.whatsapp.net": "14155550100", + "14155550100:5@s.whatsapp.net": "14155550100", + "14155550100.0@s.whatsapp.net": "14155550100", + "14155550100.0:5@s.whatsapp.net": "14155550100", + } + for in, want := range tests { + if got := JIDUser(in); got != want { + t.Errorf("JIDUser(%q) = %q, want %q", in, got, want) + } + } +} + +func TestNormalizeJID(t *testing.T) { + tests := map[string]string{ + "14155550100:5@s.whatsapp.net": "14155550100@s.whatsapp.net", + "14155550100@S.WHATSAPP.NET": "14155550100@s.whatsapp.net", + "120363000000000000@g.us": "120363000000000000@g.us", + "bare": "bare", + } + for in, want := range tests { + if got := NormalizeJID(in); got != want { + t.Errorf("NormalizeJID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestJIDClassification(t *testing.T) { + tests := []struct { + jid string + group, newsletter, broadcast, user, lid, convo bool + }{ + {jid: "14155550100@s.whatsapp.net", user: true, convo: true}, + {jid: "120363000000000000@g.us", group: true, convo: true}, + {jid: "123456@newsletter", newsletter: true}, + {jid: "status@broadcast", broadcast: true}, + {jid: "98765@lid", user: true, lid: true, convo: true}, + } + for _, tt := range tests { + if got := IsGroupJID(tt.jid); got != tt.group { + t.Errorf("IsGroupJID(%q) = %v, want %v", tt.jid, got, tt.group) + } + if got := IsNewsletterJID(tt.jid); got != tt.newsletter { + t.Errorf("IsNewsletterJID(%q) = %v, want %v", tt.jid, got, tt.newsletter) + } + if got := IsBroadcastJID(tt.jid); got != tt.broadcast { + t.Errorf("IsBroadcastJID(%q) = %v, want %v", tt.jid, got, tt.broadcast) + } + if got := IsUserJID(tt.jid); got != tt.user { + t.Errorf("IsUserJID(%q) = %v, want %v", tt.jid, got, tt.user) + } + if got := IsLIDJID(tt.jid); got != tt.lid { + t.Errorf("IsLIDJID(%q) = %v, want %v", tt.jid, got, tt.lid) + } + if got := IsConversationJID(tt.jid); got != tt.convo { + t.Errorf("IsConversationJID(%q) = %v, want %v", tt.jid, got, tt.convo) + } + } +} + +func TestE164ToJID(t *testing.T) { + tests := map[string]string{ + "+1 (415) 555-0100": "14155550100@s.whatsapp.net", + "1-415-555-0100": "14155550100@s.whatsapp.net", + "14155550100": "14155550100@s.whatsapp.net", + " +14155550100 ": "14155550100@s.whatsapp.net", + "14155550100@s.whatsapp.net": "14155550100@s.whatsapp.net", + "": "", + "not-a-number": "", + } + for in, want := range tests { + if got := E164ToJID(in); got != want { + t.Errorf("E164ToJID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestJIDToE164(t *testing.T) { + tests := map[string]string{ + "14155550100@s.whatsapp.net": "+14155550100", + "14155550100:3@s.whatsapp.net": "+14155550100", + "120363000000000000@g.us": "", // groups carry no number + "98765@lid": "", // hidden numbers carry no number + "123@newsletter": "", + } + for in, want := range tests { + if got := JIDToE164(in); got != want { + t.Errorf("JIDToE164(%q) = %q, want %q", in, got, want) + } + } +} + +// Operators write allowlists by hand, so both punctuated numbers and raw JIDs +// have to land on the same key. +func TestParseJIDSet_SendersAcceptNumbersAndJIDs(t *testing.T) { + set := parseJIDSet("+1 (415) 555-0100, 14155550199@s.whatsapp.net", serverUser) + for _, want := range []string{"14155550100@s.whatsapp.net", "14155550199@s.whatsapp.net"} { + if !set[want] { + t.Errorf("expected %q in set, got %v", want, set) + } + } + if len(set) != 2 { + t.Errorf("expected 2 entries, got %d: %v", len(set), set) + } +} + +func TestParseJIDSet_GroupsQualifyBareIDs(t *testing.T) { + set := parseJIDSet("120363000000000000, 120363000000000001@g.us", serverGroup) + for _, want := range []string{"120363000000000000@g.us", "120363000000000001@g.us"} { + if !set[want] { + t.Errorf("expected %q in set, got %v", want, set) + } + } +} + +func TestParseJIDSet_Separators(t *testing.T) { + set := parseJIDSet("14155550100,\n14155550101,14155550102", serverUser) + if len(set) != 3 { + t.Errorf("expected 3 entries across comma and newline, got %d: %v", len(set), set) + } +} + +// A space is part of a written phone number, not a separator — splitting on it +// would shred one entry into several bogus ones. +func TestParseJIDSet_SpaceIsNotASeparator(t *testing.T) { + set := parseJIDSet("+1 (415) 555-0100", serverUser) + if len(set) != 1 || !set["14155550100@s.whatsapp.net"] { + t.Errorf("expected one entry for a spaced phone number, got %v", set) + } +} + +func TestParseJIDSet_Empty(t *testing.T) { + for _, in := range []string{"", " ", "\n"} { + if got := parseJIDSet(in, serverUser); len(got) != 0 { + t.Errorf("parseJIDSet(%q) = %v, want empty", in, got) + } + } +} + +// A device suffix in a config entry must not create a key the runtime lookup +// (which normalizes) can never hit. +func TestParseJIDSet_NormalizesDeviceSuffix(t *testing.T) { + set := parseJIDSet("14155550100:7@s.whatsapp.net", serverUser) + if !set["14155550100@s.whatsapp.net"] { + t.Errorf("expected device suffix stripped, got %v", set) + } +} diff --git a/forge-plugins/channels/whatsapp/session.go b/forge-plugins/channels/whatsapp/session.go new file mode 100644 index 00000000..266292dd --- /dev/null +++ b/forge-plugins/channels/whatsapp/session.go @@ -0,0 +1,125 @@ +package whatsapp + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + + // Pure-Go SQLite driver, registered as "sqlite". Deliberately NOT + // mattn/go-sqlite3: the release image builds with CGO_ENABLED=0 (see the + // repo Dockerfile), under which a cgo driver compiles but panics at + // sql.Open with "unknown driver". modernc has no cgo dependency. + _ "modernc.org/sqlite" +) + +// sqlDialect is the driver name modernc.org/sqlite registers with database/sql. +// whatsmeow's dbutil matches any dialect with the "sqlite" prefix, so this +// selects SQLite SQL generation as well as the driver. +const sqlDialect = "sqlite" + +// sessionDSN builds the connection string for the pairing store. +// +// foreign_keys is enabled because whatsmeow's schema relies on cascading +// deletes to clean up per-device signal state; without it a logout leaves +// orphaned rows that make the next pairing fail. Note the pragma syntax is +// modernc's (`_pragma=foreign_keys(1)`), not mattn's (`_foreign_keys=on`) — +// mattn's form is silently ignored by this driver. +func sessionDSN(path string) string { + return "file:" + path + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)" +} + +// openContainer opens (creating if absent) the whatsmeow session store at +// path and runs any pending schema upgrades. +func openContainer(ctx context.Context, path string) (*sqlstore.Container, error) { + if path == "" { + return nil, fmt.Errorf("whatsapp: session_path is empty") + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("whatsapp: creating session directory %s: %w", dir, err) + } + } + container, err := sqlstore.New(ctx, sqlDialect, sessionDSN(path), nil) + if err != nil { + return nil, fmt.Errorf("whatsapp: opening session store %s: %w", path, err) + } + + // The store holds the pairing: anyone who can read it can send messages as + // the linked account. SQLite creates the file under the process umask, + // which on a default system is world-readable — tighten it to owner-only. + // Best-effort: a store on a filesystem without POSIX modes (a mounted + // volume, Windows) is still usable, so a failure here must not block + // startup. + if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) { + return container, nil //nolint:nilerr // hardening is best-effort; see comment above + } + return container, nil +} + +// loadPairedDevice returns the paired device from the session store at path. +// +// It fails when the store holds no completed pairing. Pairing requires a human +// to scan a QR code, so it cannot happen inside `forge run` — the adapter +// refuses to start rather than blocking a server boot on a terminal +// interaction that may never come. +func loadPairedDevice(ctx context.Context, path string) (*sqlstore.Container, *store.Device, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil, fmt.Errorf("whatsapp: no session at %s — run `forge channel whatsapp-login` to pair", path) + } + return nil, nil, fmt.Errorf("whatsapp: stat session %s: %w", path, err) + } + + container, err := openContainer(ctx, path) + if err != nil { + return nil, nil, err + } + + device, err := container.GetFirstDevice(ctx) + if err != nil { + _ = container.Close() + return nil, nil, fmt.Errorf("whatsapp: reading device from %s: %w", path, err) + } + if device == nil || device.ID == nil { + _ = container.Close() + return nil, nil, fmt.Errorf("whatsapp: session at %s is not paired — run `forge channel whatsapp-login`", path) + } + return container, device, nil +} + +// NewSessionDevice opens the store at path and returns a device to pair. +// +// An existing paired device is reused so a re-login refreshes the same session +// rather than accumulating rows for abandoned pairings. Exported for the +// `forge channel whatsapp-login` command, which owns the QR flow — the adapter +// itself never creates a device. +func NewSessionDevice(ctx context.Context, path string) (*sqlstore.Container, *store.Device, error) { + container, err := openContainer(ctx, path) + if err != nil { + return nil, nil, err + } + device, err := container.GetFirstDevice(ctx) + if err != nil { + _ = container.Close() + return nil, nil, fmt.Errorf("whatsapp: reading device from %s: %w", path, err) + } + if device == nil { + device = container.NewDevice() + } + return container, device, nil +} + +// SessionExists reports whether a paired session is present at path. Used by +// the CLI and the init wizard to decide whether to offer pairing. +func SessionExists(ctx context.Context, path string) bool { + container, device, err := loadPairedDevice(ctx, path) + if err != nil { + return false + } + _ = container.Close() + return device != nil && device.ID != nil +} diff --git a/forge-plugins/channels/whatsapp/whatsapp.go b/forge-plugins/channels/whatsapp/whatsapp.go new file mode 100644 index 00000000..80bb6b95 --- /dev/null +++ b/forge-plugins/channels/whatsapp/whatsapp.go @@ -0,0 +1,675 @@ +package whatsapp + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + "sync" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/channels" + "github.com/initializ/forge/forge-plugins/channels/markdown" +) + +const ( + defaultSessionPath = ".forge/channels/whatsapp-session.db" + // handlerTimeout bounds one agent turn. Matches the Telegram adapter. + handlerTimeout = 10 * time.Minute + // typingRefresh is how often the composing indicator is re-sent. WhatsApp + // expires it after roughly 10s, so a slow turn needs a heartbeat or the + // chat looks idle while the agent is working. + typingRefresh = 8 * time.Second + // historySoftCap bounds the injected context block, mirroring the Teams + // adapter's budget guard. + historySoftCap = 5000 + // defaultSelfChatPrefix marks the agent's replies in the owner's self-chat. + // + // WhatsApp decides which side of the thread a message renders on from its + // sender, and in the self-chat the agent sends AS the owner — so its + // replies are visually identical to the owner's own prompts. No wire-level + // setting changes that; a text marker is the only way to tell them apart. + defaultSelfChatPrefix = "⚒ Forge: " + // staleGrace is how far before startup an inbound message may be dated and + // still be acted on. + // + // Reconnecting delivers a backlog, and after a restart the dedup ring is + // empty — so without this the agent answers a replayed conversation, and + // in the self-chat it answers its OWN replayed replies, which loops. The + // window is wide enough that a brief restart still picks up messages sent + // while the agent was down, and narrow enough that a history sync of an + // old conversation is ignored. + staleGrace = 5 * time.Minute +) + +// Plugin implements channels.ChannelPlugin for WhatsApp over the WhatsApp Web +// multidevice protocol. +// +// Unlike every other adapter in this repo, authentication is not a token from +// config: it is a QR pairing captured out-of-band by +// `forge channel whatsapp-login` and persisted to a local session store. Start +// therefore fails closed on an unpaired session rather than attempting to pair +// — pairing needs a human at a terminal, which `forge run` cannot assume. +type Plugin struct { + cfg adapterConfig + + // Built at Start. + container *sqlstore.Container + client *whatsmeow.Client + dedup *dedup + history *chatHistory + + // The paired account's identities, captured at Start. WhatsApp addresses + // the same account by phone number (ownJID) and by hidden-number LID + // (ownLID); a mention may arrive under either, so both are kept. + ownJID string + ownLID string + // ownPushName is the display name other participants see, used to strip a + // typed "@Name" prefix off the prompt. + ownPushName string + + // logger is an optional structured ops logger (SetLogger). When set, + // operational signals route through it; nil → log.Printf. + logger channels.Logger + + // startedAt bounds how far back inbound messages are acted on. See + // staleGrace. + startedAt time.Time + + // Lifecycle. + stopCh chan struct{} + once sync.Once +} + +type adapterConfig struct { + SessionPath string + SelfChatPrefix string + Admission admissionConfig + IncludeRecentHistory bool + RecentHistoryCount int +} + +// New returns an uninitialised plugin. Init must be called before Start. +func New() *Plugin { + return &Plugin{ + dedup: newDedup(1000), + stopCh: make(chan struct{}), + } +} + +func (p *Plugin) Name() string { return "whatsapp" } + +// SetLogger wires a structured ops logger (channels.LoggerAware). Optional. +func (p *Plugin) SetLogger(l channels.Logger) { p.logger = l } + +func (p *Plugin) Init(cfg channels.ChannelConfig) error { + settings := channels.ResolveEnvVars(&cfg) + + ac := adapterConfig{ + SessionPath: strOrDefault(settings["session_path"], defaultSessionPath), + // strOrDefault would swallow a deliberate "" (meaning "no prefix"), so + // the presence of the key is what decides here. + SelfChatPrefix: settingOrDefault(settings, "self_chat_prefix", defaultSelfChatPrefix), + Admission: admissionConfig{ + Mode: AdmitMode(strOrDefault(settings["admit"], string(AdmitDMOrGroupMention))), + AllowedGroups: parseJIDSet(settings["allowed_groups"], serverGroup), + AllowedSenders: parseJIDSet(settings["allowed_senders"], serverUser), + AllowAnySender: isAnySender(settings["allowed_senders"]), + SelfChat: parseBool(settings["self_chat"], true), + }, + IncludeRecentHistory: parseBool(settings["include_recent_history"], true), + RecentHistoryCount: parseInt(settings["recent_history_count"], 20), + } + + switch ac.Admission.Mode { + case AdmitDM, AdmitGroupMention, AdmitDMOrGroupMention: + default: + return fmt.Errorf("whatsapp: admit must be one of dm, group_mention, dm_or_group_mention, got %q", ac.Admission.Mode) + } + if ac.RecentHistoryCount < 0 { + return fmt.Errorf("whatsapp: recent_history_count must not be negative, got %d", ac.RecentHistoryCount) + } + + p.cfg = ac + if ac.IncludeRecentHistory { + p.history = newChatHistory(ac.RecentHistoryCount) + } + return nil +} + +// Start connects the paired session and dispatches inbound messages to +// handler. It blocks until ctx is cancelled or Stop is called. +func (p *Plugin) Start(ctx context.Context, handler channels.EventHandler) error { + container, device, err := loadPairedDevice(ctx, p.cfg.SessionPath) + if err != nil { + return err + } + p.container = container + + p.ownJID = device.ID.ToNonAD().String() + if lid := device.GetLID(); !lid.IsEmpty() { + p.ownLID = lid.ToNonAD().String() + } + p.ownPushName = device.PushName + + // The paired account's identities are only known once the session is + // loaded, so the owner-dependent parts of the gate are filled in here + // rather than at Init. + p.cfg.Admission.OwnJIDs = []string{p.ownJID, p.ownLID} + p.startedAt = time.Now() + + p.client = whatsmeow.NewClient(device, nil) + p.client.AddEventHandler(func(evt any) { + switch v := evt.(type) { + case *events.Message: + p.onMessage(ctx, v, handler) + case *events.LoggedOut: + // The pairing was revoked from the phone. Reconnecting cannot + // recover it — only a fresh QR scan can — so say so loudly rather + // than looping on a dead session. + p.logError("whatsapp: session logged out — the linked device was removed; re-pair with `forge channel whatsapp-login`", map[string]any{ + "reason": v.Reason.String(), + }) + case *events.Connected: + p.logInfo("whatsapp: connected", map[string]any{"jid": p.ownJID}) + case *events.Disconnected: + p.logWarn("whatsapp: disconnected, whatsmeow will retry", nil) + } + }) + + if err := p.client.Connect(); err != nil { + return fmt.Errorf("whatsapp: connecting: %w", err) + } + + select { + case <-ctx.Done(): + case <-p.stopCh: + } + return nil +} + +// Stop disconnects the client and closes the session store. Safe to call more +// than once. +func (p *Plugin) Stop() error { + var err error + p.once.Do(func() { + close(p.stopCh) + if p.client != nil { + p.client.Disconnect() + } + if p.container != nil { + err = p.container.Close() + } + }) + return err +} + +// NormalizeEvent converts a raw whatsmeow message event into a ChannelEvent. +// +// The live path does not use this — whatsmeow delivers typed events, not +// bytes, so onMessage normalizes directly. It exists to satisfy +// channels.ChannelPlugin and to keep the JSON shape testable. +func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) { + var msg events.Message + if err := json.Unmarshal(raw, &msg); err != nil { + return nil, fmt.Errorf("whatsapp: parse message event: %w", err) + } + event := p.normalizeMessage(&msg) + if event == nil { + return nil, fmt.Errorf("whatsapp: message carries no text content") + } + return event, nil +} + +// onMessage runs the dedup → admission → dispatch pipeline for one inbound +// message. +func (p *Plugin) onMessage(ctx context.Context, msg *events.Message, handler channels.EventHandler) { + // Dedup first, and for EVERY message rather than only admitted ones: a + // history sync replays messages that were already evaluated, and + // re-running admission on them would re-dispatch anything that passes. + if p.dedup.markSeen(msg.Info.ID) { + return + } + + // Drop replayed history before anything else acts on it. + if p.isStale(msg.Info.Timestamp) { + p.logDebug("whatsapp: dropping message predating startup (history replay)", map[string]any{ + "message_id": msg.Info.ID, + "sent_at": msg.Info.Timestamp.Format(time.RFC3339), + }) + return + } + + chatJID := msg.Info.Chat.String() + senderJID := msg.Info.Sender.String() + senderAlt := "" + if !msg.Info.SenderAlt.IsEmpty() { + senderAlt = msg.Info.SenderAlt.String() + } + + // Record BEFORE admission, and regardless of its verdict. The surrounding + // human conversation in a group is exactly the context that makes a later + // "@agent summarise this" answerable, and none of those lines are + // themselves admitted. + p.recordHistory(msg) + + mentioned := isMentioned(mentionedJIDs(msg.Message), p.ownJID, p.ownLID) + + result := admit(chatJID, senderJID, senderAlt, msg.Info.IsFromMe, mentioned, p.cfg.Admission) + if !result.admit { + p.logDebug(result.reason, map[string]any{ + "message_id": msg.Info.ID, + "chat": chatJID, + "sender": senderJID, + }) + return + } + + event := p.normalizeMessage(msg) + if event == nil { + // Media with no caption, a reaction, a poll vote — nothing to prompt + // the agent with. Not an error. + p.logDebug("whatsapp: dropping message with no text content", map[string]any{ + "message_id": msg.Info.ID, + "chat": chatJID, + }) + return + } + + // Inject the observed conversation so the agent can answer prompts that + // refer to the surrounding thread ("summarise the above"). Skips the + // current message so it isn't duplicated inside its own context. + if p.history != nil { + event.Message = prependHistory( + p.history.recent(chatJID, p.cfg.RecentHistoryCount), + msg.Info.ID, + event.Message, + ) + } + + go p.dispatch(event, msg.Info.Chat, handler) +} + +// isStale reports whether an inbound message predates this adapter run by more +// than staleGrace, i.e. it is replayed history rather than live traffic. +func (p *Plugin) isStale(ts time.Time) bool { + if p.startedAt.IsZero() || ts.IsZero() { + return false + } + return ts.Before(p.startedAt.Add(-staleGrace)) +} + +// recordHistory adds one observed message to the context window. +func (p *Plugin) recordHistory(msg *events.Message) { + if p.history == nil { + return + } + text := strings.TrimSpace(extractMessageText(msg.Message)) + if text == "" { + return + } + p.history.record(msg.Info.Chat.String(), historyEntry{ + ID: msg.Info.ID, + Author: historyAuthor(msg), + Text: text, + At: msg.Info.Timestamp, + }) +} + +// historyAuthor picks the most human-readable label available for a sender. +// PushName is the display name the sender chose; it is absent for some +// message sources, in which case the phone number is the best we have. +func historyAuthor(msg *events.Message) string { + if msg.Info.IsFromMe { + return "agent" + } + if msg.Info.PushName != "" { + return msg.Info.PushName + } + return JIDUser(msg.Info.Sender.String()) +} + +// dispatch forwards one normalized event to the agent and delivers the reply. +func (p *Plugin) dispatch(event *channels.ChannelEvent, chat types.JID, handler channels.EventHandler) { + // A fresh context: the agent turn must outlive the inbound event's + // context, which whatsmeow may cancel when the socket cycles. + taskCtx, cancel := context.WithTimeout(context.Background(), handlerTimeout) + defer cancel() + + stopTyping := p.startTypingIndicator(taskCtx, chat) + defer stopTyping() + + // Open channel.whatsapp.deliver around the dispatch so the internal A2A + // POST (carrying the traceparent injected by the router) nests under it. + spanCtx, _, finish := channels.StartDeliverSpan(taskCtx, "whatsapp", event) + var handlerErr error + defer finish(&handlerErr) + + resp, err := handler(spanCtx, event) + if err != nil { + handlerErr = err + p.logError("whatsapp: handler error", map[string]any{"error": err.Error(), "chat": event.WorkspaceID}) + return + } + + stopTyping() + if err := p.SendResponse(event, resp); err != nil { + handlerErr = err + p.logError("whatsapp: send response error", map[string]any{"error": err.Error(), "chat": event.WorkspaceID}) + } +} + +// normalizeMessage converts a whatsmeow message into a ChannelEvent, or nil +// when the message carries no text the agent could act on. +func (p *Plugin) normalizeMessage(msg *events.Message) *channels.ChannelEvent { + text := strings.TrimSpace(extractMessageText(msg.Message)) + if text == "" { + return nil + } + + // Strip the "@Name" the sender typed to invoke the agent in a group, so + // the prompt doesn't open with the agent's own handle. + text = markdown.StripWhatsAppMention(text, p.ownPushName, JIDUser(p.ownJID), JIDToE164(p.ownJID)) + + chatJID := msg.Info.Chat.String() + return &channels.ChannelEvent{ + Channel: "whatsapp", + WorkspaceID: chatJID, + // One durable session per chat: the router keys the A2A task on + // ThreadID, and a WhatsApp conversation is exactly the unit a user + // expects to have continuity. + ThreadID: chatJID, + UserID: JIDUser(msg.Info.Sender.String()), + MessageID: msg.Info.ID, + Message: text, + // UserEmail is deliberately empty: WhatsApp has no email identity, so + // delegated (auth.type: user) MCP tools cannot resolve an on-behalf-of + // subject on this channel. See docs/core-concepts/channels.md. + } +} + +// SendResponse delivers an agent reply back to the originating chat. +// +// Every outbound message ID is recorded in the dedup ring before returning. A +// history sync after reconnect replays our own sends, and while +// MessageSource.IsFromMe catches them on the live path, a replayed message +// sent by the human operator from their own phone is indistinguishable from +// one the agent sent — the ring is what tells them apart. +func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Message) error { + chat, err := types.ParseJID(event.WorkspaceID) + if err != nil { + return fmt.Errorf("whatsapp: parsing chat JID %q: %w", event.WorkspaceID, err) + } + + text := extractText(response) + body := markdown.ToWhatsAppText(text) + + // Mark replies in the self-chat, where the agent sends as the owner and + // WhatsApp gives no visual distinction. Every chunk is marked, not just + // the first: an unmarked continuation is indistinguishable from something + // the owner typed when scrolling back. + prefix := "" + if p.cfg.SelfChatPrefix != "" && p.cfg.Admission.isSelfChat(event.WorkspaceID) { + prefix = p.cfg.SelfChatPrefix + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, chunk := range markdown.SplitMessageWhatsApp(body) { + if strings.TrimSpace(chunk) == "" { + continue + } + chunk = applyPrefix(prefix, chunk) + resp, err := p.client.SendMessage(ctx, chat, &waE2E.Message{ + Conversation: proto.String(chunk), + }) + if err != nil { + return fmt.Errorf("whatsapp: sending to %s: %w", chat, err) + } + p.markSent(resp.ID) + + // Record our own reply so a follow-up question ("expand on point 2") + // has the answer it refers to in context. + if p.history != nil { + p.history.record(event.WorkspaceID, historyEntry{ + ID: resp.ID, + Author: "agent", + Text: chunk, + At: time.Now(), + }) + } + } + return nil +} + +// markSent records an outbound message ID in the dedup ring. Safe to call with +// an empty id. +func (p *Plugin) markSent(id string) { + if id == "" || p.dedup == nil { + return + } + p.dedup.mark(id) +} + +// startTypingIndicator shows "typing…" in the chat until the returned stop +// func is called. WhatsApp expires the indicator after ~10s, so it is +// refreshed on a ticker. The returned func is safe to call more than once. +func (p *Plugin) startTypingIndicator(ctx context.Context, chat types.JID) func() { + send := func(state types.ChatPresence) { + if err := p.client.SendChatPresence(ctx, chat, state, types.ChatPresenceMediaText); err != nil { + p.logDebug("whatsapp: chat presence failed", map[string]any{"error": err.Error()}) + } + } + send(types.ChatPresenceComposing) + + done := make(chan struct{}) + var once sync.Once + + go func() { + ticker := time.NewTicker(typingRefresh) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + send(types.ChatPresenceComposing) + } + } + }() + + return func() { + once.Do(func() { + close(done) + send(types.ChatPresencePaused) + }) + } +} + +// --- message content helpers --- + +// extractMessageText pulls the prompt text out of a WhatsApp message. +// +// Text arrives as either a bare Conversation or an ExtendedTextMessage (used +// whenever the message carries context: a mention, a link preview, a reply). +// Media captions count too — "summarise this" under a document is a prompt. +func extractMessageText(m *waE2E.Message) string { + if m == nil { + return "" + } + if c := m.GetConversation(); c != "" { + return c + } + if t := m.GetExtendedTextMessage().GetText(); t != "" { + return t + } + if c := m.GetImageMessage().GetCaption(); c != "" { + return c + } + if c := m.GetVideoMessage().GetCaption(); c != "" { + return c + } + if c := m.GetDocumentMessage().GetCaption(); c != "" { + return c + } + // A message the user edited arrives wrapped; unwrap one level. + if e := m.GetEditedMessage().GetMessage(); e != nil { + return extractMessageText(e) + } + return "" +} + +// mentionedJIDs returns the JIDs @-mentioned in a message. +// +// WhatsApp carries mentions out-of-band in contextInfo.mentionedJid rather +// than as markup in the body, so this is the only reliable source — scanning +// the text would match a plain "@name" the sender typed by hand. +func mentionedJIDs(m *waE2E.Message) []string { + if m == nil { + return nil + } + if ci := m.GetExtendedTextMessage().GetContextInfo(); ci != nil { + return ci.GetMentionedJID() + } + return nil +} + +// extractText pulls the text content out of an A2A message, mirroring the +// pattern used by Slack, Telegram and Teams. +func extractText(msg *a2a.Message) string { + if msg == nil { + return "(no response)" + } + var parts []string + for _, p := range msg.Parts { + if p.Kind == a2a.PartKindText && p.Text != "" { + parts = append(parts, p.Text) + } + } + if len(parts) == 0 { + return "(no text response)" + } + return markdown.StripCompressionMarkers(strings.Join(parts, "\n")) +} + +// --- logging --- + +func (p *Plugin) logInfo(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Info(msg, fields) + return + } + log.Printf("[whatsapp] %s %v", msg, fields) +} + +func (p *Plugin) logWarn(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Warn(msg, fields) + return + } + log.Printf("[whatsapp] WARN %s %v", msg, fields) +} + +func (p *Plugin) logError(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Error(msg, fields) + return + } + log.Printf("[whatsapp] ERROR %s %v", msg, fields) +} + +func (p *Plugin) logDebug(msg string, fields map[string]any) { + if p.logger != nil { + p.logger.Debug(msg, fields) + return + } + log.Printf("[whatsapp] DEBUG %s %v", msg, fields) +} + +// --- settings helpers --- + +func strOrDefault(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return strings.TrimSpace(s) +} + +// applyPrefix prepends the reply marker. +// +// A chunk that opens with a fenced code block gets the marker on its own line: +// inlining it would put text before the opening ``` and WhatsApp would render +// the fence literally instead of as code. +func applyPrefix(prefix, chunk string) string { + if prefix == "" { + return chunk + } + if strings.HasPrefix(chunk, "```") { + return prefix + "\n" + chunk + } + return prefix + chunk +} + +// settingOrDefault returns the configured value when the key is present — +// including when it is deliberately empty — and def when it is absent. +func settingOrDefault(settings map[string]string, key, def string) string { + if v, ok := settings[key]; ok { + return v + } + return def +} + +// isAnySender reports whether allowed_senders is the explicit opt-out that +// disables the sender allowlist. Spelled as a word rather than left empty +// because it is the setting that exposes the agent to anyone with the number, +// and an empty value should mean the safe thing, not the open one. +func isAnySender(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "any", "anyone", "*": + return true + } + return false +} + +func parseBool(s string, def bool) bool { + s = strings.TrimSpace(s) + if s == "" { + return def + } + v, err := strconv.ParseBool(s) + if err != nil { + return def + } + return v +} + +func parseInt(s string, def int) int { + s = strings.TrimSpace(s) + if s == "" { + return def + } + v, err := strconv.Atoi(s) + if err != nil { + return def + } + return v +} + +// interface guards +var ( + _ channels.ChannelPlugin = (*Plugin)(nil) + _ channels.LoggerAware = (*Plugin)(nil) +) diff --git a/forge-plugins/channels/whatsapp/whatsapp_test.go b/forge-plugins/channels/whatsapp/whatsapp_test.go new file mode 100644 index 00000000..f974962a --- /dev/null +++ b/forge-plugins/channels/whatsapp/whatsapp_test.go @@ -0,0 +1,523 @@ +package whatsapp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "google.golang.org/protobuf/proto" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/channels" +) + +func cfgWith(settings map[string]string) channels.ChannelConfig { + return channels.ChannelConfig{Adapter: "whatsapp", Settings: settings} +} + +func TestPlugin_Name(t *testing.T) { + if got := New().Name(); got != "whatsapp" { + t.Errorf("got %q, want %q", got, "whatsapp") + } +} + +func TestInit_Defaults(t *testing.T) { + p := New() + if err := p.Init(cfgWith(nil)); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SessionPath != defaultSessionPath { + t.Errorf("session path = %q, want %q", p.cfg.SessionPath, defaultSessionPath) + } + if p.cfg.Admission.Mode != AdmitDMOrGroupMention { + t.Errorf("admit = %q, want %q", p.cfg.Admission.Mode, AdmitDMOrGroupMention) + } + if !p.cfg.IncludeRecentHistory || p.history == nil { + t.Error("expected history enabled by default") + } + if p.cfg.RecentHistoryCount != 20 { + t.Errorf("history count = %d, want 20", p.cfg.RecentHistoryCount) + } +} + +func TestInit_ParsesSettings(t *testing.T) { + p := New() + err := p.Init(cfgWith(map[string]string{ + "session_path": "/tmp/x/session.db", + "admit": "dm", + "allowed_groups": "120363000000000000", + "allowed_senders": "+1 (415) 555-0100", + "include_recent_history": "false", + "recent_history_count": "5", + })) + if err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SessionPath != "/tmp/x/session.db" { + t.Errorf("session path = %q", p.cfg.SessionPath) + } + if p.cfg.Admission.Mode != AdmitDM { + t.Errorf("admit = %q", p.cfg.Admission.Mode) + } + if !p.cfg.Admission.AllowedGroups["120363000000000000@g.us"] { + t.Errorf("groups = %v", p.cfg.Admission.AllowedGroups) + } + if !p.cfg.Admission.AllowedSenders["14155550100@s.whatsapp.net"] { + t.Errorf("senders = %v", p.cfg.Admission.AllowedSenders) + } + if p.cfg.IncludeRecentHistory || p.history != nil { + t.Error("expected history disabled") + } +} + +// A typo in admit must fail loudly at Init rather than silently degrade to a +// different gate at runtime. +func TestInit_RejectsUnknownAdmitMode(t *testing.T) { + err := New().Init(cfgWith(map[string]string{"admit": "everything"})) + if err == nil { + t.Fatal("expected error for unknown admit mode") + } + if !strings.Contains(err.Error(), "admit must be one of") { + t.Errorf("error should list valid modes, got %v", err) + } +} + +func TestInit_RejectsNegativeHistoryCount(t *testing.T) { + err := New().Init(cfgWith(map[string]string{"recent_history_count": "-1"})) + if err == nil { + t.Fatal("expected error for negative history count") + } +} + +// Settings resolve through the shared _env indirection, so a session path can +// come from the environment like every other adapter's config. +func TestInit_ResolvesEnvSuffix(t *testing.T) { + t.Setenv("TEST_WA_SESSION", "/tmp/from-env.db") + p := New() + if err := p.Init(cfgWith(map[string]string{"session_path_env": "TEST_WA_SESSION"})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SessionPath != "/tmp/from-env.db" { + t.Errorf("session path = %q, want the env value", p.cfg.SessionPath) + } +} + +// Pairing needs a human scanning a QR code, so a server boot must fail with an +// actionable message rather than hang waiting for one. +func TestStart_FailsClosedWithoutSession(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{ + "session_path": filepath.Join(t.TempDir(), "absent.db"), + })); err != nil { + t.Fatalf("Init: %v", err) + } + + err := p.Start(context.Background(), nil) + if err == nil { + t.Fatal("expected Start to fail on an unpaired session") + } + if !strings.Contains(err.Error(), "whatsapp-login") { + t.Errorf("error should name the pairing command, got %v", err) + } +} + +func TestSessionExists_FalseWhenAbsent(t *testing.T) { + if SessionExists(context.Background(), filepath.Join(t.TempDir(), "absent.db")) { + t.Error("expected false for a missing session file") + } +} + +// A store that opens but holds no completed pairing is not a usable session. +func TestSessionExists_FalseForUnpairedStore(t *testing.T) { + path := filepath.Join(t.TempDir(), "fresh.db") + ctx := context.Background() + + container, device, err := NewSessionDevice(ctx, path) + if err != nil { + t.Fatalf("NewSessionDevice: %v", err) + } + defer container.Close() //nolint:errcheck + if device == nil { + t.Fatal("expected a device to pair") + } + if device.ID != nil { + t.Error("a fresh device must not be pre-paired") + } + if SessionExists(ctx, path) { + t.Error("expected false for a store with no completed pairing") + } +} + +// The store must be creatable under a directory that doesn't exist yet — +// .forge/channels/ is not present in a fresh project. +func TestNewSessionDevice_CreatesParentDirs(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "deeper", "session.db") + container, _, err := NewSessionDevice(context.Background(), path) + if err != nil { + t.Fatalf("NewSessionDevice: %v", err) + } + defer container.Close() //nolint:errcheck +} + +// The store holds the pairing — a world-readable credential file is a leak. +func TestNewSessionDevice_SessionFileIsOwnerOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.db") + container, _, err := NewSessionDevice(context.Background(), path) + if err != nil { + t.Fatalf("NewSessionDevice: %v", err) + } + defer container.Close() //nolint:errcheck + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat session: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("session file mode = %04o, want 0600", perm) + } +} + +func TestOpenContainer_CreatesDirOwnerOnly(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested") + container, err := openContainer(context.Background(), filepath.Join(dir, "s.db")) + if err != nil { + t.Fatalf("openContainer: %v", err) + } + defer container.Close() //nolint:errcheck + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("session dir mode = %04o, want 0700", perm) + } +} + +func TestOpenContainer_RejectsEmptyPath(t *testing.T) { + if _, err := openContainer(context.Background(), ""); err == nil { + t.Fatal("expected error for empty session path") + } +} + +func TestSessionDSN_UsesModerncPragmaSyntax(t *testing.T) { + dsn := sessionDSN("/tmp/x.db") + // mattn's "_foreign_keys=on" is silently ignored by modernc; the pragma + // form is what actually enables cascading deletes. + if !strings.Contains(dsn, "_pragma=foreign_keys(1)") { + t.Errorf("dsn missing the modernc foreign-keys pragma: %q", dsn) + } + if strings.Contains(dsn, "_foreign_keys=") { + t.Errorf("dsn uses the mattn pragma form, which modernc ignores: %q", dsn) + } +} + +func TestExtractMessageText(t *testing.T) { + tests := []struct { + name string + msg *waE2E.Message + want string + }{ + {"nil", nil, ""}, + {"conversation", &waE2E.Message{Conversation: proto.String("plain")}, "plain"}, + { + "extended text", + &waE2E.Message{ExtendedTextMessage: &waE2E.ExtendedTextMessage{Text: proto.String("with context")}}, + "with context", + }, + { + "image caption", + &waE2E.Message{ImageMessage: &waE2E.ImageMessage{Caption: proto.String("what is this?")}}, + "what is this?", + }, + { + "document caption", + &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{Caption: proto.String("summarise")}}, + "summarise", + }, + {"empty message", &waE2E.Message{}, ""}, + } + for _, tt := range tests { + if got := extractMessageText(tt.msg); got != tt.want { + t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) + } + } +} + +// An edited message arrives wrapped; the new text is what the agent should see. +func TestExtractMessageText_UnwrapsEdit(t *testing.T) { + msg := &waE2E.Message{ + EditedMessage: &waE2E.FutureProofMessage{ + Message: &waE2E.Message{Conversation: proto.String("corrected")}, + }, + } + if got := extractMessageText(msg); got != "corrected" { + t.Errorf("got %q, want %q", got, "corrected") + } +} + +func TestMentionedJIDs(t *testing.T) { + if got := mentionedJIDs(nil); got != nil { + t.Errorf("nil message: got %v", got) + } + if got := mentionedJIDs(&waE2E.Message{Conversation: proto.String("hi")}); got != nil { + t.Errorf("plain conversation carries no mentions: got %v", got) + } + + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String("@agent hi"), + ContextInfo: &waE2E.ContextInfo{ + MentionedJID: []string{"14155550999@s.whatsapp.net"}, + }, + }, + } + got := mentionedJIDs(msg) + if len(got) != 1 || got[0] != "14155550999@s.whatsapp.net" { + t.Errorf("got %v", got) + } +} + +func TestExtractText_A2A(t *testing.T) { + if got := extractText(nil); got != "(no response)" { + t.Errorf("nil: got %q", got) + } + if got := extractText(&a2a.Message{}); got != "(no text response)" { + t.Errorf("empty parts: got %q", got) + } + + msg := &a2a.Message{Parts: []a2a.Part{ + {Kind: a2a.PartKindText, Text: "line one"}, + {Kind: a2a.PartKindText, Text: "line two"}, + }} + if got := extractText(msg); got != "line one\nline two" { + t.Errorf("got %q", got) + } +} + +// ctxzip markers are internal artifacts; a user must never see one. +func TestExtractText_StripsCompressionMarkers(t *testing.T) { + msg := &a2a.Message{Parts: []a2a.Part{ + {Kind: a2a.PartKindText, Text: "before <> after"}, + }} + if got := extractText(msg); strings.Contains(got, "ctxzip") { + t.Errorf("marker leaked: %q", got) + } +} + +func TestParseBool(t *testing.T) { + tests := []struct { + in string + def bool + out bool + }{ + {"", true, true}, + {"", false, false}, + {"true", false, true}, + {"false", true, false}, + {"1", false, true}, + {"garbage", true, true}, + } + for _, tt := range tests { + if got := parseBool(tt.in, tt.def); got != tt.out { + t.Errorf("parseBool(%q, %v) = %v, want %v", tt.in, tt.def, got, tt.out) + } + } +} + +func TestParseInt(t *testing.T) { + tests := []struct { + in string + def int + out int + }{ + {"", 20, 20}, + {"5", 20, 5}, + {"garbage", 20, 20}, + {" 7 ", 20, 7}, + } + for _, tt := range tests { + if got := parseInt(tt.in, tt.def); got != tt.out { + t.Errorf("parseInt(%q, %d) = %d, want %d", tt.in, tt.def, got, tt.out) + } + } +} + +func TestStop_IsIdempotent(t *testing.T) { + p := New() + if err := p.Stop(); err != nil { + t.Errorf("first Stop: %v", err) + } + if err := p.Stop(); err != nil { + t.Errorf("second Stop: %v", err) + } +} + +func TestMarkSent_IgnoresEmptyID(t *testing.T) { + p := New() + p.markSent("") + if p.dedup.size() != 0 { + t.Errorf("expected empty id ignored, size = %d", p.dedup.size()) + } + p.markSent("real") + if !p.dedup.seen("real") { + t.Error("expected real id recorded") + } +} + +// --- history-replay guard --- + +// After a restart the dedup ring is empty, so a replayed conversation would be +// answered from scratch — and in the self-chat the agent would answer its own +// replayed replies, which loops. Anything predating the run is dropped. +func TestIsStale_DropsReplayedHistory(t *testing.T) { + p := New() + p.startedAt = time.Now() + + if !p.isStale(p.startedAt.Add(-time.Hour)) { + t.Error("an hour-old message should be treated as replayed history") + } + if !p.isStale(p.startedAt.Add(-staleGrace - time.Minute)) { + t.Error("a message past the grace window should be stale") + } +} + +// A brief restart must still pick up messages sent while the agent was down. +func TestIsStale_KeepsRecentMessagesWithinGrace(t *testing.T) { + p := New() + p.startedAt = time.Now() + + if p.isStale(p.startedAt.Add(-time.Minute)) { + t.Error("a message from just before startup should still be answered") + } + if p.isStale(p.startedAt.Add(time.Second)) { + t.Error("a message sent after startup is live traffic") + } +} + +// Before Start the clock is unset; nothing should be judged stale. +func TestIsStale_NoStartTimeAcceptsEverything(t *testing.T) { + p := New() + if p.isStale(time.Now().Add(-24 * time.Hour)) { + t.Error("with no start time recorded, nothing is stale") + } +} + +// A zero timestamp carries no information — don't drop on it. +func TestIsStale_ZeroTimestampNotStale(t *testing.T) { + p := New() + p.startedAt = time.Now() + if p.isStale(time.Time{}) { + t.Error("a zero timestamp must not be treated as stale") + } +} + +func TestInit_SelfChatDefaultsOn(t *testing.T) { + p := New() + if err := p.Init(cfgWith(nil)); err != nil { + t.Fatalf("Init: %v", err) + } + if !p.cfg.Admission.SelfChat { + t.Error("self_chat should default to true — it is the personal-agent flow") + } + if p.cfg.Admission.AllowAnySender { + t.Error("allowed_senders must default to owner-only, not open") + } +} + +func TestInit_AllowAnySenderOptIn(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"allowed_senders": "anyone"})); err != nil { + t.Fatalf("Init: %v", err) + } + if !p.cfg.Admission.AllowAnySender { + t.Error("allowed_senders: anyone should disable the allowlist") + } +} + +func TestInit_SelfChatCanBeDisabled(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"self_chat": "false"})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.Admission.SelfChat { + t.Error("self_chat: false should disable it") + } +} + +// --- self-chat reply prefix --- + +func TestApplyPrefix_PrependsMarker(t *testing.T) { + if got := applyPrefix("⚒ Forge: ", "hello"); got != "⚒ Forge: hello" { + t.Errorf("got %q", got) + } +} + +func TestApplyPrefix_EmptyPrefixIsNoop(t *testing.T) { + if got := applyPrefix("", "hello"); got != "hello" { + t.Errorf("got %q, want the chunk unchanged", got) + } +} + +// Inlining the marker before an opening fence makes WhatsApp render the fence +// literally instead of as a code block. +func TestApplyPrefix_FencedCodeGetsOwnLine(t *testing.T) { + got := applyPrefix("⚒ Forge: ", "```go\nfmt.Println()\n```") + if !strings.HasPrefix(got, "⚒ Forge: \n```go") { + t.Errorf("marker should sit on its own line before a fence, got %q", got) + } +} + +func TestInit_SelfChatPrefixDefault(t *testing.T) { + p := New() + if err := p.Init(cfgWith(nil)); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SelfChatPrefix != defaultSelfChatPrefix { + t.Errorf("prefix = %q, want %q", p.cfg.SelfChatPrefix, defaultSelfChatPrefix) + } + if !strings.Contains(p.cfg.SelfChatPrefix, "Forge") { + t.Errorf("default prefix should name Forge, got %q", p.cfg.SelfChatPrefix) + } +} + +func TestInit_SelfChatPrefixOverride(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"self_chat_prefix": "🤖 bot: "})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SelfChatPrefix != "🤖 bot: " { + t.Errorf("prefix = %q, want the override", p.cfg.SelfChatPrefix) + } +} + +// An explicitly empty value means "no marker" and must not fall back to the +// default the way strOrDefault would. +func TestInit_SelfChatPrefixCanBeDisabled(t *testing.T) { + p := New() + if err := p.Init(cfgWith(map[string]string{"self_chat_prefix": ""})); err != nil { + t.Fatalf("Init: %v", err) + } + if p.cfg.SelfChatPrefix != "" { + t.Errorf("prefix = %q, want it disabled", p.cfg.SelfChatPrefix) + } +} + +// The marker exists because the self-chat has no sender distinction; a normal +// DM already shows who sent what, so marking there would just be noise. +func TestSelfChatPrefix_AppliesOnlyToSelfChat(t *testing.T) { + cfg := admissionConfig{OwnJIDs: []string{testOwnJID}} + + if !cfg.isSelfChat(testOwnJID) { + t.Error("the owner's own chat should be recognised as the self-chat") + } + for _, chat := range []string{"14155550100@s.whatsapp.net", "120363000000000000@g.us"} { + if cfg.isSelfChat(chat) { + t.Errorf("%q must not be treated as the self-chat", chat) + } + } +} diff --git a/forge-plugins/go.mod b/forge-plugins/go.mod index f2854262..671ff781 100644 --- a/forge-plugins/go.mod +++ b/forge-plugins/go.mod @@ -6,4 +6,32 @@ require github.com/initializ/forge/forge-core v0.0.0 require github.com/gorilla/websocket v1.5.3 +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect + go.mau.fi/libsignal v0.2.2 // indirect + go.mau.fi/util v0.10.0 // indirect + go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.58.0 // indirect +) + replace github.com/initializ/forge/forge-core => ../forge-core diff --git a/forge-plugins/go.sum b/forge-plugins/go.sum index 25a9fc4b..ec3941fd 100644 --- a/forge-plugins/go.sum +++ b/forge-plugins/go.sum @@ -1,2 +1,59 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +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= +go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= +go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= +go.mau.fi/util v0.10.0 h1:vH9IXZmfBKa96p47HxrVqEPkrj02zDJg3o4EF172+Lk= +go.mau.fi/util v0.10.0/go.mod h1:uZwpm9sK4wO2Qqy+t6QoVq29szMsRxWXp9/BkQLG4xk= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde h1:eMHY9dMDkNuDMWhfTbMZHbbsxj7G6mfujjKei1HaFQM= +go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde/go.mod h1:z0ZZNt4hq3FZbUKnunexE/QscCx7VkLvQSvtggc/aE8= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837 h1:xJ13dqFcK/oPNImltlk7sumI/uiQ9EON/6g/iwpT1Nc= +go.mau.fi/whatsmeow v0.0.0-20260816113502-fb386f152837/go.mod h1:UFP0D7aj+biuFejsllcoNiHTQorh0ODFUyG4Txpqfks= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1 h1:4EyoinbJqFq4qozxem5nklT8+pI4OaokUuG+ayE3nKQ= +go.mau.fi/whatsmeow v0.0.0-20260909093947-9ec8f76db5f1/go.mod h1:aMd13H2xFFGH9cskcvxo4Aae+TmyFN38yw+HvsrpwVg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= diff --git a/forge-ui/go.mod b/forge-ui/go.mod index 8a5177d2..e99f25a0 100644 --- a/forge-ui/go.mod +++ b/forge-ui/go.mod @@ -5,13 +5,38 @@ go 1.25.0 require ( github.com/initializ/forge/forge-core v0.0.0 github.com/initializ/forge/forge-skills v0.0.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gowebpki/jcs v1.0.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) replace ( diff --git a/forge-ui/go.sum b/forge-ui/go.sum index f7cd70a1..b766e506 100644 --- a/forge-ui/go.sum +++ b/forge-ui/go.sum @@ -1,17 +1,89 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +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.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/forge-ui/handlers_create.go b/forge-ui/handlers_create.go index 7b984671..26d76343 100644 --- a/forge-ui/handlers_create.go +++ b/forge-ui/handlers_create.go @@ -24,7 +24,7 @@ func (s *UIServer) handleGetWizardMeta(w http.ResponseWriter, _ *http.Request) { meta := WizardMetadata{ Providers: []string{"openai", "anthropic", "gemini", "ollama", "custom"}, Frameworks: []string{"forge", "crewai", "langchain"}, - Channels: []string{"slack", "telegram"}, + Channels: []string{"slack", "telegram", "msteams", "whatsapp"}, } // Per-provider model lists diff --git a/forge.yaml.example b/forge.yaml.example index 6c3ef66d..4645b215 100644 --- a/forge.yaml.example +++ b/forge.yaml.example @@ -35,6 +35,8 @@ skills: channels: - slack - telegram +# - msteams +# - whatsapp # pair first: forge channel whatsapp-login # Secret management (optional — defaults to env vars only) # secrets: diff --git a/go.work.sum b/go.work.sum index f4542c48..e35ed40b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,8 +1,10 @@ cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794/go.mod h1:7e+I0LQFUI9AXWxOfsQROs9xPhoJtbsyWcjJqDd4KPY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= @@ -10,9 +12,9 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= @@ -26,28 +28,41 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/perf v0.0.0-20250813145418-2f7363a06fe1/go.mod h1:rjfRjhHXb3XNVh/9i5Jr2tXoTd0vOlZN5rzsM8cQE6k= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=