using FrameworkBLL.EIPConcept; using FrameworkDAL.DTO.EIPConversation; using FrameworkDAL.DTO.MessageHub.MessageHubGenerator; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace FrameworkBLL.EIPConversation { public class EIPConceptEngine : IEIPConceptEngine { private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly ConversationPlatformType _platformDispatcher; // Optional OTP store for retry tracking private readonly ConcurrentDictionary _otpStore = new(); private const int MaxOtpAttempts = 5; public EIPConceptEngine( ILogger logger, IHttpClientFactory httpClientFactory, ConversationPlatformType platformDispatcher) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _platformDispatcher = platformDispatcher ?? throw new ArgumentNullException(nameof(platformDispatcher)); _logger.LogInformation("ConversationSwitchType initialized successfully."); } #region Public Entry public async Task ProcessConversationStepAsync( string userInput, MessageHubConversationDTO conversation, LoginDTO loginContext, CancellationToken ct) { conversation.Context ??= new Dictionary(); var normalizedInput = userInput?.Trim() ?? string.Empty; _logger.LogInformation("Processing conversation step | Session={Session} | Input={Input} | CurrentStep={CurrentStepId}", conversation.SessionId, normalizedInput, conversation.CurrentStepId); // No current step -> check triggers if (!conversation.Context.ContainsKey("CurrentStepId")) return ProcessTriggerStep(normalizedInput, conversation); var currentStep = GetCurrentStep(conversation); if (currentStep == null) { _logger.LogWarning("Current step not found | Session={Session} | StepId={StepId}", conversation.SessionId, conversation.CurrentStepId); return conversation.DefaultFallbackMessage ?? "Flow configuration error."; } try { return currentStep.Type?.ToUpperInvariant() switch { "MESSAGE" => ProcessMessageStep(currentStep, conversation), "INPUT" => ProcessInputStep(currentStep, conversation, normalizedInput), "CHOICE" => ProcessChoiceStep(currentStep, conversation, normalizedInput), "ACTION" => await ProcessActionStepAsync(currentStep, conversation, loginContext, ct), "LOOP" => ProcessLoopStep(currentStep, conversation), "FILE_UPLOAD" => ProcessFileUploadStep(currentStep, conversation, normalizedInput), "END" => ProcessEndStep(currentStep, conversation), _ => HandleUnknownStepType(currentStep, conversation) }; } catch (Exception ex) { _logger.LogError(ex, "Step processing failed | StepId={StepId} | Session={Session}", currentStep.StepId, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "Processing error."; } } #endregion #region Step Processors private string ProcessTriggerStep(string input, MessageHubConversationDTO conversation) { if (conversation.Triggers == null || !conversation.Triggers.Any()) { _logger.LogWarning("No triggers configured | Session={Session}", conversation.SessionId); return conversation.DefaultFallbackMessage ?? "No triggers configured."; } input = (input ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(input)) { _logger.LogWarning("Trigger input is empty | Session={Session}", conversation.SessionId); return conversation.DefaultFallbackMessage ?? "Input cannot be empty."; } _logger.LogDebug("Processing trigger step | Session={Session} | Input={Input}", conversation.SessionId, input); // Match trigger (case-insensitive, trimmed) var trigger = conversation.Triggers .FirstOrDefault(t => t.Keywords != null && t.Keywords.Any(k => string.Equals(k.Trim(), input, StringComparison.OrdinalIgnoreCase))); if (trigger == null) { _logger.LogWarning("No trigger matched | Input={Input} | Session={Session}", input, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "Invalid input."; } // Ensure StartStepId exists in Steps bool startStepExists = conversation.Steps?.Any(s => s.StepId == trigger.StartStepId) ?? false; if (!startStepExists) { _logger.LogError( "Trigger matched but StartStepId not found in Steps | Session={Session} | Keywords={Keywords} | StartStepId={StepId}", conversation.SessionId, string.Join(",", trigger.Keywords), trigger.StartStepId); // Fallback to first valid trigger var validTrigger = conversation.Triggers.FirstOrDefault(t => conversation.Steps != null && conversation.Steps.Any(s => s.StepId == t.StartStepId)); if (validTrigger != null) { _logger.LogWarning( "Falling back to first valid trigger | Session={Session} | StepId={StepId}", conversation.SessionId, validTrigger.StartStepId); return MoveToNextStep(conversation, validTrigger.StartStepId); } return conversation.DefaultFallbackMessage ?? "Conversation flow misconfigured."; } _logger.LogInformation( "Trigger matched | Keywords={Keywords} | Session={Session} | Moving to Step={StepId}", string.Join(",", trigger.Keywords), conversation.SessionId, trigger.StartStepId); return MoveToNextStep(conversation, trigger.StartStepId); } private string ProcessMessageStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation) { if (step == null) return conversation.DefaultFallbackMessage ?? "Message step configuration error."; _logger.LogInformation("Processing MessageStep | StepId={StepId} | Session={Session} | Message={Message}", step.StepId, conversation.SessionId, step.Message); return MoveToNextStep(conversation, step.NextStepId ?? -1, step.Message); } private string ProcessInputStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation, string input) { var userInput = input.Trim(); _logger.LogInformation("Processing InputStep | StepId={StepId} | Session={Session} | Input={Input}", step.StepId, conversation.SessionId, userInput); if (step.Validation != null) { if (step.Validation.Required && string.IsNullOrWhiteSpace(userInput)) { _logger.LogWarning("Input required but missing | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); return step.Validation.ErrorMessage ?? "Input is required."; } if (!string.IsNullOrWhiteSpace(step.Validation.Regex)) { try { if (!Regex.IsMatch(userInput, step.Validation.Regex)) { _logger.LogWarning("Input failed regex validation | StepId={StepId} | Session={Session} | Input={Input}", step.StepId, conversation.SessionId, userInput); return step.Validation.ErrorMessage ?? "Invalid input format."; } } catch (Exception ex) { _logger.LogError(ex, "Invalid regex pattern | StepId={StepId} | Regex={Regex}", step.StepId, step.Validation.Regex); return "Validation configuration error."; } } } if (!string.IsNullOrWhiteSpace(step.ContextKey)) { conversation.Context[step.ContextKey] = userInput; _logger.LogDebug("Context updated | Key={Key} | Value={Value} | Session={Session}", step.ContextKey, userInput, conversation.SessionId); } return MoveToNextStep(conversation, step.NextStepId ?? -1); } private string ProcessChoiceStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation, string input) { _logger.LogInformation("Processing ChoiceStep | StepId={StepId} | Session={Session} | Input={Input}", step.StepId, conversation.SessionId, input); if (step?.Choices == null || !step.Choices.Any()) { _logger.LogWarning("No choices configured | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "No choices configured."; } var normalizedInput = input.Trim(); int? nextStepId = null; var keys = step.Choices.Keys.ToList(); // Match by index (1-based) if (int.TryParse(normalizedInput, out var index) && index > 0 && index <= keys.Count) { nextStepId = step.Choices[keys[index - 1]]; } // Match by key text else if (step.Choices.ContainsKey(normalizedInput)) { nextStepId = step.Choices[normalizedInput]; } if (nextStepId.HasValue) { _logger.LogInformation("Choice matched | Input={Input} | NextStep={NextStepId} | Session={Session}", normalizedInput, nextStepId.Value, conversation.SessionId); return MoveToNextStep(conversation, nextStepId.Value); } _logger.LogWarning("Choice not matched | Input={Input} | Session={Session}", normalizedInput, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "Invalid choice, please try again."; } private async Task ProcessActionStepAsync( ConversationFlowStepDTO step, MessageHubConversationDTO conversation, LoginDTO login, CancellationToken ct) { if (step == null) { _logger.LogError("ActionStep is null | Session={Session}", conversation?.SessionId); return conversation?.DefaultFallbackMessage ?? "Invalid action step."; } _logger.LogInformation("Processing ActionStep | StepId={StepId} | ActionType={ActionType} | Session={Session}", step.StepId, step.ActionType, conversation.SessionId); try { switch (step.ActionType?.ToUpperInvariant()) { case "SEND_OTP": _logger.LogDebug("Sending OTP | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); await HandleSendOtpAsync(step, conversation, login, ct); _logger.LogDebug("OTP sent successfully | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); break; case "CALL_API": _logger.LogDebug("Preparing API call | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); _logger.LogDebug("Context keys before API call: {Keys}", conversation.Context != null ? string.Join(",", conversation.Context.Keys) : ""); var apiResult = await ProcessApiCallStepAsync(step, conversation, ct); _logger.LogDebug("API call completed | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); _logger.LogDebug("Context keys after API call: {Keys}", conversation.Context != null ? string.Join(",", conversation.Context.Keys) : ""); _logger.LogInformation("API result | StepId={StepId} | ResultLength={Length} | Session={Session}", step.StepId, apiResult?.Length ?? 0, conversation.SessionId); return apiResult; case "CONDITIONAL": _logger.LogDebug("Processing conditional step | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); var condResult = HandleConditionalStep(step, conversation); _logger.LogDebug("Conditional step completed | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); _logger.LogInformation("Conditional result | StepId={StepId} | ResultLength={Length} | Session={Session}", step.StepId, condResult?.Length ?? 0, conversation.SessionId); return condResult; default: _logger.LogWarning("Unknown action type | StepId={StepId} | ActionType={ActionType} | Session={Session}", step.StepId, step.ActionType, conversation.SessionId); break; } _logger.LogDebug("Moving to next step | StepId={StepId} | NextStepId={NextStepId} | Session={Session}", step.StepId, step.NextStepId, conversation.SessionId); var nextStepResult = MoveToNextStep(conversation, step.NextStepId ?? -1); _logger.LogDebug("Next step result | StepId={StepId} | ResultLength={Length} | Session={Session}", step.StepId, nextStepResult?.Length ?? 0, conversation.SessionId); return nextStepResult; } catch (Exception ex) { _logger.LogError(ex, "Action step execution failed | StepId={StepId} | ActionType={ActionType} | Session={Session}", step.StepId, step.ActionType, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "Action step processing error."; } } private string ProcessLoopStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation) { var context = conversation.Context ??= new Dictionary(); var counterKey = $"LoopCounter_{step.StepId}"; int count = context.TryGetValue(counterKey, out var value) && int.TryParse(value?.ToString(), out var c) ? c : 0; count++; _logger.LogDebug("Processing LoopStep | StepId={StepId} | Iteration={Iteration} | Session={Session}", step.StepId, count, conversation.SessionId); var maxIterations = step.MaxIterations ?? 1; if (count >= maxIterations) { context.Remove(counterKey); _logger.LogInformation("Loop exited | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); return MoveToNextStep(conversation, step.ExitStepId ?? -1); } context[counterKey] = count; return MoveToNextStep(conversation, step.LoopBackStepId ?? -1); } private string ProcessFileUploadStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation, string fileData) { _logger.LogInformation("Processing FileUploadStep | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); if (step == null) return conversation.DefaultFallbackMessage ?? "File upload step error."; var context = conversation.Context ??= new Dictionary(); if (string.IsNullOrWhiteSpace(fileData)) { _logger.LogWarning("File data missing | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); return step.Validation?.ErrorMessage ?? conversation.DefaultFallbackMessage ?? "File upload required."; } if (!string.IsNullOrWhiteSpace(step.ContextKey)) { context[step.ContextKey] = fileData; _logger.LogDebug("File uploaded and saved to context | Key={Key} | Session={Session}", step.ContextKey, conversation.SessionId); } return MoveToNextStep(conversation, step.NextStepId ?? -1); } private string ProcessEndStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation) { _logger.LogInformation("Processing EndStep | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); conversation.Context?.Clear(); return step?.Message ?? conversation.DefaultFallbackMessage ?? "Conversation ended."; } private string HandleUnknownStepType(ConversationFlowStepDTO step, MessageHubConversationDTO conversation) { _logger.LogWarning("Unsupported step type | StepId={StepId} | Type={Type}", step.StepId, step.Type); return conversation.DefaultFallbackMessage ?? "Unsupported step type."; } #endregion #region Helpers private ConversationFlowStepDTO? GetCurrentStep(MessageHubConversationDTO conversation) { if (conversation.Context == null || !conversation.Context.TryGetValue("CurrentStepId", out var obj)) return null; if (!int.TryParse(obj?.ToString(), out var stepId)) return null; return conversation.Steps?.FirstOrDefault(x => x.StepId == stepId); } /// /// Advances conversation to the next step, handles conditional, loop, and auto-advance logic. /// private string MoveToNextStep( MessageHubConversationDTO conversation, int nextStepId, string? prependMessage = null, int depth = 0) { const int MaxDepth = 20; if (depth > MaxDepth) { _logger.LogError("Max recursion depth reached | Session={Session}", conversation.SessionId); return "Conversation flow error: too many auto-advances."; } if (conversation.Steps == null || !conversation.Steps.Any()) { _logger.LogError("Conversation steps are not configured | Session={Session}", conversation.SessionId); return conversation.DefaultFallbackMessage ?? "Conversation flow is not configured."; } var nextStep = conversation.Steps.FirstOrDefault(x => x.StepId == nextStepId); if (nextStep == null) { _logger.LogWarning("Next step not found | Session={Session} | StepId={StepId}", conversation.SessionId, nextStepId); return conversation.DefaultFallbackMessage ?? "Invalid conversation flow."; } conversation.Context ??= new Dictionary(); conversation.Context["CurrentStepId"] = nextStepId; conversation.CurrentStepId = nextStepId; _logger.LogInformation("Moving to next step | Session={Session} | StepId={StepId}", conversation.SessionId, nextStepId); // Render message var stepMessage = RenderStep(nextStep, conversation)?.Trim() ?? conversation.DefaultFallbackMessage ?? "No message configured."; var combinedMessage = !string.IsNullOrWhiteSpace(prependMessage) ? $"{prependMessage.Trim()}\n\n{stepMessage}" : stepMessage; // =========================== // Conditional handling // =========================== if (nextStep.Conditions != null && nextStep.Conditions.Any()) { var contextKey = SanitizeContextKey(nextStep.ConditionKey ?? string.Empty); conversation.Context.TryGetValue(contextKey, out var contextValue); var contextStr = contextValue?.ToString() ?? string.Empty; foreach (var condition in nextStep.Conditions) { if (string.IsNullOrEmpty(condition.Operator) || condition.Value == null) continue; bool matched = condition.Operator.ToUpperInvariant() switch { "IN" => condition.Value.Contains(contextStr), "NOT IN" => !condition.Value.Contains(contextStr), "==" => contextStr == condition.Value.FirstOrDefault(), "!=" => contextStr != condition.Value.FirstOrDefault(), "CONTAINS" => contextStr.Contains(condition.Value.FirstOrDefault() ?? string.Empty), ">" => double.TryParse(contextStr, out var c) && double.TryParse(condition.Value.FirstOrDefault(), out var v) && c > v, "<" => double.TryParse(contextStr, out var c1) && double.TryParse(condition.Value.FirstOrDefault(), out var v1) && c1 < v1, ">=" => double.TryParse(contextStr, out var c2) && double.TryParse(condition.Value.FirstOrDefault(), out var v2) && c2 >= v2, "<=" => double.TryParse(contextStr, out var c3) && double.TryParse(condition.Value.FirstOrDefault(), out var v3) && c3 <= v3, _ => false }; if (matched) return MoveToNextStep(conversation, condition.NextStepId, combinedMessage, depth + 1); } // Default fallback if no condition matched if (nextStep.DefaultNextStepId.HasValue) return MoveToNextStep(conversation, nextStep.DefaultNextStepId.Value, combinedMessage, depth + 1); if (nextStep.NextStepId.HasValue) return MoveToNextStep(conversation, nextStep.NextStepId.Value, combinedMessage, depth + 1); } // =========================== // Loop handling // =========================== if (nextStep.LoopBackStepId.HasValue && nextStep.ExitStepId.HasValue) { var iterationKey = $"Loop_{nextStep.StepId}_Count"; var currentIteration = conversation.GetContextValue(iterationKey) + 1; conversation.SetContextValue(iterationKey, currentIteration); if (nextStep.MaxIterations.HasValue && currentIteration < nextStep.MaxIterations.Value) return MoveToNextStep(conversation, nextStep.LoopBackStepId.Value, combinedMessage, depth + 1); return MoveToNextStep(conversation, nextStep.ExitStepId.Value, combinedMessage, depth + 1); } // =========================== // Auto-advance // =========================== if (nextStep.AutoAdvance == true && (nextStep.Type?.Equals("MESSAGE", StringComparison.OrdinalIgnoreCase) == true || nextStep.Type?.Equals("RICH_MESSAGE", StringComparison.OrdinalIgnoreCase) == true) && nextStep.NextStepId.HasValue && nextStep.NextStepId.Value != -1 && nextStep.NextStepId.Value != nextStepId) { _logger.LogInformation("Auto-advancing | Session={Session} | NextStepId={NextStepId}", conversation.SessionId, nextStep.NextStepId.Value); return MoveToNextStep(conversation, nextStep.NextStepId.Value, combinedMessage, depth + 1); } return combinedMessage; } private string RenderStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation) { if (step == null) return conversation.DefaultFallbackMessage ?? "Step not found."; var sb = new StringBuilder(); // -------------------------- // 1. Render main message // -------------------------- if (!string.IsNullOrWhiteSpace(step.Message)) { sb.AppendLine(ReplacePlaceholders(step.Message, conversation.Context)); } // -------------------------- // 2. Render choices (numbered) // -------------------------- if (step.Choices != null && step.Choices.Any()) { int i = 1; foreach (var choice in step.Choices.Keys) { sb.AppendLine($"{i}. {choice}"); i++; } } // -------------------------- // 3. Render buttons (optional) // -------------------------- if (step.Buttons != null && step.Buttons.Any()) { sb.AppendLine(); foreach (var button in step.Buttons) { sb.AppendLine($"[ {button.Title} ]"); } } // -------------------------- // 4. Append Response (if exists) // -------------------------- if (!string.IsNullOrWhiteSpace(step.Response)) { sb.AppendLine(); sb.AppendLine(ReplacePlaceholders(step.Response, conversation.Context)); } // -------------------------- // 5. Return clean string // -------------------------- return sb.ToString().Trim(); } /// /// Replaces placeholders in text using the context dictionary. /// Example: "Hello {UserName}" -> "Hello John" /// private string ReplacePlaceholders(string text, Dictionary context) { if (string.IsNullOrWhiteSpace(text) || context == null || !context.Any()) return text; foreach (var kvp in context) { var placeholder = $"{{{kvp.Key}}}"; if (text.Contains(placeholder, StringComparison.OrdinalIgnoreCase)) { text = text.Replace(placeholder, kvp.Value?.ToString() ?? string.Empty, StringComparison.OrdinalIgnoreCase); } } return text; } private static string GenerateOtp() { Span bytes = stackalloc byte[4]; RandomNumberGenerator.Fill(bytes); int value = BitConverter.ToInt32(bytes) & int.MaxValue; return ((value % 900000) + 100000).ToString("D6"); } private string BuildOtpKey(string destination, string platform) => $"{platform}:{destination}"; private async Task HandleSendOtpAsync(ConversationFlowStepDTO step, MessageHubConversationDTO conversation, LoginDTO login, CancellationToken ct) { var destination = conversation.Recipient ?? throw new Exception("Recipient missing for OTP."); var otp = GenerateOtp(); conversation.Context["OTP"] = otp; conversation.Context["OTP_EXPIRY"] = DateTime.UtcNow.AddMinutes(5); var platform = string.IsNullOrWhiteSpace(step.Platform) ? "SMS" : step.Platform; _logger.LogInformation("Sending OTP | Destination={Destination} | Platform={Platform} | OTP={Otp} | Expiry={Expiry}", destination, platform, otp, conversation.Context["OTP_EXPIRY"]); var success = await _platformDispatcher.SendOtpToPlatformAsync(destination, otp, login, platform, ct); if (!success) { _logger.LogError("OTP dispatch failed | Destination={Destination} | Platform={Platform}", destination, platform); throw new Exception("OTP dispatch failed."); } _logger.LogInformation("OTP sent successfully | Destination={Destination} | Platform={Platform}", destination, platform); } private string HandleConditionalStep(ConversationFlowStepDTO step, MessageHubConversationDTO conversation) { if (step.ConditionKey == null) return MoveToNextStep(conversation, step.FalseStepId ?? 0); var value = conversation.Context.TryGetValue(step.ConditionKey, out var v) ? v?.ToString() : null; _logger.LogInformation("Processing ConditionalStep | ConditionKey={ConditionKey} | Value={Value} | Session={Session}", step.ConditionKey, value, conversation.SessionId); return value == step.ConditionValue ? MoveToNextStep(conversation, step.TrueStepId ?? 0) : MoveToNextStep(conversation, step.FalseStepId ?? 0); } private async Task ProcessApiCallStepAsync( ConversationFlowStepDTO step, MessageHubConversationDTO conversation, CancellationToken ct) { if (step == null) { _logger.LogError("Step is null | Session={Session}", conversation?.SessionId); return conversation?.DefaultFallbackMessage ?? "API step configuration error."; } if (step.ApiConfig == null) { _logger.LogError("API configuration is missing | StepId={StepId} | Session={Session}", step.StepId, conversation?.SessionId); return conversation?.DefaultFallbackMessage ?? "API configuration missing."; } if (conversation?.Context == null) { _logger.LogError("Conversation context is missing | Session={Session}", conversation?.SessionId); return conversation?.DefaultFallbackMessage ?? "Conversation context missing."; } try { // -------------------- Determine HTTP method -------------------- var methodName = string.IsNullOrWhiteSpace(step.ApiConfig.Method) ? HttpMethod.Get.Method : step.ApiConfig.Method.ToUpperInvariant(); var httpMethod = new HttpMethod(methodName); // -------------------- Replace placeholders in URL -------------------- var url = ReplacePlaceholders(step.ApiConfig.Url, conversation.Context); _logger.LogInformation("Prepared API URL | StepId={StepId} | URL={URL} | Session={Session}", step.StepId, url, conversation.SessionId); if (string.IsNullOrWhiteSpace(url)) { _logger.LogError("API URL is empty after placeholder replacement | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "Invalid API URL."; } var client = _httpClientFactory.CreateClient("ConversationApiClient"); client.Timeout = TimeSpan.FromSeconds(15); // enforce timeout using var request = new HttpRequestMessage(httpMethod, url); // -------------------- Add headers -------------------- if (step.ApiConfig.Headers != null && step.ApiConfig.Headers.Any()) { foreach (var header in step.ApiConfig.Headers) { var value = ReplacePlaceholders(header.Value, conversation.Context); if (!string.IsNullOrWhiteSpace(value)) { request.Headers.TryAddWithoutValidation(header.Key, value); _logger.LogDebug("Added API header | StepId={StepId} | {Key}={Value} | Session={Session}", step.StepId, header.Key, value, conversation.SessionId); } } } // -------------------- Add body for POST/PUT/PATCH -------------------- if (!string.IsNullOrWhiteSpace(step.ApiConfig.BodyTemplate) && (httpMethod == HttpMethod.Post || httpMethod == HttpMethod.Put || httpMethod.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase))) { var body = ReplacePlaceholders(step.ApiConfig.BodyTemplate, conversation.Context); request.Content = new StringContent(body ?? string.Empty, Encoding.UTF8, "application/json"); _logger.LogDebug("Prepared API body | StepId={StepId} | Body={Body} | Session={Session}", step.StepId, body, conversation.SessionId); } // -------------------- Full request debug -------------------- var headerSnapshot = request.Headers.ToDictionary(h => h.Key, h => string.Join(",", h.Value)); var bodySnapshot = request.Content != null ? await request.Content.ReadAsStringAsync(ct) : ""; _logger.LogDebug("API request snapshot | Method={Method} | URL={URL} | Headers={Headers} | Body={Body} | StepId={StepId} | Session={Session}", methodName, url, string.Join(";", headerSnapshot.Select(h => $"{h.Key}={h.Value}")), bodySnapshot, step.StepId, conversation.SessionId); // -------------------- Send request -------------------- _logger.LogInformation("Sending API request | StepId={StepId} | Method={Method} | Session={Session}", step.StepId, methodName, conversation.SessionId); using var response = await client.SendAsync(request, ct); // -------------------- Read response -------------------- var content = await response.Content.ReadAsStringAsync(ct); var displayContent = content.Length > 500 ? content[..500] + "..." : content; _logger.LogInformation("API response received | StepId={StepId} | Status={Status} | Content={Content} | Session={Session}", step.StepId, response.StatusCode, displayContent, conversation.SessionId); if (!response.IsSuccessStatusCode) { _logger.LogError("API call failed | StepId={StepId} | Status={Status} | Content={Content} | Session={Session}", step.StepId, response.StatusCode, displayContent, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "API call failed."; } // -------------------- Save API response to context -------------------- if (!string.IsNullOrWhiteSpace(step.ContextKey)) { try { var jsonObject = JsonSerializer.Deserialize(content); conversation.Context[step.ContextKey] = jsonObject; _logger.LogDebug("API response saved to context | Key={Key} | StepId={StepId} | Session={Session}", step.ContextKey, step.StepId, conversation.SessionId); } catch (Exception ex) { conversation.Context[step.ContextKey] = content; _logger.LogWarning(ex, "Failed to deserialize API response, storing raw content | StepId={StepId} | ContextKey={Key} | Session={Session}", step.StepId, step.ContextKey, conversation.SessionId); } } // -------------------- Advance to next step -------------------- _logger.LogInformation("Advancing to next step | CurrentStepId={CurrentStepId} | NextStepId={NextStepId} | Session={Session}", step.StepId, step.NextStepId, conversation.SessionId); return MoveToNextStep(conversation, step.NextStepId ?? -1); } catch (TaskCanceledException ex) { _logger.LogError(ex, "API call timed out | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "API call timed out."; } catch (Exception ex) { _logger.LogError(ex, "API step execution failed | StepId={StepId} | Session={Session}", step.StepId, conversation.SessionId); return conversation.DefaultFallbackMessage ?? "API processing error."; } } /// /// Sanitize context keys to prevent cache/serialization errors /// private string SanitizeContextKey(string key) => Regex.Replace(key ?? string.Empty, @"[^a-zA-Z0-9_\-]", "_"); internal record OtpEntry(string Otp, DateTime Expiry, int Attempts); #endregion } }