using FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.FlowEngine;
using FrameworkDAL.DTO.EIPConversation;
using FrameworkDAL.DTO.MessageHub.MessageHubGenerator;
using GB5Shared.DTO.Framework.Login;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using Polly;
using Polly.Retry;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FrameworkDAL.DTO.MessageHubService.UpdateHandleStatus;
namespace FrameworkBLL.EIPConversation.EIPHandlers.ChannelHandler
{
///
/// Handles sending WhatsApp messages dynamically via Celitix or Meta.
/// Supports text, media, buttons, and template messages.
///
public class WhatsAppChannelHandler : IEIPChannelHandler
{
private readonly ILogger _logger;
private readonly IConfiguration _configuration;
private readonly HttpClient _httpClient;
private readonly AsyncRetryPolicy _retryPolicy;
public string ChannelName => "WHATSAPP";
public WhatsAppChannelHandler(
ILogger logger,
IConfiguration configuration,
HttpClient httpClient)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
// Retry policy with exponential backoff
_retryPolicy = Policy
.Handle()
.OrResult(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryCount, context) =>
{
_logger.LogWarning("[WhatsAppChannelHandler] Retry {RetryCount} | Delay={Delay}s | Reason={Reason}",
retryCount, timespan.TotalSeconds,
outcome.Exception != null ? outcome.Exception.Message : outcome.Result.StatusCode.ToString());
});
_logger.LogInformation(
"[WhatsAppChannelHandler] Initialized | CelitixEnable={Celitix} | MetaEnable={Meta}",
_configuration["MessageHubSettings:Celitix:Enable"],
_configuration["MessageHubSettings:WhatsApp:Enable"]);
}
public bool CanHandle(string channel)
{
return !string.IsNullOrWhiteSpace(channel) &&
channel.Equals(ChannelName, StringComparison.OrdinalIgnoreCase);
}
public async Task SendAsync(
EIPResponseContext context,
LoginDTO loginDTO,
CancellationToken cancellationToken)
{
if (context == null) throw new ArgumentNullException(nameof(context));
if (loginDTO == null) throw new ArgumentNullException(nameof(loginDTO));
context.Recipient ??= context.UserIdentifier ?? throw new ArgumentException("WhatsApp recipient cannot be empty.");
// When structured data is present, format it as plain text for WhatsApp
if (context.StructuredData != null)
{
var formatted = EIPTextFormatter.FormatAsText(context.StructuredData);
context.Message = string.IsNullOrWhiteSpace(context.Message)
? formatted
: $"{context.Message}\n{formatted}";
}
// QR code: encode data as image URL; send as image message
if (!string.IsNullOrWhiteSpace(context.QrCodeData))
{
var encoded = Uri.EscapeDataString(context.QrCodeData);
context.Caption = context.Message ?? string.Empty;
context.MediaUrl = $"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encoded}";
context.Message = null;
}
// Location request: append plain-text prompt (WhatsApp Cloud API has no native request-location button)
if (context.RequestLocation)
{
context.Message = (context.Message ?? string.Empty) + "\n\n\U0001f4cd Please share your current location.";
}
if (string.IsNullOrWhiteSpace(context.Message) &&
context.MediaUrl == null &&
(context.Buttons == null || context.Buttons.Count == 0) &&
string.IsNullOrWhiteSpace(context.TemplateName))
{
context.Message = $"Your action '{context.RawPayload}' has been received.";
}
var correlationId = Guid.NewGuid().ToString();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
_logger.LogInformation("[{CorrelationId}] WHATSAPP SEND START | Tenant={Tenant} | ConversationId={Conversation} | To={Recipient} | Message={Message}",
correlationId, context.TenantId, context.ConversationId ?? 0, context.Recipient, context.Message);
try
{
cancellationToken.ThrowIfCancellationRequested();
var celitixEnabled = _configuration["MessageHubSettings:Celitix:Enable"];
var metaEnabled = _configuration["MessageHubSettings:WhatsApp:Enable"];
MessageHubResponseDTO result;
if (celitixEnabled?.Equals("Y", StringComparison.OrdinalIgnoreCase) == true)
result = await SendViaCelitixAsync(context, correlationId, cancellationToken);
else if (metaEnabled?.Equals("Y", StringComparison.OrdinalIgnoreCase) == true)
result = await SendViaMetaAsync(context, correlationId, cancellationToken);
else
throw new InvalidOperationException("No WhatsApp provider enabled in configuration.");
stopwatch.Stop();
_logger.LogInformation("[{CorrelationId}] WHATSAPP SEND SUCCESS | Tenant={Tenant} | ConversationId={Conversation} | DurationMs={Duration} | Sent={Sent}",
correlationId, context.TenantId, context.ConversationId ?? 0, stopwatch.ElapsedMilliseconds, result.Success);
return new EIPChannelResponseDTO
{
IsSent = result.Success,
ExternalMessageId = Guid.NewGuid().ToString(),
Status = result.Success ? "SENT" : "FAILED",
ErrorMessage = result.Success ? null : result.Message
};
}
catch (OperationCanceledException)
{
stopwatch.Stop();
_logger.LogWarning("[{CorrelationId}] WHATSAPP SEND CANCELLED | Tenant={Tenant} | ConversationId={Conversation} | DurationMs={Duration}",
correlationId, context?.TenantId, context?.ConversationId ?? 0, stopwatch.ElapsedMilliseconds);
throw;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "[{CorrelationId}] WHATSAPP SEND FAILED | Tenant={Tenant} | ConversationId={Conversation} | DurationMs={Duration}",
correlationId, context?.TenantId, context?.ConversationId ?? 0, stopwatch.ElapsedMilliseconds);
return new EIPChannelResponseDTO { IsSent = false, Status = "FAILED", ErrorMessage = ex.ToString() };
}
}
#region Celitix & Meta Sending
private async Task SendViaCelitixAsync(
EIPResponseContext context,
string correlationId,
CancellationToken ct)
{
try
{
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
var baseUrl = _configuration["MessageHubSettings:Celitix:BaseUrl"];
var endpoint = _configuration["MessageHubSettings:Celitix:MessageEndpoint"];
var wabaNumber = _configuration["MessageHubSettings:Celitix:WabaNumber"];
var key = _configuration["MessageHubSettings:Celitix:Key"];
var keyHeaderName = _configuration["MessageHubSettings:Celitix:KeyHeaderName"];
var wabaHeaderName = _configuration["MessageHubSettings:Celitix:WabaNumberHeaderName"];
var contentType = _configuration["MessageHubSettings:Celitix:ContentType"] ?? "application/json";
var payload = BuildPayload(context);
_logger.LogInformation("[{CorrelationId}][Celitix] Sending Payload:\n{Payload}",
correlationId, payload.ToString(Newtonsoft.Json.Formatting.Indented));
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await _retryPolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}{endpoint}");
request.Content = new StringContent(payload.ToString(), Encoding.UTF8, contentType);
if (!string.IsNullOrWhiteSpace(keyHeaderName) && !string.IsNullOrWhiteSpace(key))
request.Headers.Add(keyHeaderName, key);
if (!string.IsNullOrWhiteSpace(wabaHeaderName) && !string.IsNullOrWhiteSpace(wabaNumber))
request.Headers.Add(wabaHeaderName, wabaNumber);
return await _httpClient.SendAsync(request, ct);
});
stopwatch.Stop();
var responseBody = await response.Content.ReadAsStringAsync(ct);
// Celitix returns HTTP 200 even for provider-level failures (e.g. insufficient
// balance, invalid WABA number) — the actual outcome is only visible in the body
// as [{"errorCode": , "errorDescription": "..."}]. Treat that as a
// failure regardless of the HTTP status, so it isn't silently reported as sent.
string? bodyError = TryExtractCelitixError(responseBody);
bool success = response.IsSuccessStatusCode && bodyError == null;
if (!success)
{
_logger.LogError("[{CorrelationId}][Celitix] FAILED | Status={Status} | DurationMs={Duration} | Body={Body}",
correlationId, response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
}
else
{
_logger.LogInformation("[{CorrelationId}][Celitix] SUCCESS | Status={Status} | DurationMs={Duration} | Body={Body}",
correlationId, response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
}
return new MessageHubResponseDTO
{
Success = success,
Message = success ? "Sent via Celitix" : (bodyError ?? responseBody),
ResponseData = responseBody
};
}
catch (Exception ex)
{
_logger.LogError(ex, "[{CorrelationId}][Celitix] Exception while sending WhatsApp", correlationId);
return new MessageHubResponseDTO { Success = false, Message = ex.ToString() };
}
}
///
/// Returns a non-null error description when the Celitix response body encodes a
/// provider-level error (errorCode present and non-zero), even under HTTP 200.
/// Returns null for anything that doesn't match that shape — never throws, since an
/// unrecognized/non-JSON body must not itself be treated as a send failure.
///
private static string? TryExtractCelitixError(string responseBody)
{
if (string.IsNullOrWhiteSpace(responseBody))
return null;
try
{
var token = JToken.Parse(responseBody);
var errorObj = token is JArray arr ? arr.FirstOrDefault() as JObject : token as JObject;
var errorCode = errorObj?["errorCode"]?.Value();
if (errorCode.HasValue && errorCode.Value != 0)
return errorObj?["errorDescription"]?.ToString() ?? $"Celitix error code {errorCode}";
return null;
}
catch
{
return null;
}
}
private async Task SendViaMetaAsync(
EIPResponseContext context,
string correlationId,
CancellationToken ct)
{
try
{
var authToken = _configuration["MessageHubSettings:WhatsApp:AuthToken"];
var phoneNumberId = _configuration["MessageHubSettings:WhatsApp:PhoneNumberId"];
var payload = BuildPayload(context);
_logger.LogInformation("[{CorrelationId}][Meta] Sending Payload:\n{Payload}",
correlationId, payload.ToString(Newtonsoft.Json.Formatting.Indented));
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await _retryPolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(
HttpMethod.Post,
$"https://graph.facebook.com/v18.0/{phoneNumberId}/messages");
request.Headers.Add("Authorization", $"Bearer {authToken}");
request.Content = new StringContent(
payload.ToString(),
Encoding.UTF8,
"application/json");
return await _httpClient.SendAsync(request, ct);
});
stopwatch.Stop();
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("[{CorrelationId}][Meta] FAILED | Status={Status} | DurationMs={Duration} | Body={Body}",
correlationId, response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
}
else
{
_logger.LogInformation("[{CorrelationId}][Meta] SUCCESS | Status={Status} | DurationMs={Duration} | Body={Body}",
correlationId, response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
}
return new MessageHubResponseDTO
{
Success = response.IsSuccessStatusCode,
Message = response.IsSuccessStatusCode ? "Sent via Meta" : responseBody,
ResponseData = responseBody
};
}
catch (Exception ex)
{
_logger.LogError(ex, "[{CorrelationId}][Meta] Exception while sending WhatsApp", correlationId);
return new MessageHubResponseDTO { Success = false, Message = ex.ToString() };
}
}
#endregion
#region Helpers
private static JObject BuildPayload(EIPResponseContext context)
{
var payload = new JObject
{
["messaging_product"] = "whatsapp",
["to"] = context.Recipient,
["type"] = ResolveMessageType(context)
};
if (!string.IsNullOrWhiteSpace(context.TemplateName))
{
payload["type"] = "template";
payload["template"] = new JObject
{
["name"] = context.TemplateName,
["language"] = new JObject { ["code"] = context.LanguageCode ?? "en_US" },
["components"] = context.TemplateComponents != null ? JToken.FromObject(context.TemplateComponents) : null
};
}
else if (!string.IsNullOrWhiteSpace(context.Message) && payload["type"].ToString() == "text")
{
payload["text"] = new JObject { ["body"] = context.Message };
}
else if (!string.IsNullOrWhiteSpace(context.MediaUrl) && payload["type"].ToString() == "image")
{
payload["image"] = new JObject
{
["link"] = context.MediaUrl,
["caption"] = context.Caption ?? string.Empty
};
}
else if (context.Buttons?.Count > 0 && payload["type"].ToString() == "interactive")
{
payload["interactive"] = BuildInteractiveButtons(context);
}
return payload;
}
///
/// Meta's Cloud API has two distinct, mutually-exclusive interactive shapes for buttons:
/// "button" (up to 3 REPLY buttons, type="reply") and "cta_url" (exactly one URL button,
/// no reply buttons allowed alongside it). ButtonType=URL therefore can't just be flagged
/// on a regular reply button — it needs a different top-level interactive.type.
///
private static JObject BuildInteractiveButtons(EIPResponseContext context)
{
var buttons = context.Buttons ?? new List();
var replyButtons = buttons
.Where(b => !string.Equals(b.ButtonType, "URL", StringComparison.OrdinalIgnoreCase))
.ToList();
var urlButton = buttons
.FirstOrDefault(b => string.Equals(b.ButtonType, "URL", StringComparison.OrdinalIgnoreCase));
if (urlButton != null && replyButtons.Count == 0)
{
return new JObject
{
["type"] = "cta_url",
["body"] = new JObject { ["text"] = context.Message ?? string.Empty },
["action"] = new JObject
{
["name"] = "cta_url",
["parameters"] = new JObject
{
["display_text"] = Truncate(urlButton.Title ?? urlButton.Text ?? urlButton.ButtonText ?? "Open", 20),
["url"] = urlButton.Url
}
}
};
}
// Reply buttons: Meta allows a max of 3. Any URL button mixed into the same set can't be
// rendered as a button here (Cloud API doesn't support mixing reply + cta_url) — append
// its link as plain text instead of silently dropping it.
var bodyText = context.Message ?? string.Empty;
if (urlButton != null)
bodyText += $"\n{urlButton.Title ?? urlButton.Text ?? "Link"}: {urlButton.Url}";
var apiButtons = new JArray(replyButtons.Take(3).Select(b => new JObject
{
["type"] = "reply",
["reply"] = new JObject
{
["id"] = b.Payload ?? b.Id,
["title"] = Truncate(b.Title ?? b.Text ?? b.ButtonText ?? b.Id, 20)
}
}));
return new JObject
{
["type"] = "button",
["body"] = new JObject { ["text"] = bodyText },
["action"] = new JObject { ["buttons"] = apiButtons }
};
}
/// Meta caps reply-button titles and cta_url display_text at 20 characters.
private static string Truncate(string value, int maxLength)
=> string.IsNullOrEmpty(value) || value.Length <= maxLength ? value : value[..maxLength];
private static string ResolveMessageType(EIPResponseContext context)
{
if (!string.IsNullOrWhiteSpace(context.TemplateName)) return "template";
if (!string.IsNullOrWhiteSpace(context.MediaUrl)) return "image";
if (context.Buttons?.Count > 0) return "interactive";
return "text";
}
public static EIPResponseContext ConvertToEIPResponseContext(TemplateApprovalDTO dto)
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
return new EIPResponseContext
{
TenantId = dto.TenantId,
UserIdentifier = dto.UserId,
Recipient = dto.Recipient,
Message = $"Your action '{dto.Choice}' has been received and processed.",
RawPayload = dto.RawPayload,
Buttons = new List
{
new EIPButtonDTO { Text = dto.Choice, Payload = dto.RawPayload }
}
};
}
#endregion
}
}