Skip to content

Architecture

Manaa-Soft edited this page Jul 15, 2026 · 1 revision

Architecture

Overview

OpenWA Bridge is a Frappe app that intercepts frappe_whatsapp DocType operations via override_doctype_class and redirects them through a self-hosted OpenWA (WhatsApp Web API Gateway) instance instead of the Meta Cloud API.


System Diagram

┌──────────────────────────────────────────────────────────────────┐
│                     Frappe / ERPNext Server                       │
│                                                                    │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │                    frappe_whatsapp                          │  │
│  │  ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐   │  │
│  │  │ WhatsAppMsg   │ │ WhatsAppTmpl │ │ WhatsAppNotif    │   │  │
│  │  │ (parent)      │ │ (parent)     │ │ (parent)         │   │  │
│  │  └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘   │  │
│  │         │                 │                   │              │  │
│  │         ▼                 ▼                   ▼              │  │
│  │  ┌──────────────────────────────────────────────────────┐   │  │
│  │  │            Override Classes (openwa_bridge)           │   │  │
│  │  │                                                      │   │  │
│  │  │  OverrideWhatsAppMessage                             │   │  │
│  │  │    └─ notify() → _send_via_openwa()                  │   │  │
│  │  │                                                      │   │  │
│  │  │  OverrideWhatsAppTemplates                           │   │  │
│  │  │    └─ before_save() → _sync_to_openwa()              │   │  │
│  │  │                                                      │   │  │
│  │  │  OverrideWhatsAppNotification                        │   │  │
│  │  │    └─ notify() → Template/Jinja routing              │   │  │
│  │  └──────────────────────────────────────────────────────┘   │  │
│  │                                                              │  │
│  │  ┌──────────────────────────────────────────────────────┐   │  │
│  │  │  inbound.py — Webhook endpoint (10 event types)      │   │  │
│  │  └──────────────────────────────────────────────────────┘   │  │
│  │                                                              │  │
│  │  ┌──────────────────────────────────────────────────────┐   │  │
│  │  │  utils.py — HMAC, JID helpers, API helper, etc.      │   │  │
│  │  └──────────────────────────────────────────────────────┘   │  │
│  │                                                              │  │
│  │  ┌──────────────────────────────────────────────────────┐   │  │
│  │  │  tasks.py — Health checks, outbox processing,        │   │  │
│  │  │            circuit breaker, cleanup                   │   │  │
│  │  └──────────────────────────────────────────────────────┘   │  │
│  └────────────────────────────────────────────────────────────┘  │
│                                                                    │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │  Custom Fields (fixtures): 25 fields across 3 DocTypes     │  │
│  │  Property Setters: 7 Meta field visibility toggles          │  │
│  └────────────────────────────────────────────────────────────┘  │
└──────────────────────────────┬─────────────────────────────────────┘
                               │
                     HTTP REST API
                     localhost:2785
                               │
                               ▼
┌──────────────────────────────────────────┐
│           OpenWA Gateway                  │
│  REST API (20+ endpoints)                │
│  Webhook sender → Frappe inbound.py      │
│  WhatsApp Web connection                 │
└──────────────────────────────────────────┘

Override Mechanism

Frappe's override_doctype_class in hooks.py replaces the parent class at runtime:

override_doctype_class = {
    "WhatsAppMessage": "openwa_bridge.whatsapp_message.OverrideWhatsAppMessage",
    "WhatsAppTemplates": "openwa_bridge.whatsapp_templates.OverrideWhatsAppTemplates",
    "WhatsAppNotification": "openwa_bridge.whatsapp_notification.OverrideWhatsAppNotification",
}

Each override class extends the parent and selectively intercepts methods. Non-OpenWA accounts continue working via the original Meta API.

Override Classes

Class Intercepts Behavior
OverrideWhatsAppMessage notify(), after_insert() Routes outbound messages through OpenWA or Meta API. Supports sticker content type and @mention extraction.
OverrideWhatsAppTemplates before_save(), after_insert(), on_update() Syncs templates to OpenWA instead of Meta API. Handles stale ID recovery.
OverrideWhatsAppNotification send_template_message(), notify() Routes by openwa_send_type: Template → OpenWA send-template, Jinja → render + send-text.

doc_events Hooks

doc_events = {
    "WhatsApp Account": {
        "on_update": "openwa_bridge.whatsapp_account.on_account_update",
        "on_trash": "openwa_bridge.whatsapp_account.on_account_trash"
    }
}
  • on_update: Syncs webhook secret to OpenWA on save
  • on_trash: Deletes OpenWA session when account is deleted

Credential Flow

WhatsApp Account doc (Frappe)
  ├── openwa_enabled (Check)          → routing decision
  ├── openwa_base_url (Data)          → "http://localhost:2785"
  ├── openwa_session_id (Data)        → session UUID
  ├── openwa_api_key (Password)       → encrypted, accessed via get_password()
  └── openwa_webhook_secret (Password) → for inbound HMAC verification

All passwords are encrypted in Frappe's vault. The get_api_key() helper in utils.py provides a single access point.


Key Design Decisions

1. Override, Not Replace

We extend parent classes so non-OpenWA accounts continue working via Meta API. A single Frappe instance can have both Meta and OpenWA accounts.

2. Message Doc as Carrier

Template sends create a WhatsApp Message doc first, which triggers notify() → routes to OpenWA. This preserves Frappe's message history.

3. use_template Flag

send-template vs send-text is determined by the use_template field:

  • use_template=1 → POST /messages/send-template with vars
  • use_template not set → POST /messages/send-text with rendered text

4. Two-Step Image+Text

When openwa_dynamic_header is enabled, the image is sent via send-image with the rendered text as caption. The recipient receives one message (image + caption), not two.

5. Live Doc Values

Notification parameters are resolved from the actual document at send time (not from pre-filled sample values).

6. Graceful Degradation

If OpenWA is down, the error is logged but the doc is still saved. If dynamic image fails, the template text still sends.

7. Async Outbox

Outbound messages flow through OpenWA Outbox — created in after_insert(), processed by background workers with exponential backoff retry (30s–1h cap, configurable max attempts).

8. Circuit Breaker

After N consecutive failures on an account, the circuit opens for a cooldown period. Prevents cascade failures when OpenWA is down.

9. Lenient HMAC

When a webhook secret is configured but OpenWA sends no signature header, the message is processed with a warning log instead of rejecting. Prevents drops when HMAC isn't enabled on the OpenWA side.

10. Always HTTP 200

The inbound webhook never throws — all errors return HTTP 200 with an error body to prevent OpenWA retry loops.


Outbox Pattern

notify() [before_insert]
  │
  ├─ openwa_enabled? → set _openwa_outbox_needed = True, return
  │
after_insert()
  │
  ├─ _openwa_outbox_needed? → create OpenWA Outbox doc
  │                           → frappe.enqueue(process_outbox_entry, queue="long")
  │
  ▼ [background worker]
process_outbox_entry()
  │
  ├─ load WhatsApp Message + Account
  ├─ circuit breaker check
  ├─ _send_dynamic_header_for_outbox() → send image if template has dynamic header
  ├─ _send_via_openwa() → send-template or send-text
  ├─ success → record_success(), status=Sent
  └─ failure → record_failure(), exponential backoff retry
                → after max_attempts → status=Failed

The scheduler safety-net (process_pending_outbox) runs every ~4 minutes and re-enqueues any Pending entries whose next_retry_at has passed.


File Structure

openwa_bridge/
├── hooks.py                    # App hooks, overrides, fixtures, doc_events
├── utils.py                    # HMAC, JID helpers, API helper, circuit breaker
├── install.py                  # Pre-install dependency check
├── tasks.py                    # Health checks, outbox processing, cleanup
├── whatsapp_account.py         # QR setup, session mgmt, contact mgmt, bulk msg
├── whatsapp_message.py         # Outbound routing (OverrideWhatsAppMessage)
├── whatsapp_templates.py       # Template sync (OverrideWhatsAppTemplates)
├── whatsapp_notification.py    # Notification routing (OverrideWhatsAppNotification)
├── inbound.py                  # Webhook endpoint (10 event types)
├── openwa_bridge/
│   ├── doctype/
│   │   ├── openwa_outbox/      # Async outbox DocType
│   │   └── openwa_bridge_settings/  # System-wide config
│   └── workspace/whatsapp/     # Frappe Workspace
├── public/js/
│   └── whatsapp_account.js     # Client script: QR display, setup button
├── fixtures/
│   └── custom_field.json       # Custom fields + Property Setters
└── tests/                      # 40+ unit and integration tests

hooks.py Constraints

require_type_annotated_api_methods = True

ALL @frappe.whitelist() functions MUST have type annotations:

@frappe.whitelist()
def receive_openwa_message() -> None:  # ← type hint required
    ...

Clone this wiki locally