Skip to content

[Feature]: WhatsApp channel adapter (outbound-only, like Telegram/Slack) #438

Description

@initializ-mk

Goal

Add a WhatsApp channel adapter to forge-plugins/channels/, outbound-only, following the same pattern as Telegram and Slack. It delivers agent responses out to WhatsApp (primarily scheduled-task results); it does not ingest inbound messages in v1. The closest existing precedent is the MS Teams adapter (#78 / PR #76), which is documented "outbound-only" — WhatsApp is even more one-directional (no polling either).

Provider: Meta WhatsApp Business Cloud API (Graph API) — POST https://graph.facebook.com/<version>/<PHONE_NUMBER_ID>/messages, Authorization: Bearer <ACCESS_TOKEN>.

Why outbound-only is a first-class fit

The channel contract already separates the two directions, and forge already drives a purely-outbound path that never touches inbound:

  • Interface ChannelPluginforge-core/channels/plugin.go:13-28: inbound = Start/NormalizeEvent, outbound = SendResponse. "Outbound-only" is an implementation property, not a separate interface.
  • The scheduled-task delivery path calls SendResponse with a synthetic ChannelEvent where only Channel + WorkspaceID (= the target) are set — no MessageID, no inbound origin:
    • Runner fires the notifier: forge-cli/runtime/runner.go:5009-5013 (if sched.Channel != "" && sched.ChannelTarget != "").
    • Wiring: forge-cli/cmd/run.go:326-336 builds &ChannelEvent{Channel, WorkspaceID: target} and calls plugin.SendResponse(...).
    • Schedule carries the destination: ScheduleConfig{Channel, ChannelTarget}forge-core/types/config.go:890-898.

So WhatsApp's SendResponse must work purely from event.WorkspaceID = the recipient phone number (E.164 / wa_id). Start can be a no-op blocker and NormalizeEvent can return an error — nothing calls them for an outbound-only adapter.

⚠️ Key design decision: the 24-hour window + message templates

This is the one structural difference from Telegram/Slack and must be settled before implementation.

WhatsApp Cloud API rejects free-form text outside a 24-hour customer-service window opened by a user-initiated inbound message. It requires a pre-approved message template (type: "template" with name, language, components) for proactive/business-initiated sends. Since this integration is outbound-only with no inbound in v1, there is effectively no open window for scheduled/proactive delivery → SendResponse must send a template message, not type: text.

Telegram/Slack send free text unconditionally; there is no analog. Decide and record in the issue before coding:

  1. Template-first (recommended for v1): config a default template name + language + a parameter-mapping strategy (agent summary text → a body parameter). Proactive sends always use type: template.
  2. allow_freeform flag: send type: text only when an open window is known (not knowable without inbound), else fall back to template. Lower value for v1.
  3. Long content: template bodies have length limits and can't carry arbitrary markdown, so the Telegram/Slack markdown.SplitMessage + file-upload approach does not map cleanly. v1 likely truncates the agent summary into a body parameter (or sends a media/document message). This needs an explicit answer in the issue.

Provider specifics vs Telegram/Slack

Concern Telegram/Slack WhatsApp
Auth single bot_token / app_token+bot_token phone_number_id (path segment, not secret) + access_token (bearer, secret)
Target chat ID / channel ID recipient phone number (wa_id, E.164) = event.WorkspaceID
Send shape free text, unconditional type: template (proactive) — {messaging_product:"whatsapp", to, type, ...}
Formatting HTML (TG) / mrkdwn (Slack) lightweight only: *bold*, _italic_, ```mono``` — new markdown.ToWhatsApp
Inbound webhook/poll (TG), Socket Mode (Slack) none in v1 — Start is a no-op blocker
Egress host api.telegram.org / slack capability graph.facebook.com
Approvals/Consent Slack implements ApprovalDeliverer/ConsentDeliverer not implemented — base ChannelPlugin only

File-by-file task breakdown (grounded in the current code)

File Change
forge-plugins/channels/whatsapp/whatsapp.go (new) Plugin type + New(); Name()="whatsapp"; Init reads phone_number_id + access_token (+ optional api_version, base URL), errors on missing (mirror telegram.go:60-63); Start(ctx,_) logs "outbound-only started" and blocks on <-ctx.Done(); NormalizeEvent returns an error; SendResponse POSTs to graph.facebook.com/<ver>/<phoneID>/messages (template-first per decision above). Add an unexported apiBase field so tests can point at httptest.
forge-plugins/channels/whatsapp/whatsapp_test.go (new) httptest.NewServer stub + p.apiBase = srv.URL (same pattern as telegram_test.go/slack_test.go): Init required-field errors; SendResponse posts correct Cloud API JSON (recipient + type: template payload shape); long-content truncation.
forge-plugins/channels/markdown/whatsapp.go (new) ToWhatsApp(text) — lightweight formatter alongside ToTelegramHTML / ToSlackMrkdwn / MarkdownToTeamsHTML.
forge-cli/cmd/channel.go Register in 4–5 sites: createPlugin switch (~:236-247), defaultRegistry (~:250-256), ValidArgs + guard strings (~:32-44, ~:110-111, ~:163-164), addChannelEgressToForgeYAML case (~:343-419, treat capability-based like slack/msteams), and printSetupInstructions.
forge-cli/templates/init/whatsapp-config.yaml.tmpl + env template (new) adapter: whatsapp, settings: { phone_number_id: "...", access_token_env: WHATSAPP_ACCESS_TOKEN, api_version: "..." }. Auto-discovered by EnvVarsFromConfig (forge-cli/channels/env.go:25-55) — no edit needed there.
forge-core/security/capabilities.go Add "whatsapp": {"graph.facebook.com"} to DefaultCapabilityBundles (:4-12).
docs/core-concepts/channels.md (+ new docs/channels/whatsapp.md) Supported-Channels table row, setup steps (get phone number ID + access token, register a template), config sample, outbound-only + 24h-window/template caveat.

No changes needed in forge-core/channels/* (interface already fits), forge-cli/channels/router.go, forge-cli/channels/env.go, the scheduler-notifier wiring (run.go:326-336), or forge-core/validate/* (no channel validation lives there). The ApprovalDeliverer/ConsentDeliverer type-asserts in run.go simply skip adapters that don't implement them — WhatsApp needs nothing there.

Acceptance criteria

  • forge channel add whatsapp scaffolds whatsapp-config.yaml, appends WHATSAPP_ACCESS_TOKEN to .env, adds whatsapp to forge.yaml channels:, and adds the graph.facebook.com egress capability.
  • forge run --with whatsapp starts the adapter (outbound-only; no port bound, no public inbound surface).
  • A scheduled task with channel: whatsapp, channel_target: <phone> delivers the agent's response to WhatsApp via the Cloud API.
  • Proactive sends use a pre-approved template (type: template); the 24h-window constraint is respected (no free-form text on a closed window).
  • Init errors clearly when phone_number_id or access_token is missing.
  • Egress: only graph.facebook.com is allowlisted for the adapter.
  • Unit tests cover Init validation + SendResponse payload shape against a stubbed apiBase; gofmt + golangci-lint clean; module tests pass.
  • docs/core-concepts/channels.md documents the adapter, setup, and the outbound-only/template caveat.

Open questions to resolve in-issue before implementation

  1. Template strategy: fixed default template + parameter mapping, or configurable per-schedule? What's the parameter shape for the agent's summary text?
  2. Long-response handling: truncate into a template body parameter, or send a media/document message? (The Telegram/Slack chunk/upload path does not apply.)
  3. Is there any appetite for a v2 inbound webhook (opening the 24h window + enabling free-form replies), or is outbound-only the permanent scope? Affects whether Start/NormalizeEvent are stubs or reserved.
  4. Meta Graph API version to pin, and access-token type (temporary vs system-user long-lived).

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions