feat: Whatsapp Integration
Some checks failed
Production Readiness / backend-contracts (push) Failing after 1m58s
Production Readiness / webos-typecheck (push) Successful in 1m37s
Production Readiness / ipad-parse (push) Successful in 2m17s

This commit is contained in:
Sagnik
2026-04-28 13:41:14 +05:30
parent 7ee51543d9
commit 3623bacbac
15 changed files with 2549 additions and 3 deletions

View File

@@ -0,0 +1,90 @@
"""
Evolution API (https://github.com/EvolutionAPI/evolution-api) adapter.
"""
import httpx
from typing import Any, Dict, List, Optional
from .comms_provider import CommsProvider
class EvolutionProvider(CommsProvider):
def _headers(self) -> Dict[str, str]:
return {"Content-Type": "application/json", "apikey": self.api_key}
async def _request(self, method: str, path: str, json_data: Optional[Dict] = None) -> Dict[str, Any]:
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(method, url, headers=self._headers(), json=json_data)
resp.raise_for_status()
return resp.json()
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
instance = self.instance_id or "default"
payload = {
"number": phone,
"text": message,
"options": {"delay": 1200, "presence": "composing"},
}
result = await self._request("POST", f"/message/sendText/{instance}", payload)
ext_id = result.get("key", {}).get("id") if isinstance(result, dict) else None
return {
"success": True,
"provider": "evolution",
"external_message_id": ext_id,
"status": "sent",
"raw": result,
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Evolution webhook v2 shape:
{
"event": "messages.upsert",
"instance": "default",
"data": {
"key": {"remoteJid": "123@s.whatsapp.net", "fromMe": false, "id": "..."},
"message": {"conversation": "Hello"},
"messageTimestamp": 1710000000, ...
}
}
"""
event = payload.get("event", "")
data = payload.get("data", {})
key = data.get("key", {})
remote_jid = key.get("remoteJid", "")
phone = remote_jid.replace("@s.whatsapp.net", "").replace("@g.us", "")
msg_content = data.get("message", {})
body = msg_content.get("conversation", "") or msg_content.get("extendedTextMessage", {}).get("text", "")
direction = "outbound" if key.get("fromMe") else "inbound"
return {
"provider": "evolution",
"external_message_id": key.get("id"),
"phone_e164": phone,
"direction": direction,
"message_type": "text",
"body": body,
"media_url": None,
"raw": payload,
"timestamp": data.get("messageTimestamp"),
}
async def test_connection(self) -> Dict[str, Any]:
try:
instance = self.instance_id or "default"
info = await self._request("GET", f"/instance/connectionState/{instance}")
return {
"success": True,
"message": f"Evolution instance '{instance}' state retrieved.",
"account_info": info,
}
except Exception as exc:
return {
"success": False,
"message": f"Evolution connection failed: {exc}",
}
async def fetch_templates(self) -> List[Dict[str, Any]]:
return []

View File

@@ -0,0 +1,239 @@
"""Inbound communications ingestion for Velocity CRM."""
from __future__ import annotations
import json
import os
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
PHONEUTILS_AVAILABLE = False
try:
import phonenumbers
from phonenumbers import NumberParseException
PHONEUTILS_AVAILABLE = True
except ImportError:
phonenumbers = None # type: ignore[assignment]
NumberParseException = Exception # type: ignore[assignment]
DEFAULT_COUNTRY = os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91")
def normalize_phone(phone: str, default_region: str = DEFAULT_COUNTRY) -> str | None:
"""Return an E.164-like phone number suitable for provider and CRM matching."""
if not phone:
return None
cleaned = re.sub(r"[^\d+]", "", phone.strip())
if cleaned.startswith("00"):
cleaned = "+" + cleaned[2:]
if not cleaned.startswith("+"):
cleaned = f"+{default_region}{cleaned}"
if PHONEUTILS_AVAILABLE and phonenumbers is not None:
try:
parsed = phonenumbers.parse(cleaned, None)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except NumberParseException:
pass
return cleaned if re.match(r"^\+\d{7,15}$", cleaned) else None
def _phone_digits(phone: str) -> str:
return re.sub(r"\D+", "", phone or "")
def _crm_channel(channel: str) -> str:
allowed = {"whatsapp", "sms", "call", "email", "website", "walk_in", "other"}
return channel if channel in allowed else "other"
async def get_or_create_thread(
pool,
phone_e164: str,
provider: str,
external_thread_id: str | None = None,
display_name: str | None = None,
channel: str = "whatsapp",
) -> dict[str, Any]:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT thread_id, person_id, status, unread_count
FROM comms_threads
WHERE phone_e164 = $1 AND provider = $2
LIMIT 1
""",
phone_e164,
provider,
)
if row:
return dict(row)
person_id = None
try:
person_row = await conn.fetchrow(
"""
SELECT person_id
FROM crm_people
WHERE primary_phone = $1
OR regexp_replace(COALESCE(primary_phone, ''), '[^0-9]', '', 'g') = $2
LIMIT 1
""",
phone_e164,
_phone_digits(phone_e164),
)
person_id = person_row["person_id"] if person_row else None
except Exception:
person_id = None
new_id = await conn.fetchval(
"""
INSERT INTO comms_threads
(provider, external_thread_id, person_id, phone_e164, display_name, channel, status, unread_count)
VALUES ($1, $2, $3, $4, $5, $6, 'open', 1)
RETURNING thread_id
""",
provider,
external_thread_id,
person_id,
phone_e164,
display_name or phone_e164,
channel,
)
return {
"thread_id": new_id,
"person_id": person_id,
"status": "open",
"unread_count": 1,
"is_new": True,
}
async def store_message(
pool,
thread_id: UUID,
provider: str,
external_message_id: str | None,
direction: str,
message_type: str,
body: str,
media_url: str | None = None,
raw_payload: dict[str, Any] | None = None,
sent_at: datetime | None = None,
) -> UUID:
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages
(thread_id, provider, external_message_id, direction, message_type, body, media_url,
delivery_status, sent_at, raw_payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING message_id
""",
thread_id,
provider,
external_message_id,
direction,
message_type,
body,
media_url,
"delivered" if direction == "inbound" else "sent",
sent_at or datetime.now(UTC),
json.dumps(raw_payload or {}),
)
unread_delta = 1 if direction == "inbound" else 0
await conn.execute(
"""
UPDATE comms_threads
SET last_message_at = NOW(), unread_count = unread_count + $2, updated_at = NOW()
WHERE thread_id = $1
""",
thread_id,
unread_delta,
)
return msg_id
async def maybe_create_crm_interaction(pool, person_id: UUID, body: str, channel: str = "whatsapp") -> None:
"""Mirror inbound comms into canonical CRM intelligence tables when present."""
if not person_id:
return
try:
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT to_regclass('public.intel_interactions') IS NOT NULL")
if not exists:
return
interaction_id = await conn.fetchval(
"""
INSERT INTO intel_interactions
(person_id, channel, interaction_type, happened_at, summary, source_ref, metadata_json)
VALUES ($1, $2::intel_channel, 'message', NOW(), $3, 'comms_ingest', $4::jsonb)
RETURNING interaction_id
""",
person_id,
_crm_channel(channel),
body[:500],
json.dumps({"source": "comms", "direction": "inbound"}),
)
if await conn.fetchval("SELECT to_regclass('public.intel_messages') IS NOT NULL"):
await conn.execute(
"""
INSERT INTO intel_messages
(interaction_id, sender_role, sender_name, message_text, delivered_at, metadata_json)
VALUES ($1, 'lead', NULL, $2, NOW(), $3::jsonb)
""",
interaction_id,
body,
json.dumps({"source": "comms"}),
)
except Exception:
return
async def ingest_inbound_message(pool, normalized_payload: dict[str, Any]) -> dict[str, Any]:
phone = normalize_phone(normalized_payload.get("phone_e164") or normalized_payload.get("phone") or "")
if not phone:
raise ValueError("Missing phone_e164 in payload")
provider = normalized_payload.get("provider", "unknown")
channel = normalized_payload.get("channel", "whatsapp")
thread = await get_or_create_thread(
pool,
phone_e164=phone,
provider=provider,
external_thread_id=normalized_payload.get("external_thread_id"),
display_name=normalized_payload.get("display_name") or phone,
channel=channel,
)
timestamp = normalized_payload.get("timestamp")
sent_at = datetime.fromtimestamp(timestamp, UTC) if timestamp else None
msg_id = await store_message(
pool,
thread_id=thread["thread_id"],
provider=provider,
external_message_id=normalized_payload.get("external_message_id"),
direction=normalized_payload.get("direction", "inbound"),
message_type=normalized_payload.get("message_type", "text"),
body=normalized_payload.get("body", ""),
media_url=normalized_payload.get("media_url"),
raw_payload=normalized_payload.get("raw"),
sent_at=sent_at,
)
if thread.get("person_id") and normalized_payload.get("direction", "inbound") == "inbound":
await maybe_create_crm_interaction(pool, thread["person_id"], normalized_payload.get("body", ""), channel)
return {
"thread_id": str(thread["thread_id"]),
"message_id": str(msg_id),
"person_id": str(thread["person_id"]) if thread.get("person_id") else None,
"is_new_thread": thread.get("is_new", False),
}

View File

@@ -0,0 +1,63 @@
"""
Abstract provider interface for Velocity Comms.
"""
import os
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
class CommsProvider(ABC):
def __init__(self, base_url: str, api_key: str, instance_id: Optional[str] = None):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.instance_id = instance_id
@abstractmethod
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
"""Send a message. Return provider response dict."""
...
@abstractmethod
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Convert provider webhook payload to Velocity canonical format."""
...
@abstractmethod
async def test_connection(self) -> Dict[str, Any]:
"""Test provider connectivity. Return {success, message, account_info}."""
...
async def fetch_templates(self) -> List[Dict[str, Any]]:
"""Optional: fetch message templates."""
return []
async def get_media(self, media_id: str) -> Optional[bytes]:
"""Optional: download media bytes."""
return None
async def send_template(self, phone: str, template_name: str, language: str, components: Optional[List] = None) -> Dict[str, Any]:
"""Optional: send a template message."""
raise NotImplementedError("Templates not supported by this provider.")
class MockProvider(CommsProvider):
"""Mock provider for local development and UI previews."""
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
return {
"success": True,
"provider": "mock",
"external_message_id": f"mock-{os.urandom(4).hex()}",
"status": "sent",
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return payload
async def test_connection(self) -> Dict[str, Any]:
return {
"success": True,
"message": "Mock provider is always healthy.",
"account_info": {"mode": "mock"},
}

View File

@@ -0,0 +1,95 @@
"""
WAHA (https://github.com/devlikeapro/waha) adapter.
WAHA exposes a simple HTTP API for WhatsApp Web.
"""
import httpx
from typing import Any, Dict, Optional
from .comms_provider import CommsProvider
class WahaProvider(CommsProvider):
async def _request(self, method: str, path: str, json_data: Optional[Dict] = None) -> Dict[str, Any]:
url = f"{self.base_url}/api{path}"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-Api-Key"] = self.api_key
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(method, url, headers=headers, json=json_data)
resp.raise_for_status()
return resp.json()
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
chat_id = f"{phone}@c.us"
payload = {
"chatId": chat_id,
"text": message,
"session": self.instance_id or "default",
}
if message_type == "image" and kwargs.get("media_url"):
payload["caption"] = message
payload["media"] = kwargs["media_url"]
result = await self._request("POST", "/sendImage", payload)
else:
result = await self._request("POST", "/sendText", payload)
return {
"success": True,
"provider": "waha",
"external_message_id": result.get("id"),
"status": "sent",
"raw": result,
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
WAHA webhook payload shape (v2024):
{
"event": "message",
"session": "default",
"payload": {
"id": "true_123@c.us_3EB0...",
"timestamp": 1710000000,
"from": "123@c.us",
"to": "456@c.us",
"body": "Hello",
"hasMedia": false, ...
}
}
"""
event = payload.get("event", "")
pl = payload.get("payload", {})
from_jid = pl.get("from", "")
phone = from_jid.replace("@c.us", "").replace("@g.us", "")
direction = "inbound" if event == "message" and not pl.get("fromMe") else "outbound"
return {
"provider": "waha",
"external_message_id": pl.get("id"),
"phone_e164": phone,
"direction": direction,
"message_type": "image" if pl.get("hasMedia") else "text",
"body": pl.get("body", ""),
"media_url": pl.get("mediaUrl"),
"raw": payload,
"timestamp": pl.get("timestamp"),
}
async def test_connection(self) -> Dict[str, Any]:
try:
sessions = await self._request("GET", "/sessions?all=true")
return {
"success": True,
"message": f"Connected to WAHA. Sessions: {len(sessions)}",
"account_info": {"sessions": sessions},
}
except Exception as exc:
return {
"success": False,
"message": f"WAHA connection failed: {exc}",
}
async def get_media(self, media_id: str) -> Optional[bytes]:
return None