accountSid = $this->config['twilio_sid'] ?? '';
$this->authToken = $this->config['twilio_token'] ?? '';
$this->phoneNumber = $this->config['twilio_phone'] ?? '';
}
/**
* @inheritDoc
*/
protected function initClient(): void
{
if (empty($this->accountSid) || empty($this->authToken)) {
return;
}
$authHeader = 'Basic ' . base64_encode($this->accountSid . ':' . $this->authToken);
// Client for Messages API
$this->client = new Client([
'base_uri' => 'https://api.twilio.com/',
'timeout' => $this->timeout,
'http_errors' => false,
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
]
]);
// Client for Content API (Templates)
$this->contentClient = new Client([
'base_uri' => 'https://content.twilio.com/',
'timeout' => $this->timeout,
'http_errors' => false,
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
]
]);
}
/**
* @inheritDoc
*/
public function isConfigured(): bool
{
if (!$this->configModel) {
return false;
}
if (empty($this->config['enabled'])) {
return false;
}
return !empty($this->accountSid) && !empty($this->authToken) && !empty($this->phoneNumber);
}
/**
* @inheritDoc
*/
public function testConnection(): array
{
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
try {
$response = $this->contentClient->get('v1/Content', [
'query' => ['PageSize' => 1]
]);
$statusCode = $response->getStatusCode();
if ($statusCode === 200) {
return $this->successResponse(__eva('Connection successful'));
}
if ($statusCode === 401) {
return $this->errorResponse(__eva('Invalid Twilio credentials (Account SID or Auth Token)'));
}
$body = json_decode($response->getBody()->getContents(), true);
return $this->errorResponse($body['message'] ?? __eva('Unknown error'), $body);
} catch (GuzzleException $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* @inheritDoc
*/
public function sendMessage(SendMessageRequest $request): array
{
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
$toPhone = $this->formatPhoneNumber($request->toPhone);
try {
$formParams = [
'To' => 'whatsapp:' . $toPhone,
'From' => 'whatsapp:' . $this->phoneNumber,
'Body' => $request->body,
];
if ($request->mediaUrl) {
$formParams['MediaUrl'] = $request->mediaUrl;
}
$response = $this->client->post("2010-04-01/Accounts/{$this->accountSid}/Messages.json", [
'form_params' => $formParams
]);
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
if ($statusCode >= 200 && $statusCode < 300) {
return $this->successResponse(__eva('Message sent successfully'), [
'message_id' => $body['sid'] ?? null,
'status' => $body['status'] ?? null,
'date_created' => $body['date_created'] ?? null,
]);
}
return $this->errorResponse(
$body['message'] ?? __eva('Failed to send message'),
[
'error_code' => $body['code'] ?? null,
'error_message' => $body['message'] ?? null,
]
);
} catch (GuzzleException $e) {
$this->logError('Send message failed', ['error' => $e->getMessage()]);
return $this->errorResponse($e->getMessage());
}
}
/**
* @inheritDoc
*/
public function sendTemplate(SendTemplateRequest $request): array
{
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
$toPhone = $this->formatPhoneNumber($request->toPhone);
try {
$formParams = [
'To' => 'whatsapp:' . $toPhone,
'From' => 'whatsapp:' . $this->phoneNumber,
'ContentSid' => $request->templateId,
];
// NO HACER ESTO - Convert variables to Twilio format (numeric keys)
//$variables = $this->formatTemplateVariables($request);
$variables = $request->variables;
if (!empty($variables)) {
$formParams['ContentVariables'] = json_encode($variables);
}
$response = $this->client->post("2010-04-01/Accounts/{$this->accountSid}/Messages.json", [
'form_params' => $formParams
]);
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
if ($statusCode >= 200 && $statusCode < 300) {
return $this->successResponse(__eva('Template message sent successfully'), [
'message_id' => $body['sid'] ?? null,
'status' => $body['status'] ?? null,
'date_created' => $body['date_created'] ?? null,
]);
}
return $this->errorResponse(
$body['message'] ?? __eva('Failed to send template message'),
[
'error_code' => $body['code'] ?? null,
'error_message' => $body['message'] ?? null,
]
);
} catch (GuzzleException $e) {
$this->logError('Send template failed', ['error' => $e->getMessage()]);
return $this->errorResponse($e->getMessage());
}
}
/**
* Format template variables for Twilio
* Twilio uses numeric string keys: "1", "2", "3", etc.
*
* @param SendTemplateRequest $request
* @return array
*/
protected function formatTemplateVariables(SendTemplateRequest $request): array
{
$variables = $request->variables;
// If already in Twilio format (numeric keys), return as-is
if (!empty($variables) && isset($variables['1'])) {
return $variables;
}
// If booking data format, convert to Twilio format
if (isset($variables['customer_name']) || isset($variables['date'])) {
$formatted = [];
$index = 1;
$mappings = [
'customer_name',
'restaurant_name' => $this->restaurantName,
'date' => isset($variables['date']) ? $this->formatDate($variables['date']) : '',
'time' => isset($variables['time']) ? $this->formatTime($variables['time']) : '',
'party',
'restaurant_phone' => $this->restaurantPhone,
];
foreach ($mappings as $key => $default) {
if (is_int($key)) {
$key = $default;
$default = '';
}
$value = $variables[$key] ?? $default;
if (!empty($value)) {
$formatted[(string)$index] = (string)$value;
$index++;
}
}
// Add any extra variables
foreach ($variables as $key => $value) {
if (!in_array($key, ['customer_name', 'restaurant_name', 'date', 'time', 'party', 'restaurant_phone'])) {
if (!empty($value)) {
$formatted[(string)$index] = (string)$value;
$index++;
}
}
}
return $formatted;
}
// Generic array, convert to numeric keys
$formatted = [];
$index = 1;
foreach ($variables as $value) {
$formatted[(string)$index] = (string)$value;
$index++;
}
return $formatted;
}
/**
* @inheritDoc
*/
public function getTemplates(int $pageSize = 50, ?string $cursor = null): array
{
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
try {
if ($cursor) {
// Parse cursor URL for pagination
$parsedUrl = parse_url($cursor);
$path = $parsedUrl['path'] ?? 'v1/Content';
$query = [];
if (isset($parsedUrl['query'])) {
parse_str($parsedUrl['query'], $query);
}
$response = $this->contentClient->get(ltrim($path, '/'), [
'query' => $query
]);
} else {
$response = $this->contentClient->get('v1/Content', [
'query' => ['PageSize' => min($pageSize, 50)]
]);
}
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
if ($statusCode !== 200) {
return $this->errorResponse(
$body['message'] ?? __eva('Failed to fetch templates'),
$body
);
}
$templates = [];
$contents = $body['contents'] ?? [];
foreach ($contents as $content) {
$template = $this->normalizeTemplate($content);
$templates[] = $template->toArray();
}
return $this->successResponse(__eva('Templates fetched successfully'), [
'templates' => $templates,
'total' => count($templates),
'meta' => [
'next_page_url' => $body['meta']['next_page_url'] ?? null,
'previous_page_url' => $body['meta']['previous_page_url'] ?? null,
],
]);
} catch (GuzzleException $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* @inheritDoc
*/
public function getTemplate(string $templateId): array
{
//ray('Get template: '.$templateId);
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
try {
$response = $this->contentClient->get("v1/Content/{$templateId}");
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
//ray($statusCode, $body);
if ($statusCode !== 200) {
return $this->errorResponse(
$body['message'] ?? __eva('Failed to fetch template'),
$body
);
}
$template = $this->normalizeTemplate($body);
// Get approval status
$approvalStatus = $this->getTemplateApprovalStatus($templateId);
if ($approvalStatus['success']) {
$template->approvalStatus = $approvalStatus['data']['status'] ?? NormalizedTemplate::STATUS_UNKNOWN;
$template->rejectionReason = $approvalStatus['data']['rejection_reason'] ?? null;
$template->category = $approvalStatus['data']['category'] ?? null;
}
return $this->successResponse(__eva('Template fetched successfully'), [
'template' => $template->toArray(),
]);
} catch (GuzzleException $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* Get template approval status from Twilio
*
* @param string $templateId
* @return array
*/
protected function getTemplateApprovalStatus(string $templateId): array
{
try {
$response = $this->contentClient->get("v1/Content/{$templateId}/ApprovalRequests");
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
if ($statusCode !== 200) {
return $this->errorResponse($body['message'] ?? 'Failed to fetch approval status');
}
$whatsappApproval = $body['whatsapp'] ?? [];
return $this->successResponse('Approval status fetched', [
'status' => $whatsappApproval['status'] ?? 'unknown',
'rejection_reason' => $whatsappApproval['rejection_reason'] ?? null,
'category' => $whatsappApproval['category'] ?? null,
]);
} catch (GuzzleException $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* Normalize Twilio template to NormalizedTemplate
*
* @param array $content
* @return NormalizedTemplate
*/
protected function normalizeTemplate(array $content): NormalizedTemplate
{
$template = new NormalizedTemplate(
$content['sid'],
$content['friendly_name'] ?? '',
self::PROVIDER_NAME
);
$template->language = $content['language'] ?? null;
$template->dateCreated = $content['date_created'] ?? null;
$template->dateUpdated = $content['date_updated'] ?? null;
$template->variables = $content['variables'] ?? [];
$template->rawData = $content;
$types = $content['types'] ?? [];
// Determine template type and extract content
if (isset($types['twilio/text'])) {
$template->type = NormalizedTemplate::TYPE_TEXT;
$template->body = $types['twilio/text']['body'] ?? '';
} elseif (isset($types['twilio/media'])) {
$template->type = NormalizedTemplate::TYPE_MEDIA;
$template->body = $types['twilio/media']['body'] ?? '';
$template->mediaUrl = $types['twilio/media']['media'][0] ?? null;
} elseif (isset($types['twilio/call-to-action'])) {
$template->type = NormalizedTemplate::TYPE_CALL_TO_ACTION;
$template->body = $types['twilio/call-to-action']['body'] ?? '';
$template->buttons = $this->normalizeButtons($types['twilio/call-to-action']['actions'] ?? []);
} elseif (isset($types['twilio/quick-reply'])) {
$template->type = NormalizedTemplate::TYPE_QUICK_REPLY;
$template->body = $types['twilio/quick-reply']['body'] ?? '';
$template->buttons = $this->normalizeButtons($types['twilio/quick-reply']['actions'] ?? []);
} elseif (isset($types['twilio/card'])) {
$template->type = NormalizedTemplate::TYPE_CARD;
$template->body = $types['twilio/card']['body'] ?? '';
$template->header = $types['twilio/card']['title'] ?? null;
$template->mediaUrl = $types['twilio/card']['media'][0] ?? null;
$template->buttons = $this->normalizeButtons($types['twilio/card']['actions'] ?? []);
} elseif (isset($types['twilio/list-picker'])) {
$template->type = NormalizedTemplate::TYPE_LIST;
$template->body = $types['twilio/list-picker']['body'] ?? '';
$template->buttons = $types['twilio/list-picker']['items'] ?? [];
}
return $template;
}
/**
* Normalize button actions
*
* @param array $actions
* @return array
*/
protected function normalizeButtons(array $actions): array
{
$buttons = [];
foreach ($actions as $action) {
$buttons[] = [
'type' => $action['type'] ?? 'unknown',
'text' => $action['title'] ?? $action['text'] ?? '',
'url' => $action['url'] ?? null,
'payload' => $action['id'] ?? null,
'phone' => $action['phone'] ?? null,
];
}
return $buttons;
}
/**
* @inheritDoc
*/
public function getMessageStatus(string $messageId): array
{
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
try {
$response = $this->client->get("2010-04-01/Accounts/{$this->accountSid}/Messages/{$messageId}.json");
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
if ($statusCode !== 200) {
return $this->errorResponse(
$body['message'] ?? __eva('Failed to fetch message status'),
$body
);
}
return $this->successResponse(__eva('Status fetched successfully'), [
'message_id' => $body['sid'],
'status' => $body['status'],
'error_code' => $body['error_code'] ?? null,
'error_message' => $body['error_message'] ?? null,
'date_created' => $body['date_created'],
'date_sent' => $body['date_sent'] ?? null,
'date_updated' => $body['date_updated'],
]);
} catch (GuzzleException $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* @inheritDoc
*/
public function parseIncomingWebhook(array $payload): NormalizedMessage
{
$message = new NormalizedMessage(self::PROVIDER_NAME);
// Extract phone numbers (remove 'whatsapp:' prefix)
$from = $payload['From'] ?? '';
$to = $payload['To'] ?? '';
$message->fromPhone = str_replace('whatsapp:', '', $from);
$message->toPhone = str_replace('whatsapp:', '', $to);
// BSUID support: Extract ExternalUserId field
$externalUserId = $payload['ExternalUserId'] ?? null;
if ($externalUserId) {
$message->externalUserId = str_replace('whatsapp:', '', $externalUserId);
}
// Detect if fromPhone is actually a BSUID (user hid their phone)
if ($message->fromPhone && preg_match('/^[A-Z]{2}\.[A-Za-z0-9]+$/', $message->fromPhone)) {
$message->isBsuid = true;
if (!$message->externalUserId) {
$message->externalUserId = $message->fromPhone;
}
}
// Message identifiers
$message->messageId = $payload['MessageSid'] ?? null;
$message->accountId = $payload['AccountSid'] ?? null;
// Sender info
$message->profileName = $payload['ProfileName'] ?? null;
$message->waId = $payload['WaId'] ?? null;
// Message content
$message->body = $payload['Body'] ?? '';
// Button response detection
$buttonPayload = $payload['ButtonPayload'] ?? null;
$buttonText = $payload['ButtonText'] ?? null;
$message->isButtonResponse = !empty($buttonPayload) || !empty($buttonText);
$message->buttonPayload = $buttonPayload;
$message->buttonText = $buttonText ?? ($message->isButtonResponse ? $message->body : null);
// Reply context
$message->originalMessageId = $payload['OriginalRepliedMessageSid'] ?? null;
$message->isReply = !empty($message->originalMessageId);
// Media
$message->mediaCount = (int)($payload['NumMedia'] ?? 0);
$message->media = $this->extractMedia($payload);
// Location
if (isset($payload['Latitude'])) {
$message->latitude = floatval($payload['Latitude']);
$message->longitude = floatval($payload['Longitude'] ?? 0);
$message->locationAddress = $payload['Address'] ?? null;
}
// Determine type
$message->type = $message->getMessageType();
// Raw payload
$message->rawPayload = $payload;
return $message;
}
/**
* Extract media from Twilio payload
*
* @param array $payload
* @return array
*/
protected function extractMedia(array $payload): array
{
$media = [];
$numMedia = (int)($payload['NumMedia'] ?? 0);
for ($i = 0; $i < $numMedia; $i++) {
$mediaUrl = $payload["MediaUrl{$i}"] ?? null;
$mediaType = $payload["MediaContentType{$i}"] ?? null;
if ($mediaUrl) {
$media[] = [
'url' => $mediaUrl,
'content_type' => $mediaType,
'id' => null,
];
}
}
return $media;
}
/**
* @inheritDoc
*/
public function parseStatusCallback(array $payload): NormalizedStatusUpdate
{
$status = new NormalizedStatusUpdate(self::PROVIDER_NAME);
$status->messageId = $payload['MessageSid'] ?? null;
$status->accountId = $payload['AccountSid'] ?? null;
$status->rawStatus = $payload['MessageStatus'] ?? null;
$status->status = NormalizedStatusUpdate::normalizeStatus(
$status->rawStatus ?? '',
self::PROVIDER_NAME
);
$status->toPhone = str_replace('whatsapp:', '', $payload['To'] ?? '');
$status->fromPhone = str_replace('whatsapp:', '', $payload['From'] ?? '');
$status->errorCode = $payload['ErrorCode'] ?? null;
$status->errorMessage = $payload['ErrorMessage'] ?? null;
$status->rawPayload = $payload;
return $status;
}
/**
* @inheritDoc
*/
public function validateWebhookSignature(string $signature, string $url, array $payload): bool
{
if (empty($this->authToken)) {
return false;
}
// Sort params alphabetically by key
ksort($payload);
// Concatenate URL and params
$data = $url;
foreach ($payload as $key => $value) {
$data .= $key . $value;
}
// Generate signature
$expectedSignature = base64_encode(hash_hmac('sha1', $data, $this->authToken, true));
return hash_equals($expectedSignature, $signature);
}
/**
* @inheritDoc
*/
public function generateWebhookResponse(?string $replyMessage = null): string
{
if ($replyMessage) {
return '' .
'' . htmlspecialchars($replyMessage) . '';
}
return '';
}
/**
* @inheritDoc
*/
public function getWebhookResponseContentType(): string
{
return 'text/xml';
}
/**
* @inheritDoc
*/
public function getIncomingWebhookUrl(): string
{
return $this->getWebhookBaseUrl() . '/wp-admin/admin-ajax.php?action=alexr_whatsapp_webhook&restaurant_id=' . $this->restaurantId;
}
/**
* @inheritDoc
*/
public function getStatusCallbackUrl(): string
{
return $this->getWebhookBaseUrl() . '/wp-admin/admin-ajax.php?action=alexr_whatsapp_status_callback&restaurant_id=' . $this->restaurantId;
}
/**
* @inheritDoc
*/
public function getPhoneNumber(): string
{
return $this->phoneNumber;
}
/**
* @inheritDoc
*/
public function getConfig(): array
{
return [
'provider' => self::PROVIDER_NAME,
'enabled' => $this->isConfigured(),
'restaurant_name' => $this->restaurantName,
'restaurant_phone' => $this->restaurantPhone,
'phone_number' => $this->phoneNumber,
'date_format' => $this->dateFormat,
'time_format' => $this->timeFormat,
];
}
/**
* @inheritDoc
*/
public function isStatusCallback(array $payload): bool
{
return isset($payload['MessageStatus']);
}
/**
* @inheritDoc
*/
public function isIncomingMessage(array $payload): bool
{
return isset($payload['Body']) || isset($payload['ButtonPayload']) || isset($payload['NumMedia']);
}
/**
* Check if a phone number is registered on WhatsApp
* Twilio does not provide a direct contact check API.
*
* @param string $phone
* @return array
*/
public function checkContact(string $phone): array
{
// Twilio does not support direct contact checking
return ['success' => false, 'has_whatsapp' => null, 'wa_id' => null];
}
/**
* Configure webhook URLs for this WhatsApp sender via Twilio Senders API v2.
*
* @param string $incomingUrl Webhook URL for incoming messages
* @param string $statusCallbackUrl URL for status callbacks
* @return array
*/
public function configureWebhooks(string $incomingUrl, string $statusCallbackUrl): array
{
//ray('CONFIGURING WEBHOOKS FOR: ' . $this->phoneNumber);
//ray('INCOMING URL: ' . $incomingUrl);
//ray('STATUS CALLBACK URL: ' . $statusCallbackUrl);
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
try {
$sendersClient = new Client([
'base_uri' => 'https://messaging.twilio.com/',
'timeout' => $this->timeout,
'http_errors' => false,
'headers' => [
'Authorization' => 'Basic ' . base64_encode($this->accountSid . ':' . $this->authToken),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]
]);
// Step 1: List senders to find the WhatsApp sender SID
$response = $sendersClient->get('v2/Channels/Senders', [
'query' => ['Channel' => 'whatsapp']
]);
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
if ($statusCode !== 200) {
return $this->errorResponse(
$body['message'] ?? __eva('Failed to list Twilio senders'),
$body
);
}
$senderSid = null;
$whatsappSenderId = 'whatsapp:' . $this->phoneNumber;
$senders = $body['senders'] ?? [];
foreach ($senders as $sender) {
if (($sender['sender_id'] ?? '') === $whatsappSenderId) {
$senderSid = $sender['sid'] ?? null;
break;
}
}
if (!$senderSid) {
return $this->errorResponse(
__eva('WhatsApp sender not found for phone number') . ': ' . $this->phoneNumber
);
}
// Step 2: Update webhook URLs via Senders API
$response = $sendersClient->post("v2/Channels/Senders/{$senderSid}", [
'json' => [
'sender_id' => $whatsappSenderId,
'webhook' => [
'callback_url' => $incomingUrl,
'callback_method' => 'POST',
'status_callback_url' => $statusCallbackUrl,
'status_callback_method' => 'POST',
]
]
]);
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody()->getContents(), true);
if ($statusCode >= 200 && $statusCode < 300) {
return $this->successResponse(__eva('Webhooks configured successfully'), [
'sender_sid' => $senderSid,
'callback_url' => $incomingUrl,
'status_callback_url' => $statusCallbackUrl,
]);
}
return $this->errorResponse(
$body['message'] ?? __eva('Failed to configure webhooks'),
$body
);
} catch (GuzzleException $e) {
$this->logError('Configure webhooks failed', ['error' => $e->getMessage()]);
return $this->errorResponse($e->getMessage());
}
}
/**
* Mark an inbound message as read via Twilio's Typing Indicators API.
* Sending a typing indicator automatically marks the referenced message as read
* on the customer's WhatsApp client.
*
* @param string $messageId Twilio Message SID (e.g. SMxxxxx)
* @return array
*/
public function markAsRead(string $messageId): array
{
// En whatsapp se muestran los 3 puntos demasiado tiempo, mejor no usarlo
return $this->successResponse(__eva('NO using this funcion'));
if (!$this->isConfigured()) {
return $this->errorResponse(__eva('WhatsApp service is not configured'));
}
try {
$authHeader = 'Basic ' . base64_encode($this->accountSid . ':' . $this->authToken);
$messagingClient = new Client([
'base_uri' => 'https://messaging.twilio.com/',
'timeout' => $this->timeout,
'http_errors' => false,
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
]
]);
$response = $messagingClient->post('v2/Indicators/Typing.json', [
'form_params' => [
'messageId' => $messageId,
'channel' => 'whatsapp',
]
]);
$statusCode = $response->getStatusCode();
if ($statusCode >= 200 && $statusCode < 300) {
return $this->successResponse(__eva('Message marked as read'));
}
$body = json_decode($response->getBody()->getContents(), true);
return $this->errorResponse(
$body['message'] ?? __eva('Failed to mark message as read'),
$body
);
} catch (GuzzleException $e) {
return $this->errorResponse($e->getMessage());
}
}
}