Repository navigation
Architecture
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.
┌──────────────────────────────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────┘
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.
| 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 = {
"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
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.
We extend parent classes so non-OpenWA accounts continue working via Meta API. A single Frappe instance can have both Meta and OpenWA accounts.
Template sends create a WhatsApp Message doc first, which triggers notify() → routes to OpenWA. This preserves Frappe's message history.
send-template vs send-text is determined by the use_template field:
-
use_template=1→ POST/messages/send-templatewith vars -
use_templatenot set → POST/messages/send-textwith rendered 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.
Notification parameters are resolved from the actual document at send time (not from pre-filled sample values).
If OpenWA is down, the error is logged but the doc is still saved. If dynamic image fails, the template text still sends.
Outbound messages flow through OpenWA Outbox — created in after_insert(), processed by background workers with exponential backoff retry (30s–1h cap, configurable max attempts).
After N consecutive failures on an account, the circuit opens for a cooldown period. Prevents cascade failures when OpenWA is down.
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.
The inbound webhook never throws — all errors return HTTP 200 with an error body to prevent OpenWA retry loops.
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.
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
require_type_annotated_api_methods = TrueALL @frappe.whitelist() functions MUST have type annotations:
@frappe.whitelist()
def receive_openwa_message() -> None: # ← type hint required
...