Skip to content

feat: WhatsApp Gateway — sending campaigns via OpenWA #2827

Description

@baderdean

Summary

Ajouter un WhatsApp Gateway au système Unified Sender de Leadminer, permettant d'envoyer des campagnes WhatsApp via OpenWA. Le dialogue de création de campagne sera similaire à SMS, avec une différence majeure : pas de calcul de segments SMS.

Approche : réutiliser l'infrastructure SMS existante (tables sms_campaigns et adjacentes, edge functions sms-campaigns / sms-fleet / sms-campaigns-process) en ajoutant une colonne channel et en étendant la liste des providers avec 'openwa'. Pas de nouvelles tables.

Dépendances :

Architecture

┌──────────────────────────────────────────────────────────────────┐
│                        Leadminer                                  │
│                                                                   │
│  Frontend                       Supabase Edge Functions          │
│  ────────                       ────────────────────────         │
│  WhatsAppCampaign       ───────▶ sms-campaigns                  │
│  ComposerDialog                  (étendu: channel='whatsapp')   │
│                                                                  │
│  WhatsAppSession         ───────▶ sms-fleet                     │
│  QR Dialog                       (étendu: sessions OpenWA)      │
│                                                                  │
│  SmsFleetManagement      ───────▶ sms-campaigns-process         │
│  (étendu: + openwa)              (étendu: openwa provider)      │
│                                                                  │
│                          ◀─────── whatsapp-webhook (NEW)        │
│                                  (acks delivered/read HMAC)     │
│                                                                  │
│             Tables: sms_campaigns (+ channel, delivered_count,   │
│                                      read_count)                 │
│                     sms_campaign_recipients (+ delivered_at,     │
│                                                read_at)          │
│                     sms_fleet_gateways (provider='openwa')       │
└────────────────────────────────────┼─────────────────────────────┘
                                     │ HTTP + Webhooks
                              ┌──────┴────────┐
                              │  OpenWA       │
                              │  (Docker)     │
                              │  Multi-session│
                              │  Port 2785    │
                              └───────────────┘

Décisions clés

Décision Choix
Channel Colonne channel sur sms_campaigns ('sms' | 'whatsapp')
Tables Réutiliser sms_campaigns, sms_campaign_recipients, sms_campaign_recipient_gateways — pas de nouvelles tables
Edge functions Étendre sms-campaigns, sms-campaigns-process, sms-fleet ; +1 nouvelle : whatsapp-webhook
Gateway management Réutiliser SmsFleetManagement (étendre provider list avec 'openwa')
Provider interface Implémenter SmsProvider pour OpenWA
Session QR Interface dans Leadminer (wizard + polling), endpoints exposés dans sms-fleet
Fleet mode v1 Multi-gateway (distribution des destinataires)
Webhook v1 OUI — endpoint whatsapp-webhook pour acks delivered/read (HMAC)
OpenWA dev Ajouter au docker-compose (build depuis le repo, pas d'image Hub publiée)

Phase 1 — Base de données

Migration : étendre sms_campaigns

-- Dimension channel
ALTER TABLE private.sms_campaigns
  ADD COLUMN channel TEXT NOT NULL DEFAULT 'sms'
  CHECK (channel IN ('sms', 'whatsapp'));

-- Métriques WhatsApp (alimentées par webhook OpenWA)
ALTER TABLE private.sms_campaigns
  ADD COLUMN delivered_count INTEGER NOT NULL DEFAULT 0,
  ADD COLUMN read_count INTEGER NOT NULL DEFAULT 0;

CREATE INDEX idx_sms_campaigns_channel ON private.sms_campaigns(channel);

Migration : étendre sms_campaign_recipients

-- Statuts enrichis WhatsApp
ALTER TYPE private.sms_recipient_status ADD VALUE IF NOT EXISTS 'delivered';
ALTER TYPE private.sms_recipient_status ADD VALUE IF NOT EXISTS 'read';

-- Timestamps lifecycle
ALTER TABLE private.sms_campaign_recipients
  ADD COLUMN delivered_at TIMESTAMPTZ,
  ADD COLUMN read_at TIMESTAMPTZ;

Migration : étendre sms_fleet_gateways

Pas de changement de schéma — le champ provider est en text. La valeur 'openwa' est acceptée directement.

Le champ config JSONB pour OpenWA stocke :

{
  "baseUrl": "http://openwa:2785",
  "sessionId": "wa_<uuid>",
  "apiKey": "<openwa-api-key>",
  "webhookSecret": "<hmac-shared-secret>"
}

Migration : étendre get_unified_campaigns_overview()

La fonction existe déjà sur sms_campaigns. On ajoute delivered_count, read_count, et channel à sa sortie (pour discrimination front). Pas de UNION ALL nécessaire.

Phase 2 — Edge Functions Backend

2.1 Extension de sms-fleet

Fichier : supabase/functions/sms-fleet/index.ts

Ajouts :

  • Accepter provider='openwa' dans la validation CRUD (CRUD existant suffit)
  • Sessions OpenWA (proxy vers OpenWA API) :
    • POST /sessions/create — Crée session OpenWA, retourne QR code base64
    • GET /sessions/:id/qr — Récupère QR code (pour polling frontend)
    • GET /sessions/:id/status — Statut session (connecting / connected / disconnected)
    • DELETE /sessions/:id — Supprime session OpenWA

Note : ces endpoints réutilisent les artefacts de #2826 (client OpenWAClient, table whatsapp_sessions). Si #2826 expose une couche partagée, l'importer ici plutôt que dupliquer.

2.2 Extension de sms-campaigns

Fichier : supabase/functions/sms-campaigns/index.ts

Ajouts :

  • Accepter channel: 'whatsapp' dans POST /campaigns/create
  • Quand channel='whatsapp' :
    • Skip le calcul de segments SMS (ne pas exécuter estimateSmsSegments())
    • Filtrer les gateways disponibles sur provider='openwa'
    • Validation phone E.164 inchangée
    • INSERT sms_campaigns(..., channel='whatsapp')

2.3 Provider OpenWA

Fichier : supabase/functions/sms-campaigns/providers/openwa-provider.ts

export class OpenWaProvider implements SmsProvider {
  name = 'openwa';

  constructor(private config: {
    baseUrl: string;
    sessionId: string;
    apiKey?: string;
  }) {}

  async send(params: SendSmsParams): Promise<SendSmsResult> {
    const res = await fetch(
      `${this.config.baseUrl}/api/sessions/${this.config.sessionId}/messages/text`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
        },
        body: JSON.stringify({ to: params.to, text: params.body }),
      },
    );
    if (!res.ok) return { success: false, error: await res.text() };
    const data = await res.json();
    return { success: true, messageId: data.messageId };
  }
}

Wiring dans providers/mod.ts :

case 'openwa':
  return new OpenWaProvider(config.openwa);

2.4 Extension de sms-campaigns-process

Fichier : supabase/functions/sms-campaigns-process/index.ts

Ajouts pour les campaigns avec channel='whatsapp' :

  • Routage via OpenWaProvider
  • Fleet mode : distribution des destinataires entre sessions OpenWA (round-robin pondéré par daily_limit - sent_today)
  • Throttling : await sleep(1000 + random(0, 500)) entre chaque envoi par session
  • Gestion erreurs OpenWA :
    • 404 ou not_a_whatsapp_usersend_status='failed' permanent
    • 429 → retry avec backoff exponentiel (3 tentatives max)
    • 401 ou session_not_connected → marquer le gateway en erreur, redistribuer les recipients restants
  • beforeunload handler pour sauvegarder la progression partielle (existant)

2.5 NOUVEAU : whatsapp-webhook — Acks delivered/read

Fichier : supabase/functions/whatsapp-webhook/index.ts

Endpoint public exposé à OpenWA :

POST /functions/v1/whatsapp-webhook
Headers:
  X-OpenWA-Signature: <hmac-sha256-of-body>
  X-OpenWA-Session: <sessionId>
Body:
  { event: 'message.ack', messageId: '<id>',
    status: 'delivered' | 'read',
    timestamp: <unix>, to: '<phone>' }

Logique :

  1. Lookup gateway par sessionId → récupérer webhookSecret
  2. Vérifier signature HMAC (constant-time compare)
  3. Lookup sms_campaign_recipients par provider_message_id = messageId
  4. UPDATE send_status, delivered_at / read_at
  5. Incrémenter compteur correspondant sur sms_campaigns

Idempotence : si delivered_at IS NOT NULL et event = delivered, no-op.

Phase 3 — Frontend

3.1 Types

// types/campaign.ts
type CampaignChannel = 'sms' | 'whatsapp';

// types/sms-fleet.ts
type SmsGatewayProvider =
  | 'smsgate' | 'simple-sms-gateway' | 'twilio' | 'openwa';

3.2 WhatsAppCampaignComposerDialog.vue (NOUVEAU)

Inspiré de SmsCampaignComposerDialog.vue :

  • Sélecteur de gateway via FleetGatewaySelector (filtré sur provider openwa)
  • Template message avec variables (textarea)
  • Pas de compteur/calcul de segments SMS
  • Limite destinataires (InputNumber)
  • Footer template avec unsubscribe
  • Short links toggle
  • Preview modal
  • Au submit : POST sms-campaigns/campaigns/create avec channel='whatsapp'

Alternative envisagée : prop channel sur SmsCampaignComposerDialog. Rejetée car la divergence UX (segments / pas de segments, labels, copy d'aide) justifie deux composants distincts. On factorise plutôt les sous-composants partagés (MessageTemplateField, VariableInsertButton).

3.3 WhatsAppSessionDialog.vue (NOUVEAU)

Wizard de création de session OpenWA :

  1. Saisir le nom de la session
  2. POST sms-fleet/sessions/create
  3. Affichage du QR code (image base64)
  4. Polling du statut jusqu'à connected (timeout 60s)
  5. Création automatique du gateway dans sms_fleet_gateways (provider=openwa)
  6. Redirection vers configuration gateway

3.4 Extension de SmsFleetManagement.vue

  • Ajouter 'openwa' dans la liste des providers visibles
  • Nouveau ProviderForm pour OpenWA :
    • OpenWA Base URL (input)
    • Session ID (input, ou bouton "Connecter avec QR" → lance WhatsAppSessionDialog)
    • API Key (optionnel)
    • Read-only : phone number associé une fois connecté
  • FleetGatewaySelector fonctionne déjà avec n'importe quel provider

3.5 Modification de pages/campaigns.vue

  • Ajouter 'whatsapp' au type SenderFilter
  • Tab "WhatsApp" dans SenderFilterTabs (filtre sur channel='whatsapp')
  • Affichage campagnes WhatsApp : message preview, stats sent/delivered/read/failed, badge "OpenWA"

3.6 Store sms-fleet.ts

  • Gère aussi les gateways openwa (même CRUD)
  • Pas de changement d'interface nécessaire

Phase 4 — Infrastructure

Docker Compose (dev)

OpenWA n'a pas d'image Docker pré-publiée — build depuis le repo (rmyndharis/OpenWA, branche main, version actuelle 0.1.6).

services:
  openwa:
    build:
      context: ./vendor/OpenWA  # git submodule ou clone manuel
      dockerfile: Dockerfile
    container_name: leadminer-openwa
    restart: unless-stopped
    ports:
      - "127.0.0.1:2785:2785"
    volumes:
      - openwa_data:/app/data
    environment:
      - NODE_ENV=production
      - PORT=2785
      - DATABASE_TYPE=sqlite
      - DATABASE_NAME=/app/data/openwa.sqlite
      - WEBHOOK_BASE_URL=${OPENWA_WEBHOOK_BASE_URL}
      - API_KEY=${OPENWA_API_KEY}

volumes:
  openwa_data:

Variables d'environnement

  • OPENWA_BASE_URL — URL du service OpenWA (default: http://openwa:2785)
  • OPENWA_API_KEY — Clé API OpenWA (générée au boot, à persister)
  • OPENWA_WEBHOOK_SECRET — Secret HMAC pour signature webhook (par gateway, stocké en config)
  • OPENWA_WEBHOOK_BASE_URL — URL publique de whatsapp-webhook (pour configurer OpenWA)

Métriques WhatsApp vs SMS

Métrique SMS WhatsApp
sent
delivered ✅ (webhook OpenWA)
read ✅ (webhook OpenWA)
failed
click ✅ (short links) ✅ (short links)
unsubscribe
segment calculation

Points de vigilance

  • QR Code session : Timeout de 60s pour le scan. Polling frontend → backend → OpenWA.
  • Session expirée : WhatsApp Web sessions expirent. Détecter via webhook session.disconnected + proposer reconnexion dans SmsFleetManagement.
  • Rate limits : ~1 msg/sec par session. Throttle dans le processor (sleep(1000 + random(0, 500)) entre envois).
  • Format numéro : E.164 obligatoire pour WhatsApp.
  • CGU WhatsApp : OpenWA est non-officiel. Risque de ban à documenter pour l'utilisateur final.
  • Opt-in RGPD/ToS : Vérifier le consentement WhatsApp côté création de campagne (à concevoir au-dessus du flux existant).
  • Pas de SMS segments : 1 message WhatsApp = 1 envoi. Pas de calcul.
  • Webhook public : L'edge function whatsapp-webhook doit être accessible publiquement depuis OpenWA. HMAC obligatoire.
  • Sécurité HMAC : webhookSecret distinct par gateway, comparaison constant-time.

Répartition des fichiers

supabase/functions/
├── sms-fleet/                                  # ÉTENDU
│   └── index.ts                                # + sessions OpenWA endpoints
├── sms-campaigns/                              # ÉTENDU
│   ├── index.ts                                # + channel='whatsapp', skip segments
│   └── providers/
│       ├── mod.ts                              # + openwa case
│       └── openwa-provider.ts                  # NOUVEAU
├── sms-campaigns-process/                      # ÉTENDU
│   └── index.ts                                # + routing openwa, throttle, fleet
└── whatsapp-webhook/                           # NOUVEAU
    └── index.ts                                # acks delivered/read HMAC

supabase/migrations/
└── YYYYMMDDHHMMSS_add_whatsapp_channel.sql     # ALTER sms_campaigns/_recipients

frontend/src/
├── components/
│   ├── whatsapp-fleet/
│   │   └── WhatsAppSessionDialog.vue           # NOUVEAU
│   └── campaigns/
│       └── WhatsAppCampaignComposerDialog.vue  # NOUVEAU
├── pages/
│   └── campaigns.vue                           # MODIFIÉ
├── types/
│   ├── campaign.ts                             # MODIFIÉ (+ channel type)
│   └── sms-fleet.ts                            # MODIFIÉ (+ openwa provider)
└── stores/
    └── sms-fleet.ts                            # MODIFIÉ (gère openwa)

Estimation

# Phase Estim.
1 Migration SQL (ALTER existants) 0.5 j
2a Extension sms-fleet (sessions OpenWA) 1 j
2b Extension sms-campaigns (channel routing) 1 j
2c OpenWA provider 0.5 j
2d Extension sms-campaigns-process (openwa + throttle + fleet) 1.5 j
2e NOUVEAU whatsapp-webhook (HMAC + acks) 1 j
3a WhatsAppCampaignComposerDialog 1.5 j
3b WhatsAppSessionDialog (QR wizard) 1.5 j
3c Extension SmsFleetManagement + ProviderForm 1 j
3d Modification campaigns.vue + SenderFilterTabs 0.5 j
4 Docker compose dev 0.5 j
5 Tests + QA 2 j
Total ~12.5 j-dev

Économie vs duplication de tables/functions (Option B) : ~3.5 j.

Out of scope v1

  • Templates pré-approuvés WhatsApp Business
  • Boutons interactifs CTA, listes, quick replies
  • Pièces jointes média (images, PDF, vidéo)
  • Multi-providers WhatsApp (Meta Cloud API, BSP type Twilio/360Dialog)

Références

  • OpenWA GitHub — v0.1.6, NestJS/Node 22, port 2785, webhooks HMAC natifs
  • sms-campaigns/index.ts — Pattern à étendre
  • sms-campaigns-process/index.ts — Pattern fleet mode
  • sms-fleet/index.ts — Pattern CRUD gateways
  • SmsCampaignComposerDialog.vue — Pattern composer dialog

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions