Test messages work with test number and hello_world template
This commit is contained in:
parent
d7185786e7
commit
09dfacd142
@ -1,5 +1,6 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import os
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# Database
|
# Database
|
||||||
@ -23,6 +24,7 @@ class Settings(BaseSettings):
|
|||||||
WHATSAPP_PROVIDER: str = "mock" # mock, cloud, or telegram
|
WHATSAPP_PROVIDER: str = "mock" # mock, cloud, or telegram
|
||||||
WHATSAPP_CLOUD_ACCESS_TOKEN: str = ""
|
WHATSAPP_CLOUD_ACCESS_TOKEN: str = ""
|
||||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID: str = ""
|
WHATSAPP_CLOUD_PHONE_NUMBER_ID: str = ""
|
||||||
|
WHATSAPP_CLOUD_PHONE_NUMBER_ID_OVERRIDE: str = ""
|
||||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN: str = ""
|
WHATSAPP_WEBHOOK_VERIFY_TOKEN: str = ""
|
||||||
|
|
||||||
# Telegram Provider (for testing)
|
# Telegram Provider (for testing)
|
||||||
@ -38,7 +40,8 @@ class Settings(BaseSettings):
|
|||||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
# Look for .env in the backend directory
|
||||||
|
env_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ".env")
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add the backend directory to the Python path so imports work when running from app directory
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api import auth, contacts, lists, templates, campaigns, imports, google, webhooks, stats, workers
|
from app.api import auth, contacts, lists, templates, campaigns, imports, google, webhooks, stats, workers
|
||||||
import logging
|
import logging
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -17,9 +24,10 @@ app = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
|
cors_origins = list({*settings.cors_origins_list, "http://localhost:5173"})
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins_list,
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@ -48,3 +56,6 @@ def root():
|
|||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=8000, reload=False)
|
||||||
|
|||||||
@ -1,15 +1,158 @@
|
|||||||
import httpx
|
import httpx
|
||||||
from typing import Dict, Any, Optional
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
from app.providers.base import BaseProvider
|
from app.providers.base import BaseProvider
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class WhatsAppCloudProvider(BaseProvider):
|
class WhatsAppCloudProvider(BaseProvider):
|
||||||
"""WhatsApp Cloud API provider (Meta)"""
|
"""WhatsApp Cloud API provider (Meta)
|
||||||
|
|
||||||
|
IMPORTANT: WhatsApp templates are scoped to a Phone Number ID.
|
||||||
|
A template created under phone number A will ALWAYS return error 132001
|
||||||
|
if you try to send it using phone number B's credentials.
|
||||||
|
|
||||||
|
The provider MUST use the correct PHONE_NUMBER_ID that owns the template.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.access_token = settings.WHATSAPP_CLOUD_ACCESS_TOKEN
|
self.access_token = settings.WHATSAPP_CLOUD_ACCESS_TOKEN
|
||||||
self.phone_number_id = settings.WHATSAPP_CLOUD_PHONE_NUMBER_ID
|
self.phone_number_id = settings.WHATSAPP_CLOUD_PHONE_NUMBER_ID
|
||||||
self.base_url = "https://graph.facebook.com/v18.0"
|
self.phone_number_id_override = settings.WHATSAPP_CLOUD_PHONE_NUMBER_ID_OVERRIDE
|
||||||
|
self.base_url = "https://graph.facebook.com/v22.0"
|
||||||
|
|
||||||
|
def _mask_to(self, to: str) -> str:
|
||||||
|
digits = re.sub(r"\D", "", to or "")
|
||||||
|
if len(digits) <= 4:
|
||||||
|
return "****"
|
||||||
|
return f"***{digits[-4:]}"
|
||||||
|
|
||||||
|
def _mask_token(self, token: str) -> str:
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
if len(token) <= 6:
|
||||||
|
return "***"
|
||||||
|
return f"***{token[-6:]}"
|
||||||
|
|
||||||
|
def _resolve_phone_number_id(self) -> str:
|
||||||
|
resolved = (self.phone_number_id_override or "").strip() or (self.phone_number_id or "").strip()
|
||||||
|
if self.phone_number_id_override and self.phone_number_id_override.strip():
|
||||||
|
logger.info(
|
||||||
|
"WhatsApp Cloud phone_number_id override active",
|
||||||
|
extra={"phone_number_id": resolved}
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def _validate_phone_number_id(self, phone_number_id: str) -> None:
|
||||||
|
if not phone_number_id:
|
||||||
|
raise ValueError(
|
||||||
|
"Set WHATSAPP_CLOUD_PHONE_NUMBER_ID to the value from Meta API Setup URL: "
|
||||||
|
"https://graph.facebook.com/v22.0/<ID>/messages"
|
||||||
|
)
|
||||||
|
if not phone_number_id.isdigit():
|
||||||
|
raise ValueError(
|
||||||
|
"Set WHATSAPP_CLOUD_PHONE_NUMBER_ID to the value from Meta API Setup URL: "
|
||||||
|
"https://graph.facebook.com/v22.0/<ID>/messages"
|
||||||
|
)
|
||||||
|
if len(phone_number_id) < 10 or len(phone_number_id) > 20:
|
||||||
|
raise ValueError(
|
||||||
|
"Set WHATSAPP_CLOUD_PHONE_NUMBER_ID to the value from Meta API Setup URL: "
|
||||||
|
"https://graph.facebook.com/v22.0/<ID>/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_placeholder_names(self, template_body: str) -> List[str]:
|
||||||
|
if not template_body:
|
||||||
|
return []
|
||||||
|
return re.findall(r"\{\{(\w+)\}\}", template_body)
|
||||||
|
|
||||||
|
def _build_template_parameters(
|
||||||
|
self,
|
||||||
|
template_body: str,
|
||||||
|
variables: Dict[str, str]
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
params: List[Dict[str, str]] = []
|
||||||
|
# Meta templates use ordered parameters ({{1}}, {{2}}, ...); we map
|
||||||
|
# app placeholders (e.g. {{first_name}}) to that ordered list.
|
||||||
|
for name in self._extract_placeholder_names(template_body):
|
||||||
|
value = variables.get(name) or "Friend"
|
||||||
|
params.append({"type": "text", "text": value})
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _safe_json(self, response: httpx.Response) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except Exception:
|
||||||
|
return {"raw": response.text}
|
||||||
|
|
||||||
|
def _should_retry_with_en_us(
|
||||||
|
self,
|
||||||
|
language: str,
|
||||||
|
response_json: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
if language != "en":
|
||||||
|
return False
|
||||||
|
error = response_json.get("error", {}) if isinstance(response_json, dict) else {}
|
||||||
|
error_code = error.get("code")
|
||||||
|
|
||||||
|
# Do NOT retry on 132001 — that's a phone number mismatch, not language
|
||||||
|
if error_code == 132001:
|
||||||
|
return False
|
||||||
|
|
||||||
|
message = " ".join([
|
||||||
|
str(error.get("message", "")),
|
||||||
|
str(error.get("error_user_msg", "")),
|
||||||
|
json.dumps(error.get("error_data", {}))
|
||||||
|
]).lower()
|
||||||
|
# Only retry if error explicitly mentions language or locale
|
||||||
|
return "language" in message or "locale" in message
|
||||||
|
|
||||||
|
def _post_with_retries(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
headers: Dict[str, str],
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
retries: int = 2
|
||||||
|
) -> httpx.Response:
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
response = client.post(url, headers=headers, json=payload)
|
||||||
|
if response.status_code >= 500 and attempt < retries:
|
||||||
|
time.sleep(0.5 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
return response
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _log_response(
|
||||||
|
self,
|
||||||
|
phone_number_id: str,
|
||||||
|
to: str,
|
||||||
|
template_name: Optional[str],
|
||||||
|
language: str,
|
||||||
|
url: str,
|
||||||
|
response_json: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
logger.info(
|
||||||
|
"WhatsApp Cloud response",
|
||||||
|
extra={
|
||||||
|
"phone_number_id": phone_number_id,
|
||||||
|
"url": url,
|
||||||
|
"to": self._mask_to(to),
|
||||||
|
"template_name": template_name,
|
||||||
|
"language": language,
|
||||||
|
"response": response_json
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise_api_error(self, response: httpx.Response, response_json: Dict[str, Any]) -> None:
|
||||||
|
if isinstance(response_json, dict) and response_json.get("error"):
|
||||||
|
message = response_json["error"].get("message", "Unknown error")
|
||||||
|
else:
|
||||||
|
message = response.text
|
||||||
|
raise Exception(f"WhatsApp API error: {response.status_code} - {message}")
|
||||||
|
|
||||||
def send_message(
|
def send_message(
|
||||||
self,
|
self,
|
||||||
@ -22,72 +165,136 @@ class WhatsAppCloudProvider(BaseProvider):
|
|||||||
"""
|
"""
|
||||||
Send message via WhatsApp Cloud API.
|
Send message via WhatsApp Cloud API.
|
||||||
|
|
||||||
If template_name is provided, sends a template message.
|
Requires:
|
||||||
Otherwise, sends a text message.
|
- WHATSAPP_CLOUD_PHONE_NUMBER_ID: The phone number ID that owns the template
|
||||||
|
- WHATSAPP_CLOUD_ACCESS_TOKEN: Valid Meta access token
|
||||||
|
|
||||||
|
Template ownership matters: templates are scoped to a specific Phone Number ID.
|
||||||
|
Sending from a different phone number will fail with error 132001.
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
phone_number_id = self._resolve_phone_number_id()
|
||||||
|
self._validate_phone_number_id(phone_number_id)
|
||||||
|
|
||||||
|
if not template_name:
|
||||||
|
raise Exception("WhatsApp Cloud requires an approved template name")
|
||||||
|
|
||||||
|
# Send template message
|
||||||
|
# Build template parameters only if template body has placeholders
|
||||||
|
parameters = self._build_template_parameters(template_body, variables)
|
||||||
|
|
||||||
|
# Build template object - only include components if there are parameters
|
||||||
|
template_obj = {
|
||||||
|
"name": template_name,
|
||||||
|
"language": {
|
||||||
|
"code": language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parameters:
|
||||||
|
template_obj["components"] = [
|
||||||
|
{
|
||||||
|
"type": "body",
|
||||||
|
"parameters": parameters
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
url = f"{self.base_url}/{phone_number_id}/messages"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self.access_token}",
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
if template_name:
|
|
||||||
# Send template message
|
|
||||||
# Build template parameters
|
|
||||||
components = []
|
|
||||||
if variables:
|
|
||||||
parameters = [
|
|
||||||
{"type": "text", "text": value}
|
|
||||||
for value in variables.values()
|
|
||||||
]
|
|
||||||
components.append({
|
|
||||||
"type": "body",
|
|
||||||
"parameters": parameters
|
|
||||||
})
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"messaging_product": "whatsapp",
|
"messaging_product": "whatsapp",
|
||||||
"to": to.replace("+", ""),
|
"to": to.replace("+", ""),
|
||||||
"type": "template",
|
"type": "template",
|
||||||
"template": {
|
"template": template_obj
|
||||||
"name": template_name,
|
|
||||||
"language": {
|
|
||||||
"code": language
|
|
||||||
},
|
|
||||||
"components": components
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Send text message (only for contacts with conversation window)
|
|
||||||
# Substitute variables in body
|
|
||||||
message_text = template_body
|
|
||||||
for key, value in variables.items():
|
|
||||||
message_text = message_text.replace(f"{{{{{key}}}}}", value)
|
|
||||||
|
|
||||||
payload = {
|
logger.info(
|
||||||
"messaging_product": "whatsapp",
|
"WhatsApp Cloud config",
|
||||||
"to": to.replace("+", ""),
|
extra={
|
||||||
"type": "text",
|
"phone_number_id": phone_number_id,
|
||||||
"text": {
|
"url": url,
|
||||||
"body": message_text
|
"template_name": template_name,
|
||||||
}
|
"language": language,
|
||||||
|
"access_token": self._mask_token(self.access_token),
|
||||||
|
"to": self._mask_to(to)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"WhatsApp Cloud config details: phone_number_id=%s url=%s template=%s language=%s access_token=%s to=%s",
|
||||||
|
phone_number_id,
|
||||||
|
url,
|
||||||
|
template_name,
|
||||||
|
language,
|
||||||
|
self._mask_token(self.access_token),
|
||||||
|
self._mask_to(to)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log sanitized payload (mask sensitive data)
|
||||||
|
sanitized_payload = payload.copy()
|
||||||
|
sanitized_payload["to"] = self._mask_to(sanitized_payload["to"])
|
||||||
|
logger.info(
|
||||||
|
"WhatsApp Cloud request payload: %s",
|
||||||
|
json.dumps(sanitized_payload, ensure_ascii=True)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=30.0) as client:
|
response = self._post_with_retries(url, headers, payload)
|
||||||
response = client.post(url, headers=headers, json=payload)
|
response_json = self._safe_json(response)
|
||||||
response.raise_for_status()
|
self._log_response(phone_number_id, to, template_name, language, url, response_json)
|
||||||
|
logger.info(
|
||||||
|
"WhatsApp Cloud response details: status=%s response=%s",
|
||||||
|
response.status_code,
|
||||||
|
json.dumps(response_json, ensure_ascii=True)
|
||||||
|
)
|
||||||
|
|
||||||
data = response.json()
|
if response.is_error and template_name and self._should_retry_with_en_us(language, response_json):
|
||||||
message_id = data.get("messages", [{}])[0].get("id")
|
logger.info(f"Retrying with en_US language code")
|
||||||
|
payload["template"]["language"]["code"] = "en_US"
|
||||||
|
response = self._post_with_retries(url, headers, payload)
|
||||||
|
response_json = self._safe_json(response)
|
||||||
|
self._log_response(phone_number_id, to, template_name, "en_US", url, response_json)
|
||||||
|
logger.info(
|
||||||
|
"WhatsApp Cloud response details (en_US retry): status=%s response=%s",
|
||||||
|
response.status_code,
|
||||||
|
json.dumps(response_json, ensure_ascii=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.is_error:
|
||||||
|
error_code = response_json.get("error", {}).get("code") if isinstance(response_json, dict) else None
|
||||||
|
if error_code == 132001:
|
||||||
|
logger.error(
|
||||||
|
"WhatsApp Cloud template not found (132001)",
|
||||||
|
extra={
|
||||||
|
"phone_number_id": phone_number_id,
|
||||||
|
"template_name": template_name,
|
||||||
|
"language": language,
|
||||||
|
"response": response_json
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"WhatsApp Cloud template not found (132001) details: phone_number_id=%s template=%s language=%s response=%s",
|
||||||
|
phone_number_id,
|
||||||
|
template_name,
|
||||||
|
language,
|
||||||
|
json.dumps(response_json, ensure_ascii=True)
|
||||||
|
)
|
||||||
|
raise Exception(
|
||||||
|
f"WhatsApp API error 132001: Template '{template_name}' not found for "
|
||||||
|
f"Phone Number ID {phone_number_id}. "
|
||||||
|
f"Make sure the template exists and is associated with this phone number. "
|
||||||
|
f"(Full error: {response_json.get('error', {}).get('message', 'unknown')})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._raise_api_error(response, response_json)
|
||||||
|
|
||||||
|
message_id = response_json.get("messages", [{}])[0].get("id")
|
||||||
if not message_id:
|
if not message_id:
|
||||||
raise Exception("No message ID in response")
|
raise Exception("No message ID in response")
|
||||||
|
|
||||||
return message_id
|
return message_id
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
raise Exception(f"WhatsApp API error: {e.response.status_code} - {e.response.text}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Send error: {str(e)}")
|
raise Exception(f"Send error: {str(e)}")
|
||||||
|
|
||||||
|
|||||||
185
backend/schema.sql
Normal file
185
backend/schema.sql
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
-- Enums
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'userrole') THEN
|
||||||
|
CREATE TYPE userrole AS ENUM ('ADMIN', 'USER');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'campaignstatus') THEN
|
||||||
|
CREATE TYPE campaignstatus AS ENUM ('DRAFT', 'SCHEDULED', 'SENDING', 'DONE', 'FAILED');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'recipientstatus') THEN
|
||||||
|
CREATE TYPE recipientstatus AS ENUM ('PENDING', 'SENT', 'DELIVERED', 'READ', 'FAILED');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR NOT NULL,
|
||||||
|
password_hash VARCHAR NOT NULL,
|
||||||
|
role userrole NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email ON users (email);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_users_id ON users (id);
|
||||||
|
|
||||||
|
-- contacts
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
phone_e164 VARCHAR NOT NULL,
|
||||||
|
first_name VARCHAR,
|
||||||
|
last_name VARCHAR,
|
||||||
|
email VARCHAR,
|
||||||
|
opted_in BOOLEAN NOT NULL,
|
||||||
|
conversation_window_open BOOLEAN NOT NULL,
|
||||||
|
source VARCHAR,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
CONSTRAINT uq_user_phone UNIQUE (user_id, phone_e164)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_contacts_user_phone ON contacts (user_id, phone_e164);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_contacts_id ON contacts (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_contacts_user_id ON contacts (user_id);
|
||||||
|
|
||||||
|
-- contact_tags
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_contact_tags_id ON contact_tags (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_contact_tags_user_id ON contact_tags (user_id);
|
||||||
|
|
||||||
|
-- contact_tag_map
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_tag_map (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES contact_tags(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_contact_tag UNIQUE (contact_id, tag_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_contact_tag_map_id ON contact_tag_map (id);
|
||||||
|
|
||||||
|
-- dnd_list
|
||||||
|
CREATE TABLE IF NOT EXISTS dnd_list (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
phone_e164 VARCHAR NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
CONSTRAINT uq_dnd_user_phone UNIQUE (user_id, phone_e164)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_dnd_list_id ON dnd_list (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_dnd_list_phone_e164 ON dnd_list (phone_e164);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_dnd_list_user_id ON dnd_list (user_id);
|
||||||
|
|
||||||
|
-- lists
|
||||||
|
CREATE TABLE IF NOT EXISTS lists (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_lists_id ON lists (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_lists_user_id ON lists (user_id);
|
||||||
|
|
||||||
|
-- list_members
|
||||||
|
CREATE TABLE IF NOT EXISTS list_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||||
|
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_list_contact UNIQUE (list_id, contact_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_list_members_contact_id ON list_members (contact_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_list_members_id ON list_members (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_list_members_list_id ON list_members (list_id);
|
||||||
|
|
||||||
|
-- templates
|
||||||
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
language VARCHAR NOT NULL,
|
||||||
|
body_text TEXT NOT NULL,
|
||||||
|
is_whatsapp_template BOOLEAN NOT NULL,
|
||||||
|
provider_template_name VARCHAR,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_templates_id ON templates (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates (user_id);
|
||||||
|
|
||||||
|
-- campaigns
|
||||||
|
CREATE TABLE IF NOT EXISTS campaigns (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
template_id INTEGER REFERENCES templates(id) ON DELETE SET NULL,
|
||||||
|
list_id INTEGER REFERENCES lists(id) ON DELETE SET NULL,
|
||||||
|
status campaignstatus NOT NULL,
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_campaigns_id ON campaigns (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_campaigns_status ON campaigns (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_campaigns_user_id ON campaigns (user_id);
|
||||||
|
|
||||||
|
-- campaign_recipients
|
||||||
|
CREATE TABLE IF NOT EXISTS campaign_recipients (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||||
|
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
status recipientstatus NOT NULL,
|
||||||
|
provider_message_id VARCHAR,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_campaign_recipients_campaign_status ON campaign_recipients (campaign_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_campaign_recipients_campaign_id ON campaign_recipients (campaign_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_campaign_recipients_contact_id ON campaign_recipients (contact_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_campaign_recipients_id ON campaign_recipients (id);
|
||||||
|
|
||||||
|
-- send_logs
|
||||||
|
CREATE TABLE IF NOT EXISTS send_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||||
|
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
provider VARCHAR NOT NULL,
|
||||||
|
request_payload_json JSON,
|
||||||
|
response_payload_json JSON,
|
||||||
|
status VARCHAR NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_send_logs_campaign_id ON send_logs (campaign_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_send_logs_created_at ON send_logs (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_send_logs_id ON send_logs (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_send_logs_user_id ON send_logs (user_id);
|
||||||
|
|
||||||
|
-- jobs
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR NOT NULL,
|
||||||
|
payload_json JSON NOT NULL,
|
||||||
|
status VARCHAR NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL,
|
||||||
|
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_jobs_status_run_after ON jobs (status, run_after);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_jobs_id ON jobs (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_jobs_user_id ON jobs (user_id);
|
||||||
|
|
||||||
|
-- google_tokens
|
||||||
|
CREATE TABLE IF NOT EXISTS google_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
encrypted_token TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ix_google_tokens_user_id ON google_tokens (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_google_tokens_id ON google_tokens (id);
|
||||||
52
backend/scripts/send_whatsapp_template.py
Normal file
52
backend/scripts/send_whatsapp_template.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from app.providers.whatsapp_cloud import WhatsAppCloudProvider
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
to_number = os.getenv("WHATSAPP_TEST_TO", "")
|
||||||
|
if not to_number:
|
||||||
|
raise SystemExit("ERROR: Set WHATSAPP_TEST_TO to an E.164 number (e.g., 14155552671 or +14155552671)")
|
||||||
|
|
||||||
|
template_name = os.getenv("WHATSAPP_TEMPLATE_NAME", "hello_world")
|
||||||
|
template_language = os.getenv("WHATSAPP_TEMPLATE_LANGUAGE", "en_US")
|
||||||
|
first_name = os.getenv("WHATSAPP_TEST_FIRST_NAME", "Friend")
|
||||||
|
|
||||||
|
# Determine template body based on template name
|
||||||
|
# hello_world has NO variables (no {{...}})
|
||||||
|
# custom_hello_world has {{first_name}}
|
||||||
|
if template_name == "hello_world":
|
||||||
|
template_body = "Hello World" # No variables
|
||||||
|
variables = {}
|
||||||
|
else:
|
||||||
|
template_body = "Hello {{first_name}}, welcome!" # Has variable
|
||||||
|
variables = {"first_name": first_name}
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"WhatsApp Cloud Template Debug Test")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
print(f"Phone Number: {to_number}")
|
||||||
|
print(f"Template Name: {template_name}")
|
||||||
|
print(f"Template Language: {template_language}")
|
||||||
|
print(f"Template Body: {template_body}")
|
||||||
|
print(f"Variables: {variables}")
|
||||||
|
print(f"{'='*70}\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = WhatsAppCloudProvider()
|
||||||
|
message_id = provider.send_message(
|
||||||
|
to=to_number,
|
||||||
|
template_name=template_name,
|
||||||
|
template_body=template_body,
|
||||||
|
variables=variables,
|
||||||
|
language=template_language
|
||||||
|
)
|
||||||
|
print(f"✅ SUCCESS: Message sent with ID: {message_id}\n")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ FAILED: {str(e)}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
@ -31,11 +31,11 @@ services:
|
|||||||
GOOGLE_CLIENT_SECRET: your-google-client-secret
|
GOOGLE_CLIENT_SECRET: your-google-client-secret
|
||||||
GOOGLE_REDIRECT_URI: http://localhost:8000/api/imports/google/callback
|
GOOGLE_REDIRECT_URI: http://localhost:8000/api/imports/google/callback
|
||||||
GOOGLE_TOKEN_ENCRYPTION_KEY: your-fernet-key-32-bytes-base64
|
GOOGLE_TOKEN_ENCRYPTION_KEY: your-fernet-key-32-bytes-base64
|
||||||
WHATSAPP_PROVIDER: telegram
|
WHATSAPP_PROVIDER: cloud
|
||||||
WHATSAPP_CLOUD_ACCESS_TOKEN: your-whatsapp-token
|
WHATSAPP_CLOUD_ACCESS_TOKEN: EAAMdmYX7DJUBQoJYK1CyY9X6QLZAO4FkdMnhDZBbn0JjpbpHTPIBAXNA5nGq1cO9Ge96uti7CLq1kTfaMEnLoZBOsYNbOQGyQBnNbyZCmHmo7liDKSzahww3RE4WZB3Cej6UNHEttzN0gQHeAMJ7ZA3vEhCCIBNphKILJYglqoY8IYTm1G5pdqsELaPjvNRmBLVwMtEZBuKr2NLpsJqHIFiU7UUFk9DkuEXCCR8xwmYmZBrjDhsCEYwVjLO7URq8RagmGl7GBYdwQRX51zlrtqSiGaU1
|
||||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID: your-phone-number-id
|
WHATSAPP_CLOUD_PHONE_NUMBER_ID: "968263383040867"
|
||||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN: your-webhook-verify-token
|
WHATSAPP_WEBHOOK_VERIFY_TOKEN: your-webhook-verify-token
|
||||||
TELEGRAM_BOT_TOKEN: 8428015346:AAH2MOb9D1HUlINOxcDFMa6q98qGo4rPSYo
|
TELEGRAM_BOT_TOKEN: ""
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
2033
frontend/package-lock.json
generated
Normal file
2033
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
const baseURL = API_URL ? `${API_URL.replace(/\/$/, '')}/api` : '/api';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: `${API_URL}/api`,
|
baseURL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,6 +91,7 @@ export const TemplatesPage = () => {
|
|||||||
<label className="form-label">Language</label>
|
<label className="form-label">Language</label>
|
||||||
<select className="form-select" value={formData.language} onChange={(e) => setFormData({ ...formData, language: e.target.value })}>
|
<select className="form-select" value={formData.language} onChange={(e) => setFormData({ ...formData, language: e.target.value })}>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
|
<option value="en_US">English (United States)</option>
|
||||||
<option value="es">Spanish</option>
|
<option value="es">Spanish</option>
|
||||||
<option value="fr">French</option>
|
<option value="fr">French</option>
|
||||||
<option value="ar">Arabic</option>
|
<option value="ar">Arabic</option>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user