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 } }