Skip to content

feat: WhatsApp Gateway — sending campaigns via OpenWA #2827

@baderdean

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions