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 typing import List
|
||||
import os
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
@ -23,6 +24,7 @@ class Settings(BaseSettings):
|
||||
WHATSAPP_PROVIDER: str = "mock" # mock, cloud, or telegram
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN: str = ""
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID: str = ""
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID_OVERRIDE: str = ""
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN: str = ""
|
||||
|
||||
# Telegram Provider (for testing)
|
||||
@ -38,7 +40,8 @@ class Settings(BaseSettings):
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
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
|
||||
|
||||
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.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.api import auth, contacts, lists, templates, campaigns, imports, google, webhooks, stats, workers
|
||||
import logging
|
||||
import uvicorn
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -17,9 +24,10 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# CORS
|
||||
cors_origins = list({*settings.cors_origins_list, "http://localhost:5173"})
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@ -48,3 +56,6 @@ def root():
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000, reload=False)
|
||||
|
||||
@ -1,15 +1,158 @@
|
||||
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.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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):
|
||||
self.access_token = settings.WHATSAPP_CLOUD_ACCESS_TOKEN
|
||||
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(
|
||||
self,
|
||||
@ -22,72 +165,136 @@ class WhatsAppCloudProvider(BaseProvider):
|
||||
"""
|
||||
Send message via WhatsApp Cloud API.
|
||||
|
||||
If template_name is provided, sends a template message.
|
||||
Otherwise, sends a text message.
|
||||
Requires:
|
||||
- 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 = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"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 = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to.replace("+", ""),
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": template_name,
|
||||
"language": {
|
||||
"code": language
|
||||
},
|
||||
"components": components
|
||||
"template": template_obj
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to.replace("+", ""),
|
||||
"type": "text",
|
||||
"text": {
|
||||
"body": message_text
|
||||
}
|
||||
logger.info(
|
||||
"WhatsApp Cloud config",
|
||||
extra={
|
||||
"phone_number_id": phone_number_id,
|
||||
"url": url,
|
||||
"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:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
response = self._post_with_retries(url, headers, payload)
|
||||
response_json = self._safe_json(response)
|
||||
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()
|
||||
message_id = data.get("messages", [{}])[0].get("id")
|
||||
if response.is_error and template_name and self._should_retry_with_en_us(language, response_json):
|
||||
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:
|
||||
raise Exception("No message ID in response")
|
||||
|
||||
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:
|
||||
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_REDIRECT_URI: http://localhost:8000/api/imports/google/callback
|
||||
GOOGLE_TOKEN_ENCRYPTION_KEY: your-fernet-key-32-bytes-base64
|
||||
WHATSAPP_PROVIDER: telegram
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN: your-whatsapp-token
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID: your-phone-number-id
|
||||
WHATSAPP_PROVIDER: cloud
|
||||
WHATSAPP_CLOUD_ACCESS_TOKEN: EAAMdmYX7DJUBQoJYK1CyY9X6QLZAO4FkdMnhDZBbn0JjpbpHTPIBAXNA5nGq1cO9Ge96uti7CLq1kTfaMEnLoZBOsYNbOQGyQBnNbyZCmHmo7liDKSzahww3RE4WZB3Cej6UNHEttzN0gQHeAMJ7ZA3vEhCCIBNphKILJYglqoY8IYTm1G5pdqsELaPjvNRmBLVwMtEZBuKr2NLpsJqHIFiU7UUFk9DkuEXCCR8xwmYmZBrjDhsCEYwVjLO7URq8RagmGl7GBYdwQRX51zlrtqSiGaU1
|
||||
WHATSAPP_CLOUD_PHONE_NUMBER_ID: "968263383040867"
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN: your-webhook-verify-token
|
||||
TELEGRAM_BOT_TOKEN: 8428015346:AAH2MOb9D1HUlINOxcDFMa6q98qGo4rPSYo
|
||||
TELEGRAM_BOT_TOKEN: ""
|
||||
depends_on:
|
||||
postgres:
|
||||
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';
|
||||
|
||||
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({
|
||||
baseURL: `${API_URL}/api`,
|
||||
baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
@ -91,6 +91,7 @@ export const TemplatesPage = () => {
|
||||
<label className="form-label">Language</label>
|
||||
<select className="form-select" value={formData.language} onChange={(e) => setFormData({ ...formData, language: e.target.value })}>
|
||||
<option value="en">English</option>
|
||||
<option value="en_US">English (United States)</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="ar">Arabic</option>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user